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 0000000000000000000000000000000000000000..61c0e2b78cc9213fa7b4061676606ecfde8679a4 --- /dev/null +++ b/app/assets/javascripts/admin/database_diagnostics/components/collation_checker.vue @@ -0,0 +1,440 @@ + + + diff --git a/app/assets/javascripts/admin/database_diagnostics/components/combined_diagnostics.vue b/app/assets/javascripts/admin/database_diagnostics/components/combined_diagnostics.vue new file mode 100644 index 0000000000000000000000000000000000000000..29795a5d1b4a9592f6427ecd61adcc2edd0225a0 --- /dev/null +++ b/app/assets/javascripts/admin/database_diagnostics/components/combined_diagnostics.vue @@ -0,0 +1,38 @@ + + +// combined_diagnostics.vue + diff --git a/app/assets/javascripts/admin/database_diagnostics/components/schema_checker.vue b/app/assets/javascripts/admin/database_diagnostics/components/schema_checker.vue new file mode 100644 index 0000000000000000000000000000000000000000..10aa2ca383f9858896659af6b12381ade2701e9b --- /dev/null +++ b/app/assets/javascripts/admin/database_diagnostics/components/schema_checker.vue @@ -0,0 +1,241 @@ + + + diff --git a/app/assets/javascripts/admin/database_diagnostics/components/schema_section.vue b/app/assets/javascripts/admin/database_diagnostics/components/schema_section.vue new file mode 100644 index 0000000000000000000000000000000000000000..44d6a1d07ede1e4d776d0f649a946f20675a8dc7 --- /dev/null +++ b/app/assets/javascripts/admin/database_diagnostics/components/schema_section.vue @@ -0,0 +1,178 @@ + + + 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 0000000000000000000000000000000000000000..b51ef312e195eb555369f253a81c97e379e0ca09 --- /dev/null +++ b/app/assets/javascripts/admin/database_diagnostics/index.js @@ -0,0 +1,26 @@ +import Vue from 'vue'; +import CombinedDiagnostics from './components/combined_diagnostics.vue'; + +export const initDatabaseDiagnosticsApp = () => { + const el = document.getElementById('js-database-diagnostics'); + + if (!el) { + return false; + } + + const { runCollationCheckUrl, collationCheckResultsUrl, runSchemaCheckUrl, schemaCheckResultsUrl } = el.dataset; + + return new Vue({ + el, + render(h) { + return h(CombinedDiagnostics, { + props: { + runCollationCheckUrl, + collationCheckResultsUrl, + runSchemaCheckUrl, + schemaCheckResultsUrl + }, + }); + }, + }); +}; 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 0000000000000000000000000000000000000000..9a35ceb8b5d028b0f583d1e5000358275d826fdc --- /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 0000000000000000000000000000000000000000..f843ec59b0589cddf0685f5c98343fb654aeac71 --- /dev/null +++ b/app/controllers/admin/database_diagnostics_controller.rb @@ -0,0 +1,44 @@ +# 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 run_schema_check schema_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 + + render json: { status: 'scheduled', job_id: job_id } + end + + def run_schema_check + Database::SchemaCheckerWorker.perform_async # rubocop:disable CodeReuse/Worker -- Simple direct call + + respond_to do |format| + format.json { render json: { status: 'scheduled' } } + end + end + + def schema_check_results + result = Rails.cache.read(Database::SchemaCheckerWorker::SCHEMA_CHECK_CACHE_KEY) + + render json: result + 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 9430882e93a01b982a4f48d9436967fdbf016b97..2bd723dfa05e40e947c3250dd91c4ab6c50e28ef 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 0000000000000000000000000000000000000000..8d7269cef68729a0988fc17634d6306f4c13d801 --- /dev/null +++ b/app/views/admin/database_diagnostics/index.html.haml @@ -0,0 +1,17 @@ +- page_title _('Database Diagnostics') + +%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), + run_schema_check_url: run_schema_check_admin_database_diagnostics_path(format: :json), + schema_check_results_url: schema_check_results_admin_database_diagnostics_path(format: :json) + } +} diff --git a/config/routes/admin.rb b/config/routes/admin.rb index 7b74b6609e555431481a6229594d0a3312421eea..a810279dfc664ebc596dced1b534b3ae751b898f 100644 --- a/config/routes/admin.rb +++ b/config/routes/admin.rb @@ -120,6 +120,14 @@ 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 + post :run_schema_check + get :schema_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 94f8bf5daeb5aa86e90223c72e02c72a942303cd..d361dc620e4a3de270174c335c4087700f07bd96 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 4e9ede9ea06f13a360285acf6db6c9558ae71a99..bf2cbcead79017a572dda64b4a0b5f24d27ea1af 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -3688,6 +3688,9 @@ msgstr "" msgid "Activity|An error occurred while retrieving activity. Reload the page to try again." msgstr "" +msgid "Actual Version" +msgstr "" + msgid "Add" msgstr "" @@ -5939,6 +5942,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 "" @@ -7431,6 +7437,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 "" @@ -7587,6 +7596,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 "" @@ -15269,6 +15281,12 @@ msgstr "" msgid "Collapse sidebar" msgstr "" +msgid "Collation Mismatches" +msgstr "" + +msgid "Collation Name" +msgstr "" + msgid "Collector hostname" msgstr "" @@ -18894,6 +18912,9 @@ msgstr "" msgid "Correlation ID" msgstr "" +msgid "Corrupted Indexes" +msgstr "" + msgid "Cost Factor Settings" msgstr "" @@ -21241,9 +21262,87 @@ 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|Details" +msgstr "" + +msgid "DatabaseDiagnostics|Detect collation-related index corruption issues that may occur after OS upgrades" +msgstr "" + +msgid "DatabaseDiagnostics|Detect database schema inconsistencies and structural issues" +msgstr "" + +msgid "DatabaseDiagnostics|Foreign Keys" +msgstr "" + +msgid "DatabaseDiagnostics|Indexes" +msgstr "" + +msgid "DatabaseDiagnostics|Issues detected" +msgstr "" + +msgid "DatabaseDiagnostics|Last checked: %{timestamp}" +msgstr "" + +msgid "DatabaseDiagnostics|Learn more" +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 diagnostics have been run yet. Click \"Run Schema Check\" to analyze your database schema." +msgstr "" + +msgid "DatabaseDiagnostics|No issues detected." +msgstr "" + +msgid "DatabaseDiagnostics|Run Collation Check" +msgstr "" + +msgid "DatabaseDiagnostics|Run Schema Check" +msgstr "" + +msgid "DatabaseDiagnostics|Running diagnostics..." +msgstr "" + +msgid "DatabaseDiagnostics|Schema Health Check" +msgstr "" + +msgid "DatabaseDiagnostics|Sequences" +msgstr "" + +msgid "DatabaseDiagnostics|Tables" +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 "" + msgid "DatadogIntegration|%{link_start}API key%{link_end} used for authentication with Datadog." msgstr "" @@ -24346,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 "" @@ -33511,6 +33613,9 @@ msgstr "" msgid "Index" msgstr "" +msgid "Index Name" +msgstr "" + msgid "Index deletion is canceled" msgstr "" @@ -41693,6 +41798,9 @@ msgstr "" msgid "No child items are currently assigned." msgstr "" +msgid "No collation mismatches detected." +msgstr "" + msgid "No comment templates found." msgstr "" @@ -41723,6 +41831,9 @@ msgstr "" msgid "No contributions were found" msgstr "" +msgid "No corrupted indexes detected." +msgstr "" + msgid "No credit card data for matching" msgstr "" @@ -61496,12 +61607,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 "" @@ -62267,6 +62384,9 @@ msgstr "" msgid "TXT" msgstr "" +msgid "Table" +msgstr "" + msgid "Table of contents" msgstr "" @@ -65947,6 +66067,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 "" @@ -66888,6 +67011,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 0000000000000000000000000000000000000000..e7c6d3a6922323485f565ccc4535ff1f03941568 --- /dev/null +++ b/spec/frontend/admin/database_diagnostics/components/collation_checker_spec.js @@ -0,0 +1,300 @@ +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; + + // 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'); + 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', + pollingIntervalMs: TEST_POLLING_INTERVAL_MS, + maxPollingAttempts: TEST_MAX_POLLING_ATTEMPTS, + ...props, + }, + }); + }; + + const clickRunButton = async () => { + findRunButton().vm.$emit('click'); + await nextTick(); + }; + + const collationMismatchResults = { + 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(() => { + 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); + createComponent(); + }); + + it('shows a loading indicator initially', () => { + expect(findLoadingAlert().exists()).toBe(true); + expect(findLoadingAlert().findComponent(GlLoadingIcon).exists()).toBe(true); + }); + + it('renders the title', async () => { + 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); + expect(findNoResultsMessage().exists()).toBe(true); + }); + + it('enables the run button after loading completes', async () => { + expect(findRunButton().props('disabled')).toBe(true); + + await waitForPromises(); + + expect(findRunButton().props('disabled')).toBe(false); + }); + }); + + describe('with results showing collation mismatches', () => { + beforeEach(async () => { + mockAxios + .onGet('/admin/database_diagnostics/collation_check_results.json') + .reply(200, collationMismatchResults); + 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); + mockAxios.onPost('/admin/database_diagnostics/run_collation_check.json').reply(200); + createComponent(); + await waitForPromises(); + }); + + it('shows loading state and disables button when run button is clicked', async () => { + await clickRunButton(); + + expect(findRunButton().props('disabled')).toBe(true); + + await waitForPromises(); + + expect(findRunningAlert().exists()).toBe(true); + expect(findRunButton().props('disabled')).toBe(true); + }); + + it('makes the correct API call when run button is clicked', async () => { + await clickRunButton(); + await waitForPromises(); + + 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 after polling', async () => { + await clickRunButton(); + await waitForPromises(); + + expect(findRunningAlert().exists()).toBe(true); + + mockAxios + .onGet('/admin/database_diagnostics/collation_check_results.json') + .reply(200, collationMismatchResults); + + jest.advanceTimersByTime(TEST_POLLING_INTERVAL_MS); + await nextTick(); + await waitForPromises(); + + expect(findRunningAlert().exists()).toBe(false); + expect(findDatabaseMain().exists()).toBe(true); + }); + }); + + describe('error handling', () => { + 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', + }); + + 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); + mockAxios.onPost('/admin/database_diagnostics/run_collation_check.json').reply(500, { + error: 'Failed to schedule diagnostics', + }); + + createComponent(); + await waitForPromises(); + + await clickRunButton(); + await waitForPromises(); + + expect(findErrorAlert().exists()).toBe(true); + expect(findErrorAlert().text()).toContain('Failed to schedule diagnostics'); + }); + }); + + describe('polling', () => { + it('stops polling after reaching the maximum attempts', async () => { + // 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(); + + // Use our custom helper to simulate user clicking the button + await clickRunButton(); + await waitForPromises(); + + // Verify initial state + expect(findRunningAlert().exists()).toBe(true); + + 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); + expect(findErrorAlert().exists()).toBe(true); + expect(findErrorAlert().text()).toContain('taking longer than expected'); + }); + }); +}); diff --git a/spec/policies/global_policy_spec.rb b/spec/policies/global_policy_spec.rb index 1685cc42b28d9d08ea374efaf94f0432c25bbe95..1bb19c79b26d1923399c38768ecffee2f50630db 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 0000000000000000000000000000000000000000..871c4bdf2f4ce894a1dd5b71e58781bcd19e56c5 --- /dev/null +++ b/spec/requests/admin/database_diagnostics_controller_spec.rb @@ -0,0 +1,128 @@ +# 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', :enable_admin_mode do + before do + login_as(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', :enable_admin_mode do + before do + login_as(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', :enable_admin_mode do + before do + login_as(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