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>