hbase-commits mailing list archives

Site index · List index
Message view « Date » · « Thread »
Top « Date » · « Thread »
From ndimi...@apache.org
Subject [hbase] branch master updated: HBASE-23653 Expose content of meta table in web ui (#1020)
Date Thu, 16 Jan 2020 16:46:55 GMT
This is an automated email from the ASF dual-hosted git repository.

ndimiduk pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hbase.git


The following commit(s) were added to refs/heads/master by this push:
     new 00fc467  HBASE-23653 Expose content of meta table in web ui (#1020)
00fc467 is described below

commit 00fc46756abb99de6f833997499505f89c9752e8
Author: Nick Dimiduk <ndimiduk@apache.org>
AuthorDate: Thu Jan 16 08:46:39 2020 -0800

    HBASE-23653 Expose content of meta table in web ui (#1020)
    
    Adds a display of the content of 'hbase:meta' to the Master's
    table.jsp, when that table is selected. Supports basic pagination,
    filtering, &c.
    
    Signed-off-by: stack <stack@apache.org>
    Signed-off-by: Bharath Vissapragada <bharathv@apache.org>
---
 .../org/apache/hadoop/hbase/RegionLocations.java   |  10 +-
 .../hbase/client/TableDescriptorBuilder.java       |   1 +
 hbase-server/pom.xml                               |   5 +
 .../hadoop/hbase/master/webapp/MetaBrowser.java    | 424 +++++++++++++++++++++
 .../hbase/master/webapp/RegionReplicaInfo.java     | 143 +++++++
 .../main/resources/hbase-webapps/master/table.jsp  | 197 ++++++++--
 .../hbase/ClearUserNamespacesAndTablesRule.java    | 167 ++++++++
 .../org/apache/hadoop/hbase/ConnectionRule.java    |  75 ++++
 .../apache/hadoop/hbase/HBaseTestingUtility.java   |   3 +-
 .../org/apache/hadoop/hbase/MiniClusterRule.java   |  92 +++++
 .../hbase/client/hamcrest/BytesMatchers.java       |  56 +++
 .../hbase/master/webapp/TestMetaBrowser.java       | 360 +++++++++++++++++
 .../master/webapp/TestMetaBrowserNoCluster.java    | 168 ++++++++
 pom.xml                                            |   6 +
 14 files changed, 1680 insertions(+), 27 deletions(-)

diff --git a/hbase-client/src/main/java/org/apache/hadoop/hbase/RegionLocations.java b/hbase-client/src/main/java/org/apache/hadoop/hbase/RegionLocations.java
index e119ebb..0d3a464 100644
--- a/hbase-client/src/main/java/org/apache/hadoop/hbase/RegionLocations.java
+++ b/hbase-client/src/main/java/org/apache/hadoop/hbase/RegionLocations.java
@@ -18,7 +18,10 @@
 
 package org.apache.hadoop.hbase;
 
+import java.util.Arrays;
 import java.util.Collection;
+import java.util.Iterator;
+
 import org.apache.hadoop.hbase.client.RegionInfo;
 import org.apache.hadoop.hbase.client.RegionReplicaUtil;
 import org.apache.hadoop.hbase.util.Bytes;
@@ -31,7 +34,7 @@ import org.apache.yetus.audience.InterfaceAudience;
  * (assuming small number of locations)
  */
 @InterfaceAudience.Private
-public class RegionLocations {
+public class RegionLocations implements Iterable<HRegionLocation> {
 
   private final int numNonNullElements;
 
@@ -362,6 +365,11 @@ public class RegionLocations {
   }
 
   @Override
+  public Iterator<HRegionLocation> iterator() {
+    return Arrays.asList(locations).iterator();
+  }
+
+  @Override
   public String toString() {
     StringBuilder builder = new StringBuilder("[");
     for (HRegionLocation loc : locations) {
diff --git a/hbase-client/src/main/java/org/apache/hadoop/hbase/client/TableDescriptorBuilder.java b/hbase-client/src/main/java/org/apache/hadoop/hbase/client/TableDescriptorBuilder.java
index b97e85a..20acf3a 100644
--- a/hbase-client/src/main/java/org/apache/hadoop/hbase/client/TableDescriptorBuilder.java
+++ b/hbase-client/src/main/java/org/apache/hadoop/hbase/client/TableDescriptorBuilder.java
@@ -49,6 +49,7 @@ import org.apache.hadoop.hbase.shaded.protobuf.ProtobufUtil;
 import org.apache.hadoop.hbase.shaded.protobuf.generated.HBaseProtos;
 
 /**
+ * Convenience class for composing an instance of {@link TableDescriptor}.
  * @since 2.0.0
  */
 @InterfaceAudience.Public
diff --git a/hbase-server/pom.xml b/hbase-server/pom.xml
index 741078b..57227ab 100644
--- a/hbase-server/pom.xml
+++ b/hbase-server/pom.xml
@@ -443,6 +443,11 @@
       <scope>test</scope>
     </dependency>
     <dependency>
+      <groupId>org.hamcrest</groupId>
+      <artifactId>hamcrest-library</artifactId>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
       <groupId>org.bouncycastle</groupId>
       <artifactId>bcprov-jdk15on</artifactId>
       <scope>test</scope>
diff --git a/hbase-server/src/main/java/org/apache/hadoop/hbase/master/webapp/MetaBrowser.java b/hbase-server/src/main/java/org/apache/hadoop/hbase/master/webapp/MetaBrowser.java
new file mode 100644
index 0000000..5b07427
--- /dev/null
+++ b/hbase-server/src/main/java/org/apache/hadoop/hbase/master/webapp/MetaBrowser.java
@@ -0,0 +1,424 @@
+/*
+ * 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.hadoop.hbase.master.webapp;
+
+import java.io.UnsupportedEncodingException;
+import java.net.URLDecoder;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.stream.StreamSupport;
+import javax.servlet.http.HttpServletRequest;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.builder.ToStringBuilder;
+import org.apache.commons.lang3.builder.ToStringStyle;
+import org.apache.hadoop.hbase.CompareOperator;
+import org.apache.hadoop.hbase.HConstants;
+import org.apache.hadoop.hbase.TableName;
+import org.apache.hadoop.hbase.client.AdvancedScanResultConsumer;
+import org.apache.hadoop.hbase.client.AsyncConnection;
+import org.apache.hadoop.hbase.client.AsyncTable;
+import org.apache.hadoop.hbase.client.ResultScanner;
+import org.apache.hadoop.hbase.client.Scan;
+import org.apache.hadoop.hbase.filter.Filter;
+import org.apache.hadoop.hbase.filter.FilterList;
+import org.apache.hadoop.hbase.filter.PrefixFilter;
+import org.apache.hadoop.hbase.filter.SingleColumnValueFilter;
+import org.apache.hadoop.hbase.master.RegionState;
+import org.apache.hadoop.hbase.util.Bytes;
+import org.apache.yetus.audience.InterfaceAudience;
+import org.apache.hbase.thirdparty.com.google.common.collect.Iterators;
+import org.apache.hbase.thirdparty.io.netty.handler.codec.http.QueryStringEncoder;
+
+/**
+ * <p>
+ * Support class for the "Meta Entries" section in {@code resources/hbase-webapps/master/table.jsp}.
+ * </p>
+ * <p>
+ * <b>Interface</b>. This class's intended consumer is {@code table.jsp}. As such, it's primary
+ * interface is the active {@link HttpServletRequest}, from which it uses the {@code scan_*}
+ * request parameters. This class supports paging through an optionally filtered view of the
+ * contents of {@code hbase:meta}. Those filters and the pagination offset are specified via these
+ * request parameters. It provides helper methods for constructing pagination links.
+ * <ul>
+ *   <li>{@value #NAME_PARAM} - the name of the table requested. The only table of our concern here
+ *   is {@code hbase:meta}; any other value is effectively ignored by the giant conditional in the
+ *   jsp.</li>
+ *   <li>{@value #SCAN_LIMIT_PARAM} - specifies a limit on the number of region (replicas) rendered
+ *   on the by the table in a single request -- a limit on page size. This corresponds to the
+ *   number of {@link RegionReplicaInfo} objects produced by {@link Results#iterator()}. When a
+ *   value for {@code scan_limit} is invalid or not specified, the default value of
+ *   {@value #SCAN_LIMIT_DEFAULT} is used. In order to avoid excessive resource consumption, a
+ *   maximum value of {@value #SCAN_LIMIT_MAX} is enforced.</li>
+ *   <li>{@value #SCAN_REGION_STATE_PARAM} - an optional filter on {@link RegionState}.</li>
+ *   <li>{@value #SCAN_START_PARAM} - specifies the rowkey at which a scan should start. For usage
+ *   details, see the below section on <b>Pagination</b>.</li>
+ *   <li>{@value #SCAN_TABLE_PARAM} - specifies a filter on the values returned, limiting them to
+ *   regions from a specified table. This parameter is implemented as a prefix filter on the
+ *   {@link Scan}, so in effect it can be used for simple namespace and multi-table matches.</li>
+ * </ul>
+ * </p>
+ * <p>
+ * <b>Pagination</b>. A single page of results are made available via {@link #getResults()} / an
+ * instance of {@link Results}. Callers use its {@link Iterator} consume the page of
+ * {@link RegionReplicaInfo} instances, each of which represents a region or region replica. Helper
+ * methods are provided for building page navigation controls preserving the user's selected filter
+ * set: {@link #buildFirstPageUrl()}, {@link #buildNextPageUrl(byte[])}. Pagination is implemented
+ * using a simple offset + limit system. Offset is provided by the {@value #SCAN_START_PARAM},
+ * limit via {@value #SCAN_LIMIT_PARAM}. Under the hood, the {@link Scan} is constructed with
+ * {@link Scan#setMaxResultSize(long)} set to ({@value SCAN_LIMIT_PARAM} +1), while the
+ * {@link Results} {@link Iterator} honors {@value #SCAN_LIMIT_PARAM}. The +1 allows the caller to
+ * know if a "next page" is available via {@link Results#hasMoreResults()}. Note that this
+ * pagination strategy is incomplete when it comes to region replicas and can potentially omit
+ * rendering replicas that fall between the last rowkey offset and {@code replicaCount % page size}.
+ * </p>
+ * <p>
+ * <b>Error Messages</b>. Any time there's an error parsing user input, a message will be populated
+ * in {@link #getErrorMessages()}. Any fields which produce an error will have their filter values
+ * set to the default, except for a value of {@value  #SCAN_LIMIT_PARAM} that exceeds
+ * {@value #SCAN_LIMIT_MAX}, in which case {@value #SCAN_LIMIT_MAX} is used.
+ * </p>
+ */
+@InterfaceAudience.Private
+public class MetaBrowser {
+  public static final String NAME_PARAM = "name";
+  public static final String SCAN_LIMIT_PARAM = "scan_limit";
+  public static final String SCAN_REGION_STATE_PARAM = "scan_region_state";
+  public static final String SCAN_START_PARAM = "scan_start";
+  public static final String SCAN_TABLE_PARAM = "scan_table";
+
+  public static final int SCAN_LIMIT_DEFAULT = 10;
+  public static final int SCAN_LIMIT_MAX = 10_000;
+
+  private final AsyncConnection connection;
+  private final HttpServletRequest request;
+  private final List<String> errorMessages;
+  private final String name;
+  private final Integer scanLimit;
+  private final RegionState.State scanRegionState;
+  private final byte[] scanStart;
+  private final TableName scanTable;
+
+  public MetaBrowser(final AsyncConnection connection, final HttpServletRequest request) {
+    this.connection = connection;
+    this.request = request;
+    this.errorMessages = new LinkedList<>();
+    this.name = resolveName(request);
+    this.scanLimit = resolveScanLimit(request);
+    this.scanRegionState = resolveScanRegionState(request);
+    this.scanStart = resolveScanStart(request);
+    this.scanTable = resolveScanTable(request);
+  }
+
+  public List<String> getErrorMessages() {
+    return errorMessages;
+  }
+
+  public String getName() {
+    return name;
+  }
+
+  public Integer getScanLimit() {
+    return scanLimit;
+  }
+
+  public byte[] getScanStart() {
+    return scanStart;
+  }
+
+  public RegionState.State getScanRegionState() {
+    return scanRegionState;
+  }
+
+  public TableName getScanTable() {
+    return scanTable;
+  }
+
+  public Results getResults() {
+    final AsyncTable<AdvancedScanResultConsumer> asyncTable =
+      connection.getTable(TableName.META_TABLE_NAME);
+    return new Results(asyncTable.getScanner(buildScan()));
+  }
+
+  @Override
+  public String toString() {
+    return new ToStringBuilder(this, ToStringStyle.SHORT_PREFIX_STYLE)
+      .append("scanStart", scanStart)
+      .append("scanLimit", scanLimit)
+      .append("scanTable", scanTable)
+      .append("scanRegionState", scanRegionState)
+      .toString();
+  }
+
+  private static String resolveName(final HttpServletRequest request) {
+    return resolveRequestParameter(request, NAME_PARAM);
+  }
+
+  private Integer resolveScanLimit(final HttpServletRequest request) {
+    final String requestValueStr = resolveRequestParameter(request, SCAN_LIMIT_PARAM);
+    if (StringUtils.isBlank(requestValueStr)) {
+      return null;
+    }
+
+    final Integer requestValue = tryParseInt(requestValueStr);
+    if (requestValue == null) {
+      errorMessages.add(buildScanLimitMalformedErrorMessage(requestValueStr));
+      return null;
+    }
+    if (requestValue <= 0) {
+      errorMessages.add(buildScanLimitLTEQZero(requestValue));
+      return SCAN_LIMIT_DEFAULT;
+    }
+
+    final int truncatedValue = Math.min(requestValue, SCAN_LIMIT_MAX);
+    if (requestValue != truncatedValue) {
+      errorMessages.add(buildScanLimitExceededErrorMessage(requestValue));
+    }
+    return truncatedValue;
+  }
+
+  private RegionState.State resolveScanRegionState(final HttpServletRequest request) {
+    final String requestValueStr = resolveRequestParameter(request, SCAN_REGION_STATE_PARAM);
+    if (requestValueStr == null) {
+      return null;
+    }
+    final RegionState.State requestValue = tryValueOf(RegionState.State.class, requestValueStr);
+    if (requestValue == null) {
+      errorMessages.add(buildScanRegionStateMalformedErrorMessage(requestValueStr));
+      return null;
+    }
+    return requestValue;
+  }
+
+  private static byte[] resolveScanStart(final HttpServletRequest request) {
+    // TODO: handle replicas that fall between the last rowkey and pagination limit.
+    final String requestValue = resolveRequestParameter(request, SCAN_START_PARAM);
+    if (requestValue == null) {
+      return null;
+    }
+    return Bytes.toBytesBinary(requestValue);
+  }
+
+  private static TableName resolveScanTable(final HttpServletRequest request) {
+    final String requestValue = resolveRequestParameter(request, SCAN_TABLE_PARAM);
+    if (requestValue == null) {
+      return null;
+    }
+    return TableName.valueOf(requestValue);
+  }
+
+  private static String resolveRequestParameter(final HttpServletRequest request,
+    final String param) {
+    if (request == null) {
+      return null;
+    }
+    final String requestValueStrEnc = request.getParameter(param);
+    if (StringUtils.isBlank(requestValueStrEnc)) {
+      return null;
+    }
+    return urlDecode(requestValueStrEnc);
+  }
+
+  private static Filter buildTableFilter(final TableName tableName) {
+    return new PrefixFilter(tableName.toBytes());
+  }
+
+  private static Filter buildScanRegionStateFilter(final RegionState.State state) {
+    return new SingleColumnValueFilter(
+      HConstants.CATALOG_FAMILY,
+      HConstants.STATE_QUALIFIER,
+      CompareOperator.EQUAL,
+      // use the same serialization strategy as found in MetaTableAccessor#addRegionStateToPut
+      Bytes.toBytes(state.name()));
+  }
+
+  private Filter buildScanFilter() {
+    if (scanTable == null && scanRegionState == null) {
+      return null;
+    }
+
+    final List<Filter> filters = new ArrayList<>(2);
+    if (scanTable != null) {
+      filters.add(buildTableFilter(scanTable));
+    }
+    if (scanRegionState != null) {
+      filters.add(buildScanRegionStateFilter(scanRegionState));
+    }
+    if (filters.size() == 1) {
+      return filters.get(0);
+    }
+    return new FilterList(FilterList.Operator.MUST_PASS_ALL, filters);
+  }
+
+  private Scan buildScan() {
+    final Scan metaScan = new Scan()
+      .addFamily(HConstants.CATALOG_FAMILY)
+      .readVersions(1)
+      .setLimit((scanLimit != null ? scanLimit : SCAN_LIMIT_DEFAULT) + 1);
+    if (scanStart != null) {
+      metaScan.withStartRow(scanStart, false);
+    }
+    final Filter filter = buildScanFilter();
+    if (filter != null) {
+      metaScan.setFilter(filter);
+    }
+    return metaScan;
+  }
+
+  /**
+   * Adds {@code value} to {@code encoder} under {@code paramName} when {@code value} is non-null.
+   */
+  private void addParam(final QueryStringEncoder encoder, final String paramName,
+    final Object value) {
+    if (value != null) {
+      encoder.addParam(paramName, value.toString());
+    }
+  }
+
+  private QueryStringEncoder buildFirstPageEncoder() {
+    final QueryStringEncoder encoder =
+      new QueryStringEncoder(request.getRequestURI());
+    addParam(encoder, NAME_PARAM, name);
+    addParam(encoder, SCAN_LIMIT_PARAM, scanLimit);
+    addParam(encoder, SCAN_REGION_STATE_PARAM, scanRegionState);
+    addParam(encoder, SCAN_TABLE_PARAM, scanTable);
+    return encoder;
+  }
+
+  public String buildFirstPageUrl() {
+    return buildFirstPageEncoder().toString();
+  }
+
+  static String buildStartParamFrom(final byte[] lastRow) {
+    if (lastRow == null) {
+      return null;
+    }
+    return urlEncode(Bytes.toStringBinary(lastRow));
+  }
+
+  public String buildNextPageUrl(final byte[] lastRow) {
+    final QueryStringEncoder encoder = buildFirstPageEncoder();
+    final String startRow = buildStartParamFrom(lastRow);
+    addParam(encoder, SCAN_START_PARAM, startRow);
+    return encoder.toString();
+  }
+
+  private static String urlEncode(final String val) {
+    if (StringUtils.isEmpty(val)) {
+      return null;
+    }
+    try {
+      return URLEncoder.encode(val, StandardCharsets.UTF_8.toString());
+    } catch (UnsupportedEncodingException e) {
+      return null;
+    }
+  }
+
+  private static String urlDecode(final String val) {
+    if (StringUtils.isEmpty(val)) {
+      return null;
+    }
+    try {
+      return URLDecoder.decode(val, StandardCharsets.UTF_8.toString());
+    } catch (UnsupportedEncodingException e) {
+      return null;
+    }
+  }
+
+  private static Integer tryParseInt(final String val) {
+    if (StringUtils.isEmpty(val)) {
+      return null;
+    }
+    try {
+      return Integer.parseInt(val);
+    } catch (NumberFormatException e) {
+      return null;
+    }
+  }
+
+  private static <T extends Enum<T>> T tryValueOf(final Class<T> clazz,
+    final String value) {
+    if (clazz == null || value == null) {
+      return null;
+    }
+    try {
+      return T.valueOf(clazz, value);
+    } catch (IllegalArgumentException e) {
+      return null;
+    }
+  }
+
+  private static String buildScanLimitExceededErrorMessage(final int requestValue) {
+    return String.format(
+      "Requested SCAN_LIMIT value %d exceeds maximum value %d.", requestValue, SCAN_LIMIT_MAX);
+  }
+
+  private static String buildScanLimitMalformedErrorMessage(final String requestValue) {
+    return String.format(
+      "Requested SCAN_LIMIT value '%s' cannot be parsed as an integer.", requestValue);
+  }
+
+  private static String buildScanLimitLTEQZero(final int requestValue) {
+    return String.format("Requested SCAN_LIMIT value %d is <= 0.", requestValue);
+  }
+
+  private static String buildScanRegionStateMalformedErrorMessage(final String requestValue) {
+    return String.format(
+      "Requested SCAN_REGION_STATE value '%s' cannot be parsed as a RegionState.", requestValue);
+  }
+
+  /**
+   * Encapsulates the results produced by this {@link MetaBrowser} instance.
+   */
+  public final class Results implements AutoCloseable, Iterable<RegionReplicaInfo> {
+
+    private final ResultScanner resultScanner;
+    private final Iterator<RegionReplicaInfo> sourceIterator;
+
+    private Results(final ResultScanner resultScanner) {
+      this.resultScanner = resultScanner;
+      this.sourceIterator =  StreamSupport.stream(resultScanner.spliterator(), false)
+        .map(RegionReplicaInfo::from)
+        .flatMap(Collection::stream)
+        .iterator();
+    }
+
+    /**
+     * @return {@code true} when the underlying {@link ResultScanner} is not yet exhausted,
+     *   {@code false} otherwise.
+     */
+    public boolean hasMoreResults() {
+      return sourceIterator.hasNext();
+    }
+
+    @Override
+    public void close() {
+      if (resultScanner != null) {
+        resultScanner.close();
+      }
+    }
+
+    @Override public Iterator<RegionReplicaInfo> iterator() {
+      return Iterators.limit(sourceIterator, scanLimit != null ? scanLimit : SCAN_LIMIT_DEFAULT);
+    }
+  }
+}
diff --git a/hbase-server/src/main/java/org/apache/hadoop/hbase/master/webapp/RegionReplicaInfo.java b/hbase-server/src/main/java/org/apache/hadoop/hbase/master/webapp/RegionReplicaInfo.java
new file mode 100644
index 0000000..554d49b
--- /dev/null
+++ b/hbase-server/src/main/java/org/apache/hadoop/hbase/master/webapp/RegionReplicaInfo.java
@@ -0,0 +1,143 @@
+/*
+ * 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.hadoop.hbase.master.webapp;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.StreamSupport;
+import org.apache.commons.lang3.builder.EqualsBuilder;
+import org.apache.commons.lang3.builder.HashCodeBuilder;
+import org.apache.commons.lang3.builder.ToStringBuilder;
+import org.apache.commons.lang3.builder.ToStringStyle;
+import org.apache.hadoop.hbase.HRegionLocation;
+import org.apache.hadoop.hbase.MetaTableAccessor;
+import org.apache.hadoop.hbase.RegionLocations;
+import org.apache.hadoop.hbase.ServerName;
+import org.apache.hadoop.hbase.client.RegionInfo;
+import org.apache.hadoop.hbase.client.Result;
+import org.apache.hadoop.hbase.master.RegionState;
+import org.apache.hadoop.hbase.master.assignment.RegionStateStore;
+import org.apache.hadoop.hbase.util.Bytes;
+import org.apache.yetus.audience.InterfaceAudience;
+
+/**
+ * A POJO that consolidates the information about a single region replica that's stored in meta.
+ */
+@InterfaceAudience.Private
+public final class RegionReplicaInfo {
+  private final byte[] row;
+  private final RegionInfo regionInfo;
+  private final RegionState.State regionState;
+  private final ServerName serverName;
+
+  private RegionReplicaInfo(final Result result, final HRegionLocation location) {
+    this.row = result != null ? result.getRow() : null;
+    this.regionInfo = location != null ? location.getRegion() : null;
+    this.regionState = (result != null && regionInfo != null)
+      ? RegionStateStore.getRegionState(result, regionInfo)
+      : null;
+    this.serverName = location != null ? location.getServerName() : null;
+  }
+
+  public static List<RegionReplicaInfo> from(final Result result) {
+    if (result == null) {
+      return Collections.singletonList(null);
+    }
+
+    final RegionLocations locations = MetaTableAccessor.getRegionLocations(result);
+    if (locations == null) {
+      return Collections.singletonList(null);
+    }
+
+    return StreamSupport.stream(locations.spliterator(), false)
+      .map(location -> new RegionReplicaInfo(result, location))
+      .collect(Collectors.toList());
+  }
+
+  public byte[] getRow() {
+    return row;
+  }
+
+  public RegionInfo getRegionInfo() {
+    return regionInfo;
+  }
+
+  public byte[] getRegionName() {
+    return regionInfo != null ? regionInfo.getRegionName() : null;
+  }
+
+  public byte[] getStartKey() {
+    return regionInfo != null ? regionInfo.getStartKey() : null;
+  }
+
+  public byte[] getEndKey() {
+    return regionInfo != null ? regionInfo.getEndKey() : null;
+  }
+
+  public Integer getReplicaId() {
+    return regionInfo != null ? regionInfo.getReplicaId() : null;
+  }
+
+  public RegionState.State getRegionState() {
+    return regionState;
+  }
+
+  public ServerName getServerName() {
+    return serverName;
+  }
+
+  @Override
+  public boolean equals(Object other) {
+    if (this == other) {
+      return true;
+    }
+
+    if (other == null || getClass() != other.getClass()) {
+      return false;
+    }
+
+    RegionReplicaInfo that = (RegionReplicaInfo) other;
+
+    return new EqualsBuilder()
+      .append(row, that.row)
+      .append(regionInfo, that.regionInfo)
+      .append(regionState, that.regionState)
+      .append(serverName, that.serverName)
+      .isEquals();
+  }
+
+  @Override
+  public int hashCode() {
+    return new HashCodeBuilder(17, 37)
+      .append(row)
+      .append(regionInfo)
+      .append(regionState)
+      .append(serverName)
+      .toHashCode();
+  }
+
+  @Override public String toString() {
+    return new ToStringBuilder(this, ToStringStyle.SHORT_PREFIX_STYLE)
+      .append("row", Bytes.toStringBinary(row))
+      .append("regionInfo", regionInfo)
+      .append("regionState", regionState)
+      .append("serverName", serverName)
+      .toString();
+  }
+}
diff --git a/hbase-server/src/main/resources/hbase-webapps/master/table.jsp b/hbase-server/src/main/resources/hbase-webapps/master/table.jsp
index 6a76d99..b5c677a 100644
--- a/hbase-server/src/main/resources/hbase-webapps/master/table.jsp
+++ b/hbase-server/src/main/resources/hbase-webapps/master/table.jsp
@@ -17,76 +17,96 @@
  * limitations under the License.
  */
 --%>
-<%@page import="java.net.URLEncoder"%>
 <%@ page contentType="text/html;charset=UTF-8"
   import="static org.apache.commons.lang3.StringEscapeUtils.escapeXml"
+  import="java.net.URLEncoder"
   import="java.util.ArrayList"
   import="java.util.Collection"
   import="java.util.HashMap"
   import="java.util.LinkedHashMap"
   import="java.util.List"
   import="java.util.Map"
+  import="java.util.Optional"
   import="java.util.TreeMap"
-  import=" java.util.concurrent.TimeUnit"
+  import="java.util.concurrent.TimeUnit"
   import="org.apache.commons.lang3.StringEscapeUtils"
   import="org.apache.hadoop.conf.Configuration"
-  import="org.apache.hadoop.hbase.HTableDescriptor"
   import="org.apache.hadoop.hbase.HColumnDescriptor"
   import="org.apache.hadoop.hbase.HConstants"
   import="org.apache.hadoop.hbase.HRegionLocation"
+  import="org.apache.hadoop.hbase.HTableDescriptor"
+  import="org.apache.hadoop.hbase.RegionMetrics"
+  import="org.apache.hadoop.hbase.RegionMetricsBuilder"
+  import="org.apache.hadoop.hbase.ServerMetrics"
   import="org.apache.hadoop.hbase.ServerName"
+  import="org.apache.hadoop.hbase.Size"
   import="org.apache.hadoop.hbase.TableName"
   import="org.apache.hadoop.hbase.TableNotFoundException"
   import="org.apache.hadoop.hbase.client.AsyncAdmin"
   import="org.apache.hadoop.hbase.client.AsyncConnection"
   import="org.apache.hadoop.hbase.client.CompactionState"
-  import="org.apache.hadoop.hbase.client.ConnectionFactory"
   import="org.apache.hadoop.hbase.client.RegionInfo"
   import="org.apache.hadoop.hbase.client.RegionInfoBuilder"
   import="org.apache.hadoop.hbase.client.RegionLocator"
   import="org.apache.hadoop.hbase.client.RegionReplicaUtil"
   import="org.apache.hadoop.hbase.client.Table"
   import="org.apache.hadoop.hbase.master.HMaster"
-  import="org.apache.hadoop.hbase.master.assignment.RegionStates"
   import="org.apache.hadoop.hbase.master.RegionState"
+  import="org.apache.hadoop.hbase.master.assignment.RegionStates"
+  import="org.apache.hadoop.hbase.master.webapp.MetaBrowser"
+  import="org.apache.hadoop.hbase.master.webapp.RegionReplicaInfo"
   import="org.apache.hadoop.hbase.quotas.QuotaSettingsFactory"
-  import="org.apache.hadoop.hbase.quotas.QuotaTableUtil"
-  import="org.apache.hadoop.hbase.quotas.SpaceQuotaSnapshot"
-  import="org.apache.hadoop.hbase.quotas.ThrottleSettings"
-  import="org.apache.hadoop.hbase.util.Bytes"
-  import="org.apache.hadoop.hbase.util.FSUtils"
-  import="org.apache.hadoop.hbase.zookeeper.MetaTableLocator"
-  import="org.apache.hadoop.util.StringUtils"
-  import="org.apache.hbase.thirdparty.com.google.protobuf.ByteString"%>
+  import="org.apache.hadoop.hbase.quotas.QuotaTableUtil"%>
+<%@ page import="org.apache.hadoop.hbase.quotas.SpaceQuotaSnapshot" %>
+<%@ page import="org.apache.hadoop.hbase.quotas.ThrottleSettings" %>
+<%@ page import="org.apache.hadoop.hbase.util.Bytes" %>
+<%@ page import="org.apache.hadoop.hbase.util.FSUtils" %>
+<%@ page import="org.apache.hadoop.hbase.zookeeper.MetaTableLocator" %>
+<%@ page import="org.apache.hadoop.util.StringUtils" %>
+<%@ page import="org.apache.hbase.thirdparty.com.google.protobuf.ByteString" %>
 <%@ page import="org.apache.hadoop.hbase.shaded.protobuf.generated.ClusterStatusProtos" %>
 <%@ page import="org.apache.hadoop.hbase.shaded.protobuf.generated.HBaseProtos" %>
 <%@ page import="org.apache.hadoop.hbase.shaded.protobuf.generated.QuotaProtos.Quotas" %>
 <%@ page import="org.apache.hadoop.hbase.shaded.protobuf.generated.QuotaProtos.SpaceQuota" %>
-<%@ page import="org.apache.hadoop.hbase.ServerMetrics" %>
-<%@ page import="org.apache.hadoop.hbase.RegionMetrics" %>
-<%@ page import="org.apache.hadoop.hbase.Size" %>
-<%@ page import="org.apache.hadoop.hbase.RegionMetricsBuilder" %>
 <%!
   /**
    * @return An empty region load stamped with the passed in <code>regionInfo</code>
    * region name.
    */
-  private RegionMetrics getEmptyRegionMetrics(final RegionInfo regionInfo) {
+  private static RegionMetrics getEmptyRegionMetrics(final RegionInfo regionInfo) {
     return RegionMetricsBuilder.toRegionMetrics(ClusterStatusProtos.RegionLoad.newBuilder().
             setRegionSpecifier(HBaseProtos.RegionSpecifier.newBuilder().
                     setType(HBaseProtos.RegionSpecifier.RegionSpecifierType.REGION_NAME).
                     setValue(ByteString.copyFrom(regionInfo.getRegionName())).build()).build());
   }
+
+  /**
+   * Given dicey information that may or not be available in meta, render a link to the region on
+   * its region server.
+   * @return an anchor tag if one can be built, {@code null} otherwise.
+   */
+  private static String buildRegionServerLink(final ServerName serverName, final int rsInfoPort,
+    final RegionInfo regionInfo, final RegionState.State regionState) {
+    if (serverName == null || regionInfo == null) { return null; }
+
+    if (regionState != RegionState.State.OPEN) {
+      // region is assigned to RS, but RS knows nothing of it. don't bother with a link.
+      return serverName.getServerName();
+    }
+
+    final String socketAddress = serverName.getHostname() + ":" + rsInfoPort;
+    final String URI = "//" + socketAddress + "/region.jsp"
+      + "?name=" + regionInfo.getEncodedName();
+    return "<a href=\"" + URI + "\">" + serverName.getServerName() + "</a>";
+  }
 %>
 <%
-  final String ZEROKB = "0 KB";
   final String ZEROMB = "0 MB";
   HMaster master = (HMaster)getServletContext().getAttribute(HMaster.MASTER);
   Configuration conf = master.getConfiguration();
   String fqtn = request.getParameter("name");
   final String escaped_fqtn = StringEscapeUtils.escapeHtml4(fqtn);
   Table table;
-  String tableHeader;
   boolean withReplica = false;
   boolean showFragmentation = conf.getBoolean("hbase.master.ui.fragmentation.enabled", false);
   boolean readOnly = conf.getBoolean("hbase.master.ui.readonly", false);
@@ -127,8 +147,11 @@
       pageTitle = "Table: " + escaped_fqtn;
   }
   pageContext.setAttribute("pageTitle", pageTitle);
-  AsyncConnection connection = ConnectionFactory.createAsyncConnection(master.getConfiguration()).get();
-  AsyncAdmin admin = connection.getAdminBuilder().setOperationTimeout(5, TimeUnit.SECONDS).build();
+  final AsyncConnection connection = master.getAsyncConnection();
+  final AsyncAdmin admin = connection.getAdminBuilder()
+    .setOperationTimeout(5, TimeUnit.SECONDS)
+    .build();
+  final MetaBrowser metaBrowser = new MetaBrowser(connection, request);
 %>
 
 <jsp:include page="header.jsp">
@@ -351,6 +374,134 @@ if (fqtn != null && master.isInitialized()) {
     </div>
   </div>
 </div>
+<h2 id="meta-entries">Meta Entries</h2>
+<%
+  if (!metaBrowser.getErrorMessages().isEmpty()) {
+    for (final String errorMessage : metaBrowser.getErrorMessages()) {
+%>
+<div class="alert alert-warning" role="alert">
+  <%= errorMessage %>
+</div>
+<%
+    }
+  }
+%>
+<table class="table table-striped">
+  <tr>
+    <th>RegionName</th>
+    <th>Start Key</th>
+    <th>End Key</th>
+    <th>Replica ID</th>
+    <th>RegionState</th>
+    <th>ServerName</th>
+  </tr>
+<%
+  final boolean metaScanHasMore;
+  byte[] lastRow = null;
+  try (final MetaBrowser.Results results = metaBrowser.getResults()) {
+    for (final RegionReplicaInfo regionReplicaInfo : results) {
+      lastRow = Optional.ofNullable(regionReplicaInfo)
+        .map(RegionReplicaInfo::getRow)
+        .orElse(null);
+      if (regionReplicaInfo == null) {
+%>
+  <tr>
+    <td colspan="6">Null result</td>
+  </tr>
+<%
+      continue;
+    }
+
+    final String regionNameDisplay = regionReplicaInfo.getRegionName() != null
+      ? Bytes.toStringBinary(regionReplicaInfo.getRegionName())
+      : "";
+    final String startKeyDisplay = regionReplicaInfo.getStartKey() != null
+      ? Bytes.toStringBinary(regionReplicaInfo.getStartKey())
+      : "";
+    final String endKeyDisplay = regionReplicaInfo.getEndKey() != null
+      ? Bytes.toStringBinary(regionReplicaInfo.getEndKey())
+      : "";
+    final String replicaIdDisplay = regionReplicaInfo.getReplicaId() != null
+      ? regionReplicaInfo.getReplicaId().toString()
+      : "";
+    final String regionStateDisplay = regionReplicaInfo.getRegionState() != null
+      ? regionReplicaInfo.getRegionState().toString()
+      : "";
+
+    final RegionInfo regionInfo = regionReplicaInfo.getRegionInfo();
+    final ServerName serverName = regionReplicaInfo.getServerName();
+    final RegionState.State regionState = regionReplicaInfo.getRegionState();
+    final int rsPort = master.getRegionServerInfoPort(serverName);
+%>
+  <tr>
+    <td><%= regionNameDisplay %></td>
+    <td><%= startKeyDisplay %></td>
+    <td><%= endKeyDisplay %></td>
+    <td><%= replicaIdDisplay %></td>
+    <td><%= regionStateDisplay %></td>
+    <td><%= buildRegionServerLink(serverName, rsPort, regionInfo, regionState) %></td>
+  </tr>
+<%
+    }
+
+    metaScanHasMore = results.hasMoreResults();
+  }
+%>
+</table>
+<div class="row">
+  <div class="col-md-4">
+    <ul class="pagination" style="margin: 20px 0">
+      <li>
+        <a href="<%= metaBrowser.buildFirstPageUrl() %>" aria-label="Previous">
+          <span aria-hidden="true">&#x21E4;</span>
+        </a>
+      </li>
+      <li<%= metaScanHasMore ? "" : " class=\"disabled\"" %>>
+        <a<%= metaScanHasMore ? " href=\"" + metaBrowser.buildNextPageUrl(lastRow) + "\"" : "" %> aria-label="Next">
+          <span aria-hidden="true">&raquo;</span>
+        </a>
+      </li>
+    </ul>
+  </div>
+  <div class="col-md-8">
+    <form action="/table.jsp" method="get" class="form-inline pull-right" style="margin: 20px 0">
+      <input type="hidden" name="name" value="<%= TableName.META_TABLE_NAME %>" />
+      <div class="form-group">
+        <label for="scan-limit">Scan Limit</label>
+        <input type="text" id="scan-limit" name="<%= MetaBrowser.SCAN_LIMIT_PARAM %>"
+          class="form-control" placeholder="<%= MetaBrowser.SCAN_LIMIT_DEFAULT %>"
+          <%= metaBrowser.getScanLimit() != null
+            ? "value=\"" + metaBrowser.getScanLimit() + "\""
+            : ""
+          %>
+          aria-describedby="scan-limit" style="display:inline; width:auto" />
+        <label for="table-name-filter">Table</label>
+        <input type="text" id="table-name-filter" name="<%= MetaBrowser.SCAN_TABLE_PARAM %>"
+          <%= metaBrowser.getScanTable() != null
+            ? "value=\"" + metaBrowser.getScanTable() + "\""
+            : ""
+          %>
+          aria-describedby="scan-filter-table" style="display:inline; width:auto" />
+        <label for="region-state-filter">Region State</label>
+        <select class="form-control" id="region-state-filter" style="display:inline; width:auto"
+          name="<%= MetaBrowser.SCAN_REGION_STATE_PARAM %>">
+          <option></option>
+<%
+  for (final RegionState.State state : RegionState.State.values()) {
+    final boolean selected = metaBrowser.getScanRegionState() == state;
+%>
+          <option<%= selected ? " selected" : "" %>><%= state %></option>
+<%
+  }
+%>
+        </select>
+        <button type="submit" class="btn btn-primary" style="display:inline; width:auto">
+          Filter Results
+        </button>
+      </div>
+    </form>
+  </div>
+</div>
 <%} else {
   RegionStates states = master.getAssignmentManager().getRegionStates();
   Map<RegionState.State, List<RegionInfo>> regionStates = states.getRegionByStateOfTable(table.getName());
@@ -838,8 +989,6 @@ if (withReplica) {
   for(StackTraceElement element : ex.getStackTrace()) {
     %><%= StringEscapeUtils.escapeHtml4(element.toString()) %><%
   }
-} finally {
-  connection.close();
 }
 } // end else
 %>
diff --git a/hbase-server/src/test/java/org/apache/hadoop/hbase/ClearUserNamespacesAndTablesRule.java b/hbase-server/src/test/java/org/apache/hadoop/hbase/ClearUserNamespacesAndTablesRule.java
new file mode 100644
index 0000000..6400eb8
--- /dev/null
+++ b/hbase-server/src/test/java/org/apache/hadoop/hbase/ClearUserNamespacesAndTablesRule.java
@@ -0,0 +1,167 @@
+/*
+ * 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.hadoop.hbase;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.StringJoiner;
+import java.util.concurrent.CompletableFuture;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+import org.apache.hadoop.hbase.client.AsyncAdmin;
+import org.apache.hadoop.hbase.client.AsyncConnection;
+import org.junit.ClassRule;
+import org.junit.Rule;
+import org.junit.rules.ExternalResource;
+import org.junit.rules.TestRule;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A {@link TestRule} that clears all user namespaces and tables
+ * {@link ExternalResource#before() before} the test executes. Can be used in either the
+ * {@link Rule} or {@link ClassRule} positions. Lazily realizes the provided
+ * {@link AsyncConnection} so as to avoid initialization races with other {@link Rule Rules}.
+ * <b>Does not</b> {@link AsyncConnection#close() close()} provided connection instance when
+ * finished.
+ * </p>
+ * Use in combination with {@link MiniClusterRule} and {@link ConnectionRule}, for example:
+ *
+ * <pre>{@code
+ *   public class TestMyClass {
+ *     @ClassRule
+ *     public static final MiniClusterRule miniClusterRule = new MiniClusterRule();
+ *
+ *     private final ConnectionRule connectionRule =
+ *       new ConnectionRule(miniClusterRule::createConnection);
+ *     private final ClearUserNamespacesAndTablesRule clearUserNamespacesAndTablesRule =
+ *       new ClearUserNamespacesAndTablesRule(connectionRule::getConnection);
+ *
+ *     @Rule
+ *     public TestRule rule = RuleChain
+ *       .outerRule(connectionRule)
+ *       .around(clearUserNamespacesAndTablesRule);
+ *   }
+ * }</pre>
+ */
+public class ClearUserNamespacesAndTablesRule extends ExternalResource {
+  private static final Logger logger =
+    LoggerFactory.getLogger(ClearUserNamespacesAndTablesRule.class);
+
+  private final Supplier<AsyncConnection> connectionSupplier;
+  private AsyncAdmin admin;
+
+  public ClearUserNamespacesAndTablesRule(final Supplier<AsyncConnection> connectionSupplier) {
+    this.connectionSupplier = connectionSupplier;
+  }
+
+  @Override
+  protected void before() throws Throwable {
+    final AsyncConnection connection = Objects.requireNonNull(connectionSupplier.get());
+    admin = connection.getAdmin();
+
+    clearTablesAndNamespaces().join();
+  }
+
+  private CompletableFuture<Void> clearTablesAndNamespaces() {
+    return deleteUserTables().thenCompose(_void -> deleteUserNamespaces());
+  }
+
+  private CompletableFuture<Void> deleteUserTables() {
+    return listTableNames()
+      .thenApply(tableNames -> tableNames.stream()
+        .map(tableName -> disableIfEnabled(tableName).thenCompose(_void -> deleteTable(tableName)))
+        .toArray(CompletableFuture[]::new))
+      .thenCompose(CompletableFuture::allOf);
+  }
+
+  private CompletableFuture<List<TableName>> listTableNames() {
+    return CompletableFuture
+      .runAsync(() -> logger.trace("listing tables"))
+      .thenCompose(_void -> admin.listTableNames(false))
+      .thenApply(tableNames -> {
+        if (logger.isTraceEnabled()) {
+          final StringJoiner joiner = new StringJoiner(", ", "[", "]");
+          tableNames.stream().map(TableName::getNameAsString).forEach(joiner::add);
+          logger.trace("found existing tables {}", joiner.toString());
+        }
+        return tableNames;
+      });
+  }
+
+  private CompletableFuture<Boolean> isTableEnabled(final TableName tableName) {
+    return admin.isTableEnabled(tableName)
+      .thenApply(isEnabled -> {
+        logger.trace("table {} is enabled.", tableName);
+        return isEnabled;
+      });
+  }
+
+  private CompletableFuture<Void> disableIfEnabled(final TableName tableName) {
+    return isTableEnabled(tableName)
+      .thenCompose(isEnabled -> isEnabled
+        ? disableTable(tableName)
+        : CompletableFuture.completedFuture(null));
+  }
+
+  private CompletableFuture<Void> disableTable(final TableName tableName) {
+    return CompletableFuture
+      .runAsync(() -> logger.trace("disabling enabled table {}", tableName))
+      .thenCompose(_void -> admin.disableTable(tableName));
+  }
+
+  private CompletableFuture<Void> deleteTable(final TableName tableName) {
+    return CompletableFuture
+      .runAsync(() -> logger.trace("deleting disabled table {}", tableName))
+      .thenCompose(_void -> admin.deleteTable(tableName));
+  }
+
+  private CompletableFuture<List<String>> listUserNamespaces() {
+    return CompletableFuture
+      .runAsync(() -> logger.trace("listing namespaces"))
+      .thenCompose(_void -> admin.listNamespaceDescriptors())
+      .thenApply(namespaceDescriptors -> {
+        final StringJoiner joiner = new StringJoiner(", ", "[", "]");
+        final List<String> names = namespaceDescriptors.stream()
+          .map(NamespaceDescriptor::getName)
+          .peek(joiner::add)
+          .collect(Collectors.toList());
+        logger.trace("found existing namespaces {}", joiner);
+        return names;
+      })
+      .thenApply(namespaces -> namespaces.stream()
+        .filter(namespace -> !Objects.equals(
+          namespace, NamespaceDescriptor.SYSTEM_NAMESPACE.getName()))
+        .filter(namespace -> !Objects.equals(
+          namespace, NamespaceDescriptor.DEFAULT_NAMESPACE.getName()))
+        .collect(Collectors.toList()));
+  }
+
+  private CompletableFuture<Void> deleteNamespace(final String namespace) {
+    return CompletableFuture
+      .runAsync(() -> logger.trace("deleting namespace {}", namespace))
+      .thenCompose(_void -> admin.deleteNamespace(namespace));
+  }
+
+  private CompletableFuture<Void> deleteUserNamespaces() {
+    return listUserNamespaces()
+      .thenCompose(namespaces -> CompletableFuture.allOf(namespaces.stream()
+        .map(this::deleteNamespace)
+        .toArray(CompletableFuture[]::new)));
+  }
+}
diff --git a/hbase-server/src/test/java/org/apache/hadoop/hbase/ConnectionRule.java b/hbase-server/src/test/java/org/apache/hadoop/hbase/ConnectionRule.java
new file mode 100644
index 0000000..bf4c5aa
--- /dev/null
+++ b/hbase-server/src/test/java/org/apache/hadoop/hbase/ConnectionRule.java
@@ -0,0 +1,75 @@
+/*
+ * 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.hadoop.hbase;
+
+import java.io.IOException;
+import java.util.concurrent.CompletableFuture;
+import java.util.function.Supplier;
+import org.apache.hadoop.hbase.client.AsyncConnection;
+import org.junit.ClassRule;
+import org.junit.Rule;
+import org.junit.rules.ExternalResource;
+
+/**
+ * A {@link Rule} that manages the lifecycle of an instance of {@link AsyncConnection}. Can be used
+ * in either the {@link Rule} or {@link ClassRule} positions.
+ * </p>
+ * Use in combination with {@link MiniClusterRule}, for example:
+ *
+ * <pre>{@code
+ *   public class TestMyClass {
+ *     private static final MiniClusterRule miniClusterRule = new MiniClusterRule();
+ *     private static final ConnectionRule connectionRule =
+ *       new ConnectionRule(miniClusterRule::createConnection);
+ *
+ *     @ClassRule
+ *     public static final TestRule rule = RuleChain
+ *       .outerRule(connectionRule)
+ *       .around(connectionRule);
+ *   }
+ * }</pre>
+ */
+public class ConnectionRule extends ExternalResource {
+
+  private final Supplier<CompletableFuture<AsyncConnection>> connectionSupplier;
+  private AsyncConnection connection;
+
+  public ConnectionRule(final Supplier<CompletableFuture<AsyncConnection>> connectionSupplier) {
+    this.connectionSupplier = connectionSupplier;
+  }
+
+  public AsyncConnection getConnection() {
+    return connection;
+  }
+
+  @Override
+  protected void before() throws Throwable {
+    this.connection = connectionSupplier.get().join();
+  }
+
+  @Override
+  protected void after() {
+    if (this.connection != null) {
+      try {
+        connection.close();
+      } catch (IOException e) {
+        throw new RuntimeException(e);
+      }
+    }
+  }
+}
diff --git a/hbase-server/src/test/java/org/apache/hadoop/hbase/HBaseTestingUtility.java b/hbase-server/src/test/java/org/apache/hadoop/hbase/HBaseTestingUtility.java
index f1e91de..79160cd 100644
--- a/hbase-server/src/test/java/org/apache/hadoop/hbase/HBaseTestingUtility.java
+++ b/hbase-server/src/test/java/org/apache/hadoop/hbase/HBaseTestingUtility.java
@@ -1283,10 +1283,9 @@ public class HBaseTestingUtility extends HBaseZKTestingUtility {
 
   /**
    * Stops mini hbase, zk, and hdfs clusters.
-   * @throws IOException
    * @see #startMiniCluster(int)
    */
-  public void shutdownMiniCluster() throws Exception {
+  public void shutdownMiniCluster() throws IOException {
     LOG.info("Shutting down minicluster");
     shutdownMiniHBaseCluster();
     shutdownMiniDFSCluster();
diff --git a/hbase-server/src/test/java/org/apache/hadoop/hbase/MiniClusterRule.java b/hbase-server/src/test/java/org/apache/hadoop/hbase/MiniClusterRule.java
new file mode 100644
index 0000000..6ac4838
--- /dev/null
+++ b/hbase-server/src/test/java/org/apache/hadoop/hbase/MiniClusterRule.java
@@ -0,0 +1,92 @@
+/*
+ * 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.hadoop.hbase;
+
+import java.io.IOException;
+import java.util.concurrent.CompletableFuture;
+import org.apache.hadoop.hbase.client.AsyncConnection;
+import org.apache.hadoop.hbase.client.ConnectionFactory;
+import org.junit.ClassRule;
+import org.junit.Rule;
+import org.junit.rules.ExternalResource;
+import org.junit.rules.TestRule;
+
+/**
+ * A {@link TestRule} that manages an instance of the {@link MiniHBaseCluster}. Can be used in
+ * either the {@link Rule} or {@link ClassRule} positions. Built on top of an instance of
+ * {@link HBaseTestingUtility}, so be weary of intermixing direct use of that class with this Rule.
+ * </p>
+ * Use in combination with {@link ConnectionRule}, for example:
+ *
+ * <pre>{@code
+ *   public class TestMyClass {
+ *     @ClassRule
+ *     public static final MiniClusterRule miniClusterRule = new MiniClusterRule();
+ *
+ *     @Rule
+ *     public final ConnectionRule connectionRule =
+ *       new ConnectionRule(miniClusterRule::createConnection);
+ *   }
+ * }</pre>
+ */
+public class MiniClusterRule extends ExternalResource {
+  private final HBaseTestingUtility testingUtility;
+  private final StartMiniClusterOption miniClusterOptions;
+
+  private MiniHBaseCluster miniCluster;
+
+  /**
+   * Create an instance over the default options provided by {@link StartMiniClusterOption}.
+   */
+  public MiniClusterRule() {
+    this(StartMiniClusterOption.builder().build());
+  }
+
+  /**
+   * Create an instance using the provided {@link StartMiniClusterOption}.
+   */
+  public MiniClusterRule(final StartMiniClusterOption miniClusterOptions) {
+    this.testingUtility = new HBaseTestingUtility();
+    this.miniClusterOptions = miniClusterOptions;
+  }
+
+  /**
+   * Create a {@link AsyncConnection} to the managed {@link MiniHBaseCluster}. It's up to the caller
+   * to {@link AsyncConnection#close() close()} the connection when finished.
+   */
+  public CompletableFuture<AsyncConnection> createConnection() {
+    if (miniCluster == null) {
+      throw new IllegalStateException("test cluster not initialized");
+    }
+    return ConnectionFactory.createAsyncConnection(miniCluster.getConf());
+  }
+
+  @Override
+  protected void before() throws Throwable {
+    miniCluster = testingUtility.startMiniCluster(miniClusterOptions);
+  }
+
+  @Override
+  protected void after() {
+    try {
+      testingUtility.shutdownMiniCluster();
+    } catch (IOException e) {
+      throw new RuntimeException(e);
+    }
+  }
+}
diff --git a/hbase-server/src/test/java/org/apache/hadoop/hbase/client/hamcrest/BytesMatchers.java b/hbase-server/src/test/java/org/apache/hadoop/hbase/client/hamcrest/BytesMatchers.java
new file mode 100644
index 0000000..581ac9f
--- /dev/null
+++ b/hbase-server/src/test/java/org/apache/hadoop/hbase/client/hamcrest/BytesMatchers.java
@@ -0,0 +1,56 @@
+/*
+ * 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.hadoop.hbase.client.hamcrest;
+
+import static org.hamcrest.core.Is.is;
+import org.apache.hadoop.hbase.util.Bytes;
+import org.hamcrest.Description;
+import org.hamcrest.Matcher;
+import org.hamcrest.TypeSafeDiagnosingMatcher;
+
+/**
+ * Helper methods for matching against values passed through the helper methods of {@link Bytes}.
+ */
+public final class BytesMatchers {
+
+  private BytesMatchers() {}
+
+  public static Matcher<byte[]> bytesAsStringBinary(final String binary) {
+    return bytesAsStringBinary(is(binary));
+  }
+
+  public static Matcher<byte[]> bytesAsStringBinary(final Matcher<String> matcher) {
+    return new TypeSafeDiagnosingMatcher<byte[]>() {
+      @Override protected boolean matchesSafely(byte[] item, Description mismatchDescription) {
+        final String binary = Bytes.toStringBinary(item);
+        if (matcher.matches(binary)) {
+          return true;
+        }
+        mismatchDescription.appendText("was a byte[] with a Bytes.toStringBinary value ");
+        matcher.describeMismatch(binary, mismatchDescription);
+        return false;
+      }
+
+      @Override public void describeTo(Description description) {
+        description
+          .appendText("has a byte[] with a Bytes.toStringBinary value that ")
+          .appendDescriptionOf(matcher);
+      }
+    };
+  }
+}
diff --git a/hbase-server/src/test/java/org/apache/hadoop/hbase/master/webapp/TestMetaBrowser.java b/hbase-server/src/test/java/org/apache/hadoop/hbase/master/webapp/TestMetaBrowser.java
new file mode 100644
index 0000000..86230ef
--- /dev/null
+++ b/hbase-server/src/test/java/org/apache/hadoop/hbase/master/webapp/TestMetaBrowser.java
@@ -0,0 +1,360 @@
+/*
+ * 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.hadoop.hbase.master.webapp;
+
+import static org.apache.hadoop.hbase.client.hamcrest.BytesMatchers.bytesAsStringBinary;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.allOf;
+import static org.hamcrest.Matchers.contains;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.hasProperty;
+import static org.hamcrest.Matchers.startsWith;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import javax.servlet.http.HttpServletRequest;
+import org.apache.hadoop.hbase.ClearUserNamespacesAndTablesRule;
+import org.apache.hadoop.hbase.ConnectionRule;
+import org.apache.hadoop.hbase.HBaseClassTestRule;
+import org.apache.hadoop.hbase.MiniClusterRule;
+import org.apache.hadoop.hbase.NamespaceDescriptor;
+import org.apache.hadoop.hbase.TableName;
+import org.apache.hadoop.hbase.client.AsyncAdmin;
+import org.apache.hadoop.hbase.client.AsyncConnection;
+import org.apache.hadoop.hbase.client.ColumnFamilyDescriptor;
+import org.apache.hadoop.hbase.client.ColumnFamilyDescriptorBuilder;
+import org.apache.hadoop.hbase.client.TableDescriptor;
+import org.apache.hadoop.hbase.client.TableDescriptorBuilder;
+import org.apache.hadoop.hbase.master.RegionState;
+import org.apache.hadoop.hbase.testclassification.MasterTests;
+import org.apache.hadoop.hbase.testclassification.MediumTests;
+import org.apache.hadoop.hbase.util.RegionSplitter;
+import org.junit.Before;
+import org.junit.ClassRule;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+import org.junit.rules.RuleChain;
+import org.junit.rules.TestName;
+import org.junit.rules.TestRule;
+import org.apache.hbase.thirdparty.org.apache.commons.collections4.IterableUtils;
+
+/**
+ * Cluster-backed correctness tests for the functionality provided by {@link MetaBrowser}.
+ */
+@Category({ MasterTests.class, MediumTests.class})
+public class TestMetaBrowser {
+
+  @ClassRule
+  public static final HBaseClassTestRule testRule =
+    HBaseClassTestRule.forClass(TestMetaBrowser.class);
+  @ClassRule
+  public static final MiniClusterRule miniClusterRule = new MiniClusterRule();
+
+  private final ConnectionRule connectionRule =
+    new ConnectionRule(miniClusterRule::createConnection);
+  private final ClearUserNamespacesAndTablesRule clearUserNamespacesAndTablesRule =
+    new ClearUserNamespacesAndTablesRule(connectionRule::getConnection);
+
+  @Rule
+  public TestRule rule = RuleChain.outerRule(connectionRule)
+    .around(clearUserNamespacesAndTablesRule);
+
+  @Rule
+  public TestName testNameRule = new TestName();
+
+  private AsyncConnection connection;
+  private AsyncAdmin admin;
+
+  @Before
+  public void before() {
+    connection = connectionRule.getConnection();
+    admin = connection.getAdmin();
+  }
+
+  @Test
+  public void noFilters() {
+    final String namespaceName = testNameRule.getMethodName();
+    final TableName a = TableName.valueOf("a");
+    final TableName b = TableName.valueOf(namespaceName, "b");
+
+    CompletableFuture.allOf(
+      createTable(a),
+      createNamespace(namespaceName).thenCompose(_void -> createTable(b, 2)))
+      .join();
+
+    final HttpServletRequest request = new MockRequestBuilder().build();
+    final List<RegionReplicaInfo> rows;
+    try (final MetaBrowser.Results results = new MetaBrowser(connection, request).getResults()) {
+      rows = IterableUtils.toList(results);
+    }
+    assertThat(rows, contains(
+      hasProperty("row", bytesAsStringBinary(startsWith(a + ",,"))),
+      hasProperty("row", bytesAsStringBinary(startsWith(b + ",,"))),
+      hasProperty("row", bytesAsStringBinary(startsWith(b + ",80000000")))));
+  }
+
+  @Test
+  public void limit() {
+    final String tableName = testNameRule.getMethodName();
+    createTable(TableName.valueOf(tableName), 8).join();
+
+    final HttpServletRequest request = new MockRequestBuilder()
+      .setLimit(5)
+      .build();
+    final List<RegionReplicaInfo> rows;
+    try (final MetaBrowser.Results results = new MetaBrowser(connection, request).getResults()) {
+      rows = IterableUtils.toList(results);
+    }
+    assertThat(rows, contains(
+      hasProperty("row", bytesAsStringBinary(startsWith(tableName + ",,"))),
+      hasProperty("row", bytesAsStringBinary(startsWith(tableName + ",20000000"))),
+      hasProperty("row", bytesAsStringBinary(startsWith(tableName + ",40000000"))),
+      hasProperty("row", bytesAsStringBinary(startsWith(tableName + ",60000000"))),
+      hasProperty("row", bytesAsStringBinary(startsWith(tableName + ",80000000")))));
+  }
+
+  @Test
+  public void regionStateFilter() {
+    final String namespaceName = testNameRule.getMethodName();
+    final TableName foo = TableName.valueOf(namespaceName, "foo");
+    final TableName bar = TableName.valueOf(namespaceName, "bar");
+
+    createNamespace(namespaceName)
+      .thenCompose(_void1 -> CompletableFuture.allOf(
+        createTable(foo, 2).thenCompose(_void2 -> admin.disableTable(foo)),
+        createTable(bar, 2)))
+      .join();
+
+    final HttpServletRequest request = new MockRequestBuilder()
+      .setLimit(10_000)
+      .setRegionState(RegionState.State.OPEN)
+      .setTable(namespaceName)
+      .build();
+    final List<RegionReplicaInfo> rows;
+    try (final MetaBrowser.Results results = new MetaBrowser(connection, request).getResults()) {
+      rows = IterableUtils.toList(results);
+    }
+    assertThat(rows, contains(
+      hasProperty("row", bytesAsStringBinary(startsWith(bar.toString() + ",,"))),
+      hasProperty("row", bytesAsStringBinary(startsWith(bar.toString() + ",80000000")))));
+  }
+
+  @Test
+  public void scanTableFilter() {
+    final String namespaceName = testNameRule.getMethodName();
+    final TableName a = TableName.valueOf("a");
+    final TableName b = TableName.valueOf(namespaceName, "b");
+
+    CompletableFuture.allOf(
+      createTable(a),
+      createNamespace(namespaceName).thenCompose(_void -> createTable(b, 2)))
+      .join();
+
+    final HttpServletRequest request = new MockRequestBuilder()
+      .setTable(namespaceName)
+      .build();
+    final List<RegionReplicaInfo> rows;
+    try (final MetaBrowser.Results results = new MetaBrowser(connection, request).getResults()) {
+      rows = IterableUtils.toList(results);
+    }
+    assertThat(rows, contains(
+      hasProperty("row", bytesAsStringBinary(startsWith(b + ",,"))),
+      hasProperty("row", bytesAsStringBinary(startsWith(b + ",80000000")))));
+  }
+
+  @Test
+  public void paginateWithReplicas() {
+    final String namespaceName = testNameRule.getMethodName();
+    final TableName a = TableName.valueOf("a");
+    final TableName b = TableName.valueOf(namespaceName, "b");
+
+    CompletableFuture.allOf(
+      createTableWithReplicas(a, 2),
+      createNamespace(namespaceName).thenCompose(_void -> createTable(b, 2)))
+      .join();
+
+    final HttpServletRequest request1 = new MockRequestBuilder()
+      .setLimit(2)
+      .build();
+    final List<RegionReplicaInfo> rows1;
+    try (final MetaBrowser.Results results = new MetaBrowser(connection, request1).getResults()) {
+      rows1 = IterableUtils.toList(results);
+    }
+    assertThat(rows1, contains(
+      allOf(
+        hasProperty("regionName", bytesAsStringBinary(startsWith(a + ",,"))),
+        hasProperty("replicaId", equalTo(0))),
+      allOf(
+        hasProperty("regionName", bytesAsStringBinary(startsWith(a + ",,"))),
+        hasProperty("replicaId", equalTo(1)))));
+
+    final HttpServletRequest request2 = new MockRequestBuilder()
+      .setLimit(2)
+      .setStart(MetaBrowser.buildStartParamFrom(rows1.get(rows1.size() - 1).getRow()))
+      .build();
+    final List<RegionReplicaInfo> rows2;
+    try (final MetaBrowser.Results results = new MetaBrowser(connection, request2).getResults()) {
+      rows2 = IterableUtils.toList(results);
+    }
+    assertThat(rows2, contains(
+      hasProperty("row", bytesAsStringBinary(startsWith(b + ",,"))),
+      hasProperty("row", bytesAsStringBinary(startsWith(b + ",80000000")))));
+  }
+
+  @Test
+  public void paginateWithTableFilter() {
+    final String namespaceName = testNameRule.getMethodName();
+    final TableName a = TableName.valueOf("a");
+    final TableName b = TableName.valueOf(namespaceName, "b");
+
+    CompletableFuture.allOf(
+      createTable(a),
+      createNamespace(namespaceName).thenCompose(_void -> createTable(b, 5)))
+      .join();
+
+    final HttpServletRequest request1 = new MockRequestBuilder()
+      .setLimit(2)
+      .setTable(namespaceName)
+      .build();
+    final List<RegionReplicaInfo> rows1;
+    try (final MetaBrowser.Results results = new MetaBrowser(connection, request1).getResults()) {
+      rows1 = IterableUtils.toList(results);
+    }
+    assertThat(rows1, contains(
+      hasProperty("row", bytesAsStringBinary(startsWith(b + ",,"))),
+      hasProperty("row", bytesAsStringBinary(startsWith(b + ",33333333")))));
+
+    final HttpServletRequest request2 = new MockRequestBuilder()
+      .setLimit(2)
+      .setTable(namespaceName)
+      .setStart(MetaBrowser.buildStartParamFrom(rows1.get(rows1.size() - 1).getRow()))
+      .build();
+    final List<RegionReplicaInfo> rows2;
+    try (final MetaBrowser.Results results = new MetaBrowser(connection, request2).getResults()) {
+      rows2 = IterableUtils.toList(results);
+    }
+    assertThat(rows2, contains(
+      hasProperty("row", bytesAsStringBinary(startsWith(b + ",66666666"))),
+      hasProperty("row", bytesAsStringBinary(startsWith(b + ",99999999")))));
+
+    final HttpServletRequest request3 = new MockRequestBuilder()
+      .setLimit(2)
+      .setTable(namespaceName)
+      .setStart(MetaBrowser.buildStartParamFrom(rows2.get(rows2.size() - 1).getRow()))
+      .build();
+    final List<RegionReplicaInfo> rows3;
+    try (final MetaBrowser.Results results = new MetaBrowser(connection, request3).getResults()) {
+      rows3 = IterableUtils.toList(results);
+    }
+    assertThat(rows3, contains(
+      hasProperty("row", bytesAsStringBinary(startsWith(b + ",cccccccc")))));
+  }
+
+  private ColumnFamilyDescriptor columnFamilyDescriptor() {
+    return ColumnFamilyDescriptorBuilder.of("f1");
+  }
+
+  private TableDescriptor tableDescriptor(final TableName tableName) {
+    return TableDescriptorBuilder.newBuilder(tableName)
+      .setColumnFamily(columnFamilyDescriptor())
+      .build();
+  }
+
+  private TableDescriptor tableDescriptor(final TableName tableName, final int replicaCount) {
+    return TableDescriptorBuilder.newBuilder(tableName)
+      .setRegionReplication(replicaCount)
+      .setColumnFamily(columnFamilyDescriptor())
+      .build();
+  }
+
+  private CompletableFuture<Void> createTable(final TableName tableName) {
+    return admin.createTable(tableDescriptor(tableName));
+  }
+
+  private CompletableFuture<Void> createTable(final TableName tableName, final int splitCount) {
+    return admin.createTable(
+      tableDescriptor(tableName),
+      new RegionSplitter.HexStringSplit().split(splitCount));
+  }
+
+  private CompletableFuture<Void> createTableWithReplicas(final TableName tableName,
+    final int replicaCount) {
+    return admin.createTable(tableDescriptor(tableName, replicaCount));
+  }
+
+  private CompletableFuture<Void> createNamespace(final String namespace) {
+    final NamespaceDescriptor descriptor = NamespaceDescriptor.create(namespace).build();
+    return admin.createNamespace(descriptor);
+  }
+
+  /**
+   * Helper for mocking an {@link HttpServletRequest} relevant to the test.
+   */
+  static class MockRequestBuilder {
+
+    private String limit = null;
+    private String regionState = null;
+    private String start = null;
+    private String table = null;
+
+    public MockRequestBuilder setLimit(final int value) {
+      this.limit = Integer.toString(value);
+      return this;
+    }
+
+    public MockRequestBuilder setLimit(final String value) {
+      this.limit = value;
+      return this;
+    }
+
+    public MockRequestBuilder setRegionState(final RegionState.State value) {
+      this.regionState = value.toString();
+      return this;
+    }
+
+    public MockRequestBuilder setRegionState(final String value) {
+      this.regionState = value;
+      return this;
+    }
+
+    public MockRequestBuilder setStart(final String value) {
+      this.start = value;
+      return this;
+    }
+
+    public MockRequestBuilder setTable(final String value) {
+      this.table = value;
+      return this;
+    }
+
+    public HttpServletRequest build() {
+      final HttpServletRequest request = mock(HttpServletRequest.class);
+      when(request.getRequestURI()).thenReturn("/table.jsp");
+      when(request.getParameter("name")).thenReturn("hbase%3Ameta");
+
+      when(request.getParameter("scan_limit")).thenReturn(limit);
+      when(request.getParameter("scan_region_state")).thenReturn(regionState);
+      when(request.getParameter("scan_start")).thenReturn(start);
+      when(request.getParameter("scan_table")).thenReturn(table);
+
+      return request;
+    }
+  }
+}
diff --git a/hbase-server/src/test/java/org/apache/hadoop/hbase/master/webapp/TestMetaBrowserNoCluster.java b/hbase-server/src/test/java/org/apache/hadoop/hbase/master/webapp/TestMetaBrowserNoCluster.java
new file mode 100644
index 0000000..ebb1227
--- /dev/null
+++ b/hbase-server/src/test/java/org/apache/hadoop/hbase/master/webapp/TestMetaBrowserNoCluster.java
@@ -0,0 +1,168 @@
+/*
+ * 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.hadoop.hbase.master.webapp;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.contains;
+import static org.hamcrest.Matchers.containsInAnyOrder;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import javax.servlet.http.HttpServletRequest;
+import org.apache.hadoop.hbase.HBaseClassTestRule;
+import org.apache.hadoop.hbase.TableName;
+import org.apache.hadoop.hbase.client.AsyncConnection;
+import org.apache.hadoop.hbase.master.RegionState;
+import org.apache.hadoop.hbase.master.webapp.TestMetaBrowser.MockRequestBuilder;
+import org.apache.hadoop.hbase.testclassification.MasterTests;
+import org.apache.hadoop.hbase.testclassification.SmallTests;
+import org.junit.Before;
+import org.junit.ClassRule;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+/**
+ * Cluster-backed correctness tests for the functionality provided by {@link MetaBrowser}.
+ */
+@Category({ MasterTests.class, SmallTests.class})
+public class TestMetaBrowserNoCluster {
+
+  @ClassRule
+  public static final HBaseClassTestRule testRule =
+    HBaseClassTestRule.forClass(TestMetaBrowserNoCluster.class);
+
+  @Mock
+  private AsyncConnection connection;
+
+  @Before
+  public void before() {
+    MockitoAnnotations.initMocks(this);
+  }
+
+  @Test
+  public void buildFirstPageQueryStringNoParams() {
+    final HttpServletRequest request = new MockRequestBuilder().build();
+    final MetaBrowser metaBrowser = new MetaBrowser(connection, request);
+
+    assertEquals("hbase:meta", metaBrowser.getName());
+    assertNull(metaBrowser.getScanLimit());
+    assertNull(metaBrowser.getScanRegionState());
+    assertNull(metaBrowser.getScanStart());
+    assertNull(metaBrowser.getScanTable());
+    assertEquals("/table.jsp?name=hbase%3Ameta", metaBrowser.buildFirstPageUrl());
+  }
+
+  @Test
+  public void buildFirstPageQueryStringNonNullParams() {
+    final HttpServletRequest request = new MockRequestBuilder()
+      .setLimit(50)
+      .setRegionState(RegionState.State.ABNORMALLY_CLOSED)
+      .setTable("foo%3Abar")
+      .build();
+    final MetaBrowser metaBrowser = new MetaBrowser(connection, request);
+
+    assertEquals(50, metaBrowser.getScanLimit().intValue());
+    assertEquals(RegionState.State.ABNORMALLY_CLOSED, metaBrowser.getScanRegionState());
+    assertEquals(TableName.valueOf("foo", "bar"), metaBrowser.getScanTable());
+    assertEquals(
+      "/table.jsp?name=hbase%3Ameta"
+        + "&scan_limit=50"
+        + "&scan_region_state=ABNORMALLY_CLOSED"
+        + "&scan_table=foo%3Abar",
+      metaBrowser.buildNextPageUrl(null));
+  }
+
+  @Test
+  public void buildNextPageQueryString() {
+    final HttpServletRequest request = new MockRequestBuilder().build();
+    final MetaBrowser metaBrowser = new MetaBrowser(connection, request);
+
+    assertEquals(
+      "/table.jsp?name=hbase%3Ameta&scan_start=%255Cx80%255Cx00%255Cx7F",
+      metaBrowser.buildNextPageUrl(new byte[] { Byte.MIN_VALUE, (byte) 0, Byte.MAX_VALUE }));
+  }
+
+  @Test
+  public void unparseableLimitParam() {
+    final HttpServletRequest request = new MockRequestBuilder()
+      .setLimit("foo")
+      .build();
+    final MetaBrowser metaBrowser = new MetaBrowser(connection, request);
+    assertNull(metaBrowser.getScanLimit());
+    assertThat(metaBrowser.getErrorMessages(), contains(
+      "Requested SCAN_LIMIT value 'foo' cannot be parsed as an integer."));
+  }
+
+  @Test
+  public void zeroLimitParam() {
+    final HttpServletRequest request = new MockRequestBuilder()
+      .setLimit(0)
+      .build();
+    final MetaBrowser metaBrowser = new MetaBrowser(connection, request);
+    assertEquals(MetaBrowser.SCAN_LIMIT_DEFAULT, metaBrowser.getScanLimit().intValue());
+    assertThat(metaBrowser.getErrorMessages(), contains(
+      "Requested SCAN_LIMIT value 0 is <= 0."));
+  }
+
+  @Test
+  public void negativeLimitParam() {
+    final HttpServletRequest request = new MockRequestBuilder()
+      .setLimit(-10)
+      .build();
+    final MetaBrowser metaBrowser = new MetaBrowser(connection, request);
+    assertEquals(MetaBrowser.SCAN_LIMIT_DEFAULT, metaBrowser.getScanLimit().intValue());
+    assertThat(metaBrowser.getErrorMessages(), contains(
+      "Requested SCAN_LIMIT value -10 is <= 0."));
+  }
+
+  @Test
+  public void excessiveLimitParam() {
+    final HttpServletRequest request = new MockRequestBuilder()
+      .setLimit(10_001)
+      .build();
+    final MetaBrowser metaBrowser = new MetaBrowser(connection, request);
+    assertEquals(MetaBrowser.SCAN_LIMIT_MAX, metaBrowser.getScanLimit().intValue());
+    assertThat(metaBrowser.getErrorMessages(), contains(
+      "Requested SCAN_LIMIT value 10001 exceeds maximum value 10000."));
+  }
+
+  @Test
+  public void invalidRegionStateParam() {
+    final HttpServletRequest request = new MockRequestBuilder()
+      .setRegionState("foo")
+      .build();
+    final MetaBrowser metaBrowser = new MetaBrowser(connection, request);
+    assertNull(metaBrowser.getScanRegionState());
+    assertThat(metaBrowser.getErrorMessages(), contains(
+      "Requested SCAN_REGION_STATE value 'foo' cannot be parsed as a RegionState."));
+  }
+
+  @Test
+  public void multipleErrorMessages() {
+    final HttpServletRequest request = new MockRequestBuilder()
+      .setLimit("foo")
+      .setRegionState("bar")
+      .build();
+    final MetaBrowser metaBrowser = new MetaBrowser(connection, request);
+    assertThat(metaBrowser.getErrorMessages(), containsInAnyOrder(
+      "Requested SCAN_LIMIT value 'foo' cannot be parsed as an integer.",
+      "Requested SCAN_REGION_STATE value 'bar' cannot be parsed as a RegionState."
+    ));
+  }
+}
diff --git a/pom.xml b/pom.xml
index 275673a..0e9351f 100755
--- a/pom.xml
+++ b/pom.xml
@@ -2153,6 +2153,12 @@
         <scope>test</scope>
       </dependency>
       <dependency>
+        <groupId>org.hamcrest</groupId>
+        <artifactId>hamcrest-library</artifactId>
+        <version>${hamcrest.version}</version>
+        <scope>test</scope>
+      </dependency>
+      <dependency>
         <groupId>org.mockito</groupId>
         <artifactId>mockito-core</artifactId>
         <version>${mockito-core.version}</version>


Mime
View raw message