This is an automated email from the ASF dual-hosted git repository. dsmiley pushed a commit to branch branch_10x in repository https://gitbox.apache.org/repos/asf/solr.git
commit 69264b6dd1381875ba6cb0011d78eb197bc44f29 Author: Eric Pugh <[email protected]> AuthorDate: Tue Oct 27 14:31:14 2026 -0400 SOLR-16398: Migrate admin UI's replication details fetch to the v2 API (#5241) (cherry picked from commit bbfbe465d88107fd8b7b193bd4532ff092892e39) --- .../SOLR-16398-admin-ui-replication-details-v2.yml | 13 +++ .../AdminUiCoreOverviewReplicationErrorsTest.java | 121 +++++++++++++++++++++ .../web/js/angular/controllers/core-overview.js | 32 ++++-- .../web/js/angular/controllers/replication.js | 67 +++++++----- solr/webapp/web/js/angular/services.js | 4 + 5 files changed, 201 insertions(+), 36 deletions(-) diff --git a/changelog/unreleased/SOLR-16398-admin-ui-replication-details-v2.yml b/changelog/unreleased/SOLR-16398-admin-ui-replication-details-v2.yml new file mode 100644 index 00000000000..62231a45495 --- /dev/null +++ b/changelog/unreleased/SOLR-16398-admin-ui-replication-details-v2.yml @@ -0,0 +1,13 @@ +# See https://github.com/apache/solr/blob/main/dev-docs/changelog.adoc +title: + Admin UI's Replication screen and core overview page now fetch replication status via the v2 + GET /api/cores/{coreName}/replication API instead of the v1 replication handler. +type: changed +authors: + - name: Eric Pugh + nick: epugh + - name: Nick Shanin + nick: nick-boss-tech +links: + - name: SOLR-16398 + url: https://issues.apache.org/jira/browse/SOLR-16398 diff --git a/solr/webapp/src/test/org/apache/solr/webapp/AdminUiCoreOverviewReplicationErrorsTest.java b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiCoreOverviewReplicationErrorsTest.java new file mode 100644 index 00000000000..fe2c4a1a0c9 --- /dev/null +++ b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiCoreOverviewReplicationErrorsTest.java @@ -0,0 +1,121 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.webapp; + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.nio.file.Path; +import java.util.Properties; +import org.apache.solr.embedded.JettyConfig; +import org.apache.solr.embedded.JettySolrRunner; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; +import org.openqa.selenium.By; +import org.openqa.selenium.JavascriptExecutor; + +/** + * Covers {@code core-overview.js}'s two replication-fetch-failure paths: a generic failure shows + * the inline "not configured" message, while a 401/403 routes through {@code ApiErrorHandler} + * instead. + */ +public class AdminUiCoreOverviewReplicationErrorsTest extends AdminUiStandaloneTestBase { + + private static final String CORE = "replicationerrors"; + + /** 0 = pass through; otherwise the status the filter forces for this core's /replication call. */ + private static volatile int forcedReplicationStatus = 0; + + @BeforeClass + public static void startStandaloneNode() throws Exception { + Path home = buildStandaloneHome(CORE); + JettyConfig.Builder config = JettyConfig.builder(); + config.withFilter(ForceReplicationStatusFilter.class, "/*"); + configureJettyForUi(config); + standaloneJetty = new JettySolrRunner(home.toString(), new Properties(), config.build()); + standaloneJetty.start(); + baseUrl = standaloneJetty.getBaseUrl().toString(); + } + + @AfterClass + public static void stopStandaloneNode() throws Exception { + if (standaloneJetty != null) { + standaloneJetty.stop(); + standaloneJetty = null; + } + } + + @Test + public void testGenericReplicationFailureShowsInlineMessage() { + forcedReplicationStatus = HttpServletResponse.SC_SERVICE_UNAVAILABLE; + openPage(CORE + "/core-overview", By.id("dashboard")); + + waitForTextContains(By.cssSelector("#replication .message-container"), "not configured"); + assertNoSevereConsoleErrors("/replication"); + } + + @Test + public void testReplicationForbiddenDoesNotShowConfigurationMessage() throws Exception { + // Not asserting #authz-failures itself: app.js's httpInterceptor clears + // $rootScope.showAuthzFailures on every successful v1 call, and core-overview fires several + // of those (Luke, CoreInfo, Ping) alongside this one, so the banner isn't reliably still up + // by the time we'd check it. This test only guards the message text below. + forcedReplicationStatus = HttpServletResponse.SC_FORBIDDEN; + openPage(CORE + "/core-overview", By.id("dashboard")); + + // replicationMessage is always set once the request settles (to the message, or to null + // for an auth failure), so waiting for it to be defined signals the request is done. + waitUntil( + "the replication-details request should have completed", this::replicationRequestSettled); + assertTrue( + driver.findElements(By.cssSelector("#replication .message-container")).stream() + .noneMatch(e -> e.getText().contains("not configured"))); + assertNoSevereConsoleErrors("/replication"); + } + + private boolean replicationRequestSettled() { + Object settled = + ((JavascriptExecutor) driver) + .executeScript( + "var el = document.querySelector('#replication');" + + "if (!el) return false;" + + "var scope = angular.element(el).scope();" + + "return scope.replicationMessage !== undefined;"); + return Boolean.TRUE.equals(settled); + } + + /** Forces the configured HTTP status for this core's v2 replication-details call, when armed. */ + public static class ForceReplicationStatusFilter implements Filter { + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + HttpServletRequest req = (HttpServletRequest) request; + if (forcedReplicationStatus != 0 + && req.getRequestURI().endsWith("/cores/" + CORE + "/replication")) { + ((HttpServletResponse) response).sendError(forcedReplicationStatus); + return; + } + chain.doFilter(request, response); + } + } +} diff --git a/solr/webapp/web/js/angular/controllers/core-overview.js b/solr/webapp/web/js/angular/controllers/core-overview.js index 7ea076b432a..d71eeed49a8 100644 --- a/solr/webapp/web/js/angular/controllers/core-overview.js +++ b/solr/webapp/web/js/angular/controllers/core-overview.js @@ -15,8 +15,15 @@ limitations under the License. */ +// follower.leaderDetails comes from an internal v1 call to the leader, so its booleans are +// always the stringified "true"/"false" form, even though the rest of this response is v2. +// TODO: drop this once that internal leader fetch is v2-native too. +var isTruthy = function(value) { + return value === true || value === 'true'; +}; + solrAdminApp.controller('CoreOverviewController', -function($scope, $rootScope, $routeParams, Luke, CoreInfo, Update, Replication, Ping, Constants) { +function($scope, $rootScope, $routeParams, $timeout, Luke, CoreInfo, Update, ReplicationV2, Ping, Constants, ApiErrorHandler) { $scope.resetMenu("overview", Constants.IS_CORE_PAGE); $scope.refreshIndex = function() { Luke.index({core: $routeParams.core}, @@ -31,15 +38,24 @@ function($scope, $rootScope, $routeParams, Luke, CoreInfo, Update, Replication, }; $scope.refreshReplication = function() { - Replication.details({core: $routeParams.core}, - function(data) { - $scope.isFollower = data.details.isFollower == "true"; - $scope.isLeader = data.details.isLeader == "true"; + ReplicationV2.fetchReplicationDetails($routeParams.core, {}, function(error, data, response) { + $timeout(function() { + if (error) { + // An auth failure isn't about replication being unconfigured, so route it through the + // shared login-redirect/permission-banner handling instead of this message. + if (response && (response.status === 401 || response.status === 403)) { + $scope.replicationMessage = null; + ApiErrorHandler.handle(response); + } else { + $scope.replicationMessage = "Replication is not configured"; + } + return; + } + $scope.isFollower = isTruthy(data.details.isFollower); + $scope.isLeader = isTruthy(data.details.isLeader); $scope.replication = data.details; - }, - function(error) { - $scope.replicationMessage = "Replication is not configured"; }); + }); }; $scope.refreshInfo = function() { diff --git a/solr/webapp/web/js/angular/controllers/replication.js b/solr/webapp/web/js/angular/controllers/replication.js index 6e5fd50086a..a6380701919 100644 --- a/solr/webapp/web/js/angular/controllers/replication.js +++ b/solr/webapp/web/js/angular/controllers/replication.js @@ -22,33 +22,37 @@ solrAdminApp.controller('ReplicationController', $scope.iterationCount = 1; $scope.refresh = function() { - Replication.details({core:$routeParams.core}, function(response) { - var timeout; - var interval; - if ($scope.interval) $interval.cancel($scope.interval); - $scope.isFollower = (response.details.isFollower === 'true'); - if ($scope.isFollower) { - $scope.progress = getProgressDetails(response.details.follower); - $scope.iterations = getIterations(response.details.follower); - $scope.versions = getFollowerVersions(response.details); - $scope.settings = getFollowerSettings(response.details); - if ($scope.settings.isReplicating) { - timeout = $timeout($scope.refresh, 1000); - } else if(!$scope.settings.isPollingDisabled && $scope.settings.pollInterval) { - interval = $scope.interval = $interval(function() { - $scope.settings.tick--; - }, 1000, $scope.settings.tick); - timeout = $timeout($scope.refresh, 1000*(1+$scope.settings.tick)); + ReplicationV2.fetchReplicationDetails($routeParams.core, {}, function(error, data, response) { + $timeout(function() { + if (error) { ApiErrorHandler.handle(response); return; } + + var timeout; + var interval; + if ($scope.interval) $interval.cancel($scope.interval); + $scope.isFollower = isTruthy(data.details.isFollower); + if ($scope.isFollower) { + $scope.progress = getProgressDetails(data.details.follower); + $scope.iterations = getIterations(data.details.follower); + $scope.versions = getFollowerVersions(data.details); + $scope.settings = getFollowerSettings(data.details); + if ($scope.settings.isReplicating) { + timeout = $timeout($scope.refresh, 1000); + } else if(!$scope.settings.isPollingDisabled && $scope.settings.pollInterval) { + interval = $scope.interval = $interval(function() { + $scope.settings.tick--; + }, 1000, $scope.settings.tick); + timeout = $timeout($scope.refresh, 1000*(1+$scope.settings.tick)); + } + } else { + $scope.versions = getLeaderVersions(data.details); } - } else { - $scope.versions = getLeaderVersions(response.details); - } - $scope.leader = getLeaderSettings(response.details, $scope.isFollower); + $scope.leader = getLeaderSettings(data.details, $scope.isFollower); - var onRouteChangeOff = $scope.$on('$routeChangeStart', function() { - if (interval) $interval.cancel(interval); - if (timeout) $timeout.cancel(timeout); - onRouteChangeOff(); + var onRouteChangeOff = $scope.$on('$routeChangeStart', function() { + if (interval) $interval.cancel(interval); + if (timeout) $timeout.cancel(timeout); + onRouteChangeOff(); + }); }); }); @@ -64,6 +68,13 @@ solrAdminApp.controller('ReplicationController', $scope.refresh(); }); +// follower.leaderDetails comes from an internal v1 call to the leader, so its booleans are +// always the stringified "true"/"false" form, even though the rest of this response is v2. +// TODO: drop this once that internal leader fetch is v2-native too. +var isTruthy = function(value) { + return value === true || value === 'true'; +}; + var getProgressDetails = function(progress) { progress.timeRemaining = parseSeconds(progress.timeRemaining); @@ -184,9 +195,9 @@ var parseSeconds = function(time) { var getFollowerSettings = function(data) { var settings = {}; settings.leaderUrl = data.follower.leaderUrl; - settings.isPollingDisabled = data.follower.isPollingDisabled == 'true'; + settings.isPollingDisabled = isTruthy(data.follower.isPollingDisabled); settings.pollInterval = data.follower.pollInterval; - settings.isReplicating = data.follower.isReplicating == 'true'; + settings.isReplicating = isTruthy(data.follower.isReplicating); settings.nextExecutionAt = data.follower.nextExecutionAt; if(settings.isReplicating) { @@ -209,7 +220,7 @@ var getFollowerSettings = function(data) { var getLeaderSettings = function(details, isFollower) { var leader = {}; var leaderData = isFollower ? details.follower.leaderDetails.leader : details.leader; - leader.replicationEnabled = leaderData.replicationEnabled == "true"; + leader.replicationEnabled = isTruthy(leaderData.replicationEnabled); leader.replicateAfter = leaderData.replicateAfter.join(", "); if (leaderData.confFiles) { diff --git a/solr/webapp/web/js/angular/services.js b/solr/webapp/web/js/angular/services.js index 42105030d76..3a85680e9d3 100644 --- a/solr/webapp/web/js/angular/services.js +++ b/solr/webapp/web/js/angular/services.js @@ -17,6 +17,10 @@ var solrAdminServices = angular.module('solrAdminServices', ['ngResource']); +// The generated v2 client caches GET responses by default. Set this here, once, since +// ApiClient.instance is shared across every *V2 factory below. +solrApi.ApiClient.instance.cache = false; + solrAdminServices.factory('Metrics', ['$resource', 'PrometheusParser', function($resource, PrometheusParser) { return $resource('admin/metrics', {"wt":"prometheus", "node": "@node", "_":Date.now()}, {
