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 000000000000..5e0b010fc2cc --- /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 e8e44ad6ebd7..83316587a814 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 void testCollectionDetailDisplay() { assertNoSevereConsoleErrors(); } + @Test + public void testHealthStatusDisplay() { + openPage("~collections/" + COLLECTION, By.id("collections")); + waitForPageContains("shard1"); + + Map 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 417074936677..2518d065cf08 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 17707f34243f..76c08a792d94 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 8973d31562c6..0ab8ce6b8fa9 100644 --- a/solr/webapp/web/partials/collections.html +++ b/solr/webapp/web/partials/collections.html @@ -205,7 +205,7 @@

Alias: {{collection.name}}

-

Collection: {{collection.name}}

+

Collection: {{collection.name}}

@@ -253,7 +253,7 @@

Collection: {{collection.name}}