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 3f5d4c5bf8a SOLR-15395: Introduce coloured dots that update every ten 
seconds for health. (#5045)
3f5d4c5bf8a is described below

commit 3f5d4c5bf8ac96fec3a3219398b0c52c5418d48c
Author: Eric Pugh <[email protected]>
AuthorDate: Sat Oct 10 07:50:59 2026 -0400

    SOLR-15395: Introduce coloured dots that update every ten seconds for 
health. (#5045)
---
 .../solr-15395-admin-ui-collection-health.yml      |   8 ++
 .../solr/webapp/AdminUiCollectionsScreenTest.java  |  20 +++
 solr/webapp/web/css/angular/collections.css        |  43 +++++++
 .../web/js/angular/controllers/collections.js      | 137 +++++++++++++++------
 solr/webapp/web/partials/collections.html          |   4 +-
 5 files changed, 172 insertions(+), 40 deletions(-)

diff --git a/changelog/unreleased/solr-15395-admin-ui-collection-health.yml 
b/changelog/unreleased/solr-15395-admin-ui-collection-health.yml
new file mode 100644
index 00000000000..5e0b010fc2c
--- /dev/null
+++ b/changelog/unreleased/solr-15395-admin-ui-collection-health.yml
@@ -0,0 +1,8 @@
+# See https://github.com/apache/solr/blob/main/dev-docs/changelog.adoc
+title: Show collection and shard health (GREEN/YELLOW/ORANGE/RED, from 
SOLR-15300) as a colored dot in the Admin UI's Collections screen.
+type: added
+authors:
+  - name: Eric Pugh
+links:
+  - name: SOLR-15395
+    url: https://issues.apache.org/jira/browse/SOLR-15395
diff --git 
a/solr/webapp/src/test/org/apache/solr/webapp/AdminUiCollectionsScreenTest.java 
b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiCollectionsScreenTest.java
index e8e44ad6ebd..83316587a81 100644
--- 
a/solr/webapp/src/test/org/apache/solr/webapp/AdminUiCollectionsScreenTest.java
+++ 
b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiCollectionsScreenTest.java
@@ -46,6 +46,26 @@ public class AdminUiCollectionsScreenTest extends 
AdminUiTestBase {
     assertNoSevereConsoleErrors();
   }
 
+  @Test
+  public void testHealthStatusDisplay() {
+    openPage("~collections/" + COLLECTION, By.id("collections"));
+    waitForPageContains("shard1");
+
+    Map<String, By> healthDots =
+        Map.of(
+            "collection header", By.cssSelector("#collection-data h2 
.health-dot"),
+            "shard header", By.cssSelector("#shard-data h2 .health-dot"));
+    healthDots.forEach(
+        (label, locator) -> {
+          WebElement dot = waitFor(locator);
+          assertTrue(
+              label + " health dot should be GREEN: " + 
dot.getAttribute("class"),
+              dot.getAttribute("class").contains("health-GREEN"));
+        });
+
+    assertNoSevereConsoleErrors();
+  }
+
   @Test
   public void testCreateAndDeleteCollectionViaUi() throws Exception {
     String name = "uicreated";
diff --git a/solr/webapp/web/css/angular/collections.css 
b/solr/webapp/web/css/angular/collections.css
index 41707493667..2518d065cf0 100644
--- a/solr/webapp/web/css/angular/collections.css
+++ b/solr/webapp/web/css/angular/collections.css
@@ -395,3 +395,46 @@ button.disabled
     /* styles when the element is disabled */
     color: #c0c0c0;
 }
+
+.health-dot {
+  display: inline-block;
+  width: 10px;
+  height: 10px;
+  border-radius: 50%;
+  margin: 0 6px;
+  vertical-align: middle;
+}
+
+#content #collections #data #collection-data h2
+{
+  display: flex;
+  align-items: center;
+}
+
+#content #collections #data #shard-data h2 .shard-name
+{
+  display: inline-flex;
+  align-items: center;
+}
+
+#content #collections #data #collection-data h2 .health-dot,
+#content #collections #data #shard-data h2 .shard-name .health-dot
+{
+  flex: 0 0 auto;
+}
+
+.health-dot.health-GREEN {
+  background-color: #57A957;
+}
+
+.health-dot.health-YELLOW {
+  background-color: #d5dd00;
+}
+
+.health-dot.health-ORANGE {
+  background-color: #c48f00;
+}
+
+.health-dot.health-RED {
+  background-color: #C43C35;
+}
diff --git a/solr/webapp/web/js/angular/controllers/collections.js 
b/solr/webapp/web/js/angular/controllers/collections.js
index 17707f34243..76c08a792d9 100644
--- a/solr/webapp/web/js/angular/controllers/collections.js
+++ b/solr/webapp/web/js/angular/controllers/collections.js
@@ -30,15 +30,80 @@ solrAdminApp.controller('CollectionsController',
             });
           });
 
-          CollectionsV2.listCollections({detailed: true}, function (error, 
data, response) {
+          // Just names for the sidebar/alias-picker - the detailed 
per-collection data
+          // (shards, replicas, health) for whichever one is selected is 
fetched separately
+          // below, narrowed to that one collection, instead of fetching 
detail for all of them.
+          CollectionsV2.listCollections({}, function (error, data, response) {
             $timeout(function() {
               if (error) { ApiErrorHandler.handle(response); return; }
               $scope.collections = [];
-              for (var name in data.collectionsDetail) {
+              for (var i in data.collections) {
+                  var name = data.collections[i];
                   if (name.startsWith("._designer_")) {
                       continue;
                   }
-                  var collection = data.collectionsDetail[name];
+                  $scope.collections.push({name: name});
+              }
+            });
+          });
+
+          $scope.refreshSelectedCollection();
+
+          // Fetch aliases using getAliases to get properties
+          AliasesV2.getAliases(function (error, adata, response) {
+              $timeout(function() {
+                  if (error) { ApiErrorHandler.handle(response); return; }
+                  // TODO: Population of aliases array duplicated in app.js
+                  $scope.aliases = [];
+                  for (var key in adata.aliases) {
+                      var props = {};
+                      if (key in adata.properties) {
+                          props = adata.properties[key];
+                      }
+                      var alias = {name: key, collections: adata.aliases[key], 
type: 'alias', properties: props};
+                      $scope.aliases.push(alias);
+                      if ($routeParams.collection == 'alias_' + key) {
+                          $scope.collection = alias;
+                      }
+                  }
+                  if ($routeParams.collection && 
$routeParams.collection.indexOf('alias_') === 0 && !$scope.collection) {
+                      alert("No collection or alias called " + 
$routeParams.collection);
+                      $location.path("/~collections");
+                  }
+              });
+          });
+
+          ConfigSetsV2.listConfigSet(function(error, data, response) {
+              $timeout(function() {
+                  if (error) { ApiErrorHandler.handle(response); return; }
+                  $scope.configs = [];
+                  var items = data.configSets;
+                  for (var i in items) {
+                      $scope.configs.push({name: items[i]});
+                  }
+              });
+          });
+
+          $timeout.cancel($scope.timeout);
+          $scope.timeout = $timeout($scope.refreshHealth, 10000);
+      };
+
+      // Fetches shard/replica detail for the single collection named by the 
route,
+      // narrowed server-side via the "collection" param instead of fetching 
every collection.
+      $scope.refreshSelectedCollection = function() {
+          var name = $routeParams.collection;
+          if (!name || name.indexOf('alias_') === 0) {
+              return;
+          }
+          CollectionsV2.listCollections({detailed: true, collection: name}, 
function(error, data, response) {
+              $timeout(function() {
+                  if (error) { ApiErrorHandler.handle(response); return; }
+                  var collection = data.collectionsDetail && 
data.collectionsDetail[name];
+                  if (!collection) {
+                      alert("No collection or alias called " + name);
+                      $location.path("/~collections");
+                      return;
+                  }
                   collection.name = name;
                   collection.type = 'collection';
                   var shards = collection.shards;
@@ -58,49 +123,44 @@ solrAdminApp.controller('CollectionsController',
                       }
                       collection.shards.push(shard);
                   }
-                  $scope.collections.push(collection);
-                  if ($routeParams.collection == name) {
-                      $scope.collection = collection;
-                  }
-              }
-              // Fetch aliases using getAliases to get properties
-              AliasesV2.getAliases(function (error, adata, response) {
-                  $timeout(function() {
-                      if (error) { ApiErrorHandler.handle(response); return; }
-                      // TODO: Population of aliases array duplicated in app.js
-                      $scope.aliases = [];
-                      for (var key in adata.aliases) {
-                          var props = {};
-                          if (key in adata.properties) {
-                              props = adata.properties[key];
-                          }
-                          var alias = {name: key, collections: 
adata.aliases[key], type: 'alias', properties: props};
-                          $scope.aliases.push(alias);
-                          if ($routeParams.collection == 'alias_' + key) {
-                              $scope.collection = alias;
-                          }
-                      }
-                      // Decide what is selected in list
-                      if ($routeParams.collection && !$scope.collection) {
-                          alert("No collection or alias called " + 
$routeParams.collection);
-                          $location.path("/~collections");
-                      }
-                  });
+                  $scope.collection = collection;
               });
-            });
           });
-          ConfigSetsV2.listConfigSet(function(error, data, response) {
+      };
+
+      // Patches health onto the selected collection in place, so it never 
disturbs expanded/open UI state.
+      $scope.refreshHealth = function() {
+          var name = $scope.collection && $scope.collection.type === 
'collection' && $scope.collection.name;
+          if (!name) {
+              $scope.timeout = $timeout($scope.refreshHealth, 10000);
+              return;
+          }
+          CollectionsV2.listCollections({detailed: true, collection: name}, 
function(error, data, response) {
               $timeout(function() {
-                  if (error) { ApiErrorHandler.handle(response); return; }
-                  $scope.configs = [];
-                  var items = data.configSets;
-                  for (var i in items) {
-                      $scope.configs.push({name: items[i]});
+                  if (error) {
+                      ApiErrorHandler.handle(response);
+                  } else if ($scope.collection && $scope.collection.name === 
name) {
+                      var freshCollection = data.collectionsDetail && 
data.collectionsDetail[name];
+                      if (freshCollection) {
+                          $scope.collection.health = freshCollection.health;
+                          for (var shardName in freshCollection.shards) {
+                              var existingShard = 
$scope.collection.shards.find(function(s) { return s.name === shardName; });
+                              if (existingShard) {
+                                  existingShard.health = 
freshCollection.shards[shardName].health;
+                              }
+                          }
+                      }
                   }
+                  $scope.timeout = $timeout($scope.refreshHealth, 10000);
               });
           });
       };
 
+      var onRouteChangeOff = $scope.$on('$routeChangeStart', function() {
+          $timeout.cancel($scope.timeout);
+          onRouteChangeOff();
+      });
+
       $scope.hideAll = function() {
           $scope.showRename = false;
           $scope.showAdd = false;
@@ -242,6 +302,7 @@ solrAdminApp.controller('CollectionsController',
                  ApiErrorHandler.handle(response);
              } else {
                  $scope.reloadSuccess = true;
+                 $scope.refreshHealth();
              }
            });
         });
diff --git a/solr/webapp/web/partials/collections.html 
b/solr/webapp/web/partials/collections.html
index 8973d31562c..0ab8ce6b8fa 100644
--- a/solr/webapp/web/partials/collections.html
+++ b/solr/webapp/web/partials/collections.html
@@ -205,7 +205,7 @@ limitations under the License.
 
       <div class="block" id="collection-data" ng-show="collection.type === 
'collection'">
 
-          <h2>Collection: {{collection.name}}</h2>
+          <h2>Collection: {{collection.name}} <span class="health-dot" 
ng-if="collection.health" ng-class="'health-' + collection.health" 
title="Health: {{collection.health}}"></span></h2>
 
           <div class="message-container">
               <div class="message"></div>
@@ -253,7 +253,7 @@ limitations under the License.
         <div class="content shard" ng-repeat="shard in collection.shards">
           <a ng-click="toggleShard(shard)">
             <h2>
-                  <span ng-class="{open:shard.show}"><b>Shard: 
{{shard.name}}</b></span>
+                  <span class="shard-name" 
ng-class="{open:shard.show}"><b>Shard: {{shard.name}}</b> <span 
class="health-dot" ng-if="shard.health" ng-class="'health-' + shard.health" 
title="Health: {{shard.health}}"></span></span>
                   <div style="float:right" ng-show="!shard.range || 
shard.state == 'inactive'"><a ng-click="toggleRemoveShard(shard)"><span 
class="rem"></span></a></div>
               </h2>
           </a>

Reply via email to