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 c6866f59fc1ecde267e13a0b6d553d2a6a978986
Author: Jalaz Kumar <[email protected]>
AuthorDate: Fri Oct 23 06:52:34 2026 +0530

    SOLR-16398: Add V2 JAX-RS API for replication details (#5179)
    
    Co-authored-by: Eric Pugh <[email protected]>
    (cherry picked from commit 5b2ac4dea21a7c86e31e2ba0cea58c262dc558c8)
---
 .../unreleased/SOLR-16398-replication-details.yml  |  11 ++
 .../solr/client/api/endpoint/ReplicationApis.java  |  14 ++
 .../api/model/ReplicationDetailsResponse.java      | 207 ++++++++++++++++++++
 .../apache/solr/handler/ReplicationHandler.java    | 209 ++++++++++++++-------
 .../solr/handler/admin/api/ClusterCommands.java    |  28 ++-
 .../solr/handler/admin/api/CoreReplication.java    |   9 +
 .../solr/handler/admin/api/ReplicationAPIBase.java |   7 +
 .../org/apache/solr/handler/api/V2ApiUtils.java    |  38 +++-
 .../handler/admin/api/CoreReplicationAPITest.java  |  30 +++
 .../admin/api/FetchReplicationDetailsAPITest.java  |  69 +++++++
 .../pages/user-managed-index-replication.adoc      |  20 +-
 11 files changed, 567 insertions(+), 75 deletions(-)

diff --git a/changelog/unreleased/SOLR-16398-replication-details.yml 
b/changelog/unreleased/SOLR-16398-replication-details.yml
new file mode 100644
index 00000000000..19808888bf0
--- /dev/null
+++ b/changelog/unreleased/SOLR-16398-replication-details.yml
@@ -0,0 +1,11 @@
+title:
+   Add V2 JAX-RS API for replication details (GET 
/api/cores/{coreName}/replication)
+type: added
+authors:
+  - name: Jalaz Kumar
+    nick: jaykay12
+  - name: Eric Pugh
+    nick: epugh
+links:
+  - name: SOLR-16398
+    url: https://issues.apache.org/jira/browse/SOLR-16398
diff --git 
a/solr/api/src/java/org/apache/solr/client/api/endpoint/ReplicationApis.java 
b/solr/api/src/java/org/apache/solr/client/api/endpoint/ReplicationApis.java
index 1f0476f8661..f95335dc407 100644
--- a/solr/api/src/java/org/apache/solr/client/api/endpoint/ReplicationApis.java
+++ b/solr/api/src/java/org/apache/solr/client/api/endpoint/ReplicationApis.java
@@ -31,11 +31,25 @@ import jakarta.ws.rs.core.StreamingOutput;
 import java.io.IOException;
 import org.apache.solr.client.api.model.FileListResponse;
 import org.apache.solr.client.api.model.IndexVersionResponse;
+import org.apache.solr.client.api.model.ReplicationDetailsResponse;
 import org.apache.solr.client.api.util.CoreApiParameters;
 
 @Path("/cores/{coreName}/replication")
 public interface ReplicationApis {
 
+  @GET
+  @CoreApiParameters
+  @Operation(
+      summary = "Return replication configuration and current status.",
+      tags = {"replication"})
+  ReplicationDetailsResponse fetchReplicationDetails(
+      @Parameter(
+              description = "When true on a follower, include nested details 
fetched from leader",
+              required = false)
+          @QueryParam("follower")
+          @DefaultValue("true")
+          Boolean follower);
+
   @GET
   @CoreApiParameters
   @Path("/indexversion")
diff --git 
a/solr/api/src/java/org/apache/solr/client/api/model/ReplicationDetailsResponse.java
 
b/solr/api/src/java/org/apache/solr/client/api/model/ReplicationDetailsResponse.java
new file mode 100644
index 00000000000..9a1054c5240
--- /dev/null
+++ 
b/solr/api/src/java/org/apache/solr/client/api/model/ReplicationDetailsResponse.java
@@ -0,0 +1,207 @@
+/*
+ * 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.client.api.model;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.util.List;
+import org.apache.solr.client.api.util.ReflectWritable;
+
+public class ReplicationDetailsResponse extends SolrJerseyResponse {
+
+  @JsonProperty("status")
+  @Schema(description = "Request status, typically OK")
+  public String status;
+
+  @JsonProperty("details")
+  @Schema(description = "Replication configuration and status details")
+  public ReplicationDetails details;
+
+  public ReplicationDetailsResponse() {}
+
+  public ReplicationDetailsResponse(ReplicationDetails details, String status) 
{
+    this.details = details;
+    this.status = status;
+  }
+
+  /** Top-level replication details payload (nested under {@code details} in 
the response). */
+  public static class ReplicationDetails implements ReflectWritable {
+    @JsonProperty("indexSize")
+    public String indexSize;
+
+    @JsonProperty("indexPath")
+    public String indexPath;
+
+    @JsonProperty("commits")
+    public List<CommitDetails> commits;
+
+    @JsonProperty("isLeader")
+    public Boolean isLeader;
+
+    @JsonProperty("isFollower")
+    public Boolean isFollower;
+
+    @JsonProperty("indexVersion")
+    public Long indexVersion;
+
+    @JsonProperty("generation")
+    public Long generation;
+
+    @JsonProperty("leader")
+    public LeaderDetails leader;
+
+    @JsonProperty("follower")
+    public FollowerDetails follower;
+
+    /** Optional snapshot/backup stats (heterogeneous NamedList from the 
handler). */
+    @JsonProperty("backup")
+    public Object backup;
+  }
+
+  public static class CommitDetails implements ReflectWritable {
+    @JsonProperty("indexVersion")
+    public Long indexVersion;
+
+    @JsonProperty("generation")
+    public Long generation;
+
+    @JsonProperty("filelist")
+    public List<String> filelist;
+  }
+
+  public static class LeaderDetails implements ReflectWritable {
+    @JsonProperty("confFiles")
+    public String confFiles;
+
+    @JsonProperty("replicateAfter")
+    public List<String> replicateAfter;
+
+    @JsonProperty("replicationEnabled")
+    public Boolean replicationEnabled;
+
+    @JsonProperty("replicableVersion")
+    public Long replicableVersion;
+
+    @JsonProperty("replicableGeneration")
+    public Long replicableGeneration;
+  }
+
+  public static class FollowerDetails implements ReflectWritable {
+    /** Nested leader details (remote NamedList or map), present when 
follower=true. */
+    @JsonProperty("leaderDetails")
+    public Object leaderDetails;
+
+    @JsonProperty("ERROR")
+    public String error;
+
+    @JsonProperty("leaderUrl")
+    public String leaderUrl;
+
+    @JsonProperty("pollInterval")
+    public String pollInterval;
+
+    @JsonProperty("nextExecutionAt")
+    public String nextExecutionAt;
+
+    @JsonProperty("indexReplicatedAt")
+    public String indexReplicatedAt;
+
+    @JsonProperty("indexReplicatedAtList")
+    public List<String> indexReplicatedAtList;
+
+    @JsonProperty("replicationFailedAtList")
+    public List<String> replicationFailedAtList;
+
+    @JsonProperty("timesIndexReplicated")
+    public Integer timesIndexReplicated;
+
+    @JsonProperty("confFilesReplicated")
+    public String confFilesReplicated;
+
+    @JsonProperty("timesConfigReplicated")
+    public Integer timesConfigReplicated;
+
+    @JsonProperty("confFilesReplicatedAt")
+    public String confFilesReplicatedAt;
+
+    @JsonProperty("lastCycleBytesDownloaded")
+    public Long lastCycleBytesDownloaded;
+
+    @JsonProperty("timesFailed")
+    public Integer timesFailed;
+
+    @JsonProperty("replicationFailedAt")
+    public String replicationFailedAt;
+
+    @JsonProperty("previousCycleTimeInSeconds")
+    public Long previousCycleTimeInSeconds;
+
+    @JsonProperty("currentDate")
+    public String currentDate;
+
+    @JsonProperty("isPollingDisabled")
+    public Boolean isPollingDisabled;
+
+    @JsonProperty("isReplicating")
+    public Boolean isReplicating;
+
+    @JsonProperty("filesToDownload")
+    public List<String> filesToDownload;
+
+    @JsonProperty("numFilesToDownload")
+    public String numFilesToDownload;
+
+    @JsonProperty("bytesToDownload")
+    public String bytesToDownload;
+
+    @JsonProperty("filesDownloaded")
+    public List<String> filesDownloaded;
+
+    @JsonProperty("numFilesDownloaded")
+    public String numFilesDownloaded;
+
+    @JsonProperty("replicationStartTime")
+    public String replicationStartTime;
+
+    @JsonProperty("timeElapsed")
+    public String timeElapsed;
+
+    @JsonProperty("currentFile")
+    public String currentFile;
+
+    @JsonProperty("currentFileSize")
+    public String currentFileSize;
+
+    @JsonProperty("currentFileSizeDownloaded")
+    public String currentFileSizeDownloaded;
+
+    @JsonProperty("currentFileSizePercent")
+    public String currentFileSizePercent;
+
+    @JsonProperty("bytesDownloaded")
+    public String bytesDownloaded;
+
+    @JsonProperty("totalPercent")
+    public String totalPercent;
+
+    @JsonProperty("timeRemaining")
+    public String timeRemaining;
+
+    @JsonProperty("downloadSpeed")
+    public String downloadSpeed;
+  }
+}
diff --git a/solr/core/src/java/org/apache/solr/handler/ReplicationHandler.java 
b/solr/core/src/java/org/apache/solr/handler/ReplicationHandler.java
index 01058b59c9b..8287459f28f 100644
--- a/solr/core/src/java/org/apache/solr/handler/ReplicationHandler.java
+++ b/solr/core/src/java/org/apache/solr/handler/ReplicationHandler.java
@@ -62,7 +62,6 @@ import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.locks.ReentrantLock;
-import java.util.function.BiConsumer;
 import java.util.function.Consumer;
 import java.util.regex.Matcher;
 import java.util.zip.Adler32;
@@ -74,9 +73,15 @@ import org.apache.lucene.index.IndexWriter;
 import org.apache.lucene.store.Directory;
 import org.apache.lucene.store.IOContext;
 import org.apache.lucene.store.IndexInput;
+import org.apache.solr.api.Api;
 import org.apache.solr.api.JerseyResource;
 import org.apache.solr.client.api.model.FileMetaData;
 import org.apache.solr.client.api.model.IndexVersionResponse;
+import org.apache.solr.client.api.model.ReplicationDetailsResponse;
+import 
org.apache.solr.client.api.model.ReplicationDetailsResponse.CommitDetails;
+import 
org.apache.solr.client.api.model.ReplicationDetailsResponse.FollowerDetails;
+import 
org.apache.solr.client.api.model.ReplicationDetailsResponse.LeaderDetails;
+import 
org.apache.solr.client.api.model.ReplicationDetailsResponse.ReplicationDetails;
 import org.apache.solr.client.api.model.SolrJerseyResponse;
 import org.apache.solr.common.SolrException;
 import org.apache.solr.common.SolrException.ErrorCode;
@@ -324,7 +329,14 @@ public class ReplicationHandler extends RequestHandlerBase
     } else if (command.equals(CMD_SHOW_COMMITS)) {
       populateCommitInfo(rsp);
     } else if (command.equals(CMD_DETAILS)) {
-      getReplicationDetails(rsp, solrParams.getBool("follower", true));
+      final CoreReplication coreReplicationAPI = new CoreReplication(core, 
req, rsp);
+      // stringifyBooleans=true: V1's command=details has always rendered 
isLeader/isFollower/etc.
+      // as Strings, while the v2 API exposes them as proper JSON booleans.
+      V2ApiUtils.squashObjectIntoNamedList(
+          rsp.getValues(),
+          
coreReplicationAPI.fetchReplicationDetails(solrParams.getBool("follower", 
true)),
+          true,
+          true);
     } else if (CMD_ENABLE_REPL.equalsIgnoreCase(command)) {
       replicationEnabled.set(true);
       rsp.add(STATUS, OK_STATUS);
@@ -1018,63 +1030,65 @@ public class ReplicationHandler extends 
RequestHandlerBase
   // TODO Should a failure retrieving any piece of info mark the overall 
request as a failure?  Is
   // there a core set of values that are required to make a response here 
useful?
   /** Used for showing statistics and progress information. */
-  private NamedList<Object> getReplicationDetails(
-      SolrQueryResponse rsp, boolean showFollowerDetails) {
-    NamedList<Object> details = new SimpleOrderedMap<>();
-    NamedList<Object> leader = new SimpleOrderedMap<>();
-    NamedList<Object> follower = new SimpleOrderedMap<>();
-
-    details.add("indexSize", NumberUtils.readableSize(core.getIndexSize()));
-    details.add("indexPath", core.getIndexDir());
-    details.add(CMD_SHOW_COMMITS, getCommits());
-    details.add("isLeader", String.valueOf(isLeader));
-    details.add("isFollower", String.valueOf(isFollower));
+  public ReplicationDetailsResponse getReplicationDetails(boolean 
showFollowerDetails) {
+    ReplicationDetails details = new ReplicationDetails();
+    LeaderDetails leader = new LeaderDetails();
+    FollowerDetails follower = new FollowerDetails();
+
+    boolean shouldFollowerBePopulated = false;
+
+    details.indexSize = NumberUtils.readableSize(core.getIndexSize());
+    details.indexPath = core.getIndexDir();
+    details.commits = getCommitDetails();
+    details.isLeader = isLeader;
+    details.isFollower = isFollower;
     CommitVersionInfo vInfo = getIndexVersion();
-    details.add("indexVersion", null == vInfo ? 0 : vInfo.version);
-    details.add(GENERATION, null == vInfo ? 0 : vInfo.generation);
+    details.indexVersion = null == vInfo ? 0 : vInfo.version;
+    details.generation = null == vInfo ? 0 : vInfo.generation;
 
     IndexCommit commit = indexCommitPoint; // make a copy so it won't change
 
     if (isLeader) {
-      if (includeConfFiles != null) leader.add(CONF_FILES, includeConfFiles);
-      leader.add(REPLICATE_AFTER, getReplicateAfterStrings());
-      leader.add("replicationEnabled", 
String.valueOf(replicationEnabled.get()));
+      if (includeConfFiles != null) leader.confFiles = includeConfFiles;
+      leader.replicateAfter = getReplicateAfterStrings();
+      leader.replicationEnabled = replicationEnabled.get();
     }
 
     if (isLeader && commit != null) {
       CommitVersionInfo repCommitInfo = CommitVersionInfo.build(commit);
-      leader.add("replicableVersion", repCommitInfo.version);
-      leader.add("replicableGeneration", repCommitInfo.generation);
+      leader.replicableVersion = repCommitInfo.version;
+      leader.replicableGeneration = repCommitInfo.generation;
     }
 
     IndexFetcher fetcher = currentIndexFetcher;
     if (fetcher != null) {
+      shouldFollowerBePopulated = true;
       Properties props = loadReplicationProperties();
       if (showFollowerDetails) {
         try {
           NamedList<Object> nl = fetcher.getDetails();
-          follower.add("leaderDetails", nl.get(CMD_DETAILS));
+          follower.leaderDetails = nl.get(CMD_DETAILS);
         } catch (Exception e) {
           log.warn("Exception while invoking 'details' method for replication 
on leader ", e);
-          follower.add(ERR_STATUS, "invalid_leader");
+          follower.error = "invalid_leader";
         }
       }
-      follower.add(LEADER_URL, fetcher.getLeaderCoreUrl());
+      follower.leaderUrl = fetcher.getLeaderCoreUrl();
       if (getPollInterval() != null) {
-        follower.add(ReplicationAPIBase.POLL_INTERVAL, getPollInterval());
+        follower.pollInterval = getPollInterval();
       }
       Date nextScheduled = getNextScheduledExecTime();
       if (nextScheduled != null && !isPollingDisabled()) {
-        follower.add(NEXT_EXECUTION_AT, nextScheduled.toString());
+        follower.nextExecutionAt = nextScheduled.toString();
       } else if (isPollingDisabled()) {
-        follower.add(NEXT_EXECUTION_AT, "Polling disabled");
+        follower.nextExecutionAt = "Polling disabled";
       }
-      addReplicationProperties(follower::add, props);
+      addReplicationProperties(follower, props);
 
-      follower.add("currentDate", new Date().toString());
-      follower.add("isPollingDisabled", String.valueOf(isPollingDisabled()));
+      follower.currentDate = new Date().toString();
+      follower.isPollingDisabled = isPollingDisabled();
       boolean isReplicating = isReplicating();
-      follower.add("isReplicating", String.valueOf(isReplicating));
+      follower.isReplicating = isReplicating;
       if (isReplicating) {
         try {
           long bytesToDownload = 0;
@@ -1090,9 +1104,9 @@ public class ReplicationHandler extends RequestHandlerBase
             bytesToDownload += (Long) file.get(SIZE);
           }
 
-          follower.add("filesToDownload", filesToDownload);
-          follower.add("numFilesToDownload", 
String.valueOf(filesToDownload.size()));
-          follower.add("bytesToDownload", 
NumberUtils.readableSize(bytesToDownload));
+          follower.filesToDownload = filesToDownload;
+          follower.numFilesToDownload = String.valueOf(filesToDownload.size());
+          follower.bytesToDownload = NumberUtils.readableSize(bytesToDownload);
 
           long bytesDownloaded = 0;
           List<String> filesDownloaded = new ArrayList<>();
@@ -1121,17 +1135,17 @@ public class ReplicationHandler extends 
RequestHandlerBase
                 percentDownloaded = (float) (currFileSizeDownloaded * 100) / 
currFileSize;
             }
           }
-          follower.add("filesDownloaded", filesDownloaded);
-          follower.add("numFilesDownloaded", 
String.valueOf(filesDownloaded.size()));
+          follower.filesDownloaded = filesDownloaded;
+          follower.numFilesDownloaded = String.valueOf(filesDownloaded.size());
 
           long estimatedTimeRemaining = 0;
 
           Date replicationStartTimeStamp = 
fetcher.getReplicationStartTimeStamp();
           if (replicationStartTimeStamp != null) {
-            follower.add("replicationStartTime", 
replicationStartTimeStamp.toString());
+            follower.replicationStartTime = 
replicationStartTimeStamp.toString();
           }
           long elapsed = fetcher.getReplicationTimeElapsed();
-          follower.add("timeElapsed", elapsed + "s");
+          follower.timeElapsed = elapsed + "s";
 
           if (bytesDownloaded > 0)
             estimatedTimeRemaining =
@@ -1140,53 +1154,105 @@ public class ReplicationHandler extends 
RequestHandlerBase
           long downloadSpeed = 0;
           if (bytesToDownload > 0) totalPercent = (float) (bytesDownloaded * 
100) / bytesToDownload;
           if (elapsed > 0) downloadSpeed = (bytesDownloaded / elapsed);
-          if (currFile != null) follower.add("currentFile", currFile);
-          follower.add("currentFileSize", 
NumberUtils.readableSize(currFileSize));
-          follower.add(
-              "currentFileSizeDownloaded", 
NumberUtils.readableSize(currFileSizeDownloaded));
-          follower.add("currentFileSizePercent", 
String.valueOf(percentDownloaded));
-          follower.add("bytesDownloaded", 
NumberUtils.readableSize(bytesDownloaded));
-          follower.add("totalPercent", String.valueOf(totalPercent));
-          follower.add("timeRemaining", String.valueOf(estimatedTimeRemaining) 
+ "s");
-          follower.add("downloadSpeed", 
NumberUtils.readableSize(downloadSpeed));
+          if (currFile != null) follower.currentFile = currFile;
+          follower.currentFileSize = NumberUtils.readableSize(currFileSize);
+          follower.currentFileSizeDownloaded = 
NumberUtils.readableSize(currFileSizeDownloaded);
+          follower.currentFileSizePercent = String.valueOf(percentDownloaded);
+          follower.bytesDownloaded = NumberUtils.readableSize(bytesDownloaded);
+          follower.totalPercent = String.valueOf(totalPercent);
+          follower.timeRemaining = estimatedTimeRemaining + "s";
+          follower.downloadSpeed = NumberUtils.readableSize(downloadSpeed);
         } catch (Exception e) {
           log.error("Exception while writing replication details: ", e);
         }
       }
     }
 
-    if (isLeader) details.add("leader", leader);
-    if (follower.size() > 0) details.add("follower", follower);
+    if (isLeader) details.leader = leader;
+    if (shouldFollowerBePopulated) details.follower = follower;
 
     NamedList<?> snapshotStats = snapShootDetails;
-    if (snapshotStats != null) details.add(CMD_BACKUP, snapshotStats);
+    if (snapshotStats != null) details.backup = snapshotStats;
 
-    if (rsp.getValues().get(STATUS) == null) {
-      rsp.add(STATUS, OK_STATUS);
-    }
-    rsp.add(CMD_DETAILS, details);
-    return details;
+    return new ReplicationDetailsResponse(details, OK_STATUS);
   }
 
-  private void addReplicationProperties(BiConsumer<String, Object> consumer, 
Properties props) {
-    addVal(consumer, IndexFetcher.INDEX_REPLICATED_AT, props, Date.class);
-    addVal(consumer, IndexFetcher.INDEX_REPLICATED_AT_LIST, props, List.class);
-    addVal(consumer, IndexFetcher.REPLICATION_FAILED_AT_LIST, props, 
List.class);
-    addVal(consumer, IndexFetcher.TIMES_INDEX_REPLICATED, props, 
Integer.class);
-    addVal(consumer, IndexFetcher.CONF_FILES_REPLICATED, props, String.class);
-    addVal(consumer, IndexFetcher.TIMES_CONFIG_REPLICATED, props, 
Integer.class);
-    addVal(consumer, IndexFetcher.CONF_FILES_REPLICATED_AT, props, Date.class);
-    addVal(consumer, IndexFetcher.LAST_CYCLE_BYTES_DOWNLOADED, props, 
Long.class);
-    addVal(consumer, IndexFetcher.TIMES_FAILED, props, Integer.class);
-    addVal(consumer, IndexFetcher.REPLICATION_FAILED_AT, props, Date.class);
-    addVal(consumer, IndexFetcher.PREVIOUS_CYCLE_TIME_TAKEN, props, 
Long.class);
+  @SuppressWarnings("unchecked")
+  private void addReplicationProperties(FollowerDetails followerDetails, 
Properties props) {
+    addVal(
+        v -> followerDetails.indexReplicatedAt = (String) v,
+        IndexFetcher.INDEX_REPLICATED_AT,
+        props,
+        Date.class);
+    addVal(
+        v -> followerDetails.indexReplicatedAtList = (List<String>) v,
+        IndexFetcher.INDEX_REPLICATED_AT_LIST,
+        props,
+        List.class);
+    addVal(
+        v -> followerDetails.replicationFailedAtList = (List<String>) v,
+        IndexFetcher.REPLICATION_FAILED_AT_LIST,
+        props,
+        List.class);
+    addVal(
+        v -> followerDetails.timesIndexReplicated = (Integer) v,
+        IndexFetcher.TIMES_INDEX_REPLICATED,
+        props,
+        Integer.class);
+    addVal(
+        v -> followerDetails.confFilesReplicated = (String) v,
+        IndexFetcher.CONF_FILES_REPLICATED,
+        props,
+        String.class);
+    addVal(
+        v -> followerDetails.timesConfigReplicated = (Integer) v,
+        IndexFetcher.TIMES_CONFIG_REPLICATED,
+        props,
+        Integer.class);
+    addVal(
+        v -> followerDetails.confFilesReplicatedAt = (String) v,
+        IndexFetcher.CONF_FILES_REPLICATED_AT,
+        props,
+        Date.class);
+    addVal(
+        v -> followerDetails.lastCycleBytesDownloaded = (Long) v,
+        IndexFetcher.LAST_CYCLE_BYTES_DOWNLOADED,
+        props,
+        Long.class);
+    addVal(
+        v -> followerDetails.timesFailed = (Integer) v,
+        IndexFetcher.TIMES_FAILED,
+        props,
+        Integer.class);
+    addVal(
+        v -> followerDetails.replicationFailedAt = (String) v,
+        IndexFetcher.REPLICATION_FAILED_AT,
+        props,
+        Date.class);
+    addVal(
+        v -> followerDetails.previousCycleTimeInSeconds = (Long) v,
+        IndexFetcher.PREVIOUS_CYCLE_TIME_TAKEN,
+        props,
+        Long.class);
   }
 
-  private void addVal(
-      BiConsumer<String, Object> consumer, String key, Properties props, 
Class<?> clzz) {
+  @SuppressWarnings("unchecked")
+  private List<CommitDetails> getCommitDetails() {
+    List<CommitDetails> commits = new ArrayList<>();
+    for (NamedList<Object> namedList : getCommits()) {
+      CommitDetails commitDetail = new CommitDetails();
+      commitDetail.indexVersion = (Long) namedList.get("indexVersion");
+      commitDetail.generation = (Long) namedList.get(GENERATION);
+      commitDetail.filelist = (List<String>) namedList.get(CMD_GET_FILE_LIST);
+      commits.add(commitDetail);
+    }
+    return commits;
+  }
+
+  private void addVal(Consumer<Object> consumer, String key, Properties props, 
Class<?> clzz) {
     Object val = formatVal(key, props, clzz);
     if (val != null) {
-      consumer.accept(key, val);
+      consumer.accept(val);
     }
   }
 
@@ -1467,6 +1533,11 @@ public class ReplicationHandler extends 
RequestHandlerBase
     return Boolean.TRUE;
   }
 
+  @Override
+  public Collection<Api> getApis() {
+    return List.of();
+  }
+
   // check leader or follower is enabled
   private boolean isEnabled(NamedList<?> params) {
     if (params == null) return false;
diff --git 
a/solr/core/src/java/org/apache/solr/handler/admin/api/ClusterCommands.java 
b/solr/core/src/java/org/apache/solr/handler/admin/api/ClusterCommands.java
index 4094f373205..c70b56ed3e6 100644
--- a/solr/core/src/java/org/apache/solr/handler/admin/api/ClusterCommands.java
+++ b/solr/core/src/java/org/apache/solr/handler/admin/api/ClusterCommands.java
@@ -255,11 +255,31 @@ public class ClusterCommands extends AdminAPIBase 
implements ClusterCommandsApi
       return;
     }
     final Object statusVal = values.getVal(idx);
+    final String stateKey;
+    final String msgKey;
     if (statusVal instanceof CommandStatus commandStatus) {
-      final SimpleOrderedMap<String> status = new SimpleOrderedMap<>();
-      status.add("state", commandStatus.state.getKey());
-      status.add("msg", commandStatus.msg);
-      values.setVal(idx, status);
+      stateKey = commandStatus.state == null ? null : 
commandStatus.state.getKey();
+      msgKey = commandStatus.msg;
+    } else if (statusVal instanceof NamedList<?> namedList) {
+      stateKey = requestStateWireKey(namedList.get("state"));
+      msgKey = namedList.get("msg") == null ? null : 
namedList.get("msg").toString();
+    } else {
+      return;
+    }
+
+    final SimpleOrderedMap<String> status = new SimpleOrderedMap<>();
+    status.add("state", stateKey);
+    status.add("msg", msgKey);
+    values.setVal(idx, status);
+  }
+
+  private static String requestStateWireKey(Object state) {
+    if (state == null) {
+      return null;
+    }
+    if (state instanceof CommandStatus.RequestState requestState) {
+      return requestState.getKey();
     }
+    return CommandStatus.RequestState.fromKey(state.toString()).getKey();
   }
 }
diff --git 
a/solr/core/src/java/org/apache/solr/handler/admin/api/CoreReplication.java 
b/solr/core/src/java/org/apache/solr/handler/admin/api/CoreReplication.java
index 1389c6e780f..9985eb05a67 100644
--- a/solr/core/src/java/org/apache/solr/handler/admin/api/CoreReplication.java
+++ b/solr/core/src/java/org/apache/solr/handler/admin/api/CoreReplication.java
@@ -24,6 +24,7 @@ import java.io.IOException;
 import org.apache.solr.client.api.endpoint.ReplicationApis;
 import org.apache.solr.client.api.model.FileListResponse;
 import org.apache.solr.client.api.model.IndexVersionResponse;
+import org.apache.solr.client.api.model.ReplicationDetailsResponse;
 import org.apache.solr.common.SolrException;
 import org.apache.solr.core.SolrCore;
 import org.apache.solr.jersey.PermissionName;
@@ -42,6 +43,14 @@ public class CoreReplication extends ReplicationAPIBase 
implements ReplicationAp
     super(solrCore, req, rsp);
   }
 
+  @Override
+  @PermissionName(CORE_READ_PERM)
+  public ReplicationDetailsResponse fetchReplicationDetails(Boolean follower) {
+    // Boxed (rather than the primitive boolean used elsewhere for similar 
flags) so this method
+    // can also be called directly, bypassing the JAX-RS @DefaultValue, with 
no value supplied.
+    return doFetchReplicationDetails(follower == null || follower);
+  }
+
   @Override
   @PermissionName(CORE_READ_PERM)
   public IndexVersionResponse fetchIndexVersion() throws IOException {
diff --git 
a/solr/core/src/java/org/apache/solr/handler/admin/api/ReplicationAPIBase.java 
b/solr/core/src/java/org/apache/solr/handler/admin/api/ReplicationAPIBase.java
index 743aad55e88..378c5699d31 100644
--- 
a/solr/core/src/java/org/apache/solr/handler/admin/api/ReplicationAPIBase.java
+++ 
b/solr/core/src/java/org/apache/solr/handler/admin/api/ReplicationAPIBase.java
@@ -48,6 +48,7 @@ import org.apache.solr.api.JerseyResource;
 import org.apache.solr.client.api.model.FileListResponse;
 import org.apache.solr.client.api.model.FileMetaData;
 import org.apache.solr.client.api.model.IndexVersionResponse;
+import org.apache.solr.client.api.model.ReplicationDetailsResponse;
 import org.apache.solr.common.SolrException;
 import org.apache.solr.common.util.FastOutputStream;
 import org.apache.solr.common.util.IOUtils;
@@ -93,6 +94,12 @@ public abstract class ReplicationAPIBase extends 
JerseyResource {
     this.solrQueryResponse = solrQueryResponse;
   }
 
+  protected ReplicationDetailsResponse doFetchReplicationDetails(boolean 
showFollowerDetails) {
+    ReplicationHandler replicationHandler =
+        (ReplicationHandler) 
solrCore.getRequestHandler(ReplicationHandler.PATH);
+    return replicationHandler.getReplicationDetails(showFollowerDetails);
+  }
+
   protected IndexVersionResponse doFetchIndexVersion() throws IOException {
     ReplicationHandler replicationHandler =
         (ReplicationHandler) 
solrCore.getRequestHandler(ReplicationHandler.PATH);
diff --git a/solr/core/src/java/org/apache/solr/handler/api/V2ApiUtils.java 
b/solr/core/src/java/org/apache/solr/handler/api/V2ApiUtils.java
index 25758ccee03..478c7c09be6 100644
--- a/solr/core/src/java/org/apache/solr/handler/api/V2ApiUtils.java
+++ b/solr/core/src/java/org/apache/solr/handler/api/V2ApiUtils.java
@@ -23,12 +23,15 @@ import static 
org.apache.solr.handler.admin.api.ReplicationAPIBase.FILE_STREAM;
 
 import com.fasterxml.jackson.annotation.JsonAnyGetter;
 import com.fasterxml.jackson.annotation.JsonProperty;
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
 import org.apache.solr.client.api.model.SolrJerseyResponse;
+import org.apache.solr.client.api.util.ReflectWritable;
 import org.apache.solr.common.MapWriter.EntryWriter;
 import org.apache.solr.common.params.SolrParams;
 import org.apache.solr.common.util.NamedList;
+import org.apache.solr.common.util.SimpleOrderedMap;
 import org.apache.solr.common.util.StrUtils;
 import org.apache.solr.common.util.Utils;
 import org.apache.solr.response.PrometheusResponseWriter;
@@ -112,6 +115,20 @@ public class V2ApiUtils {
 
   public static void squashObjectIntoNamedList(
       NamedList<Object> destination, Object o, boolean trimHeader) {
+    squashObjectIntoNamedList(destination, o, trimHeader, false);
+  }
+
+  /**
+   * Like {@link #squashObjectIntoNamedList(NamedList, Object, boolean)}, but 
renders {@code
+   * Boolean} leaf values as their String form ("true"/"false") instead of a 
JSON boolean.
+   *
+   * <p>Use only when a V1 API must preserve a legacy String-typed boolean 
field that the same data
+   * now exposes as a properly-typed {@code Boolean} on its v2 response POJO - 
see {@code
+   * ReplicationHandler#getReplicationDetails}, whose {@code isLeader}/{@code 
isFollower}/etc.
+   * fields have always been Strings in the V1 {@code command=details} 
response.
+   */
+  public static void squashObjectIntoNamedList(
+      NamedList<Object> destination, Object o, boolean trimHeader, boolean 
stringifyBooleans) {
     final var ew =
         new EntryWriter() {
           @Override
@@ -120,7 +137,7 @@ public class V2ApiUtils {
             if (trimHeader && kStr.equals("responseHeader")) {
               return null;
             }
-            destination.add(kStr, value);
+            destination.add(kStr, convertReflectWriteableValue(value, 
stringifyBooleans));
             return this; // returning "this" means we can't use a lambda :-(
           }
         };
@@ -135,4 +152,23 @@ public class V2ApiUtils {
           return prop.value().isEmpty() ? field.getName() : prop.value();
         });
   }
+
+  private static Object convertReflectWriteableValue(Object value, boolean 
stringifyBooleans) {
+    if (stringifyBooleans && value instanceof Boolean bool) {
+      return bool.toString();
+    }
+    if (value instanceof ReflectWritable) {
+      NamedList<Object> result = new SimpleOrderedMap<>();
+      squashObjectIntoNamedList(result, value, true, stringifyBooleans);
+      return result;
+    }
+    if (value instanceof List<?> list) {
+      List<Object> objects = new ArrayList<>(list.size());
+      for (Object item : list) {
+        objects.add(convertReflectWriteableValue(item, stringifyBooleans));
+      }
+      return objects;
+    }
+    return value;
+  }
 }
diff --git 
a/solr/core/src/test/org/apache/solr/handler/admin/api/CoreReplicationAPITest.java
 
b/solr/core/src/test/org/apache/solr/handler/admin/api/CoreReplicationAPITest.java
index cd7de60c095..8406f8db35b 100644
--- 
a/solr/core/src/test/org/apache/solr/handler/admin/api/CoreReplicationAPITest.java
+++ 
b/solr/core/src/test/org/apache/solr/handler/admin/api/CoreReplicationAPITest.java
@@ -21,6 +21,7 @@ import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
 import io.opentelemetry.api.trace.Span;
+import jakarta.ws.rs.core.Response;
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
@@ -32,6 +33,8 @@ import org.apache.solr.SolrTestCaseJ4;
 import org.apache.solr.client.api.model.FileListResponse;
 import org.apache.solr.client.api.model.FileMetaData;
 import org.apache.solr.client.api.model.IndexVersionResponse;
+import org.apache.solr.client.api.model.ReplicationDetailsResponse;
+import 
org.apache.solr.client.api.model.ReplicationDetailsResponse.ReplicationDetails;
 import org.apache.solr.common.SolrException;
 import org.apache.solr.core.SolrCore;
 import org.apache.solr.core.SolrResourceLoader;
@@ -70,6 +73,33 @@ public class CoreReplicationAPITest extends SolrTestCaseJ4 {
     coreReplicationAPI = new CoreReplicationAPIMock(mockCore, 
mockQueryRequest, queryResponse);
   }
 
+  @Test
+  public void testFetchReplicationDetails() {
+    ReplicationDetails details = new ReplicationDetails();
+    details.isLeader = true;
+    details.isFollower = false;
+    details.indexVersion = 42L;
+    details.generation = 7L;
+    ReplicationDetailsResponse expectedResponse =
+        new ReplicationDetailsResponse(details, 
Response.Status.OK.getReasonPhrase());
+
+    
when(mockReplicationHandler.getReplicationDetails(true)).thenReturn(expectedResponse);
+    
when(mockReplicationHandler.getReplicationDetails(false)).thenReturn(expectedResponse);
+
+    ReplicationDetailsResponse withDefaultFollower =
+        coreReplicationAPI.fetchReplicationDetails(null);
+    assertSame(expectedResponse, withDefaultFollower);
+    assertEquals(Boolean.TRUE, withDefaultFollower.details.isLeader);
+    assertEquals(Long.valueOf(42L), withDefaultFollower.details.indexVersion);
+
+    ReplicationDetailsResponse withFollowerTrue = 
coreReplicationAPI.fetchReplicationDetails(true);
+    assertSame(expectedResponse, withFollowerTrue);
+
+    ReplicationDetailsResponse withFollowerFalse =
+        coreReplicationAPI.fetchReplicationDetails(false);
+    assertSame(expectedResponse, withFollowerFalse);
+  }
+
   @Test
   public void testGetIndexVersion() throws Exception {
     IndexVersionResponse expected = new IndexVersionResponse(123L, 123L, 
"testGeneration");
diff --git 
a/solr/core/src/test/org/apache/solr/handler/admin/api/FetchReplicationDetailsAPITest.java
 
b/solr/core/src/test/org/apache/solr/handler/admin/api/FetchReplicationDetailsAPITest.java
new file mode 100644
index 00000000000..885e83c2fd1
--- /dev/null
+++ 
b/solr/core/src/test/org/apache/solr/handler/admin/api/FetchReplicationDetailsAPITest.java
@@ -0,0 +1,69 @@
+/*
+ * 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.handler.admin.api;
+
+import static org.apache.solr.core.CoreContainer.ALLOW_PATHS_SYSPROP;
+
+import org.apache.solr.SolrTestCase;
+import org.apache.solr.client.solrj.request.ReplicationApi;
+import org.apache.solr.util.ExternalPaths;
+import org.apache.solr.util.SolrJettyTestRule;
+import org.junit.BeforeClass;
+import org.junit.ClassRule;
+import org.junit.Test;
+
+/** HTTP tests for {@code GET /api/cores/coreName/replication} via the 
generated SolrJ request. */
+public class FetchReplicationDetailsAPITest extends SolrTestCase {
+
+  private static final String CORE_NAME = "replicationDetailsApiTestCore";
+
+  @ClassRule public static final SolrJettyTestRule solrTestRule = new 
SolrJettyTestRule();
+
+  @BeforeClass
+  public static void setupSolr() throws Exception {
+    System.setProperty(ALLOW_PATHS_SYSPROP, 
ExternalPaths.SERVER_HOME.toAbsolutePath().toString());
+    solrTestRule.startSolr(createTempDir());
+    
solrTestRule.newCollection(CORE_NAME).withConfigSet(ExternalPaths.DEFAULT_CONFIGSET).create();
+  }
+
+  @Test
+  public void testFetchReplicationDetails() throws Exception {
+    var request = new ReplicationApi.FetchReplicationDetails(CORE_NAME);
+    var response = request.process(solrTestRule.getSolrClient(CORE_NAME));
+
+    assertNotNull(response);
+    assertNull(response.error);
+    assertEquals("OK", response.status);
+    assertNotNull(response.details);
+    assertEquals(Boolean.TRUE, response.details.isLeader);
+    assertEquals(Boolean.FALSE, response.details.isFollower);
+    assertNotNull(response.details.indexSize);
+    assertNotNull(response.details.indexPath);
+    assertNotNull(response.details.commits);
+  }
+
+  @Test
+  public void testFetchReplicationDetailsWithFollowerFalse() throws Exception {
+    var request = new ReplicationApi.FetchReplicationDetails(CORE_NAME);
+    request.setFollower(false);
+    var response = request.process(solrTestRule.getSolrClient(CORE_NAME));
+
+    assertNotNull(response);
+    assertNull(response.error);
+    assertNotNull(response.details);
+  }
+}
diff --git 
a/solr/solr-ref-guide/modules/deployment-guide/pages/user-managed-index-replication.adoc
 
b/solr/solr-ref-guide/modules/deployment-guide/pages/user-managed-index-replication.adoc
index 60513c1470a..b3e8484098e 100644
--- 
a/solr/solr-ref-guide/modules/deployment-guide/pages/user-managed-index-replication.adoc
+++ 
b/solr/solr-ref-guide/modules/deployment-guide/pages/user-managed-index-replication.adoc
@@ -430,8 +430,26 @@ 
http://{followerHost}:{port}/solr/{coreName}/replication?command=disablepoll
 `details`::
 Retrieve configuration details and current status.
 +
+[tabs#details-request]
+======
+V1 API::
++
+====
 [source,bash]
+----
 http://{followerHost}:{port}/solr/{coreName}/replication?command=details
+----
+====
+
+V2 API::
++
+====
+[source,bash]
+----
+http://{followerHost}:{port}/api/cores/{coreName}/replication
+----
+====
+======
 
 `filelist`::
 Retrieve a list of Lucene files present in the specified host's index.
@@ -575,7 +593,7 @@ A snapshot with the name `snapshot._name_` must exist or an 
error will be return
 
 `repository`::: The name of the backup repository to use.
  When not specified, it defaults to local file system.
- 
+
 `location`::: The location where the snapshot is created.
 
 

Reply via email to