From 5a54faaf9d12b024ec2fb261ae0bfb6e118671a7 Mon Sep 17 00:00:00 2001 From: Bishwa Hang Rai Date: Wed, 16 Jul 2025 22:13:02 +0200 Subject: [PATCH 01/17] Add Database Diagnostics monitoring page Adds a new admin page for database diagnostics. It will show collation mismatches and any corrupted indexes related to collation. Changelog: added --- .../components/collation_checker.vue | 432 ++++++++++++++++++ .../admin/database_diagnostics/index.js | 24 + .../pages/admin/database_diagnostics/index.js | 3 + .../admin/database_diagnostics_controller.rb | 31 ++ app/policies/global_policy.rb | 1 + .../database_diagnostics/index.html.haml | 18 + config/routes/admin.rb | 6 + lib/sidebars/admin/menus/monitoring_menu.rb | 10 + locale/gitlab.pot | 105 +++++ .../components/collation_checker_spec.js | 281 ++++++++++++ spec/policies/global_policy_spec.rb | 11 +- .../database_diagnostics_controller_spec.rb | 131 ++++++ 12 files changed, 1048 insertions(+), 5 deletions(-) create mode 100644 app/assets/javascripts/admin/database_diagnostics/components/collation_checker.vue create mode 100644 app/assets/javascripts/admin/database_diagnostics/index.js create mode 100644 app/assets/javascripts/pages/admin/database_diagnostics/index.js create mode 100644 app/controllers/admin/database_diagnostics_controller.rb create mode 100644 app/views/admin/database_diagnostics/index.html.haml create mode 100644 spec/frontend/admin/database_diagnostics/components/collation_checker_spec.js create mode 100644 spec/requests/admin/database_diagnostics_controller_spec.rb diff --git a/app/assets/javascripts/admin/database_diagnostics/components/collation_checker.vue b/app/assets/javascripts/admin/database_diagnostics/components/collation_checker.vue new file mode 100644 index 00000000000000..902e366a56c871 --- /dev/null +++ b/app/assets/javascripts/admin/database_diagnostics/components/collation_checker.vue @@ -0,0 +1,432 @@ + + + diff --git a/app/assets/javascripts/admin/database_diagnostics/index.js b/app/assets/javascripts/admin/database_diagnostics/index.js new file mode 100644 index 00000000000000..9696ba5cc45dab --- /dev/null +++ b/app/assets/javascripts/admin/database_diagnostics/index.js @@ -0,0 +1,24 @@ +import Vue from 'vue'; +import CollationChecker from './components/collation_checker.vue'; + +export const initDatabaseDiagnosticsApp = () => { + const el = document.getElementById('js-database-diagnostics'); + + if (!el) { + return false; + } + + const { runCollationCheckUrl, collationCheckResultsUrl } = el.dataset; + + return new Vue({ + el, + render(h) { + return h(CollationChecker, { + props: { + runCollationCheckUrl, + collationCheckResultsUrl, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/pages/admin/database_diagnostics/index.js b/app/assets/javascripts/pages/admin/database_diagnostics/index.js new file mode 100644 index 00000000000000..9a35ceb8b5d028 --- /dev/null +++ b/app/assets/javascripts/pages/admin/database_diagnostics/index.js @@ -0,0 +1,3 @@ +import { initDatabaseDiagnosticsApp } from '~/admin/database_diagnostics'; + +initDatabaseDiagnosticsApp(); diff --git a/app/controllers/admin/database_diagnostics_controller.rb b/app/controllers/admin/database_diagnostics_controller.rb new file mode 100644 index 00000000000000..8b2c080756ab0c --- /dev/null +++ b/app/controllers/admin/database_diagnostics_controller.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Admin + class DatabaseDiagnosticsController < Admin::ApplicationController + feature_category :database + authorize! :read_admin_database_diagnostics, only: %i[index run_collation_check collation_check_results] + + def index + # Just render the view + end + + def run_collation_check + job_id = ::Database::CollationCheckerWorker.perform_async # rubocop:disable CodeReuse/Worker -- Simple direct call + + respond_to do |format| + format.json { render json: { status: 'scheduled', job_id: job_id } } + end + end + + def collation_check_results + results_json = Rails.cache.read(::Database::CollationCheckerWorker::COLLATION_CHECK_CACHE_KEY) + + if results_json.present? + results = Gitlab::Json.parse(results_json) + render json: results + else + render json: { error: 'No results available yet' }, status: :not_found + end + end + end +end diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb index 9430882e93a01b..2bd723dfa05e40 100644 --- a/app/policies/global_policy.rb +++ b/app/policies/global_policy.rb @@ -159,6 +159,7 @@ class GlobalPolicy < BasePolicy enable :read_admin_cicd enable :read_admin_gitaly_servers enable :read_admin_health_check + enable :read_admin_database_diagnostics enable :read_admin_metrics_dashboard enable :read_admin_system_information enable :read_admin_users diff --git a/app/views/admin/database_diagnostics/index.html.haml b/app/views/admin/database_diagnostics/index.html.haml new file mode 100644 index 00000000000000..d04dcb3053bf8d --- /dev/null +++ b/app/views/admin/database_diagnostics/index.html.haml @@ -0,0 +1,18 @@ +- page_title _('Database Diagnostics') + +- content_for :page_specific_javascripts do + = webpack_bundle_tag 'pages/admin/database_diagnostics/index' + +%section.settings.monitoring + .settings-header + %h1 + = _('Database Diagnostics') + %p + = _('Tools for detecting and resolving database issues in self-managed GitLab instances.') + +#js-database-diagnostics{ + data: { + run_collation_check_url: run_collation_check_admin_database_diagnostics_path(format: :json), + collation_check_results_url: collation_check_results_admin_database_diagnostics_path(format: :json) + } +} diff --git a/config/routes/admin.rb b/config/routes/admin.rb index 7b74b6609e5554..81e89b008b81ac 100644 --- a/config/routes/admin.rb +++ b/config/routes/admin.rb @@ -120,6 +120,12 @@ resource :health_check, controller: 'health_check', only: [:show] resource :background_jobs, controller: 'background_jobs', only: [:show] + resources :database_diagnostics, controller: 'database_diagnostics', only: [:index] do + collection do + post :run_collation_check + get :collation_check_results + end + end resource :system_info, controller: 'system_info', only: [:show] diff --git a/lib/sidebars/admin/menus/monitoring_menu.rb b/lib/sidebars/admin/menus/monitoring_menu.rb index 94f8bf5daeb5aa..d361dc620e4a3d 100644 --- a/lib/sidebars/admin/menus/monitoring_menu.rb +++ b/lib/sidebars/admin/menus/monitoring_menu.rb @@ -10,6 +10,7 @@ def configure_menu_items add_item(background_migrations_menu_item) add_item(background_jobs_menu_item) add_item(health_check_menu_item) + add_item(database_diagnostics_menu_item) add_item(metrics_dashboard_menu_item) true end @@ -67,6 +68,15 @@ def health_check_menu_item ) { can?(current_user, :read_admin_health_check) } end + def database_diagnostics_menu_item + build_menu_item( + title: _('Database diagnostics'), + link: admin_database_diagnostics_path, + active_routes: { controller: 'database_diagnostics' }, + item_id: :database_diagnostics + ) { can?(current_user, :read_admin_database_diagnostics) } + end + def metrics_dashboard_menu_item build_menu_item( title: _('Metrics Dashboard'), diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 0e4b9aec97bfe6..8a4ecf654d3c43 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -3644,6 +3644,9 @@ msgstr "" msgid "Activity|An error occurred while retrieving activity. Reload the page to try again." msgstr "" +msgid "Actual Version" +msgstr "" + msgid "Add" msgstr "" @@ -5907,6 +5910,9 @@ msgstr "" msgid "AdvancedSearch|You have pending obsolete migrations" msgstr "" +msgid "Affected Columns" +msgstr "" + msgid "After it is removed, the fork relationship can only be restored by using the API. This project will no longer be able to receive or send merge requests to the upstream project or other forks." msgstr "" @@ -7399,6 +7405,9 @@ msgstr "" msgid "An error occurred while fetching reference" msgstr "" +msgid "An error occurred while fetching results" +msgstr "" + msgid "An error occurred while fetching reviewers." msgstr "" @@ -7555,6 +7564,9 @@ msgstr "" msgid "An error occurred while searching for labels, please try again." msgstr "" +msgid "An error occurred while starting diagnostics" +msgstr "" + msgid "An error occurred while subscribing to this page. Please try again later." msgstr "" @@ -15222,6 +15234,12 @@ msgstr "" msgid "Collapse sidebar" msgstr "" +msgid "Collation Mismatches" +msgstr "" + +msgid "Collation Name" +msgstr "" + msgid "Collector hostname" msgstr "" @@ -18856,6 +18874,9 @@ msgstr "" msgid "Correlation ID" msgstr "" +msgid "Corrupted Indexes" +msgstr "" + msgid "Cost Factor Settings" msgstr "" @@ -21203,9 +21224,66 @@ msgstr "" msgid "Database '%{database_name}' is using PostgreSQL %{pg_version_current}, but this version of GitLab requires PostgreSQL %{pg_version_minimum}. Please upgrade your environment to a supported PostgreSQL version. See %{pg_requirements_url} for details." msgstr "" +msgid "Database Diagnostics" +msgstr "" + +msgid "Database diagnostics" +msgstr "" + msgid "Database update failed" msgstr "" +msgid "DatabaseDiagnostics|Checking for recent diagnostic results..." +msgstr "" + +msgid "DatabaseDiagnostics|Collation Health Check" +msgstr "" + +msgid "DatabaseDiagnostics|Collation mismatches are shown for informational purposes and may not indicate a problem." +msgstr "" + +msgid "DatabaseDiagnostics|Contact Support" +msgstr "" + +msgid "DatabaseDiagnostics|Database: %{name}" +msgstr "" + +msgid "DatabaseDiagnostics|Detect collation-related index corruption issues that may occur after OS upgrades" +msgstr "" + +msgid "DatabaseDiagnostics|Issues detected" +msgstr "" + +msgid "DatabaseDiagnostics|Last checked: %{timestamp}" +msgstr "" + +msgid "DatabaseDiagnostics|Learn more" +msgstr "" + +msgid "DatabaseDiagnostics|No database issues detected" +msgstr "" + +msgid "DatabaseDiagnostics|No diagnostics have been run yet. Click \"Run Collation Check\" to analyze your database for potential collation issues." +msgstr "" + +msgid "DatabaseDiagnostics|No results available yet." +msgstr "" + +msgid "DatabaseDiagnostics|Run Collation Check" +msgstr "" + +msgid "DatabaseDiagnostics|Running diagnostics..." +msgstr "" + +msgid "DatabaseDiagnostics|Size: %{size}" +msgstr "" + +msgid "DatabaseDiagnostics|These issues require manual remediation. Read our documentation on PostgreSQL OS upgrades for step-by-step instructions." +msgstr "" + +msgid "DatabaseDiagnostics|Your database indexes appear to be healthy and free of collation-related corruption." +msgstr "" + msgid "DatadogIntegration|%{link_start}API key%{link_end} used for authentication with Datadog." msgstr "" @@ -24320,6 +24398,9 @@ msgstr "" msgid "Duplicate page: A page with that title already exists in the file %{file}" msgstr "" +msgid "Duplicates" +msgstr "" + msgid "Duration" msgstr "" @@ -33440,6 +33521,9 @@ msgstr "" msgid "Index" msgstr "" +msgid "Index Name" +msgstr "" + msgid "Index deletion is canceled" msgstr "" @@ -41621,6 +41705,9 @@ msgstr "" msgid "No child items are currently assigned." msgstr "" +msgid "No collation mismatches detected." +msgstr "" + msgid "No comment templates found." msgstr "" @@ -41651,6 +41738,9 @@ msgstr "" msgid "No contributions were found" msgstr "" +msgid "No corrupted indexes detected." +msgstr "" + msgid "No credit card data for matching" msgstr "" @@ -61346,12 +61436,18 @@ msgstr "" msgid "StorageSize|Unknown" msgstr "" +msgid "Stored Version" +msgstr "" + msgid "Strikethrough (%{modifierKey}%{shiftKey}X)" msgstr "" msgid "Strikethrough text" msgstr "" +msgid "Structural" +msgstr "" + msgid "Sub-batch size" msgstr "" @@ -62114,6 +62210,9 @@ msgstr "" msgid "TXT" msgstr "" +msgid "Table" +msgstr "" + msgid "Table of contents" msgstr "" @@ -65791,6 +65890,9 @@ msgstr "" msgid "ToolCoverage|Project coverage" msgstr "" +msgid "Tools for detecting and resolving database issues in self-managed GitLab instances." +msgstr "" + msgid "Topic %{source_topic} was successfully merged into topic %{target_topic}." msgstr "" @@ -66732,6 +66834,9 @@ msgstr "" msgid "Unhelpful or irrelevant" msgstr "" +msgid "Unique" +msgstr "" + msgid "Unique identifier for the target chat or the username of the target channel (in the format `@channelusername`)." msgstr "" diff --git a/spec/frontend/admin/database_diagnostics/components/collation_checker_spec.js b/spec/frontend/admin/database_diagnostics/components/collation_checker_spec.js new file mode 100644 index 00000000000000..0058eb865bfd8f --- /dev/null +++ b/spec/frontend/admin/database_diagnostics/components/collation_checker_spec.js @@ -0,0 +1,281 @@ +import { nextTick } from 'vue'; +import { GlLoadingIcon } from '@gitlab/ui'; +import MockAdapter from 'axios-mock-adapter'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import axios from '~/lib/utils/axios_utils'; +import waitForPromises from 'helpers/wait_for_promises'; +import CollationChecker from '~/admin/database_diagnostics/components/collation_checker.vue'; + +describe('CollationChecker component', () => { + let wrapper; + let mockAxios; + + const findTitle = () => wrapper.findByTestId('title'); + const findRunButton = () => wrapper.findByTestId('run-diagnostics-button'); + const findLoadingAlert = () => wrapper.findByTestId('loading-alert'); + const findRunningAlert = () => wrapper.findByTestId('running-alert'); + const findErrorAlert = () => wrapper.findByTestId('error-alert'); + const findNoResultsMessage = () => wrapper.findByTestId('no-results-message'); + + // Database sections + const findDatabaseMain = () => wrapper.findByTestId('database-main'); + const findDatabaseCI = () => wrapper.findByTestId('database-ci'); + + // Other sections + const findCollationInfoAlert = () => wrapper.findByTestId('collation-info-alert'); + const findCorruptedIndexesTable = () => wrapper.findByTestId('corrupted-indexes-table'); + const findNoCollationMismatchesAlert = () => + wrapper.findByTestId('no-collation-mismatches-alert'); + const findNoCorruptedIndexesAlert = () => wrapper.findByTestId('no-corrupted-indexes-alert'); + + // Action card + const findActionCard = () => wrapper.findByTestId('action-card'); + const findLearnMoreButton = () => wrapper.findByTestId('learn-more-button'); + const findContactSupportButton = () => wrapper.findByTestId('contact-support-button'); + + const createComponent = (props = {}) => { + wrapper = shallowMountExtended(CollationChecker, { + propsData: { + runCollationCheckUrl: '/admin/database_diagnostics/run_collation_check.json', + collationCheckResultsUrl: '/admin/database_diagnostics/collation_check_results.json', + ...props, + }, + }); + }; + + const mockResults = { + metadata: { + last_run_at: '2025-07-23T10:00:00Z', + }, + databases: { + main: { + collation_mismatches: [ + { + collation_name: 'en_US.UTF-8', + provider: 'c', + stored_version: '2.28', + actual_version: '2.31', + }, + ], + corrupted_indexes: [ + { + index_name: 'index_users_on_name', + table_name: 'users', + affected_columns: 'name', + index_type: 'btree', + is_unique: true, + size_bytes: 5678901, + corruption_types: ['duplicates'], + needs_deduplication: true, + }, + ], + }, + ci: { + collation_mismatches: [], + corrupted_indexes: [], + }, + }, + }; + + const noIssuesResults = { + metadata: { + last_run_at: '2025-07-23T10:00:00Z', + }, + databases: { + main: { + collation_mismatches: [], + corrupted_indexes: [], + }, + }, + }; + + beforeEach(() => { + mockAxios = new MockAdapter(axios); + }); + + afterEach(() => { + mockAxios.restore(); + }); + + describe('initial state', () => { + beforeEach(() => { + mockAxios.onGet('/admin/database_diagnostics/collation_check_results.json').reply(404, { + error: 'No results available yet', + }); + createComponent(); + }); + + it('renders the title', () => { + expect(findTitle().exists()).toBe(true); + }); + + it('shows a loading indicator initially', () => { + expect(findLoadingAlert().exists()).toBe(true); + expect(findLoadingAlert().findComponent(GlLoadingIcon).exists()).toBe(true); + }); + + it('shows no results message after loading', async () => { + await waitForPromises(); + + expect(findLoadingAlert().exists()).toBe(false); + expect(findNoResultsMessage().exists()).toBe(true); + }); + + it('enables the run button after loading completes', async () => { + await waitForPromises(); + + expect(findRunButton().attributes('disabled')).toBeUndefined(); + }); + }); + + describe('with results showing collation mismatches', () => { + beforeEach(async () => { + mockAxios + .onGet('/admin/database_diagnostics/collation_check_results.json') + .reply(200, mockResults); + createComponent(); + await waitForPromises(); + }); + + it('renders both database sections', () => { + expect(findDatabaseMain().exists()).toBe(true); + expect(findDatabaseCI().exists()).toBe(true); + }); + + it('displays collation mismatches alert', () => { + expect(findCollationInfoAlert().exists()).toBe(true); + }); + + it('displays corrupted indexes table', () => { + expect(findCorruptedIndexesTable().exists()).toBe(true); + }); + + it('displays action card with remediation links', () => { + expect(findActionCard().exists()).toBe(true); + expect(findLearnMoreButton().exists()).toBe(true); + expect(findContactSupportButton().exists()).toBe(true); + }); + }); + + describe('with no issues', () => { + beforeEach(async () => { + mockAxios + .onGet('/admin/database_diagnostics/collation_check_results.json') + .reply(200, noIssuesResults); + createComponent(); + await waitForPromises(); + }); + + it('shows success alerts for no mismatches and no corrupted indexes', () => { + expect(findNoCollationMismatchesAlert().exists()).toBe(true); + expect(findNoCorruptedIndexesAlert().exists()).toBe(true); + }); + + it('does not display the action card', () => { + expect(findActionCard().exists()).toBe(false); + }); + }); + + describe('running diagnostics', () => { + beforeEach(async () => { + // Initial load - no results + mockAxios.onGet('/admin/database_diagnostics/collation_check_results.json').reply(404); + createComponent(); + await waitForPromises(); + + // Reset mock history + mockAxios.reset(); + + // Set up for the run request + mockAxios.onPost('/admin/database_diagnostics/run_collation_check.json').reply(200); + }); + + it('shows loading state when run button is clicked', async () => { + // Prevent polling by mocking the startPolling method + jest.spyOn(wrapper.vm, 'startPolling').mockImplementation(() => {}); + + findRunButton().vm.$emit('click'); + await nextTick(); + + expect(findRunningAlert().exists()).toBe(true); + + const disabledAttr = findRunButton().attributes('disabled'); + expect(disabledAttr !== undefined).toBe(true); + }); + + it('makes the correct API call when run button is clicked', async () => { + // Prevent polling by mocking the startPolling method + jest.spyOn(wrapper.vm, 'startPolling').mockImplementation(() => {}); + + findRunButton().vm.$emit('click'); + await nextTick(); + await waitForPromises(); // Make sure the API call completes + + expect(mockAxios.history.post.length).toBeGreaterThan(0); + expect(mockAxios.history.post[0].url).toBe( + '/admin/database_diagnostics/run_collation_check.json', + ); + }); + + it('updates the view when results are available', async () => { + // First click the button - this starts polling + // We'll avoid real polling by mocking startPolling + jest.spyOn(wrapper.vm, 'startPolling').mockImplementation(() => {}); + + findRunButton().vm.$emit('click'); + await nextTick(); + + // Verify we're in loading state + expect(findRunningAlert().exists()).toBe(true); + + // Now change the mock to return results and manually update the component + mockAxios + .onGet('/admin/database_diagnostics/collation_check_results.json') + .reply(200, mockResults); + + // Set results directly instead of polling + wrapper.vm.results = mockResults; + wrapper.vm.isRunning = false; + await nextTick(); + + // Verify we've updated the UI + expect(findRunningAlert().exists()).toBe(false); + expect(findDatabaseMain().exists()).toBe(true); + expect(findDatabaseCI().exists()).toBe(true); + }); + }); + + describe('error handling', () => { + it('displays error alert when API request fails', async () => { + mockAxios.onGet('/admin/database_diagnostics/collation_check_results.json').reply(500, { + error: 'Internal server error', + }); + + createComponent(); + await waitForPromises(); + + expect(findErrorAlert().exists()).toBe(true); + expect(findErrorAlert().text()).toContain('Internal server error'); + }); + + it('displays error alert when run diagnostic request fails', async () => { + mockAxios.onGet('/admin/database_diagnostics/collation_check_results.json').reply(404); + createComponent(); + await waitForPromises(); + + // Set up mock for the button click + mockAxios.onPost('/admin/database_diagnostics/run_collation_check.json').reply(500, { + error: 'Failed to schedule diagnostics', + }); + + // Prevent polling + jest.spyOn(wrapper.vm, 'startPolling').mockImplementation(() => {}); + + findRunButton().vm.$emit('click'); + await waitForPromises(); + + expect(findErrorAlert().exists()).toBe(true); + expect(findErrorAlert().text()).toContain('Failed to schedule diagnostics'); + }); + }); +}); diff --git a/spec/policies/global_policy_spec.rb b/spec/policies/global_policy_spec.rb index 1685cc42b28d9d..1bb19c79b26d19 100644 --- a/spec/policies/global_policy_spec.rb +++ b/spec/policies/global_policy_spec.rb @@ -752,18 +752,19 @@ let(:permissions) do [ :access_admin_area, - :read_application_statistics, - :read_admin_users, - :read_admin_groups, - :read_admin_projects, :read_admin_audit_log, :read_admin_background_jobs, :read_admin_background_migrations, :read_admin_cicd, + :read_admin_database_diagnostics, :read_admin_gitaly_servers, + :read_admin_groups, :read_admin_health_check, :read_admin_metrics_dashboard, - :read_admin_system_information + :read_admin_projects, + :read_admin_system_information, + :read_admin_users, + :read_application_statistics ] end diff --git a/spec/requests/admin/database_diagnostics_controller_spec.rb b/spec/requests/admin/database_diagnostics_controller_spec.rb new file mode 100644 index 00000000000000..061a3b731f238c --- /dev/null +++ b/spec/requests/admin/database_diagnostics_controller_spec.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Admin::DatabaseDiagnostics', feature_category: :database do + include AdminModeHelper + + let_it_be(:admin) { create(:admin) } + let_it_be(:user) { create(:user) } + + shared_examples 'unauthorized request' do + context 'when user is not an admin' do + before do + login_as(user) + end + + it 'returns 404 response' do + send_request + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'when admin mode is disabled' do + before do + login_as(admin) + end + + it 'redirects to admin mode enable' do + send_request + + expect(response).to redirect_to(new_admin_session_path) + end + end + end + + describe 'GET /admin/database_diagnostics' do + subject(:send_request) do + get admin_database_diagnostics_path + end + + it_behaves_like 'unauthorized request' + + context 'when admin mode is enabled' do + before do + login_as(admin) + enable_admin_mode!(admin) + end + + it 'returns 200 response' do + send_request + + expect(response).to have_gitlab_http_status(:ok) + end + end + end + + describe 'POST /admin/database_diagnostics/run_collation_check' do + subject(:send_request) do + post run_collation_check_admin_database_diagnostics_path(format: :json) + end + + it_behaves_like 'unauthorized request' + + context 'when admin mode is enabled' do + before do + login_as(admin) + enable_admin_mode!(admin) + end + + it 'returns 200 response and schedules the worker' do + expect(::Database::CollationCheckerWorker).to receive(:perform_async).and_return('job_id') + + send_request + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to include('status' => 'scheduled', 'job_id' => 'job_id') + end + end + end + + describe 'GET /admin/database_diagnostics/collation_check_results' do + subject(:send_request) do + get collation_check_results_admin_database_diagnostics_path(format: :json) + end + + it_behaves_like 'unauthorized request' + + context 'when admin mode is enabled' do + before do + login_as(admin) + enable_admin_mode!(admin) + end + + context 'when results are available' do + let(:results) do + { + metadata: { last_run_at: Time.current.iso8601 }, + databases: { + main: { + collation_mismatches: [], + corrupted_indexes: [] + } + } + } + end + + it 'returns 200 response with the results' do + allow(Rails.cache).to receive(:read) + expect(Rails.cache).to receive(:read) + .with(::Database::CollationCheckerWorker::COLLATION_CHECK_CACHE_KEY) + .and_return(results.to_json) + + send_request + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to include('metadata', 'databases') + end + end + + context 'when no results are available' do + it 'returns 404 response' do + send_request + + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response).to include('error' => 'No results available yet') + end + end + end + end +end -- GitLab From 824577487a26f7f2d8a4db7b0a7983aff1f57bfe Mon Sep 17 00:00:00 2001 From: Bishwa Hang Rai Date: Wed, 23 Jul 2025 17:43:50 +0200 Subject: [PATCH 02/17] Add limit to polling --- .../components/collation_checker.vue | 18 ++++++++- locale/gitlab.pot | 3 ++ .../components/collation_checker_spec.js | 40 +++++++++++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/admin/database_diagnostics/components/collation_checker.vue b/app/assets/javascripts/admin/database_diagnostics/components/collation_checker.vue index 902e366a56c871..d19892c685fa61 100644 --- a/app/assets/javascripts/admin/database_diagnostics/components/collation_checker.vue +++ b/app/assets/javascripts/admin/database_diagnostics/components/collation_checker.vue @@ -35,6 +35,9 @@ const I18N = { checkingCache: s__('DatabaseDiagnostics|Checking for recent diagnostic results...'), noResults: s__('DatabaseDiagnostics|No results available yet.'), indexSize: s__('DatabaseDiagnostics|Size: %{size}'), + jobTakingTooLong: s__( + 'DatabaseDiagnostics|The database diagnostic job is taking longer than expected. You can check back later or try running it again.', + ), }; export default { @@ -67,6 +70,8 @@ export default { results: null, error: null, pollingId: null, + pollingAttempts: 0, + maxPollingAttempts: 60, // 5 minutes (60 * 5 seconds) }; }, computed: { @@ -152,6 +157,7 @@ export default { this.isLoading = true; this.isRunning = true; this.error = null; + this.pollingAttempts = 0; // Reset polling attempts counter try { await axios.post(this.runCollationCheckUrl); @@ -169,7 +175,7 @@ export default { startPolling() { this.pollingId = setInterval(() => { this.pollResults(); - }, 2000); // Poll every 2 seconds + }, 5000); // Poll every 5 seconds }, stopPolling() { @@ -188,10 +194,20 @@ export default { this.isLoading = false; this.isRunning = false; this.stopPolling(); + this.pollingAttempts = 0; // Reset counter on success } } catch (error) { // If it's a 404, the job is still running if (error.response && error.response.status === 404) { + this.pollingAttempts += 1; + + if (this.pollingAttempts >= this.maxPollingAttempts) { + this.stopPolling(); + this.isLoading = false; + this.isRunning = false; + this.error = this.$options.i18n.jobTakingTooLong; + this.pollingAttempts = 0; // Reset counter + } return; } diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 8a4ecf654d3c43..a914fafe7a7950 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -21278,6 +21278,9 @@ msgstr "" msgid "DatabaseDiagnostics|Size: %{size}" msgstr "" +msgid "DatabaseDiagnostics|The database diagnostic job is taking longer than expected. You can check back later or try running it again." +msgstr "" + msgid "DatabaseDiagnostics|These issues require manual remediation. Read our documentation on PostgreSQL OS upgrades for step-by-step instructions." msgstr "" diff --git a/spec/frontend/admin/database_diagnostics/components/collation_checker_spec.js b/spec/frontend/admin/database_diagnostics/components/collation_checker_spec.js index 0058eb865bfd8f..64f03658ae7f31 100644 --- a/spec/frontend/admin/database_diagnostics/components/collation_checker_spec.js +++ b/spec/frontend/admin/database_diagnostics/components/collation_checker_spec.js @@ -278,4 +278,44 @@ describe('CollationChecker component', () => { expect(findErrorAlert().text()).toContain('Failed to schedule diagnostics'); }); }); + + describe('polling', () => { + it('has the correct polling interval and attempt limit', () => { + createComponent(); + expect(wrapper.vm.maxPollingAttempts).toBe(60); // Verify attempt limit for 5 minutes of polling + }); + + it('stops polling after reaching the maximum attempts', async () => { + // Mock API responses + mockAxios.onGet('/admin/database_diagnostics/collation_check_results.json').reply(404); + mockAxios.onPost('/admin/database_diagnostics/run_collation_check.json').reply(200); + + createComponent(); + await waitForPromises(); + + // Set up spies before any actions + const stopPollingSpy = jest.spyOn(wrapper.vm, 'stopPolling'); + + // Run diagnostics to initialize the state + await wrapper.vm.runDatabaseDiagnostics(); + + // Reset the spy count after initialization + stopPollingSpy.mockClear(); + + // Directly set max attempts and trigger poll + wrapper.vm.pollingAttempts = wrapper.vm.maxPollingAttempts; + + // Mock the 404 response for the polling + mockAxios.onGet('/admin/database_diagnostics/collation_check_results.json').reply(404); + + // Call the method we're testing + await wrapper.vm.pollResults(); + + // Verify the expected behavior + expect(stopPollingSpy).toHaveBeenCalled(); + expect(wrapper.vm.isRunning).toBe(false); + expect(wrapper.vm.isLoading).toBe(false); + expect(wrapper.vm.error).toEqual(expect.stringContaining('taking longer than expected')); + }); + }); }); -- GitLab From 291d7a411adf893df34d01b4abcf876af16deb73 Mon Sep 17 00:00:00 2001 From: Bishwa Hang Rai Date: Wed, 23 Jul 2025 21:03:39 +0200 Subject: [PATCH 03/17] Remove webpack_bundle_tag from index.html.haml --- app/controllers/admin/database_diagnostics_controller.rb | 4 +--- app/views/admin/database_diagnostics/index.html.haml | 3 --- .../admin/database_diagnostics_controller_spec.rb | 9 +++------ 3 files changed, 4 insertions(+), 12 deletions(-) diff --git a/app/controllers/admin/database_diagnostics_controller.rb b/app/controllers/admin/database_diagnostics_controller.rb index 8b2c080756ab0c..d7460f35f4dca7 100644 --- a/app/controllers/admin/database_diagnostics_controller.rb +++ b/app/controllers/admin/database_diagnostics_controller.rb @@ -12,9 +12,7 @@ def index def run_collation_check job_id = ::Database::CollationCheckerWorker.perform_async # rubocop:disable CodeReuse/Worker -- Simple direct call - respond_to do |format| - format.json { render json: { status: 'scheduled', job_id: job_id } } - end + render json: { status: 'scheduled', job_id: job_id } end def collation_check_results diff --git a/app/views/admin/database_diagnostics/index.html.haml b/app/views/admin/database_diagnostics/index.html.haml index d04dcb3053bf8d..221390c5805a84 100644 --- a/app/views/admin/database_diagnostics/index.html.haml +++ b/app/views/admin/database_diagnostics/index.html.haml @@ -1,8 +1,5 @@ - page_title _('Database Diagnostics') -- content_for :page_specific_javascripts do - = webpack_bundle_tag 'pages/admin/database_diagnostics/index' - %section.settings.monitoring .settings-header %h1 diff --git a/spec/requests/admin/database_diagnostics_controller_spec.rb b/spec/requests/admin/database_diagnostics_controller_spec.rb index 061a3b731f238c..871c4bdf2f4ce8 100644 --- a/spec/requests/admin/database_diagnostics_controller_spec.rb +++ b/spec/requests/admin/database_diagnostics_controller_spec.rb @@ -41,10 +41,9 @@ it_behaves_like 'unauthorized request' - context 'when admin mode is enabled' do + context 'when admin mode is enabled', :enable_admin_mode do before do login_as(admin) - enable_admin_mode!(admin) end it 'returns 200 response' do @@ -62,10 +61,9 @@ it_behaves_like 'unauthorized request' - context 'when admin mode is enabled' do + context 'when admin mode is enabled', :enable_admin_mode do before do login_as(admin) - enable_admin_mode!(admin) end it 'returns 200 response and schedules the worker' do @@ -86,10 +84,9 @@ it_behaves_like 'unauthorized request' - context 'when admin mode is enabled' do + context 'when admin mode is enabled', :enable_admin_mode do before do login_as(admin) - enable_admin_mode!(admin) end context 'when results are available' do -- GitLab From e0170c6465fc4f7ba2ef66193f21dc31e41fc8de Mon Sep 17 00:00:00 2001 From: Bishwa Hang Rai Date: Fri, 25 Jul 2025 22:48:08 +0200 Subject: [PATCH 04/17] Improve CollationChecker component testing Improves test coverage while better simulating real user behavior --- .../components/collation_checker.vue | 100 +++++------ .../components/collation_checker_spec.js | 161 +++++++++--------- 2 files changed, 130 insertions(+), 131 deletions(-) diff --git a/app/assets/javascripts/admin/database_diagnostics/components/collation_checker.vue b/app/assets/javascripts/admin/database_diagnostics/components/collation_checker.vue index d19892c685fa61..f93cead498c511 100644 --- a/app/assets/javascripts/admin/database_diagnostics/components/collation_checker.vue +++ b/app/assets/javascripts/admin/database_diagnostics/components/collation_checker.vue @@ -7,6 +7,9 @@ import { helpPagePath } from '~/helpers/help_page_helper'; import { bytes } from '~/lib/utils/unit_format'; import { SUPPORT_URL } from '~/sessions/new/constants'; +export const POLLING_INTERVAL_MS = 5000; // 5 seconds +export const MAX_POLLING_ATTEMPTS = 60; // 5 minutes total (60 × 5 seconds) + const I18N = { title: s__('DatabaseDiagnostics|Collation Health Check'), description: s__( @@ -43,6 +46,22 @@ const I18N = { export default { name: 'CollationChecker', i18n: I18N, + supportUrl: SUPPORT_URL, + collationMismatchFields: [ + { key: 'collation_name', label: __('Collation Name') }, + { key: 'provider', label: __('Provider') }, + { key: 'stored_version', label: __('Stored Version') }, + { key: 'actual_version', label: __('Actual Version') }, + ], + corruptedIndexesFields: [ + { key: 'index_name', label: __('Index Name') }, + { key: 'table_name', label: __('Table') }, + { key: 'affected_columns', label: __('Affected Columns') }, + { key: 'index_type', label: __('Type') }, + { key: 'is_unique', label: __('Unique') }, + { key: 'size', label: __('Size') }, + { key: 'corruption_types', label: __('Issues') }, + ], components: { GlAlert, GlButton, @@ -61,6 +80,16 @@ export default { type: String, required: true, }, + pollingIntervalMs: { + type: Number, + required: false, + default: POLLING_INTERVAL_MS, + }, + maxPollingAttempts: { + type: Number, + required: false, + default: MAX_POLLING_ATTEMPTS, + }, }, data() { return { @@ -71,12 +100,11 @@ export default { error: null, pollingId: null, pollingAttempts: 0, - maxPollingAttempts: 60, // 5 minutes (60 * 5 seconds) }; }, computed: { formattedLastRunAt() { - if (!this.results || !this.results.metadata || !this.results.metadata.last_run_at) { + if (!this.results?.metadata?.last_run_at) { return ''; } return formatDate(new Date(this.results.metadata.last_run_at)); @@ -85,40 +113,15 @@ export default { return this.results !== null && this.results.databases; }, hasIssues() { - if (!this.results || !this.results.databases) return false; + if (!this.results?.databases) return false; - return Object.values(this.results.databases).some( - (db) => db.corrupted_indexes && db.corrupted_indexes.length > 0, - ); + return Object.values(this.results.databases).some((db) => db.corrupted_indexes?.length > 0); }, documentationUrl() { return helpPagePath('administration/postgresql/upgrading_os'); }, - supportUrl() { - return SUPPORT_URL; - }, - collationMismatchFields() { - return [ - { key: 'collation_name', label: __('Collation Name') }, - { key: 'provider', label: __('Provider') }, - { key: 'stored_version', label: __('Stored Version') }, - { key: 'actual_version', label: __('Actual Version') }, - ]; - }, - corruptedIndexesFields() { - return [ - { key: 'index_name', label: __('Index Name') }, - { key: 'table_name', label: __('Table') }, - { key: 'affected_columns', label: __('Affected Columns') }, - { key: 'index_type', label: __('Type') }, - { key: 'is_unique', label: __('Unique') }, - { key: 'size', label: __('Size') }, - { key: 'corruption_types', label: __('Issues') }, - ]; - }, }, created() { - // Try to fetch results on component creation this.fetchResults(); }, beforeDestroy() { @@ -138,14 +141,11 @@ export default { this.results = data; } } catch (error) { - if (error.response && error.response.status === 404) { - // No results yet is an expected condition + if (error.response?.status === 404) { this.error = null; } else { this.error = - error.response && error.response.data && error.response.data.error - ? error.response.data.error - : __('An error occurred while fetching results'); + error.response?.data?.error ?? __('An error occurred while fetching results'); } } finally { this.isLoading = false; @@ -157,7 +157,6 @@ export default { this.isLoading = true; this.isRunning = true; this.error = null; - this.pollingAttempts = 0; // Reset polling attempts counter try { await axios.post(this.runCollationCheckUrl); @@ -166,16 +165,16 @@ export default { this.isLoading = false; this.isRunning = false; this.error = - error.response && error.response.data && error.response.data.error - ? error.response.data.error - : __('An error occurred while starting diagnostics'); + error.response?.data?.error ?? __('An error occurred while starting diagnostics'); } }, startPolling() { + this.stopPolling(); + this.pollingId = setInterval(() => { this.pollResults(); - }, 5000); // Poll every 5 seconds + }, this.pollingIntervalMs); }, stopPolling() { @@ -183,22 +182,21 @@ export default { clearInterval(this.pollingId); this.pollingId = null; } + this.pollingAttempts = 0; }, async pollResults() { try { const { data } = await axios.get(this.collationCheckResultsUrl); - if (data && data.databases) { + if (data?.databases) { this.results = data; this.isLoading = false; this.isRunning = false; this.stopPolling(); - this.pollingAttempts = 0; // Reset counter on success } } catch (error) { - // If it's a 404, the job is still running - if (error.response && error.response.status === 404) { + if (error.response?.status === 404) { this.pollingAttempts += 1; if (this.pollingAttempts >= this.maxPollingAttempts) { @@ -206,7 +204,6 @@ export default { this.isLoading = false; this.isRunning = false; this.error = this.$options.i18n.jobTakingTooLong; - this.pollingAttempts = 0; // Reset counter } return; } @@ -214,19 +211,16 @@ export default { this.isLoading = false; this.isRunning = false; this.stopPolling(); - this.error = - error.response && error.response.data && error.response.data.error - ? error.response.data.error - : __('An error occurred while fetching results'); + this.error = error.response?.data?.error ?? __('An error occurred while fetching results'); } }, hasMismatches(dbResults) { - return dbResults.collation_mismatches && dbResults.collation_mismatches.length > 0; + return dbResults.collation_mismatches?.length > 0; }, hasCorruptedIndexes(dbResults) { - return dbResults.corrupted_indexes && dbResults.corrupted_indexes.length > 0; + return dbResults.corrupted_indexes?.length > 0; }, formatBytes(byteSize) { @@ -313,7 +307,7 @@ export default { { let wrapper; let mockAxios; + // Set shorter polling values for tests to keep them fast + const TEST_POLLING_INTERVAL_MS = 50; + const TEST_MAX_POLLING_ATTEMPTS = 2; + const findTitle = () => wrapper.findByTestId('title'); const findRunButton = () => wrapper.findByTestId('run-diagnostics-button'); const findLoadingAlert = () => wrapper.findByTestId('loading-alert'); @@ -38,11 +45,18 @@ describe('CollationChecker component', () => { propsData: { runCollationCheckUrl: '/admin/database_diagnostics/run_collation_check.json', collationCheckResultsUrl: '/admin/database_diagnostics/collation_check_results.json', + pollingIntervalMs: TEST_POLLING_INTERVAL_MS, + maxPollingAttempts: TEST_MAX_POLLING_ATTEMPTS, ...props, }, }); }; + const clickRunButton = async () => { + findRunButton().vm.$emit('click'); + await nextTick(); + }; + const mockResults = { metadata: { last_run_at: '2025-07-23T10:00:00Z', @@ -90,31 +104,42 @@ describe('CollationChecker component', () => { }; beforeEach(() => { + jest.useFakeTimers(); mockAxios = new MockAdapter(axios); }); afterEach(() => { mockAxios.restore(); + jest.clearAllTimers(); }); - describe('initial state', () => { - beforeEach(() => { - mockAxios.onGet('/admin/database_diagnostics/collation_check_results.json').reply(404, { - error: 'No results available yet', - }); - createComponent(); - }); - - it('renders the title', () => { - expect(findTitle().exists()).toBe(true); + describe('constants', () => { + it('exports polling constants for reuse', () => { + expect(POLLING_INTERVAL_MS).toBe(5000); + expect(MAX_POLLING_ATTEMPTS).toBe(60); }); + }); + describe('initial state', () => { it('shows a loading indicator initially', () => { + mockAxios.onGet('/admin/database_diagnostics/collation_check_results.json').reply(404); + createComponent(); + expect(findLoadingAlert().exists()).toBe(true); expect(findLoadingAlert().findComponent(GlLoadingIcon).exists()).toBe(true); }); + it('renders the title', async () => { + mockAxios.onGet('/admin/database_diagnostics/collation_check_results.json').reply(404); + createComponent(); + await waitForPromises(); + + expect(findTitle().exists()).toBe(true); + }); + it('shows no results message after loading', async () => { + mockAxios.onGet('/admin/database_diagnostics/collation_check_results.json').reply(404); + createComponent(); await waitForPromises(); expect(findLoadingAlert().exists()).toBe(false); @@ -122,9 +147,14 @@ describe('CollationChecker component', () => { }); it('enables the run button after loading completes', async () => { + mockAxios.onGet('/admin/database_diagnostics/collation_check_results.json').reply(404); + createComponent(); + + expect(findRunButton().props('disabled')).toBe(true); + await waitForPromises(); - expect(findRunButton().attributes('disabled')).toBeUndefined(); + expect(findRunButton().props('disabled')).toBe(false); }); }); @@ -180,73 +210,53 @@ describe('CollationChecker component', () => { beforeEach(async () => { // Initial load - no results mockAxios.onGet('/admin/database_diagnostics/collation_check_results.json').reply(404); + mockAxios.onPost('/admin/database_diagnostics/run_collation_check.json').reply(200); createComponent(); await waitForPromises(); - - // Reset mock history - mockAxios.reset(); - - // Set up for the run request - mockAxios.onPost('/admin/database_diagnostics/run_collation_check.json').reply(200); }); - it('shows loading state when run button is clicked', async () => { - // Prevent polling by mocking the startPolling method - jest.spyOn(wrapper.vm, 'startPolling').mockImplementation(() => {}); + it('shows loading state and disables button when run button is clicked', async () => { + await clickRunButton(); - findRunButton().vm.$emit('click'); - await nextTick(); + expect(findRunButton().props('disabled')).toBe(true); - expect(findRunningAlert().exists()).toBe(true); + await waitForPromises(); - const disabledAttr = findRunButton().attributes('disabled'); - expect(disabledAttr !== undefined).toBe(true); + expect(findRunningAlert().exists()).toBe(true); + expect(findRunButton().props('disabled')).toBe(true); }); it('makes the correct API call when run button is clicked', async () => { - // Prevent polling by mocking the startPolling method - jest.spyOn(wrapper.vm, 'startPolling').mockImplementation(() => {}); - - findRunButton().vm.$emit('click'); - await nextTick(); - await waitForPromises(); // Make sure the API call completes + await clickRunButton(); + await waitForPromises(); - expect(mockAxios.history.post.length).toBeGreaterThan(0); + expect(mockAxios.history.post).toHaveLength(1); expect(mockAxios.history.post[0].url).toBe( '/admin/database_diagnostics/run_collation_check.json', ); }); - it('updates the view when results are available', async () => { - // First click the button - this starts polling - // We'll avoid real polling by mocking startPolling - jest.spyOn(wrapper.vm, 'startPolling').mockImplementation(() => {}); - - findRunButton().vm.$emit('click'); - await nextTick(); + it('updates the view when results are available after polling', async () => { + await clickRunButton(); + await waitForPromises(); - // Verify we're in loading state expect(findRunningAlert().exists()).toBe(true); - // Now change the mock to return results and manually update the component mockAxios .onGet('/admin/database_diagnostics/collation_check_results.json') .reply(200, mockResults); - // Set results directly instead of polling - wrapper.vm.results = mockResults; - wrapper.vm.isRunning = false; + jest.advanceTimersByTime(TEST_POLLING_INTERVAL_MS); await nextTick(); + await waitForPromises(); - // Verify we've updated the UI expect(findRunningAlert().exists()).toBe(false); expect(findDatabaseMain().exists()).toBe(true); - expect(findDatabaseCI().exists()).toBe(true); }); }); describe('error handling', () => { - it('displays error alert when API request fails', async () => { + it('displays error alert when initial API request fails', async () => { mockAxios.onGet('/admin/database_diagnostics/collation_check_results.json').reply(500, { error: 'Internal server error', }); @@ -260,18 +270,14 @@ describe('CollationChecker component', () => { it('displays error alert when run diagnostic request fails', async () => { mockAxios.onGet('/admin/database_diagnostics/collation_check_results.json').reply(404); - createComponent(); - await waitForPromises(); - - // Set up mock for the button click mockAxios.onPost('/admin/database_diagnostics/run_collation_check.json').reply(500, { error: 'Failed to schedule diagnostics', }); - // Prevent polling - jest.spyOn(wrapper.vm, 'startPolling').mockImplementation(() => {}); + createComponent(); + await waitForPromises(); - findRunButton().vm.$emit('click'); + await clickRunButton(); await waitForPromises(); expect(findErrorAlert().exists()).toBe(true); @@ -280,42 +286,41 @@ describe('CollationChecker component', () => { }); describe('polling', () => { - it('has the correct polling interval and attempt limit', () => { - createComponent(); - expect(wrapper.vm.maxPollingAttempts).toBe(60); // Verify attempt limit for 5 minutes of polling - }); - it('stops polling after reaching the maximum attempts', async () => { - // Mock API responses + // Set up API mocks mockAxios.onGet('/admin/database_diagnostics/collation_check_results.json').reply(404); mockAxios.onPost('/admin/database_diagnostics/run_collation_check.json').reply(200); createComponent(); await waitForPromises(); - // Set up spies before any actions - const stopPollingSpy = jest.spyOn(wrapper.vm, 'stopPolling'); + // Use our custom helper to simulate user clicking the button + await clickRunButton(); + await waitForPromises(); - // Run diagnostics to initialize the state - await wrapper.vm.runDatabaseDiagnostics(); + // Verify initial state + expect(findRunningAlert().exists()).toBe(true); - // Reset the spy count after initialization - stopPollingSpy.mockClear(); + // Advance timers to simulate reaching max attempts + // Refactored to avoid linting errors with await in loops and ++ operator + const advanceAndWait = async (totalAttempts) => { + let attemptsLeft = totalAttempts; - // Directly set max attempts and trigger poll - wrapper.vm.pollingAttempts = wrapper.vm.maxPollingAttempts; + while (attemptsLeft > 0) { + jest.advanceTimersByTime(TEST_POLLING_INTERVAL_MS); + attemptsLeft -= 1; + } - // Mock the 404 response for the polling - mockAxios.onGet('/admin/database_diagnostics/collation_check_results.json').reply(404); + await nextTick(); + await waitForPromises(); + }; - // Call the method we're testing - await wrapper.vm.pollResults(); + await advanceAndWait(TEST_MAX_POLLING_ATTEMPTS); - // Verify the expected behavior - expect(stopPollingSpy).toHaveBeenCalled(); - expect(wrapper.vm.isRunning).toBe(false); - expect(wrapper.vm.isLoading).toBe(false); - expect(wrapper.vm.error).toEqual(expect.stringContaining('taking longer than expected')); + // Verify polling has stopped and error is shown + expect(findRunningAlert().exists()).toBe(false); + expect(findErrorAlert().exists()).toBe(true); + expect(findErrorAlert().text()).toContain('taking longer than expected'); }); }); }); -- GitLab From 512f5f464c833e8ec4e6783801d09abc4f1e7635 Mon Sep 17 00:00:00 2001 From: Bishwa Hang Rai Date: Mon, 28 Jul 2025 13:41:07 +0200 Subject: [PATCH 05/17] Refactor specs --- .../components/collation_checker.vue | 5 ++- .../components/collation_checker_spec.js | 44 ++++--------------- 2 files changed, 13 insertions(+), 36 deletions(-) diff --git a/app/assets/javascripts/admin/database_diagnostics/components/collation_checker.vue b/app/assets/javascripts/admin/database_diagnostics/components/collation_checker.vue index f93cead498c511..02c38d34179680 100644 --- a/app/assets/javascripts/admin/database_diagnostics/components/collation_checker.vue +++ b/app/assets/javascripts/admin/database_diagnostics/components/collation_checker.vue @@ -120,6 +120,9 @@ export default { documentationUrl() { return helpPagePath('administration/postgresql/upgrading_os'); }, + shouldShowNoResultsMessage() { + return !this.hasResults && !this.isLoading && !this.isRunning && !this.isInitialLoad; + }, }, created() { this.fetchResults(); @@ -431,7 +434,7 @@ export default { { let wrapper; @@ -57,7 +54,7 @@ describe('CollationChecker component', () => { await nextTick(); }; - const mockResults = { + const collationMismatchResults = { metadata: { last_run_at: '2025-07-23T10:00:00Z', }, @@ -113,25 +110,18 @@ describe('CollationChecker component', () => { jest.clearAllTimers(); }); - describe('constants', () => { - it('exports polling constants for reuse', () => { - expect(POLLING_INTERVAL_MS).toBe(5000); - expect(MAX_POLLING_ATTEMPTS).toBe(60); - }); - }); - describe('initial state', () => { - it('shows a loading indicator initially', () => { + beforeEach(() => { mockAxios.onGet('/admin/database_diagnostics/collation_check_results.json').reply(404); createComponent(); + }); + it('shows a loading indicator initially', () => { expect(findLoadingAlert().exists()).toBe(true); expect(findLoadingAlert().findComponent(GlLoadingIcon).exists()).toBe(true); }); it('renders the title', async () => { - mockAxios.onGet('/admin/database_diagnostics/collation_check_results.json').reply(404); - createComponent(); await waitForPromises(); expect(findTitle().exists()).toBe(true); @@ -147,9 +137,6 @@ describe('CollationChecker component', () => { }); it('enables the run button after loading completes', async () => { - mockAxios.onGet('/admin/database_diagnostics/collation_check_results.json').reply(404); - createComponent(); - expect(findRunButton().props('disabled')).toBe(true); await waitForPromises(); @@ -162,7 +149,7 @@ describe('CollationChecker component', () => { beforeEach(async () => { mockAxios .onGet('/admin/database_diagnostics/collation_check_results.json') - .reply(200, mockResults); + .reply(200, collationMismatchResults); createComponent(); await waitForPromises(); }); @@ -244,7 +231,7 @@ describe('CollationChecker component', () => { mockAxios .onGet('/admin/database_diagnostics/collation_check_results.json') - .reply(200, mockResults); + .reply(200, collationMismatchResults); jest.advanceTimersByTime(TEST_POLLING_INTERVAL_MS); await nextTick(); @@ -301,21 +288,8 @@ describe('CollationChecker component', () => { // Verify initial state expect(findRunningAlert().exists()).toBe(true); - // Advance timers to simulate reaching max attempts - // Refactored to avoid linting errors with await in loops and ++ operator - const advanceAndWait = async (totalAttempts) => { - let attemptsLeft = totalAttempts; - - while (attemptsLeft > 0) { - jest.advanceTimersByTime(TEST_POLLING_INTERVAL_MS); - attemptsLeft -= 1; - } - - await nextTick(); - await waitForPromises(); - }; - - await advanceAndWait(TEST_MAX_POLLING_ATTEMPTS); + await jest.advanceTimersByTime(TEST_POLLING_INTERVAL_MS * TEST_MAX_POLLING_ATTEMPTS); + await waitForPromises(); // Verify polling has stopped and error is shown expect(findRunningAlert().exists()).toBe(false); -- GitLab From 9b5f80234a8ef8e17123a7c46def2c2ced536ab7 Mon Sep 17 00:00:00 2001 From: Bishwa Hang Rai Date: Wed, 16 Jul 2025 22:13:02 +0200 Subject: [PATCH 06/17] Add Database Diagnostics monitoring page Adds a new admin page for database diagnostics. It will show collation mismatches and any corrupted indexes related to collation. Changelog: added --- .../components/collation_checker.vue | 432 ++++++++++++++++++ .../admin/database_diagnostics/index.js | 24 + .../pages/admin/database_diagnostics/index.js | 3 + .../admin/database_diagnostics_controller.rb | 31 ++ app/policies/global_policy.rb | 1 + .../database_diagnostics/index.html.haml | 18 + config/routes/admin.rb | 6 + lib/sidebars/admin/menus/monitoring_menu.rb | 10 + locale/gitlab.pot | 105 +++++ .../components/collation_checker_spec.js | 281 ++++++++++++ spec/policies/global_policy_spec.rb | 11 +- .../database_diagnostics_controller_spec.rb | 131 ++++++ 12 files changed, 1048 insertions(+), 5 deletions(-) create mode 100644 app/assets/javascripts/admin/database_diagnostics/components/collation_checker.vue create mode 100644 app/assets/javascripts/admin/database_diagnostics/index.js create mode 100644 app/assets/javascripts/pages/admin/database_diagnostics/index.js create mode 100644 app/controllers/admin/database_diagnostics_controller.rb create mode 100644 app/views/admin/database_diagnostics/index.html.haml create mode 100644 spec/frontend/admin/database_diagnostics/components/collation_checker_spec.js create mode 100644 spec/requests/admin/database_diagnostics_controller_spec.rb diff --git a/app/assets/javascripts/admin/database_diagnostics/components/collation_checker.vue b/app/assets/javascripts/admin/database_diagnostics/components/collation_checker.vue new file mode 100644 index 00000000000000..902e366a56c871 --- /dev/null +++ b/app/assets/javascripts/admin/database_diagnostics/components/collation_checker.vue @@ -0,0 +1,432 @@ + + + diff --git a/app/assets/javascripts/admin/database_diagnostics/index.js b/app/assets/javascripts/admin/database_diagnostics/index.js new file mode 100644 index 00000000000000..9696ba5cc45dab --- /dev/null +++ b/app/assets/javascripts/admin/database_diagnostics/index.js @@ -0,0 +1,24 @@ +import Vue from 'vue'; +import CollationChecker from './components/collation_checker.vue'; + +export const initDatabaseDiagnosticsApp = () => { + const el = document.getElementById('js-database-diagnostics'); + + if (!el) { + return false; + } + + const { runCollationCheckUrl, collationCheckResultsUrl } = el.dataset; + + return new Vue({ + el, + render(h) { + return h(CollationChecker, { + props: { + runCollationCheckUrl, + collationCheckResultsUrl, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/pages/admin/database_diagnostics/index.js b/app/assets/javascripts/pages/admin/database_diagnostics/index.js new file mode 100644 index 00000000000000..9a35ceb8b5d028 --- /dev/null +++ b/app/assets/javascripts/pages/admin/database_diagnostics/index.js @@ -0,0 +1,3 @@ +import { initDatabaseDiagnosticsApp } from '~/admin/database_diagnostics'; + +initDatabaseDiagnosticsApp(); diff --git a/app/controllers/admin/database_diagnostics_controller.rb b/app/controllers/admin/database_diagnostics_controller.rb new file mode 100644 index 00000000000000..8b2c080756ab0c --- /dev/null +++ b/app/controllers/admin/database_diagnostics_controller.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Admin + class DatabaseDiagnosticsController < Admin::ApplicationController + feature_category :database + authorize! :read_admin_database_diagnostics, only: %i[index run_collation_check collation_check_results] + + def index + # Just render the view + end + + def run_collation_check + job_id = ::Database::CollationCheckerWorker.perform_async # rubocop:disable CodeReuse/Worker -- Simple direct call + + respond_to do |format| + format.json { render json: { status: 'scheduled', job_id: job_id } } + end + end + + def collation_check_results + results_json = Rails.cache.read(::Database::CollationCheckerWorker::COLLATION_CHECK_CACHE_KEY) + + if results_json.present? + results = Gitlab::Json.parse(results_json) + render json: results + else + render json: { error: 'No results available yet' }, status: :not_found + end + end + end +end diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb index 9430882e93a01b..2bd723dfa05e40 100644 --- a/app/policies/global_policy.rb +++ b/app/policies/global_policy.rb @@ -159,6 +159,7 @@ class GlobalPolicy < BasePolicy enable :read_admin_cicd enable :read_admin_gitaly_servers enable :read_admin_health_check + enable :read_admin_database_diagnostics enable :read_admin_metrics_dashboard enable :read_admin_system_information enable :read_admin_users diff --git a/app/views/admin/database_diagnostics/index.html.haml b/app/views/admin/database_diagnostics/index.html.haml new file mode 100644 index 00000000000000..d04dcb3053bf8d --- /dev/null +++ b/app/views/admin/database_diagnostics/index.html.haml @@ -0,0 +1,18 @@ +- page_title _('Database Diagnostics') + +- content_for :page_specific_javascripts do + = webpack_bundle_tag 'pages/admin/database_diagnostics/index' + +%section.settings.monitoring + .settings-header + %h1 + = _('Database Diagnostics') + %p + = _('Tools for detecting and resolving database issues in self-managed GitLab instances.') + +#js-database-diagnostics{ + data: { + run_collation_check_url: run_collation_check_admin_database_diagnostics_path(format: :json), + collation_check_results_url: collation_check_results_admin_database_diagnostics_path(format: :json) + } +} diff --git a/config/routes/admin.rb b/config/routes/admin.rb index 7b74b6609e5554..81e89b008b81ac 100644 --- a/config/routes/admin.rb +++ b/config/routes/admin.rb @@ -120,6 +120,12 @@ resource :health_check, controller: 'health_check', only: [:show] resource :background_jobs, controller: 'background_jobs', only: [:show] + resources :database_diagnostics, controller: 'database_diagnostics', only: [:index] do + collection do + post :run_collation_check + get :collation_check_results + end + end resource :system_info, controller: 'system_info', only: [:show] diff --git a/lib/sidebars/admin/menus/monitoring_menu.rb b/lib/sidebars/admin/menus/monitoring_menu.rb index 94f8bf5daeb5aa..d361dc620e4a3d 100644 --- a/lib/sidebars/admin/menus/monitoring_menu.rb +++ b/lib/sidebars/admin/menus/monitoring_menu.rb @@ -10,6 +10,7 @@ def configure_menu_items add_item(background_migrations_menu_item) add_item(background_jobs_menu_item) add_item(health_check_menu_item) + add_item(database_diagnostics_menu_item) add_item(metrics_dashboard_menu_item) true end @@ -67,6 +68,15 @@ def health_check_menu_item ) { can?(current_user, :read_admin_health_check) } end + def database_diagnostics_menu_item + build_menu_item( + title: _('Database diagnostics'), + link: admin_database_diagnostics_path, + active_routes: { controller: 'database_diagnostics' }, + item_id: :database_diagnostics + ) { can?(current_user, :read_admin_database_diagnostics) } + end + def metrics_dashboard_menu_item build_menu_item( title: _('Metrics Dashboard'), diff --git a/locale/gitlab.pot b/locale/gitlab.pot index bffbc7381cead2..18d6c890d29c0b 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -3685,6 +3685,9 @@ msgstr "" msgid "Activity|An error occurred while retrieving activity. Reload the page to try again." msgstr "" +msgid "Actual Version" +msgstr "" + msgid "Add" msgstr "" @@ -5936,6 +5939,9 @@ msgstr "" msgid "AdvancedSearch|You have pending obsolete migrations" msgstr "" +msgid "Affected Columns" +msgstr "" + msgid "After it is removed, the fork relationship can only be restored by using the API. This project will no longer be able to receive or send merge requests to the upstream project or other forks." msgstr "" @@ -7428,6 +7434,9 @@ msgstr "" msgid "An error occurred while fetching reference" msgstr "" +msgid "An error occurred while fetching results" +msgstr "" + msgid "An error occurred while fetching reviewers." msgstr "" @@ -7584,6 +7593,9 @@ msgstr "" msgid "An error occurred while searching for labels, please try again." msgstr "" +msgid "An error occurred while starting diagnostics" +msgstr "" + msgid "An error occurred while subscribing to this page. Please try again later." msgstr "" @@ -15266,6 +15278,12 @@ msgstr "" msgid "Collapse sidebar" msgstr "" +msgid "Collation Mismatches" +msgstr "" + +msgid "Collation Name" +msgstr "" + msgid "Collector hostname" msgstr "" @@ -18903,6 +18921,9 @@ msgstr "" msgid "Correlation ID" msgstr "" +msgid "Corrupted Indexes" +msgstr "" + msgid "Cost Factor Settings" msgstr "" @@ -21250,9 +21271,66 @@ msgstr "" msgid "Database '%{database_name}' is using PostgreSQL %{pg_version_current}, but this version of GitLab requires PostgreSQL %{pg_version_minimum}. Please upgrade your environment to a supported PostgreSQL version. See %{pg_requirements_url} for details." msgstr "" +msgid "Database Diagnostics" +msgstr "" + +msgid "Database diagnostics" +msgstr "" + msgid "Database update failed" msgstr "" +msgid "DatabaseDiagnostics|Checking for recent diagnostic results..." +msgstr "" + +msgid "DatabaseDiagnostics|Collation Health Check" +msgstr "" + +msgid "DatabaseDiagnostics|Collation mismatches are shown for informational purposes and may not indicate a problem." +msgstr "" + +msgid "DatabaseDiagnostics|Contact Support" +msgstr "" + +msgid "DatabaseDiagnostics|Database: %{name}" +msgstr "" + +msgid "DatabaseDiagnostics|Detect collation-related index corruption issues that may occur after OS upgrades" +msgstr "" + +msgid "DatabaseDiagnostics|Issues detected" +msgstr "" + +msgid "DatabaseDiagnostics|Last checked: %{timestamp}" +msgstr "" + +msgid "DatabaseDiagnostics|Learn more" +msgstr "" + +msgid "DatabaseDiagnostics|No database issues detected" +msgstr "" + +msgid "DatabaseDiagnostics|No diagnostics have been run yet. Click \"Run Collation Check\" to analyze your database for potential collation issues." +msgstr "" + +msgid "DatabaseDiagnostics|No results available yet." +msgstr "" + +msgid "DatabaseDiagnostics|Run Collation Check" +msgstr "" + +msgid "DatabaseDiagnostics|Running diagnostics..." +msgstr "" + +msgid "DatabaseDiagnostics|Size: %{size}" +msgstr "" + +msgid "DatabaseDiagnostics|These issues require manual remediation. Read our documentation on PostgreSQL OS upgrades for step-by-step instructions." +msgstr "" + +msgid "DatabaseDiagnostics|Your database indexes appear to be healthy and free of collation-related corruption." +msgstr "" + msgid "DatadogIntegration|%{link_start}API key%{link_end} used for authentication with Datadog." msgstr "" @@ -24367,6 +24445,9 @@ msgstr "" msgid "Duplicate page: A page with that title already exists in the file %{file}" msgstr "" +msgid "Duplicates" +msgstr "" + msgid "Duration" msgstr "" @@ -33535,6 +33616,9 @@ msgstr "" msgid "Index" msgstr "" +msgid "Index Name" +msgstr "" + msgid "Index deletion is canceled" msgstr "" @@ -41696,6 +41780,9 @@ msgstr "" msgid "No child items are currently assigned." msgstr "" +msgid "No collation mismatches detected." +msgstr "" + msgid "No comment templates found." msgstr "" @@ -41726,6 +41813,9 @@ msgstr "" msgid "No contributions were found" msgstr "" +msgid "No corrupted indexes detected." +msgstr "" + msgid "No credit card data for matching" msgstr "" @@ -61472,12 +61562,18 @@ msgstr "" msgid "StorageSize|Unknown" msgstr "" +msgid "Stored Version" +msgstr "" + msgid "Strikethrough (%{modifierKey}%{shiftKey}X)" msgstr "" msgid "Strikethrough text" msgstr "" +msgid "Structural" +msgstr "" + msgid "Sub-batch size" msgstr "" @@ -62243,6 +62339,9 @@ msgstr "" msgid "TXT" msgstr "" +msgid "Table" +msgstr "" + msgid "Table of contents" msgstr "" @@ -65923,6 +66022,9 @@ msgstr "" msgid "ToolCoverage|Project coverage" msgstr "" +msgid "Tools for detecting and resolving database issues in self-managed GitLab instances." +msgstr "" + msgid "Topic %{source_topic} was successfully merged into topic %{target_topic}." msgstr "" @@ -66864,6 +66966,9 @@ msgstr "" msgid "Unhelpful or irrelevant" msgstr "" +msgid "Unique" +msgstr "" + msgid "Unique identifier for the target chat or the username of the target channel (in the format `@channelusername`)." msgstr "" diff --git a/spec/frontend/admin/database_diagnostics/components/collation_checker_spec.js b/spec/frontend/admin/database_diagnostics/components/collation_checker_spec.js new file mode 100644 index 00000000000000..0058eb865bfd8f --- /dev/null +++ b/spec/frontend/admin/database_diagnostics/components/collation_checker_spec.js @@ -0,0 +1,281 @@ +import { nextTick } from 'vue'; +import { GlLoadingIcon } from '@gitlab/ui'; +import MockAdapter from 'axios-mock-adapter'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import axios from '~/lib/utils/axios_utils'; +import waitForPromises from 'helpers/wait_for_promises'; +import CollationChecker from '~/admin/database_diagnostics/components/collation_checker.vue'; + +describe('CollationChecker component', () => { + let wrapper; + let mockAxios; + + const findTitle = () => wrapper.findByTestId('title'); + const findRunButton = () => wrapper.findByTestId('run-diagnostics-button'); + const findLoadingAlert = () => wrapper.findByTestId('loading-alert'); + const findRunningAlert = () => wrapper.findByTestId('running-alert'); + const findErrorAlert = () => wrapper.findByTestId('error-alert'); + const findNoResultsMessage = () => wrapper.findByTestId('no-results-message'); + + // Database sections + const findDatabaseMain = () => wrapper.findByTestId('database-main'); + const findDatabaseCI = () => wrapper.findByTestId('database-ci'); + + // Other sections + const findCollationInfoAlert = () => wrapper.findByTestId('collation-info-alert'); + const findCorruptedIndexesTable = () => wrapper.findByTestId('corrupted-indexes-table'); + const findNoCollationMismatchesAlert = () => + wrapper.findByTestId('no-collation-mismatches-alert'); + const findNoCorruptedIndexesAlert = () => wrapper.findByTestId('no-corrupted-indexes-alert'); + + // Action card + const findActionCard = () => wrapper.findByTestId('action-card'); + const findLearnMoreButton = () => wrapper.findByTestId('learn-more-button'); + const findContactSupportButton = () => wrapper.findByTestId('contact-support-button'); + + const createComponent = (props = {}) => { + wrapper = shallowMountExtended(CollationChecker, { + propsData: { + runCollationCheckUrl: '/admin/database_diagnostics/run_collation_check.json', + collationCheckResultsUrl: '/admin/database_diagnostics/collation_check_results.json', + ...props, + }, + }); + }; + + const mockResults = { + metadata: { + last_run_at: '2025-07-23T10:00:00Z', + }, + databases: { + main: { + collation_mismatches: [ + { + collation_name: 'en_US.UTF-8', + provider: 'c', + stored_version: '2.28', + actual_version: '2.31', + }, + ], + corrupted_indexes: [ + { + index_name: 'index_users_on_name', + table_name: 'users', + affected_columns: 'name', + index_type: 'btree', + is_unique: true, + size_bytes: 5678901, + corruption_types: ['duplicates'], + needs_deduplication: true, + }, + ], + }, + ci: { + collation_mismatches: [], + corrupted_indexes: [], + }, + }, + }; + + const noIssuesResults = { + metadata: { + last_run_at: '2025-07-23T10:00:00Z', + }, + databases: { + main: { + collation_mismatches: [], + corrupted_indexes: [], + }, + }, + }; + + beforeEach(() => { + mockAxios = new MockAdapter(axios); + }); + + afterEach(() => { + mockAxios.restore(); + }); + + describe('initial state', () => { + beforeEach(() => { + mockAxios.onGet('/admin/database_diagnostics/collation_check_results.json').reply(404, { + error: 'No results available yet', + }); + createComponent(); + }); + + it('renders the title', () => { + expect(findTitle().exists()).toBe(true); + }); + + it('shows a loading indicator initially', () => { + expect(findLoadingAlert().exists()).toBe(true); + expect(findLoadingAlert().findComponent(GlLoadingIcon).exists()).toBe(true); + }); + + it('shows no results message after loading', async () => { + await waitForPromises(); + + expect(findLoadingAlert().exists()).toBe(false); + expect(findNoResultsMessage().exists()).toBe(true); + }); + + it('enables the run button after loading completes', async () => { + await waitForPromises(); + + expect(findRunButton().attributes('disabled')).toBeUndefined(); + }); + }); + + describe('with results showing collation mismatches', () => { + beforeEach(async () => { + mockAxios + .onGet('/admin/database_diagnostics/collation_check_results.json') + .reply(200, mockResults); + createComponent(); + await waitForPromises(); + }); + + it('renders both database sections', () => { + expect(findDatabaseMain().exists()).toBe(true); + expect(findDatabaseCI().exists()).toBe(true); + }); + + it('displays collation mismatches alert', () => { + expect(findCollationInfoAlert().exists()).toBe(true); + }); + + it('displays corrupted indexes table', () => { + expect(findCorruptedIndexesTable().exists()).toBe(true); + }); + + it('displays action card with remediation links', () => { + expect(findActionCard().exists()).toBe(true); + expect(findLearnMoreButton().exists()).toBe(true); + expect(findContactSupportButton().exists()).toBe(true); + }); + }); + + describe('with no issues', () => { + beforeEach(async () => { + mockAxios + .onGet('/admin/database_diagnostics/collation_check_results.json') + .reply(200, noIssuesResults); + createComponent(); + await waitForPromises(); + }); + + it('shows success alerts for no mismatches and no corrupted indexes', () => { + expect(findNoCollationMismatchesAlert().exists()).toBe(true); + expect(findNoCorruptedIndexesAlert().exists()).toBe(true); + }); + + it('does not display the action card', () => { + expect(findActionCard().exists()).toBe(false); + }); + }); + + describe('running diagnostics', () => { + beforeEach(async () => { + // Initial load - no results + mockAxios.onGet('/admin/database_diagnostics/collation_check_results.json').reply(404); + createComponent(); + await waitForPromises(); + + // Reset mock history + mockAxios.reset(); + + // Set up for the run request + mockAxios.onPost('/admin/database_diagnostics/run_collation_check.json').reply(200); + }); + + it('shows loading state when run button is clicked', async () => { + // Prevent polling by mocking the startPolling method + jest.spyOn(wrapper.vm, 'startPolling').mockImplementation(() => {}); + + findRunButton().vm.$emit('click'); + await nextTick(); + + expect(findRunningAlert().exists()).toBe(true); + + const disabledAttr = findRunButton().attributes('disabled'); + expect(disabledAttr !== undefined).toBe(true); + }); + + it('makes the correct API call when run button is clicked', async () => { + // Prevent polling by mocking the startPolling method + jest.spyOn(wrapper.vm, 'startPolling').mockImplementation(() => {}); + + findRunButton().vm.$emit('click'); + await nextTick(); + await waitForPromises(); // Make sure the API call completes + + expect(mockAxios.history.post.length).toBeGreaterThan(0); + expect(mockAxios.history.post[0].url).toBe( + '/admin/database_diagnostics/run_collation_check.json', + ); + }); + + it('updates the view when results are available', async () => { + // First click the button - this starts polling + // We'll avoid real polling by mocking startPolling + jest.spyOn(wrapper.vm, 'startPolling').mockImplementation(() => {}); + + findRunButton().vm.$emit('click'); + await nextTick(); + + // Verify we're in loading state + expect(findRunningAlert().exists()).toBe(true); + + // Now change the mock to return results and manually update the component + mockAxios + .onGet('/admin/database_diagnostics/collation_check_results.json') + .reply(200, mockResults); + + // Set results directly instead of polling + wrapper.vm.results = mockResults; + wrapper.vm.isRunning = false; + await nextTick(); + + // Verify we've updated the UI + expect(findRunningAlert().exists()).toBe(false); + expect(findDatabaseMain().exists()).toBe(true); + expect(findDatabaseCI().exists()).toBe(true); + }); + }); + + describe('error handling', () => { + it('displays error alert when API request fails', async () => { + mockAxios.onGet('/admin/database_diagnostics/collation_check_results.json').reply(500, { + error: 'Internal server error', + }); + + createComponent(); + await waitForPromises(); + + expect(findErrorAlert().exists()).toBe(true); + expect(findErrorAlert().text()).toContain('Internal server error'); + }); + + it('displays error alert when run diagnostic request fails', async () => { + mockAxios.onGet('/admin/database_diagnostics/collation_check_results.json').reply(404); + createComponent(); + await waitForPromises(); + + // Set up mock for the button click + mockAxios.onPost('/admin/database_diagnostics/run_collation_check.json').reply(500, { + error: 'Failed to schedule diagnostics', + }); + + // Prevent polling + jest.spyOn(wrapper.vm, 'startPolling').mockImplementation(() => {}); + + findRunButton().vm.$emit('click'); + await waitForPromises(); + + expect(findErrorAlert().exists()).toBe(true); + expect(findErrorAlert().text()).toContain('Failed to schedule diagnostics'); + }); + }); +}); diff --git a/spec/policies/global_policy_spec.rb b/spec/policies/global_policy_spec.rb index 1685cc42b28d9d..1bb19c79b26d19 100644 --- a/spec/policies/global_policy_spec.rb +++ b/spec/policies/global_policy_spec.rb @@ -752,18 +752,19 @@ let(:permissions) do [ :access_admin_area, - :read_application_statistics, - :read_admin_users, - :read_admin_groups, - :read_admin_projects, :read_admin_audit_log, :read_admin_background_jobs, :read_admin_background_migrations, :read_admin_cicd, + :read_admin_database_diagnostics, :read_admin_gitaly_servers, + :read_admin_groups, :read_admin_health_check, :read_admin_metrics_dashboard, - :read_admin_system_information + :read_admin_projects, + :read_admin_system_information, + :read_admin_users, + :read_application_statistics ] end diff --git a/spec/requests/admin/database_diagnostics_controller_spec.rb b/spec/requests/admin/database_diagnostics_controller_spec.rb new file mode 100644 index 00000000000000..061a3b731f238c --- /dev/null +++ b/spec/requests/admin/database_diagnostics_controller_spec.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Admin::DatabaseDiagnostics', feature_category: :database do + include AdminModeHelper + + let_it_be(:admin) { create(:admin) } + let_it_be(:user) { create(:user) } + + shared_examples 'unauthorized request' do + context 'when user is not an admin' do + before do + login_as(user) + end + + it 'returns 404 response' do + send_request + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'when admin mode is disabled' do + before do + login_as(admin) + end + + it 'redirects to admin mode enable' do + send_request + + expect(response).to redirect_to(new_admin_session_path) + end + end + end + + describe 'GET /admin/database_diagnostics' do + subject(:send_request) do + get admin_database_diagnostics_path + end + + it_behaves_like 'unauthorized request' + + context 'when admin mode is enabled' do + before do + login_as(admin) + enable_admin_mode!(admin) + end + + it 'returns 200 response' do + send_request + + expect(response).to have_gitlab_http_status(:ok) + end + end + end + + describe 'POST /admin/database_diagnostics/run_collation_check' do + subject(:send_request) do + post run_collation_check_admin_database_diagnostics_path(format: :json) + end + + it_behaves_like 'unauthorized request' + + context 'when admin mode is enabled' do + before do + login_as(admin) + enable_admin_mode!(admin) + end + + it 'returns 200 response and schedules the worker' do + expect(::Database::CollationCheckerWorker).to receive(:perform_async).and_return('job_id') + + send_request + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to include('status' => 'scheduled', 'job_id' => 'job_id') + end + end + end + + describe 'GET /admin/database_diagnostics/collation_check_results' do + subject(:send_request) do + get collation_check_results_admin_database_diagnostics_path(format: :json) + end + + it_behaves_like 'unauthorized request' + + context 'when admin mode is enabled' do + before do + login_as(admin) + enable_admin_mode!(admin) + end + + context 'when results are available' do + let(:results) do + { + metadata: { last_run_at: Time.current.iso8601 }, + databases: { + main: { + collation_mismatches: [], + corrupted_indexes: [] + } + } + } + end + + it 'returns 200 response with the results' do + allow(Rails.cache).to receive(:read) + expect(Rails.cache).to receive(:read) + .with(::Database::CollationCheckerWorker::COLLATION_CHECK_CACHE_KEY) + .and_return(results.to_json) + + send_request + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to include('metadata', 'databases') + end + end + + context 'when no results are available' do + it 'returns 404 response' do + send_request + + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response).to include('error' => 'No results available yet') + end + end + end + end +end -- GitLab From c039f2f3c563f2ad67e4e75ddffa7c3e7c3b7b05 Mon Sep 17 00:00:00 2001 From: Bishwa Hang Rai Date: Wed, 23 Jul 2025 17:43:50 +0200 Subject: [PATCH 07/17] Add limit to polling --- .../components/collation_checker.vue | 18 ++++++++- locale/gitlab.pot | 3 ++ .../components/collation_checker_spec.js | 40 +++++++++++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/admin/database_diagnostics/components/collation_checker.vue b/app/assets/javascripts/admin/database_diagnostics/components/collation_checker.vue index 902e366a56c871..d19892c685fa61 100644 --- a/app/assets/javascripts/admin/database_diagnostics/components/collation_checker.vue +++ b/app/assets/javascripts/admin/database_diagnostics/components/collation_checker.vue @@ -35,6 +35,9 @@ const I18N = { checkingCache: s__('DatabaseDiagnostics|Checking for recent diagnostic results...'), noResults: s__('DatabaseDiagnostics|No results available yet.'), indexSize: s__('DatabaseDiagnostics|Size: %{size}'), + jobTakingTooLong: s__( + 'DatabaseDiagnostics|The database diagnostic job is taking longer than expected. You can check back later or try running it again.', + ), }; export default { @@ -67,6 +70,8 @@ export default { results: null, error: null, pollingId: null, + pollingAttempts: 0, + maxPollingAttempts: 60, // 5 minutes (60 * 5 seconds) }; }, computed: { @@ -152,6 +157,7 @@ export default { this.isLoading = true; this.isRunning = true; this.error = null; + this.pollingAttempts = 0; // Reset polling attempts counter try { await axios.post(this.runCollationCheckUrl); @@ -169,7 +175,7 @@ export default { startPolling() { this.pollingId = setInterval(() => { this.pollResults(); - }, 2000); // Poll every 2 seconds + }, 5000); // Poll every 5 seconds }, stopPolling() { @@ -188,10 +194,20 @@ export default { this.isLoading = false; this.isRunning = false; this.stopPolling(); + this.pollingAttempts = 0; // Reset counter on success } } catch (error) { // If it's a 404, the job is still running if (error.response && error.response.status === 404) { + this.pollingAttempts += 1; + + if (this.pollingAttempts >= this.maxPollingAttempts) { + this.stopPolling(); + this.isLoading = false; + this.isRunning = false; + this.error = this.$options.i18n.jobTakingTooLong; + this.pollingAttempts = 0; // Reset counter + } return; } diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 18d6c890d29c0b..3c13c252c8265d 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -21325,6 +21325,9 @@ msgstr "" msgid "DatabaseDiagnostics|Size: %{size}" msgstr "" +msgid "DatabaseDiagnostics|The database diagnostic job is taking longer than expected. You can check back later or try running it again." +msgstr "" + msgid "DatabaseDiagnostics|These issues require manual remediation. Read our documentation on PostgreSQL OS upgrades for step-by-step instructions." msgstr "" diff --git a/spec/frontend/admin/database_diagnostics/components/collation_checker_spec.js b/spec/frontend/admin/database_diagnostics/components/collation_checker_spec.js index 0058eb865bfd8f..64f03658ae7f31 100644 --- a/spec/frontend/admin/database_diagnostics/components/collation_checker_spec.js +++ b/spec/frontend/admin/database_diagnostics/components/collation_checker_spec.js @@ -278,4 +278,44 @@ describe('CollationChecker component', () => { expect(findErrorAlert().text()).toContain('Failed to schedule diagnostics'); }); }); + + describe('polling', () => { + it('has the correct polling interval and attempt limit', () => { + createComponent(); + expect(wrapper.vm.maxPollingAttempts).toBe(60); // Verify attempt limit for 5 minutes of polling + }); + + it('stops polling after reaching the maximum attempts', async () => { + // Mock API responses + mockAxios.onGet('/admin/database_diagnostics/collation_check_results.json').reply(404); + mockAxios.onPost('/admin/database_diagnostics/run_collation_check.json').reply(200); + + createComponent(); + await waitForPromises(); + + // Set up spies before any actions + const stopPollingSpy = jest.spyOn(wrapper.vm, 'stopPolling'); + + // Run diagnostics to initialize the state + await wrapper.vm.runDatabaseDiagnostics(); + + // Reset the spy count after initialization + stopPollingSpy.mockClear(); + + // Directly set max attempts and trigger poll + wrapper.vm.pollingAttempts = wrapper.vm.maxPollingAttempts; + + // Mock the 404 response for the polling + mockAxios.onGet('/admin/database_diagnostics/collation_check_results.json').reply(404); + + // Call the method we're testing + await wrapper.vm.pollResults(); + + // Verify the expected behavior + expect(stopPollingSpy).toHaveBeenCalled(); + expect(wrapper.vm.isRunning).toBe(false); + expect(wrapper.vm.isLoading).toBe(false); + expect(wrapper.vm.error).toEqual(expect.stringContaining('taking longer than expected')); + }); + }); }); -- GitLab From 228dc1497f1ea10ef0c4b3956aa35b0e69f660e6 Mon Sep 17 00:00:00 2001 From: Bishwa Hang Rai Date: Wed, 23 Jul 2025 21:03:39 +0200 Subject: [PATCH 08/17] Remove webpack_bundle_tag from index.html.haml --- app/controllers/admin/database_diagnostics_controller.rb | 4 +--- app/views/admin/database_diagnostics/index.html.haml | 3 --- .../admin/database_diagnostics_controller_spec.rb | 9 +++------ 3 files changed, 4 insertions(+), 12 deletions(-) diff --git a/app/controllers/admin/database_diagnostics_controller.rb b/app/controllers/admin/database_diagnostics_controller.rb index 8b2c080756ab0c..d7460f35f4dca7 100644 --- a/app/controllers/admin/database_diagnostics_controller.rb +++ b/app/controllers/admin/database_diagnostics_controller.rb @@ -12,9 +12,7 @@ def index def run_collation_check job_id = ::Database::CollationCheckerWorker.perform_async # rubocop:disable CodeReuse/Worker -- Simple direct call - respond_to do |format| - format.json { render json: { status: 'scheduled', job_id: job_id } } - end + render json: { status: 'scheduled', job_id: job_id } end def collation_check_results diff --git a/app/views/admin/database_diagnostics/index.html.haml b/app/views/admin/database_diagnostics/index.html.haml index d04dcb3053bf8d..221390c5805a84 100644 --- a/app/views/admin/database_diagnostics/index.html.haml +++ b/app/views/admin/database_diagnostics/index.html.haml @@ -1,8 +1,5 @@ - page_title _('Database Diagnostics') -- content_for :page_specific_javascripts do - = webpack_bundle_tag 'pages/admin/database_diagnostics/index' - %section.settings.monitoring .settings-header %h1 diff --git a/spec/requests/admin/database_diagnostics_controller_spec.rb b/spec/requests/admin/database_diagnostics_controller_spec.rb index 061a3b731f238c..871c4bdf2f4ce8 100644 --- a/spec/requests/admin/database_diagnostics_controller_spec.rb +++ b/spec/requests/admin/database_diagnostics_controller_spec.rb @@ -41,10 +41,9 @@ it_behaves_like 'unauthorized request' - context 'when admin mode is enabled' do + context 'when admin mode is enabled', :enable_admin_mode do before do login_as(admin) - enable_admin_mode!(admin) end it 'returns 200 response' do @@ -62,10 +61,9 @@ it_behaves_like 'unauthorized request' - context 'when admin mode is enabled' do + context 'when admin mode is enabled', :enable_admin_mode do before do login_as(admin) - enable_admin_mode!(admin) end it 'returns 200 response and schedules the worker' do @@ -86,10 +84,9 @@ it_behaves_like 'unauthorized request' - context 'when admin mode is enabled' do + context 'when admin mode is enabled', :enable_admin_mode do before do login_as(admin) - enable_admin_mode!(admin) end context 'when results are available' do -- GitLab From a89aa5f7897f9641f6ad7a11bd29719708700ac9 Mon Sep 17 00:00:00 2001 From: Bishwa Hang Rai Date: Fri, 25 Jul 2025 22:48:08 +0200 Subject: [PATCH 09/17] Improve CollationChecker component testing Improves test coverage while better simulating real user behavior --- .../components/collation_checker.vue | 100 +++++------ .../components/collation_checker_spec.js | 161 +++++++++--------- 2 files changed, 130 insertions(+), 131 deletions(-) diff --git a/app/assets/javascripts/admin/database_diagnostics/components/collation_checker.vue b/app/assets/javascripts/admin/database_diagnostics/components/collation_checker.vue index d19892c685fa61..f93cead498c511 100644 --- a/app/assets/javascripts/admin/database_diagnostics/components/collation_checker.vue +++ b/app/assets/javascripts/admin/database_diagnostics/components/collation_checker.vue @@ -7,6 +7,9 @@ import { helpPagePath } from '~/helpers/help_page_helper'; import { bytes } from '~/lib/utils/unit_format'; import { SUPPORT_URL } from '~/sessions/new/constants'; +export const POLLING_INTERVAL_MS = 5000; // 5 seconds +export const MAX_POLLING_ATTEMPTS = 60; // 5 minutes total (60 × 5 seconds) + const I18N = { title: s__('DatabaseDiagnostics|Collation Health Check'), description: s__( @@ -43,6 +46,22 @@ const I18N = { export default { name: 'CollationChecker', i18n: I18N, + supportUrl: SUPPORT_URL, + collationMismatchFields: [ + { key: 'collation_name', label: __('Collation Name') }, + { key: 'provider', label: __('Provider') }, + { key: 'stored_version', label: __('Stored Version') }, + { key: 'actual_version', label: __('Actual Version') }, + ], + corruptedIndexesFields: [ + { key: 'index_name', label: __('Index Name') }, + { key: 'table_name', label: __('Table') }, + { key: 'affected_columns', label: __('Affected Columns') }, + { key: 'index_type', label: __('Type') }, + { key: 'is_unique', label: __('Unique') }, + { key: 'size', label: __('Size') }, + { key: 'corruption_types', label: __('Issues') }, + ], components: { GlAlert, GlButton, @@ -61,6 +80,16 @@ export default { type: String, required: true, }, + pollingIntervalMs: { + type: Number, + required: false, + default: POLLING_INTERVAL_MS, + }, + maxPollingAttempts: { + type: Number, + required: false, + default: MAX_POLLING_ATTEMPTS, + }, }, data() { return { @@ -71,12 +100,11 @@ export default { error: null, pollingId: null, pollingAttempts: 0, - maxPollingAttempts: 60, // 5 minutes (60 * 5 seconds) }; }, computed: { formattedLastRunAt() { - if (!this.results || !this.results.metadata || !this.results.metadata.last_run_at) { + if (!this.results?.metadata?.last_run_at) { return ''; } return formatDate(new Date(this.results.metadata.last_run_at)); @@ -85,40 +113,15 @@ export default { return this.results !== null && this.results.databases; }, hasIssues() { - if (!this.results || !this.results.databases) return false; + if (!this.results?.databases) return false; - return Object.values(this.results.databases).some( - (db) => db.corrupted_indexes && db.corrupted_indexes.length > 0, - ); + return Object.values(this.results.databases).some((db) => db.corrupted_indexes?.length > 0); }, documentationUrl() { return helpPagePath('administration/postgresql/upgrading_os'); }, - supportUrl() { - return SUPPORT_URL; - }, - collationMismatchFields() { - return [ - { key: 'collation_name', label: __('Collation Name') }, - { key: 'provider', label: __('Provider') }, - { key: 'stored_version', label: __('Stored Version') }, - { key: 'actual_version', label: __('Actual Version') }, - ]; - }, - corruptedIndexesFields() { - return [ - { key: 'index_name', label: __('Index Name') }, - { key: 'table_name', label: __('Table') }, - { key: 'affected_columns', label: __('Affected Columns') }, - { key: 'index_type', label: __('Type') }, - { key: 'is_unique', label: __('Unique') }, - { key: 'size', label: __('Size') }, - { key: 'corruption_types', label: __('Issues') }, - ]; - }, }, created() { - // Try to fetch results on component creation this.fetchResults(); }, beforeDestroy() { @@ -138,14 +141,11 @@ export default { this.results = data; } } catch (error) { - if (error.response && error.response.status === 404) { - // No results yet is an expected condition + if (error.response?.status === 404) { this.error = null; } else { this.error = - error.response && error.response.data && error.response.data.error - ? error.response.data.error - : __('An error occurred while fetching results'); + error.response?.data?.error ?? __('An error occurred while fetching results'); } } finally { this.isLoading = false; @@ -157,7 +157,6 @@ export default { this.isLoading = true; this.isRunning = true; this.error = null; - this.pollingAttempts = 0; // Reset polling attempts counter try { await axios.post(this.runCollationCheckUrl); @@ -166,16 +165,16 @@ export default { this.isLoading = false; this.isRunning = false; this.error = - error.response && error.response.data && error.response.data.error - ? error.response.data.error - : __('An error occurred while starting diagnostics'); + error.response?.data?.error ?? __('An error occurred while starting diagnostics'); } }, startPolling() { + this.stopPolling(); + this.pollingId = setInterval(() => { this.pollResults(); - }, 5000); // Poll every 5 seconds + }, this.pollingIntervalMs); }, stopPolling() { @@ -183,22 +182,21 @@ export default { clearInterval(this.pollingId); this.pollingId = null; } + this.pollingAttempts = 0; }, async pollResults() { try { const { data } = await axios.get(this.collationCheckResultsUrl); - if (data && data.databases) { + if (data?.databases) { this.results = data; this.isLoading = false; this.isRunning = false; this.stopPolling(); - this.pollingAttempts = 0; // Reset counter on success } } catch (error) { - // If it's a 404, the job is still running - if (error.response && error.response.status === 404) { + if (error.response?.status === 404) { this.pollingAttempts += 1; if (this.pollingAttempts >= this.maxPollingAttempts) { @@ -206,7 +204,6 @@ export default { this.isLoading = false; this.isRunning = false; this.error = this.$options.i18n.jobTakingTooLong; - this.pollingAttempts = 0; // Reset counter } return; } @@ -214,19 +211,16 @@ export default { this.isLoading = false; this.isRunning = false; this.stopPolling(); - this.error = - error.response && error.response.data && error.response.data.error - ? error.response.data.error - : __('An error occurred while fetching results'); + this.error = error.response?.data?.error ?? __('An error occurred while fetching results'); } }, hasMismatches(dbResults) { - return dbResults.collation_mismatches && dbResults.collation_mismatches.length > 0; + return dbResults.collation_mismatches?.length > 0; }, hasCorruptedIndexes(dbResults) { - return dbResults.corrupted_indexes && dbResults.corrupted_indexes.length > 0; + return dbResults.corrupted_indexes?.length > 0; }, formatBytes(byteSize) { @@ -313,7 +307,7 @@ export default { { let wrapper; let mockAxios; + // Set shorter polling values for tests to keep them fast + const TEST_POLLING_INTERVAL_MS = 50; + const TEST_MAX_POLLING_ATTEMPTS = 2; + const findTitle = () => wrapper.findByTestId('title'); const findRunButton = () => wrapper.findByTestId('run-diagnostics-button'); const findLoadingAlert = () => wrapper.findByTestId('loading-alert'); @@ -38,11 +45,18 @@ describe('CollationChecker component', () => { propsData: { runCollationCheckUrl: '/admin/database_diagnostics/run_collation_check.json', collationCheckResultsUrl: '/admin/database_diagnostics/collation_check_results.json', + pollingIntervalMs: TEST_POLLING_INTERVAL_MS, + maxPollingAttempts: TEST_MAX_POLLING_ATTEMPTS, ...props, }, }); }; + const clickRunButton = async () => { + findRunButton().vm.$emit('click'); + await nextTick(); + }; + const mockResults = { metadata: { last_run_at: '2025-07-23T10:00:00Z', @@ -90,31 +104,42 @@ describe('CollationChecker component', () => { }; beforeEach(() => { + jest.useFakeTimers(); mockAxios = new MockAdapter(axios); }); afterEach(() => { mockAxios.restore(); + jest.clearAllTimers(); }); - describe('initial state', () => { - beforeEach(() => { - mockAxios.onGet('/admin/database_diagnostics/collation_check_results.json').reply(404, { - error: 'No results available yet', - }); - createComponent(); - }); - - it('renders the title', () => { - expect(findTitle().exists()).toBe(true); + describe('constants', () => { + it('exports polling constants for reuse', () => { + expect(POLLING_INTERVAL_MS).toBe(5000); + expect(MAX_POLLING_ATTEMPTS).toBe(60); }); + }); + describe('initial state', () => { it('shows a loading indicator initially', () => { + mockAxios.onGet('/admin/database_diagnostics/collation_check_results.json').reply(404); + createComponent(); + expect(findLoadingAlert().exists()).toBe(true); expect(findLoadingAlert().findComponent(GlLoadingIcon).exists()).toBe(true); }); + it('renders the title', async () => { + mockAxios.onGet('/admin/database_diagnostics/collation_check_results.json').reply(404); + createComponent(); + await waitForPromises(); + + expect(findTitle().exists()).toBe(true); + }); + it('shows no results message after loading', async () => { + mockAxios.onGet('/admin/database_diagnostics/collation_check_results.json').reply(404); + createComponent(); await waitForPromises(); expect(findLoadingAlert().exists()).toBe(false); @@ -122,9 +147,14 @@ describe('CollationChecker component', () => { }); it('enables the run button after loading completes', async () => { + mockAxios.onGet('/admin/database_diagnostics/collation_check_results.json').reply(404); + createComponent(); + + expect(findRunButton().props('disabled')).toBe(true); + await waitForPromises(); - expect(findRunButton().attributes('disabled')).toBeUndefined(); + expect(findRunButton().props('disabled')).toBe(false); }); }); @@ -180,73 +210,53 @@ describe('CollationChecker component', () => { beforeEach(async () => { // Initial load - no results mockAxios.onGet('/admin/database_diagnostics/collation_check_results.json').reply(404); + mockAxios.onPost('/admin/database_diagnostics/run_collation_check.json').reply(200); createComponent(); await waitForPromises(); - - // Reset mock history - mockAxios.reset(); - - // Set up for the run request - mockAxios.onPost('/admin/database_diagnostics/run_collation_check.json').reply(200); }); - it('shows loading state when run button is clicked', async () => { - // Prevent polling by mocking the startPolling method - jest.spyOn(wrapper.vm, 'startPolling').mockImplementation(() => {}); + it('shows loading state and disables button when run button is clicked', async () => { + await clickRunButton(); - findRunButton().vm.$emit('click'); - await nextTick(); + expect(findRunButton().props('disabled')).toBe(true); - expect(findRunningAlert().exists()).toBe(true); + await waitForPromises(); - const disabledAttr = findRunButton().attributes('disabled'); - expect(disabledAttr !== undefined).toBe(true); + expect(findRunningAlert().exists()).toBe(true); + expect(findRunButton().props('disabled')).toBe(true); }); it('makes the correct API call when run button is clicked', async () => { - // Prevent polling by mocking the startPolling method - jest.spyOn(wrapper.vm, 'startPolling').mockImplementation(() => {}); - - findRunButton().vm.$emit('click'); - await nextTick(); - await waitForPromises(); // Make sure the API call completes + await clickRunButton(); + await waitForPromises(); - expect(mockAxios.history.post.length).toBeGreaterThan(0); + expect(mockAxios.history.post).toHaveLength(1); expect(mockAxios.history.post[0].url).toBe( '/admin/database_diagnostics/run_collation_check.json', ); }); - it('updates the view when results are available', async () => { - // First click the button - this starts polling - // We'll avoid real polling by mocking startPolling - jest.spyOn(wrapper.vm, 'startPolling').mockImplementation(() => {}); - - findRunButton().vm.$emit('click'); - await nextTick(); + it('updates the view when results are available after polling', async () => { + await clickRunButton(); + await waitForPromises(); - // Verify we're in loading state expect(findRunningAlert().exists()).toBe(true); - // Now change the mock to return results and manually update the component mockAxios .onGet('/admin/database_diagnostics/collation_check_results.json') .reply(200, mockResults); - // Set results directly instead of polling - wrapper.vm.results = mockResults; - wrapper.vm.isRunning = false; + jest.advanceTimersByTime(TEST_POLLING_INTERVAL_MS); await nextTick(); + await waitForPromises(); - // Verify we've updated the UI expect(findRunningAlert().exists()).toBe(false); expect(findDatabaseMain().exists()).toBe(true); - expect(findDatabaseCI().exists()).toBe(true); }); }); describe('error handling', () => { - it('displays error alert when API request fails', async () => { + it('displays error alert when initial API request fails', async () => { mockAxios.onGet('/admin/database_diagnostics/collation_check_results.json').reply(500, { error: 'Internal server error', }); @@ -260,18 +270,14 @@ describe('CollationChecker component', () => { it('displays error alert when run diagnostic request fails', async () => { mockAxios.onGet('/admin/database_diagnostics/collation_check_results.json').reply(404); - createComponent(); - await waitForPromises(); - - // Set up mock for the button click mockAxios.onPost('/admin/database_diagnostics/run_collation_check.json').reply(500, { error: 'Failed to schedule diagnostics', }); - // Prevent polling - jest.spyOn(wrapper.vm, 'startPolling').mockImplementation(() => {}); + createComponent(); + await waitForPromises(); - findRunButton().vm.$emit('click'); + await clickRunButton(); await waitForPromises(); expect(findErrorAlert().exists()).toBe(true); @@ -280,42 +286,41 @@ describe('CollationChecker component', () => { }); describe('polling', () => { - it('has the correct polling interval and attempt limit', () => { - createComponent(); - expect(wrapper.vm.maxPollingAttempts).toBe(60); // Verify attempt limit for 5 minutes of polling - }); - it('stops polling after reaching the maximum attempts', async () => { - // Mock API responses + // Set up API mocks mockAxios.onGet('/admin/database_diagnostics/collation_check_results.json').reply(404); mockAxios.onPost('/admin/database_diagnostics/run_collation_check.json').reply(200); createComponent(); await waitForPromises(); - // Set up spies before any actions - const stopPollingSpy = jest.spyOn(wrapper.vm, 'stopPolling'); + // Use our custom helper to simulate user clicking the button + await clickRunButton(); + await waitForPromises(); - // Run diagnostics to initialize the state - await wrapper.vm.runDatabaseDiagnostics(); + // Verify initial state + expect(findRunningAlert().exists()).toBe(true); - // Reset the spy count after initialization - stopPollingSpy.mockClear(); + // Advance timers to simulate reaching max attempts + // Refactored to avoid linting errors with await in loops and ++ operator + const advanceAndWait = async (totalAttempts) => { + let attemptsLeft = totalAttempts; - // Directly set max attempts and trigger poll - wrapper.vm.pollingAttempts = wrapper.vm.maxPollingAttempts; + while (attemptsLeft > 0) { + jest.advanceTimersByTime(TEST_POLLING_INTERVAL_MS); + attemptsLeft -= 1; + } - // Mock the 404 response for the polling - mockAxios.onGet('/admin/database_diagnostics/collation_check_results.json').reply(404); + await nextTick(); + await waitForPromises(); + }; - // Call the method we're testing - await wrapper.vm.pollResults(); + await advanceAndWait(TEST_MAX_POLLING_ATTEMPTS); - // Verify the expected behavior - expect(stopPollingSpy).toHaveBeenCalled(); - expect(wrapper.vm.isRunning).toBe(false); - expect(wrapper.vm.isLoading).toBe(false); - expect(wrapper.vm.error).toEqual(expect.stringContaining('taking longer than expected')); + // Verify polling has stopped and error is shown + expect(findRunningAlert().exists()).toBe(false); + expect(findErrorAlert().exists()).toBe(true); + expect(findErrorAlert().text()).toContain('taking longer than expected'); }); }); }); -- GitLab From 7fcc7f1eaf8e1af57b8f0568ef47af2b18235f96 Mon Sep 17 00:00:00 2001 From: Bishwa Hang Rai Date: Wed, 16 Jul 2025 22:13:02 +0200 Subject: [PATCH 10/17] Add Database Diagnostics monitoring page Adds a new admin page for database diagnostics. It will show collation mismatches and any corrupted indexes related to collation. Changelog: added --- .../components/collation_checker.vue | 197 +++++------------- locale/gitlab.pot | 12 -- 2 files changed, 55 insertions(+), 154 deletions(-) diff --git a/app/assets/javascripts/admin/database_diagnostics/components/collation_checker.vue b/app/assets/javascripts/admin/database_diagnostics/components/collation_checker.vue index f93cead498c511..9653cdc8b98de5 100644 --- a/app/assets/javascripts/admin/database_diagnostics/components/collation_checker.vue +++ b/app/assets/javascripts/admin/database_diagnostics/components/collation_checker.vue @@ -7,9 +7,6 @@ import { helpPagePath } from '~/helpers/help_page_helper'; import { bytes } from '~/lib/utils/unit_format'; import { SUPPORT_URL } from '~/sessions/new/constants'; -export const POLLING_INTERVAL_MS = 5000; // 5 seconds -export const MAX_POLLING_ATTEMPTS = 60; // 5 minutes total (60 × 5 seconds) - const I18N = { title: s__('DatabaseDiagnostics|Collation Health Check'), description: s__( @@ -32,36 +29,12 @@ const I18N = { actionText: s__( 'DatabaseDiagnostics|These issues require manual remediation. Read our documentation on PostgreSQL OS upgrades for step-by-step instructions.', ), - collationMismatchInfo: s__( - 'DatabaseDiagnostics|Collation mismatches are shown for informational purposes and may not indicate a problem.', - ), - checkingCache: s__('DatabaseDiagnostics|Checking for recent diagnostic results...'), - noResults: s__('DatabaseDiagnostics|No results available yet.'), indexSize: s__('DatabaseDiagnostics|Size: %{size}'), - jobTakingTooLong: s__( - 'DatabaseDiagnostics|The database diagnostic job is taking longer than expected. You can check back later or try running it again.', - ), }; export default { name: 'CollationChecker', i18n: I18N, - supportUrl: SUPPORT_URL, - collationMismatchFields: [ - { key: 'collation_name', label: __('Collation Name') }, - { key: 'provider', label: __('Provider') }, - { key: 'stored_version', label: __('Stored Version') }, - { key: 'actual_version', label: __('Actual Version') }, - ], - corruptedIndexesFields: [ - { key: 'index_name', label: __('Index Name') }, - { key: 'table_name', label: __('Table') }, - { key: 'affected_columns', label: __('Affected Columns') }, - { key: 'index_type', label: __('Type') }, - { key: 'is_unique', label: __('Unique') }, - { key: 'size', label: __('Size') }, - { key: 'corruption_types', label: __('Issues') }, - ], components: { GlAlert, GlButton, @@ -80,34 +53,21 @@ export default { type: String, required: true, }, - pollingIntervalMs: { - type: Number, - required: false, - default: POLLING_INTERVAL_MS, - }, - maxPollingAttempts: { - type: Number, - required: false, - default: MAX_POLLING_ATTEMPTS, - }, }, data() { return { isLoading: false, isRunning: false, - isInitialLoad: true, results: null, error: null, pollingId: null, - pollingAttempts: 0, }; }, computed: { formattedLastRunAt() { - if (!this.results?.metadata?.last_run_at) { - return ''; - } - return formatDate(new Date(this.results.metadata.last_run_at)); + return this.results?.metadata?.last_run_at + ? formatDate(new Date(this.results.metadata.last_run_at)) + : ''; }, hasResults() { return this.results !== null && this.results.databases; @@ -115,13 +75,38 @@ export default { hasIssues() { if (!this.results?.databases) return false; - return Object.values(this.results.databases).some((db) => db.corrupted_indexes?.length > 0); + return Object.values(this.results.databases).some( + (db) => db.collation_mismatches?.length > 0 || db.corrupted_indexes?.length > 0, + ); }, documentationUrl() { return helpPagePath('administration/postgresql/upgrading_os'); }, + supportUrl() { + return SUPPORT_URL; + }, + collationMismatchFields() { + return [ + { key: 'collation_name', label: __('Collation Name') }, + { key: 'provider', label: __('Provider') }, + { key: 'stored_version', label: __('Stored Version') }, + { key: 'actual_version', label: __('Actual Version') }, + ]; + }, + corruptedIndexesFields() { + return [ + { key: 'index_name', label: __('Index Name') }, + { key: 'table_name', label: __('Table') }, + { key: 'affected_columns', label: __('Affected Columns') }, + { key: 'index_type', label: __('Type') }, + { key: 'is_unique', label: __('Unique') }, + { key: 'size', label: __('Size') }, + { key: 'corruption_types', label: __('Issues') }, + ]; + }, }, created() { + // Try to fetch results on component creation this.fetchResults(); }, beforeDestroy() { @@ -131,25 +116,14 @@ export default { bytes, async fetchResults() { - this.isInitialLoad = true; - this.isLoading = true; - this.error = null; - try { const { data } = await axios.get(this.collationCheckResultsUrl); if (data) { this.results = data; } } catch (error) { - if (error.response?.status === 404) { - this.error = null; - } else { - this.error = - error.response?.data?.error ?? __('An error occurred while fetching results'); - } - } finally { - this.isLoading = false; - this.isInitialLoad = false; + this.error = + error.response?.data?.message || __('An error occurred while fetching results'); } }, @@ -165,16 +139,14 @@ export default { this.isLoading = false; this.isRunning = false; this.error = - error.response?.data?.error ?? __('An error occurred while starting diagnostics'); + error.response?.data?.message || __('An error occurred while starting diagnostics'); } }, startPolling() { - this.stopPolling(); - this.pollingId = setInterval(() => { this.pollResults(); - }, this.pollingIntervalMs); + }, 2000); // Poll every 2 seconds }, stopPolling() { @@ -182,36 +154,24 @@ export default { clearInterval(this.pollingId); this.pollingId = null; } - this.pollingAttempts = 0; }, async pollResults() { try { const { data } = await axios.get(this.collationCheckResultsUrl); - if (data?.databases) { + if (data) { this.results = data; this.isLoading = false; this.isRunning = false; this.stopPolling(); } } catch (error) { - if (error.response?.status === 404) { - this.pollingAttempts += 1; - - if (this.pollingAttempts >= this.maxPollingAttempts) { - this.stopPolling(); - this.isLoading = false; - this.isRunning = false; - this.error = this.$options.i18n.jobTakingTooLong; - } - return; - } - this.isLoading = false; this.isRunning = false; this.stopPolling(); - this.error = error.response?.data?.error ?? __('An error occurred while fetching results'); + this.error = + error.response?.data?.message || __('An error occurred while fetching results'); } }, @@ -222,7 +182,6 @@ export default { hasCorruptedIndexes(dbResults) { return dbResults.corrupted_indexes?.length > 0; }, - formatBytes(byteSize) { return this.bytes(byteSize); }, @@ -234,14 +193,10 @@ export default {
-

{{ $options.i18n.title }}

+

{{ $options.i18n.title }}

{{ $options.i18n.description }} - + {{ $options.i18n.lastRun.replace('%{timestamp}', formattedLastRunAt) }}

@@ -258,90 +213,60 @@ export default {
- - - {{ $options.i18n.checkingCache }} - - - + {{ $options.i18n.loading }} - + {{ error }}