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 3bbb73659b2fbe38ee7e0780bf54379a0ee9019d 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) (cherry picked from commit 3f5d4c5bf8ac96fec3a3219398b0c52c5418d48c) --- .../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>
