This is an automated email from the ASF dual-hosted git repository.
epugh pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/solr.git
The following commit(s) were added to refs/heads/main by this push:
new bbfbe465d88 SOLR-16398: Migrate admin UI's replication details fetch
to the v2 API (#5241)
bbfbe465d88 is described below
commit bbfbe465d88107fd8b7b193bd4532ff092892e39
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)
---
.../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 42c2e2700f1..0badbcf2b27 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();
+ });
});
});
@@ -73,6 +77,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);
@@ -193,9 +204,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) {
@@ -218,7 +229,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 bde85be4bc3..39441a90724 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()}, {