You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@druid.apache.org by cw...@apache.org on 2020/09/12 02:43:13 UTC

[druid] branch master updated: Web console: improve query manager (convert to React hook) (#10360)

This is an automated email from the ASF dual-hosted git repository.

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


The following commit(s) were added to refs/heads/master by this push:
     new 3c8eacb  Web console: improve query manager (convert to React hook) (#10360)
3c8eacb is described below

commit 3c8eacb2d419012d562d1631cbd2a8f2926b95a5
Author: Vadim Ogievetsky <va...@ogievetsky.com>
AuthorDate: Fri Sep 11 19:42:50 2020 -0700

    Web console: improve query manager (convert to React hook) (#10360)
    
    * Better query running
    
    * update licenses
    
    * update tests
    
    * updated tests v2
    
    * fade in cancel
    
    * add exemplary tests
    
    * update mkcomp
    
    * fix inconsistent state update
    
    * remove lastParsedQuery
    
    * work if not a valid literal
    
    * remove unused params
    
    * fix licenses
    
    * better state update
    
    * get error message
    
    * isEmpty tidy
    
    * add tests around error message highlighting
    
    * pull live query selector into a component
    
    * add LiveQueryModeSelector tests
    
    * update snapshots
---
 licenses.yaml                                      |   2 +-
 web-console/lib/keywords.js                        |   2 +
 web-console/package-lock.json                      |   6 +-
 web-console/package.json                           |   2 +-
 web-console/script/mkcomp                          |  15 +-
 .../__snapshots__/auto-form.spec.tsx.snap          |   2 +-
 .../datasource-columns-table.spec.tsx.snap         | 691 ++++++++++++++++-
 .../datasource-columns-table.spec.tsx              |  62 +-
 .../datasource-columns-table.tsx                   |  91 +--
 .../__snapshots__/highlight-text.spec.tsx.snap     |  23 +
 .../highlight-text/highlight-text.scss}            |  12 +-
 .../highlight-text.spec.tsx}                       |  12 +-
 .../components/highlight-text/highlight-text.tsx   |  45 ++
 web-console/src/components/index.ts                |   1 +
 .../src/components/json-collapse/json-collapse.tsx |  47 +-
 web-console/src/components/loader/loader.scss      |  20 +
 web-console/src/components/loader/loader.spec.tsx  |   2 +-
 web-console/src/components/loader/loader.tsx       |  15 +-
 .../lookup-values-table/lookup-values-table.tsx    |  83 +--
 .../plural-pair-if-needed.tsx                      |   4 +-
 .../components/refresh-button/refresh-button.tsx   |  23 +-
 .../components/rule-editor/rule-editor.spec.tsx    |  20 +-
 .../src/components/rule-editor/rule-editor.tsx     |   4 +-
 .../__snapshots__/segment-timeline.spec.tsx.snap   |  10 +-
 .../segment-timeline/segment-timeline.tsx          |  26 +-
 .../__snapshots__/show-history.spec.tsx.snap       |  36 +-
 .../src/components/show-history/show-history.tsx   | 119 ++-
 .../__snapshots__/show-json.spec.tsx.snap          |  36 +-
 web-console/src/components/show-json/show-json.tsx | 124 ++-
 web-console/src/components/show-log/show-log.tsx   |  49 +-
 .../__snapshots__/suggestible-input.spec.tsx.snap  |   1 -
 .../suggestible-input/suggestible-input.tsx        | 169 ++---
 .../supervisor-statistics-table.spec.tsx.snap      | 828 +++++++++++++++++++--
 .../supervisor-statistics-table.spec.tsx           |  92 ++-
 .../supervisor-statistics-table.tsx                | 144 ++--
 .../components/timed-button/timed-button.spec.tsx  |   4 +-
 .../src/components/timed-button/timed-button.tsx   | 144 ++--
 web-console/src/console-application.scss           |   4 -
 web-console/src/console-application.tsx            |   6 +-
 .../compaction-dialog/compaction-dialog.tsx        | 243 +++---
 ...coordinator-dynamic-config-dialog.spec.tsx.snap | 272 ++++---
 .../coordinator-dynamic-config-dialog.spec.tsx     |   8 +-
 .../coordinator-dynamic-config-dialog.tsx          | 459 ++++++------
 .../lookup-edit-dialog/lookup-edit-dialog.tsx      |   1 +
 .../overload-dynamic-config-dialog.spec.tsx.snap   | 139 +---
 .../overload-dynamic-config-dialog.spec.tsx        |   7 +-
 .../overlord-dynamic-config-dialog.tsx             | 166 ++---
 .../query-plan-dialog/query-plan-dialog.tsx        |   4 +-
 .../__snapshots__/retention-dialog.spec.tsx.snap   |  35 +-
 .../dialogs/retention-dialog/retention-dialog.scss |   4 +
 .../retention-dialog/retention-dialog.spec.tsx     |  37 +-
 .../dialogs/retention-dialog/retention-dialog.tsx  | 258 +++----
 .../segment-table-action-dialog.spec.tsx.snap      |  36 +-
 .../__snapshots__/snitch-dialog.spec.tsx.snap      | 107 +++
 .../dialogs/snitch-dialog/snitch-dialog.spec.tsx   |  16 +
 .../src/dialogs/snitch-dialog/snitch-dialog.tsx    |   2 -
 .../dialogs/status-dialog/status-dialog.spec.tsx   |  12 +-
 .../src/dialogs/status-dialog/status-dialog.tsx    | 181 ++---
 .../supervisor-table-action-dialog.spec.tsx.snap   |  36 +-
 .../supervisor-table-action-dialog.tsx             |   2 +-
 .../task-table-action-dialog.spec.tsx.snap         |  36 +-
 web-console/src/entry.scss                         |   6 +
 .../src/{utils/index.tsx => hooks/index.ts}        |   9 +-
 .../use-global-event-listener.ts}                  |  43 +-
 .../use-interval.ts}                               |  36 +-
 web-console/src/hooks/use-query-manager.ts         |  68 ++
 .../src/utils/{index.tsx => druid-lookup.ts}       |   9 +-
 web-console/src/utils/druid-query.spec.ts          |  55 ++
 web-console/src/utils/druid-query.ts               |  84 ++-
 web-console/src/utils/general.spec.ts              |  25 +-
 web-console/src/utils/general.tsx                  |  30 +-
 web-console/src/utils/index.tsx                    |   4 +-
 web-console/src/utils/local-storage-keys.tsx       |   2 +-
 web-console/src/utils/query-cursor.ts              |   4 +
 web-console/src/utils/query-manager.tsx            |  89 ++-
 web-console/src/utils/query-state.ts               |  33 +-
 .../src/views/datasource-view/datasource-view.tsx  | 129 ++--
 .../__snapshots__/home-view.spec.tsx.snap          |  32 +-
 .../datasources-card/datasources-card.tsx          | 101 +--
 .../home-view/home-view-card/home-view-card.tsx    |   4 +-
 web-console/src/views/home-view/home-view.spec.tsx |   2 +-
 .../views/home-view/lookups-card/lookups-card.tsx  | 109 +--
 .../home-view/segments-card/segments-card.tsx      | 139 ++--
 .../home-view/services-card/services-card.tsx      | 206 ++---
 .../views/home-view/status-card/status-card.tsx    | 136 ++--
 .../supervisors-card/supervisors-card.tsx          | 139 ++--
 .../src/views/home-view/tasks-card/tasks-card.tsx  | 174 ++---
 .../src/views/ingestion-view/ingestion-view.tsx    |  54 +-
 .../src/views/load-data-view/load-data-view.tsx    |  48 +-
 .../src/views/lookups-view/lookups-view.tsx        | 166 ++---
 .../__snapshots__/query-view.spec.tsx.snap         |  66 +-
 .../views/query-view/column-tree/column-tree.tsx   |  66 +-
 .../live-query-mode-selector.spec.tsx.snap         | 147 ++++
 .../live-query-mode-selector.scss}                 |  25 +-
 .../live-query-mode-selector.spec.tsx}             |  33 +-
 .../live-query-mode-selector.tsx                   |  77 ++
 .../__snapshots__/query-error.spec.tsx.snap        |   9 +
 .../query-view/query-error/query-error.scss}       |  16 +-
 .../query-view/query-error/query-error.spec.tsx}   |  17 +-
 .../views/query-view/query-error/query-error.tsx   |  67 ++
 .../views/query-view/query-input/query-input.tsx   |   9 +-
 .../query-view/query-output/query-output.spec.tsx  |   2 -
 .../views/query-view/query-output/query-output.tsx | 126 ++--
 .../query-view/query-utils.ts}                     |  35 +-
 web-console/src/views/query-view/query-view.scss   |  22 +-
 web-console/src/views/query-view/query-view.tsx    | 236 +++---
 .../__snapshots__/run-button.spec.tsx.snap         | 117 ++-
 .../src/views/query-view/run-button/run-button.tsx |  19 +-
 .../__snapshots__/segments-view.spec.tsx.snap      |   2 +-
 .../src/views/segments-view/segments-view.tsx      | 165 ++--
 .../src/views/services-view/services-view.tsx      |  35 +-
 111 files changed, 5008 insertions(+), 3256 deletions(-)

diff --git a/licenses.yaml b/licenses.yaml
index b31c5a5..fe4eb5c 100644
--- a/licenses.yaml
+++ b/licenses.yaml
@@ -4707,7 +4707,7 @@ license_category: binary
 module: web-console
 license_name: Apache License version 2.0
 copyright: Imply Data
-version: 0.8.4
+version: 0.9.11
 
 ---
 
diff --git a/web-console/lib/keywords.js b/web-console/lib/keywords.js
index 69a3f87..dfc2bd6 100644
--- a/web-console/lib/keywords.js
+++ b/web-console/lib/keywords.js
@@ -57,6 +57,8 @@ exports.SQL_EXPRESSION_PARTS = [
   'AND',
   'NOT',
   'IN',
+  'ANY',
+  'SOME',
   'IS',
   'TO',
   'BETWEEN',
diff --git a/web-console/package-lock.json b/web-console/package-lock.json
index 2f8012b..2ebe867 100644
--- a/web-console/package-lock.json
+++ b/web-console/package-lock.json
@@ -4243,9 +4243,9 @@
       }
     },
     "druid-query-toolkit": {
-      "version": "0.8.4",
-      "resolved": "https://registry.npmjs.org/druid-query-toolkit/-/druid-query-toolkit-0.8.4.tgz",
-      "integrity": "sha512-d0/OJDh6xNxlmqu874v1K2yGH0DD5ZXhRGR8iRFNDRUUZzTLSIdOTyaulHxCQ8j1bpfhB6VN+XTWzd0V6AgqbQ==",
+      "version": "0.9.11",
+      "resolved": "https://registry.npmjs.org/druid-query-toolkit/-/druid-query-toolkit-0.9.11.tgz",
+      "integrity": "sha512-pg0Ux/y0IM2TxYEfY2cK361w+c5Efw40KVrkIK8s8uVDYF3BLVN+XWQO/ykKB73hOUHLq1ctLOxRUyKphnbyvQ==",
       "requires": {
         "tslib": "^1.10.0"
       }
diff --git a/web-console/package.json b/web-console/package.json
index da46a80..b4cdff9 100644
--- a/web-console/package.json
+++ b/web-console/package.json
@@ -68,7 +68,7 @@
     "d3-axis": "^1.0.12",
     "d3-scale": "^3.2.0",
     "d3-selection": "^1.4.0",
-    "druid-query-toolkit": "^0.8.4",
+    "druid-query-toolkit": "^0.9.11",
     "file-saver": "^2.0.2",
     "has-own-prop": "^2.0.0",
     "hjson": "^3.2.1",
diff --git a/web-console/script/mkcomp b/web-console/script/mkcomp
index 6faf4e1..1f15960 100755
--- a/web-console/script/mkcomp
+++ b/web-console/script/mkcomp
@@ -43,7 +43,6 @@ const path = `./src/${where}/${name}/`;
 fs.ensureDirSync(path);
 console.log('Making path:', path);
 
-const spaceName = name.replace(/-/g, ' ');
 const camelName = name.replace(/(^|-)[a-z]/g, s => s.replace('-', '').toUpperCase());
 const snakeName = camelName[0].toLowerCase() + camelName.substr(1);
 
@@ -144,18 +143,18 @@ writeFile(
  * limitations under the License.
  */
 
-import { render } from '@testing-library/react';
+import { shallow } from 'enzyme';
 import React from 'react';
 
 import { ${camelName} } from './${name}';
 
-describe('${spaceName}', () => {
+describe('${camelName}', () => {
   it('matches snapshot', () => {
-    const ${snakeName} = <${camelName}
-    />;
-
-    const { container } = render(${snakeName});
-    expect(container.firstChild).toMatchSnapshot();
+    const ${snakeName} = shallow(
+      <${camelName}/>
+    );
+   
+    expect(${snakeName}).toMatchSnapshot();
   });
 });
 `,
diff --git a/web-console/src/components/auto-form/__snapshots__/auto-form.spec.tsx.snap b/web-console/src/components/auto-form/__snapshots__/auto-form.spec.tsx.snap
index d450aeb..544177a 100644
--- a/web-console/src/components/auto-form/__snapshots__/auto-form.spec.tsx.snap
+++ b/web-console/src/components/auto-form/__snapshots__/auto-form.spec.tsx.snap
@@ -53,7 +53,7 @@ exports[`auto-form snapshot matches snapshot 1`] = `
     key="testThree"
     label="Test three"
   >
-    <SuggestibleInput
+    <Memo(SuggestibleInput)
       disabled={false}
       onBlur={[Function]}
       onValueChange={[Function]}
diff --git a/web-console/src/components/datasource-columns-table/__snapshots__/datasource-columns-table.spec.tsx.snap b/web-console/src/components/datasource-columns-table/__snapshots__/datasource-columns-table.spec.tsx.snap
index 6c6bb4e..5b1fe1e 100644
--- a/web-console/src/components/datasource-columns-table/__snapshots__/datasource-columns-table.spec.tsx.snap
+++ b/web-console/src/components/datasource-columns-table/__snapshots__/datasource-columns-table.spec.tsx.snap
@@ -1,45 +1,664 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`rule editor matches snapshot 1`] = `
+exports[`DatasourceColumnsTable matches snapshot on error 1`] = `
 <div
-  class="datasource-columns-table"
+  className="datasource-columns-table"
 >
   <div
-    class="main-area"
+    className="main-area"
   >
-    <div
-      class="loader"
-    >
-      <div
-        class="loader-logo"
-      >
-        <svg
-          viewBox="0 0 100 100"
-        >
-          <path
-            class="one"
-            d="M54.2,69.8h-2.7c-0.7,0-1.3-0.6-1.3-1.3c0-0.7,0.6-1.3,1.3-1.3h2.7c11.5,0,23.8-7.4,23.8-23.7
-          c0-9.1-6.9-15.8-16.4-15.8H38c-0.7,0-1.3-0.6-1.3-1.3s0.6-1.3,1.3-1.3h23.6c5.3,0,10.1,1.9,13.6,5.3c3.5,3.4,5.4,8,5.4,13.1
-          c0,6.6-2.3,13-6.3,17.7C69.5,66.8,62.5,69.8,54.2,69.8z"
-          />
-          <path
-            class="two"
-            d="M55.7,59.5h-26c-0.7,0-1.3-0.6-1.3-1.3c0-0.7,0.6-1.3,1.3-1.3h26c7.5,0,11.5-5.8,11.5-11.5
-            c0-4.2-3.2-7.3-7.7-7.3h-26c-0.7,0-1.3-0.6-1.3-1.3s0.6-1.3,1.3-1.3h26c5.9,0,10.3,4.3,10.3,9.9c0,3.7-1.3,7.2-3.7,9.8
-            C63.5,58,59.9,59.5,55.7,59.5z"
-          />
-          <path
-            class="three"
-            d="M27.2,38h-6.3c-0.7,0-1.3-0.6-1.3-1.3s0.6-1.3,1.3-1.3h6.3c0.7,0,1.3,0.6,1.3,1.3S27.9,38,27.2,38z"
-          />
-          <path
-            class="four"
-            d="M45.1,69.8h-5.8c-0.7,0-1.3-0.6-1.3-1.3c0-0.7,0.6-1.3,1.3-1.3h5.8c0.7,0,1.3,0.6,1.3,1.3
-            C46.4,69.2,45.8,69.8,45.1,69.8z"
-          />
-        </svg>
-      </div>
-    </div>
+    <ReactTable
+      AggregatedComponent={[Function]}
+      ExpanderComponent={[Function]}
+      FilterComponent={[Function]}
+      LoadingComponent={[Function]}
+      NoDataComponent={[Function]}
+      PadRowComponent={[Function]}
+      PaginationComponent={[Function]}
+      PivotValueComponent={[Function]}
+      ResizerComponent={[Function]}
+      TableComponent={[Function]}
+      TbodyComponent={[Function]}
+      TdComponent={[Function]}
+      TfootComponent={[Function]}
+      ThComponent={[Function]}
+      TheadComponent={[Function]}
+      TrComponent={[Function]}
+      TrGroupComponent={[Function]}
+      aggregatedKey="_aggregated"
+      className=""
+      collapseOnDataChange={true}
+      collapseOnPageChange={true}
+      collapseOnSortingChange={true}
+      column={
+        Object {
+          "Aggregated": undefined,
+          "Cell": undefined,
+          "Expander": undefined,
+          "Filter": undefined,
+          "Footer": undefined,
+          "Header": undefined,
+          "Pivot": undefined,
+          "PivotValue": undefined,
+          "Placeholder": undefined,
+          "aggregate": undefined,
+          "className": "",
+          "filterAll": false,
+          "filterMethod": undefined,
+          "filterable": undefined,
+          "footerClassName": "",
+          "footerStyle": Object {},
+          "getFooterProps": [Function],
+          "getHeaderProps": [Function],
+          "getProps": [Function],
+          "headerClassName": "",
+          "headerStyle": Object {},
+          "minResizeWidth": 11,
+          "minWidth": 100,
+          "resizable": undefined,
+          "show": true,
+          "sortMethod": undefined,
+          "sortable": undefined,
+          "style": Object {},
+        }
+      }
+      columns={
+        Array [
+          Object {
+            "Header": "Column name",
+            "accessor": "COLUMN_NAME",
+          },
+          Object {
+            "Header": "Data type",
+            "accessor": "DATA_TYPE",
+          },
+        ]
+      }
+      data={Array []}
+      defaultExpanded={Object {}}
+      defaultFilterMethod={[Function]}
+      defaultFiltered={Array []}
+      defaultPage={0}
+      defaultPageSize={20}
+      defaultResized={Array []}
+      defaultSortDesc={false}
+      defaultSortMethod={[Function]}
+      defaultSorted={Array []}
+      expanderDefaults={
+        Object {
+          "filterable": false,
+          "resizable": false,
+          "sortable": false,
+          "width": 35,
+        }
+      }
+      filterable={true}
+      freezeWhenExpanded={false}
+      getLoadingProps={[Function]}
+      getNoDataProps={[Function]}
+      getPaginationProps={[Function]}
+      getProps={[Function]}
+      getResizerProps={[Function]}
+      getTableProps={[Function]}
+      getTbodyProps={[Function]}
+      getTdProps={[Function]}
+      getTfootProps={[Function]}
+      getTfootTdProps={[Function]}
+      getTfootTrProps={[Function]}
+      getTheadFilterProps={[Function]}
+      getTheadFilterThProps={[Function]}
+      getTheadFilterTrProps={[Function]}
+      getTheadGroupProps={[Function]}
+      getTheadGroupThProps={[Function]}
+      getTheadGroupTrProps={[Function]}
+      getTheadProps={[Function]}
+      getTheadThProps={[Function]}
+      getTheadTrProps={[Function]}
+      getTrGroupProps={[Function]}
+      getTrProps={[Function]}
+      groupedByPivotKey="_groupedByPivot"
+      indexKey="_index"
+      loading={false}
+      loadingText="Loading..."
+      multiSort={true}
+      nestingLevelKey="_nestingLevel"
+      nextText="Next"
+      noDataText="test error"
+      ofText="of"
+      onFetchData={[Function]}
+      originalKey="_original"
+      pageJumpText="jump to page"
+      pageSizeOptions={
+        Array [
+          5,
+          10,
+          20,
+          25,
+          50,
+          100,
+        ]
+      }
+      pageText="Page"
+      pivotDefaults={Object {}}
+      pivotIDKey="_pivotID"
+      pivotValKey="_pivotVal"
+      previousText="Previous"
+      resizable={true}
+      resolveData={[Function]}
+      rowsSelectorText="rows per page"
+      rowsText="rows"
+      showPageJump={true}
+      showPageSizeOptions={true}
+      showPagination={true}
+      showPaginationBottom={true}
+      showPaginationTop={false}
+      sortable={true}
+      style={Object {}}
+      subRowsKey="_subRows"
+    />
+  </div>
+</div>
+`;
+
+exports[`DatasourceColumnsTable matches snapshot on init 1`] = `
+<div
+  className="datasource-columns-table"
+>
+  <div
+    className="main-area"
+  >
+    <ReactTable
+      AggregatedComponent={[Function]}
+      ExpanderComponent={[Function]}
+      FilterComponent={[Function]}
+      LoadingComponent={[Function]}
+      NoDataComponent={[Function]}
+      PadRowComponent={[Function]}
+      PaginationComponent={[Function]}
+      PivotValueComponent={[Function]}
+      ResizerComponent={[Function]}
+      TableComponent={[Function]}
+      TbodyComponent={[Function]}
+      TdComponent={[Function]}
+      TfootComponent={[Function]}
+      ThComponent={[Function]}
+      TheadComponent={[Function]}
+      TrComponent={[Function]}
+      TrGroupComponent={[Function]}
+      aggregatedKey="_aggregated"
+      className=""
+      collapseOnDataChange={true}
+      collapseOnPageChange={true}
+      collapseOnSortingChange={true}
+      column={
+        Object {
+          "Aggregated": undefined,
+          "Cell": undefined,
+          "Expander": undefined,
+          "Filter": undefined,
+          "Footer": undefined,
+          "Header": undefined,
+          "Pivot": undefined,
+          "PivotValue": undefined,
+          "Placeholder": undefined,
+          "aggregate": undefined,
+          "className": "",
+          "filterAll": false,
+          "filterMethod": undefined,
+          "filterable": undefined,
+          "footerClassName": "",
+          "footerStyle": Object {},
+          "getFooterProps": [Function],
+          "getHeaderProps": [Function],
+          "getProps": [Function],
+          "headerClassName": "",
+          "headerStyle": Object {},
+          "minResizeWidth": 11,
+          "minWidth": 100,
+          "resizable": undefined,
+          "show": true,
+          "sortMethod": undefined,
+          "sortable": undefined,
+          "style": Object {},
+        }
+      }
+      columns={
+        Array [
+          Object {
+            "Header": "Column name",
+            "accessor": "COLUMN_NAME",
+          },
+          Object {
+            "Header": "Data type",
+            "accessor": "DATA_TYPE",
+          },
+        ]
+      }
+      data={Array []}
+      defaultExpanded={Object {}}
+      defaultFilterMethod={[Function]}
+      defaultFiltered={Array []}
+      defaultPage={0}
+      defaultPageSize={20}
+      defaultResized={Array []}
+      defaultSortDesc={false}
+      defaultSortMethod={[Function]}
+      defaultSorted={Array []}
+      expanderDefaults={
+        Object {
+          "filterable": false,
+          "resizable": false,
+          "sortable": false,
+          "width": 35,
+        }
+      }
+      filterable={true}
+      freezeWhenExpanded={false}
+      getLoadingProps={[Function]}
+      getNoDataProps={[Function]}
+      getPaginationProps={[Function]}
+      getProps={[Function]}
+      getResizerProps={[Function]}
+      getTableProps={[Function]}
+      getTbodyProps={[Function]}
+      getTdProps={[Function]}
+      getTfootProps={[Function]}
+      getTfootTdProps={[Function]}
+      getTfootTrProps={[Function]}
+      getTheadFilterProps={[Function]}
+      getTheadFilterThProps={[Function]}
+      getTheadFilterTrProps={[Function]}
+      getTheadGroupProps={[Function]}
+      getTheadGroupThProps={[Function]}
+      getTheadGroupTrProps={[Function]}
+      getTheadProps={[Function]}
+      getTheadThProps={[Function]}
+      getTheadTrProps={[Function]}
+      getTrGroupProps={[Function]}
+      getTrProps={[Function]}
+      groupedByPivotKey="_groupedByPivot"
+      indexKey="_index"
+      loading={false}
+      loadingText="Loading..."
+      multiSort={true}
+      nestingLevelKey="_nestingLevel"
+      nextText="Next"
+      noDataText="No column data found"
+      ofText="of"
+      onFetchData={[Function]}
+      originalKey="_original"
+      pageJumpText="jump to page"
+      pageSizeOptions={
+        Array [
+          5,
+          10,
+          20,
+          25,
+          50,
+          100,
+        ]
+      }
+      pageText="Page"
+      pivotDefaults={Object {}}
+      pivotIDKey="_pivotID"
+      pivotValKey="_pivotVal"
+      previousText="Previous"
+      resizable={true}
+      resolveData={[Function]}
+      rowsSelectorText="rows per page"
+      rowsText="rows"
+      showPageJump={true}
+      showPageSizeOptions={true}
+      showPagination={true}
+      showPaginationBottom={true}
+      showPaginationTop={false}
+      sortable={true}
+      style={Object {}}
+      subRowsKey="_subRows"
+    />
+  </div>
+</div>
+`;
+
+exports[`DatasourceColumnsTable matches snapshot on loading 1`] = `
+<div
+  className="datasource-columns-table"
+>
+  <div
+    className="main-area"
+  >
+    <Memo(Loader) />
+  </div>
+</div>
+`;
+
+exports[`DatasourceColumnsTable matches snapshot on no data 1`] = `
+<div
+  className="datasource-columns-table"
+>
+  <div
+    className="main-area"
+  >
+    <ReactTable
+      AggregatedComponent={[Function]}
+      ExpanderComponent={[Function]}
+      FilterComponent={[Function]}
+      LoadingComponent={[Function]}
+      NoDataComponent={[Function]}
+      PadRowComponent={[Function]}
+      PaginationComponent={[Function]}
+      PivotValueComponent={[Function]}
+      ResizerComponent={[Function]}
+      TableComponent={[Function]}
+      TbodyComponent={[Function]}
+      TdComponent={[Function]}
+      TfootComponent={[Function]}
+      ThComponent={[Function]}
+      TheadComponent={[Function]}
+      TrComponent={[Function]}
+      TrGroupComponent={[Function]}
+      aggregatedKey="_aggregated"
+      className=""
+      collapseOnDataChange={true}
+      collapseOnPageChange={true}
+      collapseOnSortingChange={true}
+      column={
+        Object {
+          "Aggregated": undefined,
+          "Cell": undefined,
+          "Expander": undefined,
+          "Filter": undefined,
+          "Footer": undefined,
+          "Header": undefined,
+          "Pivot": undefined,
+          "PivotValue": undefined,
+          "Placeholder": undefined,
+          "aggregate": undefined,
+          "className": "",
+          "filterAll": false,
+          "filterMethod": undefined,
+          "filterable": undefined,
+          "footerClassName": "",
+          "footerStyle": Object {},
+          "getFooterProps": [Function],
+          "getHeaderProps": [Function],
+          "getProps": [Function],
+          "headerClassName": "",
+          "headerStyle": Object {},
+          "minResizeWidth": 11,
+          "minWidth": 100,
+          "resizable": undefined,
+          "show": true,
+          "sortMethod": undefined,
+          "sortable": undefined,
+          "style": Object {},
+        }
+      }
+      columns={
+        Array [
+          Object {
+            "Header": "Column name",
+            "accessor": "COLUMN_NAME",
+          },
+          Object {
+            "Header": "Data type",
+            "accessor": "DATA_TYPE",
+          },
+        ]
+      }
+      data={Array []}
+      defaultExpanded={Object {}}
+      defaultFilterMethod={[Function]}
+      defaultFiltered={Array []}
+      defaultPage={0}
+      defaultPageSize={20}
+      defaultResized={Array []}
+      defaultSortDesc={false}
+      defaultSortMethod={[Function]}
+      defaultSorted={Array []}
+      expanderDefaults={
+        Object {
+          "filterable": false,
+          "resizable": false,
+          "sortable": false,
+          "width": 35,
+        }
+      }
+      filterable={true}
+      freezeWhenExpanded={false}
+      getLoadingProps={[Function]}
+      getNoDataProps={[Function]}
+      getPaginationProps={[Function]}
+      getProps={[Function]}
+      getResizerProps={[Function]}
+      getTableProps={[Function]}
+      getTbodyProps={[Function]}
+      getTdProps={[Function]}
+      getTfootProps={[Function]}
+      getTfootTdProps={[Function]}
+      getTfootTrProps={[Function]}
+      getTheadFilterProps={[Function]}
+      getTheadFilterThProps={[Function]}
+      getTheadFilterTrProps={[Function]}
+      getTheadGroupProps={[Function]}
+      getTheadGroupThProps={[Function]}
+      getTheadGroupTrProps={[Function]}
+      getTheadProps={[Function]}
+      getTheadThProps={[Function]}
+      getTheadTrProps={[Function]}
+      getTrGroupProps={[Function]}
+      getTrProps={[Function]}
+      groupedByPivotKey="_groupedByPivot"
+      indexKey="_index"
+      loading={false}
+      loadingText="Loading..."
+      multiSort={true}
+      nestingLevelKey="_nestingLevel"
+      nextText="Next"
+      noDataText="No column data found"
+      ofText="of"
+      onFetchData={[Function]}
+      originalKey="_original"
+      pageJumpText="jump to page"
+      pageSizeOptions={
+        Array [
+          5,
+          10,
+          20,
+          25,
+          50,
+          100,
+        ]
+      }
+      pageText="Page"
+      pivotDefaults={Object {}}
+      pivotIDKey="_pivotID"
+      pivotValKey="_pivotVal"
+      previousText="Previous"
+      resizable={true}
+      resolveData={[Function]}
+      rowsSelectorText="rows per page"
+      rowsText="rows"
+      showPageJump={true}
+      showPageSizeOptions={true}
+      showPagination={true}
+      showPaginationBottom={true}
+      showPaginationTop={false}
+      sortable={true}
+      style={Object {}}
+      subRowsKey="_subRows"
+    />
+  </div>
+</div>
+`;
+
+exports[`DatasourceColumnsTable matches snapshot on some data 1`] = `
+<div
+  className="datasource-columns-table"
+>
+  <div
+    className="main-area"
+  >
+    <ReactTable
+      AggregatedComponent={[Function]}
+      ExpanderComponent={[Function]}
+      FilterComponent={[Function]}
+      LoadingComponent={[Function]}
+      NoDataComponent={[Function]}
+      PadRowComponent={[Function]}
+      PaginationComponent={[Function]}
+      PivotValueComponent={[Function]}
+      ResizerComponent={[Function]}
+      TableComponent={[Function]}
+      TbodyComponent={[Function]}
+      TdComponent={[Function]}
+      TfootComponent={[Function]}
+      ThComponent={[Function]}
+      TheadComponent={[Function]}
+      TrComponent={[Function]}
+      TrGroupComponent={[Function]}
+      aggregatedKey="_aggregated"
+      className=""
+      collapseOnDataChange={true}
+      collapseOnPageChange={true}
+      collapseOnSortingChange={true}
+      column={
+        Object {
+          "Aggregated": undefined,
+          "Cell": undefined,
+          "Expander": undefined,
+          "Filter": undefined,
+          "Footer": undefined,
+          "Header": undefined,
+          "Pivot": undefined,
+          "PivotValue": undefined,
+          "Placeholder": undefined,
+          "aggregate": undefined,
+          "className": "",
+          "filterAll": false,
+          "filterMethod": undefined,
+          "filterable": undefined,
+          "footerClassName": "",
+          "footerStyle": Object {},
+          "getFooterProps": [Function],
+          "getHeaderProps": [Function],
+          "getProps": [Function],
+          "headerClassName": "",
+          "headerStyle": Object {},
+          "minResizeWidth": 11,
+          "minWidth": 100,
+          "resizable": undefined,
+          "show": true,
+          "sortMethod": undefined,
+          "sortable": undefined,
+          "style": Object {},
+        }
+      }
+      columns={
+        Array [
+          Object {
+            "Header": "Column name",
+            "accessor": "COLUMN_NAME",
+          },
+          Object {
+            "Header": "Data type",
+            "accessor": "DATA_TYPE",
+          },
+        ]
+      }
+      data={
+        Array [
+          Object {
+            "COLUMN_NAME": "channel",
+            "DATA_TYPE": "VARCHAR",
+          },
+          Object {
+            "COLUMN_NAME": "page",
+            "DATA_TYPE": "VARCHAR",
+          },
+        ]
+      }
+      defaultExpanded={Object {}}
+      defaultFilterMethod={[Function]}
+      defaultFiltered={Array []}
+      defaultPage={0}
+      defaultPageSize={20}
+      defaultResized={Array []}
+      defaultSortDesc={false}
+      defaultSortMethod={[Function]}
+      defaultSorted={Array []}
+      expanderDefaults={
+        Object {
+          "filterable": false,
+          "resizable": false,
+          "sortable": false,
+          "width": 35,
+        }
+      }
+      filterable={true}
+      freezeWhenExpanded={false}
+      getLoadingProps={[Function]}
+      getNoDataProps={[Function]}
+      getPaginationProps={[Function]}
+      getProps={[Function]}
+      getResizerProps={[Function]}
+      getTableProps={[Function]}
+      getTbodyProps={[Function]}
+      getTdProps={[Function]}
+      getTfootProps={[Function]}
+      getTfootTdProps={[Function]}
+      getTfootTrProps={[Function]}
+      getTheadFilterProps={[Function]}
+      getTheadFilterThProps={[Function]}
+      getTheadFilterTrProps={[Function]}
+      getTheadGroupProps={[Function]}
+      getTheadGroupThProps={[Function]}
+      getTheadGroupTrProps={[Function]}
+      getTheadProps={[Function]}
+      getTheadThProps={[Function]}
+      getTheadTrProps={[Function]}
+      getTrGroupProps={[Function]}
+      getTrProps={[Function]}
+      groupedByPivotKey="_groupedByPivot"
+      indexKey="_index"
+      loading={false}
+      loadingText="Loading..."
+      multiSort={true}
+      nestingLevelKey="_nestingLevel"
+      nextText="Next"
+      noDataText="No column data found"
+      ofText="of"
+      onFetchData={[Function]}
+      originalKey="_original"
+      pageJumpText="jump to page"
+      pageSizeOptions={
+        Array [
+          5,
+          10,
+          20,
+          25,
+          50,
+          100,
+        ]
+      }
+      pageText="Page"
+      pivotDefaults={Object {}}
+      pivotIDKey="_pivotID"
+      pivotValKey="_pivotVal"
+      previousText="Previous"
+      resizable={true}
+      resolveData={[Function]}
+      rowsSelectorText="rows per page"
+      rowsText="rows"
+      showPageJump={true}
+      showPageSizeOptions={true}
+      showPagination={true}
+      showPaginationBottom={true}
+      showPaginationTop={false}
+      sortable={true}
+      style={Object {}}
+      subRowsKey="_subRows"
+    />
   </div>
 </div>
 `;
diff --git a/web-console/src/components/datasource-columns-table/datasource-columns-table.spec.tsx b/web-console/src/components/datasource-columns-table/datasource-columns-table.spec.tsx
index 96dff6b..166866d 100644
--- a/web-console/src/components/datasource-columns-table/datasource-columns-table.spec.tsx
+++ b/web-console/src/components/datasource-columns-table/datasource-columns-table.spec.tsx
@@ -16,15 +16,63 @@
  * limitations under the License.
  */
 
-import { render } from '@testing-library/react';
+import { shallow } from 'enzyme';
 import React from 'react';
 
-import { DatasourceColumnsTable } from './datasource-columns-table';
+import { QueryState } from '../../utils';
 
-describe('rule editor', () => {
-  it('matches snapshot', () => {
-    const showJson = <DatasourceColumnsTable datasourceId={'test'} downloadFilename={'test'} />;
-    const { container } = render(showJson);
-    expect(container.firstChild).toMatchSnapshot();
+import { DatasourceColumnsTable, DatasourceColumnsTableRow } from './datasource-columns-table';
+
+let columnsState: QueryState<DatasourceColumnsTableRow[]> = QueryState.INIT;
+jest.mock('../../hooks', () => {
+  return {
+    useQueryManager: () => [columnsState],
+  };
+});
+
+describe('DatasourceColumnsTable', () => {
+  function makeDatasourceColumnsTable() {
+    return <DatasourceColumnsTable datasourceId={'test'} downloadFilename={'test'} />;
+  }
+
+  it('matches snapshot on init', () => {
+    expect(shallow(makeDatasourceColumnsTable())).toMatchSnapshot();
+  });
+
+  it('matches snapshot on loading', () => {
+    columnsState = QueryState.LOADING;
+
+    expect(shallow(makeDatasourceColumnsTable())).toMatchSnapshot();
+  });
+
+  it('matches snapshot on error', () => {
+    columnsState = new QueryState({ error: new Error('test error') });
+
+    expect(shallow(makeDatasourceColumnsTable())).toMatchSnapshot();
+  });
+
+  it('matches snapshot on no data', () => {
+    columnsState = new QueryState({
+      data: [],
+    });
+
+    expect(shallow(makeDatasourceColumnsTable())).toMatchSnapshot();
+  });
+
+  it('matches snapshot on some data', () => {
+    columnsState = new QueryState({
+      data: [
+        {
+          COLUMN_NAME: 'channel',
+          DATA_TYPE: 'VARCHAR',
+        },
+        {
+          COLUMN_NAME: 'page',
+          DATA_TYPE: 'VARCHAR',
+        },
+      ],
+    });
+
+    expect(shallow(makeDatasourceColumnsTable())).toMatchSnapshot();
   });
 });
diff --git a/web-console/src/components/datasource-columns-table/datasource-columns-table.tsx b/web-console/src/components/datasource-columns-table/datasource-columns-table.tsx
index 983a97a..635a038 100644
--- a/web-console/src/components/datasource-columns-table/datasource-columns-table.tsx
+++ b/web-console/src/components/datasource-columns-table/datasource-columns-table.tsx
@@ -16,18 +16,19 @@
  * limitations under the License.
  */
 
+import { SqlLiteral } from 'druid-query-toolkit';
 import React from 'react';
 import ReactTable from 'react-table';
 
-import { queryDruidSql, QueryManager } from '../../utils';
-import { ColumnMetadata } from '../../utils/column-metadata';
+import { useQueryManager } from '../../hooks';
+import { ColumnMetadata, queryDruidSql, QueryState } from '../../utils';
 import { Loader } from '../loader/loader';
 
 import './datasource-columns-table.scss';
 
-interface TableRow {
-  columnName: string;
-  columnType: string;
+export interface DatasourceColumnsTableRow {
+  COLUMN_NAME: string;
+  DATA_TYPE: string;
 }
 
 export interface DatasourceColumnsTableProps {
@@ -36,78 +37,46 @@ export interface DatasourceColumnsTableProps {
 }
 
 export interface DatasourceColumnsTableState {
-  columns?: TableRow[];
-  loading: boolean;
-  error?: string;
+  columnsState: QueryState<DatasourceColumnsTableRow[]>;
 }
 
-export class DatasourceColumnsTable extends React.PureComponent<
-  DatasourceColumnsTableProps,
-  DatasourceColumnsTableState
-> {
-  private datasourceColumnsQueryManager: QueryManager<null, TableRow[]>;
-
-  constructor(props: DatasourceColumnsTableProps, context: any) {
-    super(props, context);
-    this.state = {
-      loading: true,
-    };
-
-    this.datasourceColumnsQueryManager = new QueryManager({
-      processQuery: async () => {
-        const { datasourceId } = this.props;
-
-        const resp = await queryDruidSql<ColumnMetadata>({
-          query: `SELECT COLUMN_NAME, DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS
-          WHERE TABLE_SCHEMA = 'druid' AND TABLE_NAME = '${datasourceId}'`,
-        });
-
-        return resp.map(object => {
-          return { columnName: object.COLUMN_NAME, columnType: object.DATA_TYPE };
-        });
-      },
-      onStateChange: ({ result, error, loading }) => {
-        this.setState({ columns: result, error, loading });
-      },
-    });
-  }
-
-  componentDidMount(): void {
-    this.datasourceColumnsQueryManager.runQuery(null);
-  }
-
-  renderTable(error?: string) {
-    const { columns } = this.state;
+export const DatasourceColumnsTable = React.memo(function DatasourceColumnsTable(
+  props: DatasourceColumnsTableProps,
+) {
+  const [columnsState] = useQueryManager<string, DatasourceColumnsTableRow[]>({
+    processQuery: async (datasourceId: string) => {
+      return await queryDruidSql<ColumnMetadata>({
+        query: `SELECT COLUMN_NAME, DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS
+          WHERE TABLE_SCHEMA = 'druid' AND TABLE_NAME = ${SqlLiteral.create(datasourceId)}`,
+      });
+    },
+    initQuery: props.datasourceId,
+  });
 
+  function renderTable() {
     return (
       <ReactTable
-        data={columns || []}
+        data={columnsState.data || []}
         defaultPageSize={20}
         filterable
         columns={[
           {
             Header: 'Column name',
-            accessor: 'columnName',
+            accessor: 'COLUMN_NAME',
           },
           {
             Header: 'Data type',
-            accessor: 'columnType',
+            accessor: 'DATA_TYPE',
           },
         ]}
-        noDataText={error ? error : 'No column data found'}
+        noDataText={columnsState.getErrorMessage() || 'No column data found'}
       />
     );
   }
 
-  render(): JSX.Element {
-    const { loading, error } = this.state;
-    this.renderTable(error);
-    return (
-      <div className="datasource-columns-table">
-        <div className="main-area">
-          {loading ? <Loader loadingText="" loading /> : this.renderTable()}
-        </div>
-      </div>
-    );
-  }
-}
+  return (
+    <div className="datasource-columns-table">
+      <div className="main-area">{columnsState.loading ? <Loader /> : renderTable()}</div>
+    </div>
+  );
+});
diff --git a/web-console/src/components/highlight-text/__snapshots__/highlight-text.spec.tsx.snap b/web-console/src/components/highlight-text/__snapshots__/highlight-text.spec.tsx.snap
new file mode 100644
index 0000000..2dc7b7f
--- /dev/null
+++ b/web-console/src/components/highlight-text/__snapshots__/highlight-text.spec.tsx.snap
@@ -0,0 +1,23 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`highlight text matches snapshot 1`] = `
+<span
+  className="highlight-text"
+>
+  <span
+    className="pre"
+  >
+    H
+  </span>
+  <span
+    className="highlighted"
+  >
+    woot
+  </span>
+  <span
+    className="post"
+  >
+     world
+  </span>
+</span>
+`;
diff --git a/web-console/src/utils/index.tsx b/web-console/src/components/highlight-text/highlight-text.scss
similarity index 80%
copy from web-console/src/utils/index.tsx
copy to web-console/src/components/highlight-text/highlight-text.scss
index 7e1cca2..6d81fbc 100644
--- a/web-console/src/utils/index.tsx
+++ b/web-console/src/components/highlight-text/highlight-text.scss
@@ -16,9 +16,9 @@
  * limitations under the License.
  */
 
-export * from './general';
-export * from './druid-query';
-export * from './query-manager';
-export * from './query-state';
-export * from './query-cursor';
-export * from './local-storage-keys';
+.highlight-text {
+  .highlighted {
+    background: rgba(255, 255, 255, 0.2);
+    font-weight: bold;
+  }
+}
diff --git a/web-console/src/components/loader/loader.spec.tsx b/web-console/src/components/highlight-text/highlight-text.spec.tsx
similarity index 76%
copy from web-console/src/components/loader/loader.spec.tsx
copy to web-console/src/components/highlight-text/highlight-text.spec.tsx
index f7bee6a..ab5dc34 100644
--- a/web-console/src/components/loader/loader.spec.tsx
+++ b/web-console/src/components/highlight-text/highlight-text.spec.tsx
@@ -16,15 +16,15 @@
  * limitations under the License.
  */
 
-import { render } from '@testing-library/react';
+import { shallow } from 'enzyme';
 import React from 'react';
 
-import { Loader } from './loader';
+import { HighlightText } from './highlight-text';
 
-describe('loader', () => {
+describe('highlight text', () => {
   it('matches snapshot', () => {
-    const loader = <Loader loading loadingText={'test'} />;
-    const { container } = render(loader);
-    expect(container.firstChild).toMatchSnapshot();
+    const highlightText = shallow(<HighlightText text="Hello world" find="ello" replace="woot" />);
+
+    expect(highlightText).toMatchSnapshot();
   });
 });
diff --git a/web-console/src/components/highlight-text/highlight-text.tsx b/web-console/src/components/highlight-text/highlight-text.tsx
new file mode 100644
index 0000000..85f55c4
--- /dev/null
+++ b/web-console/src/components/highlight-text/highlight-text.tsx
@@ -0,0 +1,45 @@
+/*
+ * 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.
+ */
+
+import React from 'react';
+
+import './highlight-text.scss';
+
+export interface HighlightTextProps {
+  text: string;
+  find: string;
+  replace: string | JSX.Element;
+}
+
+export const HighlightText = React.memo(function HighlightText(props: HighlightTextProps) {
+  const { text, find, replace } = props;
+
+  const startIndex = text.indexOf(find);
+  if (startIndex === -1) return <span className="highlight-text">text</span>;
+  const endIndex = startIndex + find.length;
+
+  const pre = text.substring(0, startIndex);
+  const post = text.substring(endIndex);
+  return (
+    <span className="highlight-text">
+      {Boolean(pre) && <span className="pre">{text.substring(0, startIndex)}</span>}
+      {typeof replace === 'string' ? <span className="highlighted">{replace}</span> : replace}
+      {Boolean(post) && <span className="post">{text.substring(endIndex)}</span>}
+    </span>
+  );
+});
diff --git a/web-console/src/components/index.ts b/web-console/src/components/index.ts
index 28d0184..a806fff 100644
--- a/web-console/src/components/index.ts
+++ b/web-console/src/components/index.ts
@@ -24,6 +24,7 @@ export * from './center-message/center-message';
 export * from './clearable-input/clearable-input';
 export * from './external-link/external-link';
 export * from './header-bar/header-bar';
+export * from './highlight-text/highlight-text';
 export * from './json-collapse/json-collapse';
 export * from './json-input/json-input';
 export * from './loader/loader';
diff --git a/web-console/src/components/json-collapse/json-collapse.tsx b/web-console/src/components/json-collapse/json-collapse.tsx
index 530c288..6be4324 100644
--- a/web-console/src/components/json-collapse/json-collapse.tsx
+++ b/web-console/src/components/json-collapse/json-collapse.tsx
@@ -17,43 +17,26 @@
  */
 
 import { Button, Collapse, TextArea } from '@blueprintjs/core';
-import React from 'react';
+import React, { useState } from 'react';
 
 interface JsonCollapseProps {
   stringValue: string;
   buttonText: string;
 }
 
-interface JsonCollapseState {
-  isOpen: boolean;
-}
-
-export class JsonCollapse extends React.PureComponent<JsonCollapseProps, JsonCollapseState> {
-  constructor(props: any) {
-    super(props);
-    this.state = {
-      isOpen: false,
-    };
-  }
+export const JsonCollapse = React.memo(function JsonCollapse(props: JsonCollapseProps) {
+  const { stringValue, buttonText } = props;
+  const [isOpen, setIsOpen] = useState(false);
 
-  render(): JSX.Element {
-    const { stringValue, buttonText } = this.props;
-    const { isOpen } = this.state;
-    const prettyValue = JSON.stringify(JSON.parse(stringValue), undefined, 2);
-    return (
-      <div className="json-collapse">
-        <Button
-          minimal
-          active={isOpen}
-          onClick={() => this.setState({ isOpen: !isOpen })}
-          text={buttonText}
-        />
-        <div>
-          <Collapse isOpen={isOpen}>
-            <TextArea readOnly value={prettyValue} />
-          </Collapse>
-        </div>
+  const prettyValue = JSON.stringify(JSON.parse(stringValue), undefined, 2);
+  return (
+    <div className="json-collapse">
+      <Button minimal active={isOpen} onClick={() => setIsOpen(!isOpen)} text={buttonText} />
+      <div>
+        <Collapse isOpen={isOpen}>
+          <TextArea readOnly value={prettyValue} />
+        </Collapse>
       </div>
-    );
-  }
-}
+    </div>
+  );
+});
diff --git a/web-console/src/components/loader/loader.scss b/web-console/src/components/loader/loader.scss
index b29d378..522083e 100644
--- a/web-console/src/components/loader/loader.scss
+++ b/web-console/src/components/loader/loader.scss
@@ -81,6 +81,26 @@
 
     .label {
       text-align: center;
+
+      &.cancel-label {
+        cursor: pointer;
+        color: #48aff0;
+        opacity: 1;
+        animation: 1s ease-in-out fadeInOpacity;
+
+        &:hover {
+          text-decoration: underline;
+        }
+      }
     }
   }
 }
+
+@keyframes fadeInOpacity {
+  0% {
+    opacity: 0;
+  }
+  100% {
+    opacity: 1;
+  }
+}
diff --git a/web-console/src/components/loader/loader.spec.tsx b/web-console/src/components/loader/loader.spec.tsx
index f7bee6a..d4f2cda 100644
--- a/web-console/src/components/loader/loader.spec.tsx
+++ b/web-console/src/components/loader/loader.spec.tsx
@@ -23,7 +23,7 @@ import { Loader } from './loader';
 
 describe('loader', () => {
   it('matches snapshot', () => {
-    const loader = <Loader loading loadingText={'test'} />;
+    const loader = <Loader loadingText={'test'} />;
     const { container } = render(loader);
     expect(container.firstChild).toMatchSnapshot();
   });
diff --git a/web-console/src/components/loader/loader.tsx b/web-console/src/components/loader/loader.tsx
index 36c4498..c2f1f50 100644
--- a/web-console/src/components/loader/loader.tsx
+++ b/web-console/src/components/loader/loader.tsx
@@ -21,13 +21,15 @@ import React from 'react';
 import './loader.scss';
 
 export interface LoaderProps {
-  loadingText?: string;
   loading?: boolean; // This is needed so that this component can be used as a LoadingComponent in react table
+  loadingText?: string;
+  cancelText?: string;
+  onCancel?: () => void;
 }
 
 export const Loader = React.memo(function Loader(props: LoaderProps) {
-  const { loadingText, loading } = props;
-  if (!loading) return null;
+  const { loadingText, loading, cancelText, onCancel } = props;
+  if (loading === false) return null;
 
   return (
     <div className="loader">
@@ -55,7 +57,12 @@ export const Loader = React.memo(function Loader(props: LoaderProps) {
             C46.4,69.2,45.8,69.8,45.1,69.8z"
           />
         </svg>
-        {loadingText ? <div className="label">{loadingText}</div> : null}
+        {loadingText && <div className="label">{loadingText}</div>}
+        {cancelText && onCancel && (
+          <div className="label cancel-label" onClick={() => onCancel()}>
+            {cancelText}
+          </div>
+        )}
       </div>
     </div>
   );
diff --git a/web-console/src/components/lookup-values-table/lookup-values-table.tsx b/web-console/src/components/lookup-values-table/lookup-values-table.tsx
index 96798f4..5758b29 100644
--- a/web-console/src/components/lookup-values-table/lookup-values-table.tsx
+++ b/web-console/src/components/lookup-values-table/lookup-values-table.tsx
@@ -16,10 +16,12 @@
  * limitations under the License.
  */
 
+import { SqlRef } from 'druid-query-toolkit';
 import React from 'react';
 import ReactTable from 'react-table';
 
-import { queryDruidSql, QueryManager } from '../../utils';
+import { useQueryManager } from '../../hooks';
+import { queryDruidSql } from '../../utils';
 import { Loader } from '../loader/loader';
 
 import './lookup-values-table.scss';
@@ -29,55 +31,27 @@ interface LookupRow {
   v: string;
 }
 
-export interface LookupColumnsTableProps {
+export interface LookupValuesTableProps {
   lookupId: string;
   downloadFilename?: string;
 }
 
-export interface LookupColumnsTableState {
-  columns?: LookupRow[];
-  loading: boolean;
-  error?: string;
-}
-
-export class LookupValuesTable extends React.PureComponent<
-  LookupColumnsTableProps,
-  LookupColumnsTableState
-> {
-  private LookupColumnsQueryManager: QueryManager<null, LookupRow[]>;
-
-  constructor(props: LookupColumnsTableProps, context: any) {
-    super(props, context);
-    this.state = {
-      loading: true,
-    };
-
-    this.LookupColumnsQueryManager = new QueryManager({
-      processQuery: async () => {
-        const { lookupId } = this.props;
-
-        const resp = await queryDruidSql<LookupRow>({
-          query: `SELECT "k", "v" FROM lookup.${lookupId}
-          LIMIT 5000`,
-        });
-
-        return resp;
-      },
-      onStateChange: ({ result, error, loading }) => {
-        this.setState({ columns: result, error, loading });
-      },
-    });
-  }
+export const LookupValuesTable = React.memo(function LookupValuesTable(
+  props: LookupValuesTableProps,
+) {
+  const [columnsState] = useQueryManager<string, LookupRow[]>({
+    processQuery: async (lookupId: string) => {
+      return await queryDruidSql<LookupRow>({
+        query: `SELECT "k", "v" FROM ${SqlRef.column(lookupId, 'lookup')} LIMIT 5000`,
+      });
+    },
+    initQuery: props.lookupId,
+  });
 
-  componentDidMount(): void {
-    this.LookupColumnsQueryManager.runQuery(null);
-  }
-
-  renderTable(error?: string) {
-    const { columns } = this.state;
+  function renderTable() {
     return (
       <ReactTable
-        data={columns || []}
+        data={columnsState.data || []}
         defaultPageSize={20}
         filterable
         columns={[
@@ -91,23 +65,16 @@ export class LookupValuesTable extends React.PureComponent<
           },
         ]}
         noDataText={
-          error
-            ? error
-            : 'Lookup data not found. If this is a new lookup it might not have propagated yet.'
+          columnsState.getErrorMessage() ||
+          'Lookup data not found. If this is a new lookup it might not have propagated yet.'
         }
       />
     );
   }
 
-  render(): JSX.Element {
-    const { loading, error } = this.state;
-    this.renderTable(error);
-    return (
-      <div className="lookup-columns-table">
-        <div className="main-area">
-          {loading ? <Loader loadingText="" loading /> : this.renderTable()}
-        </div>
-      </div>
-    );
-  }
-}
+  return (
+    <div className="lookup-columns-table">
+      <div className="main-area">{columnsState.loading ? <Loader /> : renderTable()}</div>
+    </div>
+  );
+});
diff --git a/web-console/src/components/plural-pair-if-needed/plural-pair-if-needed.tsx b/web-console/src/components/plural-pair-if-needed/plural-pair-if-needed.tsx
index c26c633..2fc60aa 100644
--- a/web-console/src/components/plural-pair-if-needed/plural-pair-if-needed.tsx
+++ b/web-console/src/components/plural-pair-if-needed/plural-pair-if-needed.tsx
@@ -21,9 +21,9 @@ import React from 'react';
 import { compact, pluralIfNeeded } from '../../utils';
 
 export interface PluralPairIfNeededProps {
-  firstCount: number;
+  firstCount: number | undefined;
   firstSingular: string;
-  secondCount: number;
+  secondCount: number | undefined;
   secondSingular: string;
 }
 
diff --git a/web-console/src/components/refresh-button/refresh-button.tsx b/web-console/src/components/refresh-button/refresh-button.tsx
index 1803161..681bd42 100644
--- a/web-console/src/components/refresh-button/refresh-button.tsx
+++ b/web-console/src/components/refresh-button/refresh-button.tsx
@@ -20,7 +20,16 @@ import { IconNames } from '@blueprintjs/icons';
 import React from 'react';
 
 import { LocalStorageKeys } from '../../utils';
-import { TimedButton } from '../timed-button/timed-button';
+import { DelayLabel, TimedButton } from '../timed-button/timed-button';
+
+const DELAYS: DelayLabel[] = [
+  { label: '5 seconds', delay: 5000 },
+  { label: '10 seconds', delay: 10000 },
+  { label: '30 seconds', delay: 30000 },
+  { label: '1 minute', delay: 60000 },
+  { label: '2 minutes', delay: 120000 },
+  { label: 'None', delay: 0 },
+];
 
 export interface RefreshButtonProps {
   onRefresh: (auto: boolean) => void;
@@ -29,20 +38,12 @@ export interface RefreshButtonProps {
 
 export const RefreshButton = React.memo(function RefreshButton(props: RefreshButtonProps) {
   const { onRefresh, localStorageKey } = props;
-  const intervals = [
-    { label: '5 seconds', value: 5000 },
-    { label: '10 seconds', value: 10000 },
-    { label: '30 seconds', value: 30000 },
-    { label: '1 minute', value: 60000 },
-    { label: '2 minutes', value: 120000 },
-    { label: 'None', value: 0 },
-  ];
 
   return (
     <TimedButton
-      defaultValue={30000}
+      defaultDelay={30000}
       label="Auto refresh every:"
-      intervals={intervals}
+      delays={DELAYS}
       icon={IconNames.REFRESH}
       text="Refresh"
       onRefresh={onRefresh}
diff --git a/web-console/src/components/rule-editor/rule-editor.spec.tsx b/web-console/src/components/rule-editor/rule-editor.spec.tsx
index 4783e19..082d982 100644
--- a/web-console/src/components/rule-editor/rule-editor.spec.tsx
+++ b/web-console/src/components/rule-editor/rule-editor.spec.tsx
@@ -29,8 +29,8 @@ describe('rule editor', () => {
         tiers={['test', 'test', 'test']}
         onChange={() => {}}
         onDelete={() => {}}
-        moveUp={null}
-        moveDown={null}
+        moveUp={undefined}
+        moveDown={undefined}
       />
     );
     const { container } = render(ruleEditor);
@@ -48,8 +48,8 @@ describe('rule editor', () => {
         tiers={['test1', 'test2', 'test3']}
         onChange={() => {}}
         onDelete={() => {}}
-        moveUp={null}
-        moveDown={null}
+        moveUp={undefined}
+        moveDown={undefined}
       />
     );
     const { container } = render(ruleEditor);
@@ -67,8 +67,8 @@ describe('rule editor', () => {
         tiers={['test1', 'test2', 'test3']}
         onChange={() => {}}
         onDelete={() => {}}
-        moveUp={null}
-        moveDown={null}
+        moveUp={undefined}
+        moveDown={undefined}
       />
     );
     const { container } = render(ruleEditor);
@@ -89,8 +89,8 @@ describe('rule editor', () => {
         tiers={['test1', 'test2', 'test3']}
         onChange={() => {}}
         onDelete={() => {}}
-        moveUp={null}
-        moveDown={null}
+        moveUp={undefined}
+        moveDown={undefined}
       />
     );
     const { container } = render(ruleEditor);
@@ -107,8 +107,8 @@ describe('rule editor', () => {
         tiers={[]}
         onChange={() => {}}
         onDelete={() => {}}
-        moveUp={null}
-        moveDown={null}
+        moveUp={undefined}
+        moveDown={undefined}
       />
     );
     const { container } = render(ruleEditor);
diff --git a/web-console/src/components/rule-editor/rule-editor.tsx b/web-console/src/components/rule-editor/rule-editor.tsx
index a5ff567..272463c 100644
--- a/web-console/src/components/rule-editor/rule-editor.tsx
+++ b/web-console/src/components/rule-editor/rule-editor.tsx
@@ -42,8 +42,8 @@ export interface RuleEditorProps {
   tiers: any[];
   onChange: (newRule: Rule) => void;
   onDelete: () => void;
-  moveUp: (() => void) | null;
-  moveDown: (() => void) | null;
+  moveUp: (() => void) | undefined;
+  moveDown: (() => void) | undefined;
 }
 
 export const RuleEditor = React.memo(function RuleEditor(props: RuleEditorProps) {
diff --git a/web-console/src/components/segment-timeline/__snapshots__/segment-timeline.spec.tsx.snap b/web-console/src/components/segment-timeline/__snapshots__/segment-timeline.spec.tsx.snap
index b4a34bb..ee45126 100644
--- a/web-console/src/components/segment-timeline/__snapshots__/segment-timeline.spec.tsx.snap
+++ b/web-console/src/components/segment-timeline/__snapshots__/segment-timeline.spec.tsx.snap
@@ -148,27 +148,27 @@ exports[`Segment Timeline matches snapshot 1`] = `
             <option
               value="1"
             >
-               1 months
+              1 months
             </option>
             <option
               value="3"
             >
-               3 months
+              3 months
             </option>
             <option
               value="6"
             >
-               6 months
+              6 months
             </option>
             <option
               value="9"
             >
-               9 months
+              9 months
             </option>
             <option
               value="12"
             >
-               1 year
+              1 year
             </option>
           </select>
           <span
diff --git a/web-console/src/components/segment-timeline/segment-timeline.tsx b/web-console/src/components/segment-timeline/segment-timeline.tsx
index e6e7502..c81d686 100644
--- a/web-console/src/components/segment-timeline/segment-timeline.tsx
+++ b/web-console/src/components/segment-timeline/segment-timeline.tsx
@@ -48,7 +48,7 @@ interface SegmentTimelineState {
   dataToRender: BarUnitData[];
   timeSpan: number; // by months
   loading: boolean;
-  error?: string;
+  error?: Error;
   xScale: AxisScale<Date> | null;
   yScale: AxisScale<number> | null;
   dStart: Date;
@@ -251,7 +251,7 @@ export class SegmentTimeline extends React.PureComponent<
             const query = `
 SELECT
   "start", "end", "datasource",
-COUNT(*) AS "count", SUM("size") as "size"
+  COUNT(*) AS "count", SUM("size") as "size"
 FROM sys.segments
 WHERE "start" > TIME_FORMAT(TIMESTAMPADD(MONTH, -${timeSpan}, CURRENT_TIMESTAMP), 'yyyy-MM-dd''T''hh:mm:ss.SSS')
 GROUP BY 1, 2, 3
@@ -300,12 +300,12 @@ ORDER BY "start" DESC`;
           );
           return { data, datasources, stackedData, singleDatasourceData };
         },
-        onStateChange: ({ result, loading, error }) => {
+        onStateChange: ({ data, loading, error }) => {
           this.setState({
-            data: result ? result.data : undefined,
-            datasources: result ? result.datasources : [],
-            stackedData: result ? result.stackedData : undefined,
-            singleDatasourceData: result ? result.singleDatasourceData : undefined,
+            data: data ? data.data : undefined,
+            datasources: data ? data.datasources : [],
+            stackedData: data ? data.stackedData : undefined,
+            singleDatasourceData: data ? data.singleDatasourceData : undefined,
             loading,
             error,
           });
@@ -448,7 +448,7 @@ ORDER BY "start" DESC`;
     if (error) {
       return (
         <div>
-          <span className={'no-data-text'}>Error when loading data: {error}</span>
+          <span className={'no-data-text'}>Error when loading data: {error.message}</span>
         </div>
       );
     }
@@ -549,11 +549,11 @@ ORDER BY "start" DESC`;
               value={timeSpan}
               fill
             >
-              <option value={1}> 1 months</option>
-              <option value={3}> 3 months</option>
-              <option value={6}> 6 months</option>
-              <option value={9}> 9 months</option>
-              <option value={12}> 1 year</option>
+              <option value={1}>1 months</option>
+              <option value={3}>3 months</option>
+              <option value={6}>6 months</option>
+              <option value={9}>9 months</option>
+              <option value={12}>1 year</option>
             </HTMLSelect>
           </FormGroup>
         </div>
diff --git a/web-console/src/components/show-history/__snapshots__/show-history.spec.tsx.snap b/web-console/src/components/show-history/__snapshots__/show-history.spec.tsx.snap
index 2ddee09..7b510a2 100644
--- a/web-console/src/components/show-history/__snapshots__/show-history.spec.tsx.snap
+++ b/web-console/src/components/show-history/__snapshots__/show-history.spec.tsx.snap
@@ -1,3 +1,37 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`show history matches snapshot 1`] = `null`;
+exports[`show history matches snapshot 1`] = `
+<div
+  class="loader"
+>
+  <div
+    class="loader-logo"
+  >
+    <svg
+      viewBox="0 0 100 100"
+    >
+      <path
+        class="one"
+        d="M54.2,69.8h-2.7c-0.7,0-1.3-0.6-1.3-1.3c0-0.7,0.6-1.3,1.3-1.3h2.7c11.5,0,23.8-7.4,23.8-23.7
+          c0-9.1-6.9-15.8-16.4-15.8H38c-0.7,0-1.3-0.6-1.3-1.3s0.6-1.3,1.3-1.3h23.6c5.3,0,10.1,1.9,13.6,5.3c3.5,3.4,5.4,8,5.4,13.1
+          c0,6.6-2.3,13-6.3,17.7C69.5,66.8,62.5,69.8,54.2,69.8z"
+      />
+      <path
+        class="two"
+        d="M55.7,59.5h-26c-0.7,0-1.3-0.6-1.3-1.3c0-0.7,0.6-1.3,1.3-1.3h26c7.5,0,11.5-5.8,11.5-11.5
+            c0-4.2-3.2-7.3-7.7-7.3h-26c-0.7,0-1.3-0.6-1.3-1.3s0.6-1.3,1.3-1.3h26c5.9,0,10.3,4.3,10.3,9.9c0,3.7-1.3,7.2-3.7,9.8
+            C63.5,58,59.9,59.5,55.7,59.5z"
+      />
+      <path
+        class="three"
+        d="M27.2,38h-6.3c-0.7,0-1.3-0.6-1.3-1.3s0.6-1.3,1.3-1.3h6.3c0.7,0,1.3,0.6,1.3,1.3S27.9,38,27.2,38z"
+      />
+      <path
+        class="four"
+        d="M45.1,69.8h-5.8c-0.7,0-1.3-0.6-1.3-1.3c0-0.7,0.6-1.3,1.3-1.3h5.8c0.7,0,1.3,0.6,1.3,1.3
+            C46.4,69.2,45.8,69.8,45.1,69.8z"
+      />
+    </svg>
+  </div>
+</div>
+`;
diff --git a/web-console/src/components/show-history/show-history.tsx b/web-console/src/components/show-history/show-history.tsx
index 52fd039..743607a 100644
--- a/web-console/src/components/show-history/show-history.tsx
+++ b/web-console/src/components/show-history/show-history.tsx
@@ -20,92 +20,73 @@ import { Tab, Tabs } from '@blueprintjs/core';
 import axios from 'axios';
 import React from 'react';
 
-import { QueryManager } from '../../utils';
+import { useQueryManager } from '../../hooks';
+import { QueryState } from '../../utils';
 import { Loader } from '../loader/loader';
 import { ShowValue } from '../show-value/show-value';
 
 import './show-history.scss';
 
-export interface PastSupervisor {
+export interface VersionSpec {
   version: string;
   spec: any;
 }
+
 export interface ShowHistoryProps {
   endpoint: string;
   downloadFilename?: string;
 }
 
 export interface ShowHistoryState {
-  data?: PastSupervisor[];
-  loading: boolean;
-  error?: string;
+  historyState: QueryState<VersionSpec[]>;
 }
 
-export class ShowHistory extends React.PureComponent<ShowHistoryProps, ShowHistoryState> {
-  private showHistoryQueryManager: QueryManager<string, PastSupervisor[]>;
-  constructor(props: ShowHistoryProps, context: any) {
-    super(props, context);
-    this.state = {
-      data: [],
-      loading: true,
-    };
-
-    this.showHistoryQueryManager = new QueryManager({
-      processQuery: async (endpoint: string) => {
-        const resp = await axios.get(endpoint);
-        return resp.data;
-      },
-      onStateChange: ({ result, loading, error }) => {
-        this.setState({
-          loading,
-          data: result,
-          error,
-        });
-      },
-    });
-  }
+export const ShowHistory = React.memo(function ShowHistory(props: ShowHistoryProps) {
+  const { downloadFilename, endpoint } = props;
 
-  componentDidMount(): void {
-    this.showHistoryQueryManager.runQuery(this.props.endpoint);
-  }
+  const [historyState] = useQueryManager<string, VersionSpec[]>({
+    processQuery: async (endpoint: string) => {
+      const resp = await axios.get(endpoint);
+      return resp.data;
+    },
+    initQuery: endpoint,
+  });
 
-  render(): JSX.Element | null {
-    const { downloadFilename, endpoint } = this.props;
-    const { data, loading, error } = this.state;
-    if (loading) return <Loader />;
-    if (!data) return null;
+  if (historyState.loading) return <Loader />;
+  if (!historyState.data) return null;
 
-    const versions = data.map((pastSupervisor: PastSupervisor, index: number) => (
-      <Tab
-        id={index}
-        key={index}
-        title={pastSupervisor.version}
-        panel={
-          <ShowValue
-            jsonValue={
-              pastSupervisor.spec ? JSON.stringify(pastSupervisor.spec, undefined, 2) : error
-            }
-            downloadFilename={`version-${pastSupervisor.version}-${downloadFilename}`}
-            endpoint={endpoint}
-          />
-        }
-        panelClassName={'panel'}
-      />
-    ));
+  const versions = historyState.data.map((pastSupervisor: VersionSpec, index: number) => (
+    <Tab
+      id={index}
+      key={index}
+      title={pastSupervisor.version}
+      panel={
+        <ShowValue
+          jsonValue={
+            pastSupervisor.spec
+              ? JSON.stringify(pastSupervisor.spec, undefined, 2)
+              : historyState.getErrorMessage()
+          }
+          downloadFilename={`version-${pastSupervisor.version}-${downloadFilename}`}
+          endpoint={endpoint}
+        />
+      }
+      panelClassName={'panel'}
+    />
+  ));
 
-    return (
-      <div className="show-history">
-        <Tabs
-          animate
-          renderActiveTabPanelOnly
-          vertical
-          className={'tab-area'}
-          defaultSelectedTabId={0}
-        >
-          {versions}
-          <Tabs.Expander />
-        </Tabs>
-      </div>
-    );
-  }
-}
+  return (
+    <div className="show-history">
+      <Tabs
+        animate
+        renderActiveTabPanelOnly
+        vertical
+        className={'tab-area'}
+        defaultSelectedTabId={0}
+      >
+        {versions}
+        <Tabs.Expander />
+      </Tabs>
+    </div>
+  );
+});
diff --git a/web-console/src/components/show-json/__snapshots__/show-json.spec.tsx.snap b/web-console/src/components/show-json/__snapshots__/show-json.spec.tsx.snap
index f10d5b3..f26b891 100644
--- a/web-console/src/components/show-json/__snapshots__/show-json.spec.tsx.snap
+++ b/web-console/src/components/show-json/__snapshots__/show-json.spec.tsx.snap
@@ -50,6 +50,40 @@ exports[`rule editor matches snapshot 1`] = `
   </div>
   <div
     class="main-area"
-  />
+  >
+    <div
+      class="loader"
+    >
+      <div
+        class="loader-logo"
+      >
+        <svg
+          viewBox="0 0 100 100"
+        >
+          <path
+            class="one"
+            d="M54.2,69.8h-2.7c-0.7,0-1.3-0.6-1.3-1.3c0-0.7,0.6-1.3,1.3-1.3h2.7c11.5,0,23.8-7.4,23.8-23.7
+          c0-9.1-6.9-15.8-16.4-15.8H38c-0.7,0-1.3-0.6-1.3-1.3s0.6-1.3,1.3-1.3h23.6c5.3,0,10.1,1.9,13.6,5.3c3.5,3.4,5.4,8,5.4,13.1
+          c0,6.6-2.3,13-6.3,17.7C69.5,66.8,62.5,69.8,54.2,69.8z"
+          />
+          <path
+            class="two"
+            d="M55.7,59.5h-26c-0.7,0-1.3-0.6-1.3-1.3c0-0.7,0.6-1.3,1.3-1.3h26c7.5,0,11.5-5.8,11.5-11.5
+            c0-4.2-3.2-7.3-7.7-7.3h-26c-0.7,0-1.3-0.6-1.3-1.3s0.6-1.3,1.3-1.3h26c5.9,0,10.3,4.3,10.3,9.9c0,3.7-1.3,7.2-3.7,9.8
+            C63.5,58,59.9,59.5,55.7,59.5z"
+          />
+          <path
+            class="three"
+            d="M27.2,38h-6.3c-0.7,0-1.3-0.6-1.3-1.3s0.6-1.3,1.3-1.3h6.3c0.7,0,1.3,0.6,1.3,1.3S27.9,38,27.2,38z"
+          />
+          <path
+            class="four"
+            d="M45.1,69.8h-5.8c-0.7,0-1.3-0.6-1.3-1.3c0-0.7,0.6-1.3,1.3-1.3h5.8c0.7,0,1.3,0.6,1.3,1.3
+            C46.4,69.2,45.8,69.8,45.1,69.8z"
+          />
+        </svg>
+      </div>
+    </div>
+  </div>
 </div>
 `;
diff --git a/web-console/src/components/show-json/show-json.tsx b/web-console/src/components/show-json/show-json.tsx
index b8282bf..f312129 100644
--- a/web-console/src/components/show-json/show-json.tsx
+++ b/web-console/src/components/show-json/show-json.tsx
@@ -21,9 +21,10 @@ import axios from 'axios';
 import copy from 'copy-to-clipboard';
 import React from 'react';
 
+import { useQueryManager } from '../../hooks';
 import { AppToaster } from '../../singletons/toaster';
 import { UrlBaser } from '../../singletons/url-baser';
-import { downloadFile, QueryManager } from '../../utils';
+import { downloadFile, QueryState } from '../../utils';
 import { Loader } from '../loader/loader';
 
 import './show-json.scss';
@@ -35,81 +36,62 @@ export interface ShowJsonProps {
 }
 
 export interface ShowJsonState {
-  jsonValue?: string;
-  loading: boolean;
-  error?: string;
+  jsonState: QueryState<string>;
 }
 
-export class ShowJson extends React.PureComponent<ShowJsonProps, ShowJsonState> {
-  private showJsonQueryManager: QueryManager<null, string>;
-  constructor(props: ShowJsonProps, context: any) {
-    super(props, context);
-    this.state = {
-      jsonValue: '',
-      loading: false,
-    };
-    this.showJsonQueryManager = new QueryManager({
-      processQuery: async () => {
-        const { endpoint, transform } = this.props;
-        const resp = await axios.get(endpoint);
-        let data = resp.data;
-        if (transform) data = transform(data);
-        return typeof data === 'string' ? data : JSON.stringify(data, undefined, 2);
-      },
-      onStateChange: ({ result, loading, error }) => {
-        this.setState({
-          loading,
-          error,
-          jsonValue: result,
-        });
-      },
-    });
-  }
+export const ShowJson = React.memo(function ShowJson(props: ShowJsonProps) {
+  const { endpoint, transform, downloadFilename } = props;
 
-  componentDidMount(): void {
-    this.showJsonQueryManager.runQuery(null);
-  }
+  const [jsonState] = useQueryManager<null, string>({
+    processQuery: async () => {
+      const resp = await axios.get(endpoint);
+      let data = resp.data;
+      if (transform) data = transform(data);
+      return typeof data === 'string' ? data : JSON.stringify(data, undefined, 2);
+    },
+    initQuery: null,
+  });
 
-  render(): JSX.Element {
-    const { endpoint, downloadFilename } = this.props;
-    const { jsonValue, error, loading } = this.state;
-
-    return (
-      <div className="show-json">
-        <div className="top-actions">
-          <ButtonGroup className="right-buttons">
-            {downloadFilename && (
-              <Button
-                disabled={loading}
-                text="Save"
-                minimal
-                onClick={() => downloadFile(jsonValue ? jsonValue : '', 'json', downloadFilename)}
-              />
-            )}
-            <Button
-              text="Copy"
-              minimal
-              disabled={loading}
-              onClick={() => {
-                copy(jsonValue ? jsonValue : '', { format: 'text/plain' });
-                AppToaster.show({
-                  message: 'JSON value copied to clipboard',
-                  intent: Intent.SUCCESS,
-                });
-              }}
-            />
+  const jsonValue = jsonState.data || '';
+  return (
+    <div className="show-json">
+      <div className="top-actions">
+        <ButtonGroup className="right-buttons">
+          {downloadFilename && (
             <Button
-              text="View raw"
-              disabled={!jsonValue}
+              disabled={jsonState.loading}
+              text="Save"
               minimal
-              onClick={() => window.open(UrlBaser.base(endpoint), '_blank')}
+              onClick={() => downloadFile(jsonValue, 'json', downloadFilename)}
             />
-          </ButtonGroup>
-        </div>
-        <div className="main-area">
-          {loading ? <Loader /> : <TextArea readOnly value={!error ? jsonValue : error} />}
-        </div>
+          )}
+          <Button
+            text="Copy"
+            minimal
+            disabled={jsonState.loading}
+            onClick={() => {
+              copy(jsonValue, { format: 'text/plain' });
+              AppToaster.show({
+                message: 'JSON value copied to clipboard',
+                intent: Intent.SUCCESS,
+              });
+            }}
+          />
+          <Button
+            text="View raw"
+            disabled={!jsonValue}
+            minimal
+            onClick={() => window.open(UrlBaser.base(endpoint), '_blank')}
+          />
+        </ButtonGroup>
       </div>
-    );
-  }
-}
+      <div className="main-area">
+        {jsonState.loading ? (
+          <Loader />
+        ) : (
+          <TextArea readOnly value={!jsonState.error ? jsonValue : jsonState.getErrorMessage()} />
+        )}
+      </div>
+    </div>
+  );
+});
diff --git a/web-console/src/components/show-log/show-log.tsx b/web-console/src/components/show-log/show-log.tsx
index 3134b3a..48fe705 100644
--- a/web-console/src/components/show-log/show-log.tsx
+++ b/web-console/src/components/show-log/show-log.tsx
@@ -24,7 +24,7 @@ import React from 'react';
 import { Loader } from '../../components';
 import { AppToaster } from '../../singletons/toaster';
 import { UrlBaser } from '../../singletons/url-baser';
-import { QueryManager } from '../../utils';
+import { QueryManager, QueryState } from '../../utils';
 
 import './show-log.scss';
 
@@ -44,9 +44,7 @@ export interface ShowLogProps {
 }
 
 export interface ShowLogState {
-  logValue?: string;
-  loading: boolean;
-  error?: string;
+  logState: QueryState<string>;
   tail: boolean;
 }
 
@@ -60,8 +58,8 @@ export class ShowLog extends React.PureComponent<ShowLogProps, ShowLogState> {
   constructor(props: ShowLogProps, context: any) {
     super(props, context);
     this.state = {
+      logState: QueryState.INIT,
       tail: true,
-      loading: true,
     };
 
     this.showLogQueryManager = new QueryManager({
@@ -74,18 +72,12 @@ export class ShowLog extends React.PureComponent<ShowLogProps, ShowLogState> {
         if (tailOffset) logValue = removeFirstPartialLine(logValue);
         return logValue;
       },
-      onStateChange: ({ result, loading, error }) => {
-        const { tail } = this.state;
-        if (result && tail) {
-          const { current } = this.log;
-          if (current) {
-            current.scrollTop = current.scrollHeight;
-          }
+      onStateChange: logState => {
+        if (logState.data) {
+          this.scrollToBottomIfNeeded();
         }
         this.setState({
-          logValue: result,
-          loading,
-          error,
+          logState,
         });
       },
     });
@@ -105,11 +97,22 @@ export class ShowLog extends React.PureComponent<ShowLogProps, ShowLogState> {
     this.removeTailer();
   }
 
+  private scrollToBottomIfNeeded(): void {
+    const { tail } = this.state;
+    if (!tail) return;
+
+    const { current } = this.log;
+    if (current) {
+      current.scrollTop = current.scrollHeight;
+    }
+  }
+
   addTailer() {
     if (this.interval) return;
-    this.interval = Number(
-      setInterval(() => this.showLogQueryManager.rerunLastQuery(true), ShowLog.CHECK_INTERVAL),
-    );
+    this.interval = setInterval(
+      () => this.showLogQueryManager.rerunLastQuery(true),
+      ShowLog.CHECK_INTERVAL,
+    ) as any;
   }
 
   removeTailer() {
@@ -134,7 +137,7 @@ export class ShowLog extends React.PureComponent<ShowLogProps, ShowLogState> {
 
   render(): JSX.Element {
     const { endpoint, downloadFilename, status } = this.props;
-    const { logValue, error, loading } = this.state;
+    const { logState } = this.state;
 
     return (
       <div className="show-log">
@@ -159,7 +162,7 @@ export class ShowLog extends React.PureComponent<ShowLogProps, ShowLogState> {
               text="Copy"
               minimal
               onClick={() => {
-                copy(logValue ? logValue : '', { format: 'text/plain' });
+                copy(logState.data || '', { format: 'text/plain' });
                 AppToaster.show({
                   message: 'Log copied to clipboard',
                   intent: Intent.SUCCESS,
@@ -174,13 +177,13 @@ export class ShowLog extends React.PureComponent<ShowLogProps, ShowLogState> {
           </ButtonGroup>
         </div>
         <div className="main-area">
-          {loading ? (
-            <Loader loadingText="" loading />
+          {logState.loading ? (
+            <Loader />
           ) : (
             <textarea
               className="bp3-input"
               readOnly
-              value={logValue ? logValue : error}
+              value={logState.data || logState.getErrorMessage()}
               ref={this.log}
             />
           )}
diff --git a/web-console/src/components/suggestible-input/__snapshots__/suggestible-input.spec.tsx.snap b/web-console/src/components/suggestible-input/__snapshots__/suggestible-input.spec.tsx.snap
index 8fedd97..9e9bb98 100644
--- a/web-console/src/components/suggestible-input/__snapshots__/suggestible-input.spec.tsx.snap
+++ b/web-console/src/components/suggestible-input/__snapshots__/suggestible-input.spec.tsx.snap
@@ -7,7 +7,6 @@ exports[`suggestible input matches snapshot 1`] = `
   <input
     class="bp3-input"
     style="padding-right: 0px;"
-    suggestions="a,b,c"
     type="text"
     value=""
   />
diff --git a/web-console/src/components/suggestible-input/suggestible-input.tsx b/web-console/src/components/suggestible-input/suggestible-input.tsx
index d6b4849..c4e37fd 100644
--- a/web-console/src/components/suggestible-input/suggestible-input.tsx
+++ b/web-console/src/components/suggestible-input/suggestible-input.tsx
@@ -28,7 +28,7 @@ import {
 } from '@blueprintjs/core';
 import { IconNames } from '@blueprintjs/icons';
 import classNames from 'classnames';
-import React from 'react';
+import React, { useRef } from 'react';
 
 export interface SuggestionGroup {
   group: string;
@@ -45,98 +45,87 @@ export interface SuggestibleInputProps extends HTMLInputProps {
   intent?: Intent;
 }
 
-export class SuggestibleInput extends React.PureComponent<SuggestibleInputProps> {
-  private lastFocusValue?: string;
+export const SuggestibleInput = React.memo(function SuggestibleInput(props: SuggestibleInputProps) {
+  const {
+    className,
+    value,
+    defaultValue,
+    onValueChange,
+    onFinalize,
+    onBlur,
+    suggestions,
+    ...rest
+  } = props;
 
-  constructor(props: SuggestibleInputProps, context: any) {
-    super(props, context);
-    // this.state = {};
-  }
+  const lastFocusValue = useRef<string>();
 
-  public handleSuggestionSelect(suggestion: undefined | string) {
-    const { onValueChange, onFinalize } = this.props;
+  function handleSuggestionSelect(suggestion: undefined | string) {
     onValueChange(suggestion);
     if (onFinalize) onFinalize();
   }
 
-  renderSuggestionsMenu() {
-    const { suggestions } = this.props;
-    if (!suggestions) return;
-
-    return (
-      <Menu>
-        {suggestions.map(suggestion => {
-          if (typeof suggestion === 'undefined') {
-            return (
-              <MenuItem
-                key="__undefined__"
-                text="(none)"
-                onClick={() => this.handleSuggestionSelect(suggestion)}
-              />
-            );
-          } else if (typeof suggestion === 'string') {
-            return (
-              <MenuItem
-                key={suggestion}
-                text={suggestion}
-                onClick={() => this.handleSuggestionSelect(suggestion)}
-              />
-            );
-          } else {
-            return (
-              <MenuItem key={suggestion.group} text={suggestion.group}>
-                {suggestion.suggestions.map(suggestion => (
-                  <MenuItem
-                    key={suggestion}
-                    text={suggestion}
-                    onClick={() => this.handleSuggestionSelect(suggestion)}
-                  />
-                ))}
-              </MenuItem>
-            );
-          }
-        })}
-      </Menu>
-    );
-  }
-
-  render(): JSX.Element {
-    const {
-      className,
-      value,
-      defaultValue,
-      onValueChange,
-      onFinalize,
-      onBlur,
-      ...rest
-    } = this.props;
-
-    const suggestionsMenu = this.renderSuggestionsMenu();
-    return (
-      <InputGroup
-        className={classNames('suggestible-input', className)}
-        value={value as string}
-        defaultValue={defaultValue as string}
-        onChange={(e: any) => {
-          onValueChange(e.target.value);
-        }}
-        onFocus={(e: any) => {
-          this.lastFocusValue = e.target.value;
-        }}
-        onBlur={(e: any) => {
-          if (onBlur) onBlur(e);
-          if (this.lastFocusValue === e.target.value) return;
-          if (onFinalize) onFinalize();
-        }}
-        rightElement={
-          suggestionsMenu && (
-            <Popover content={suggestionsMenu} position={Position.BOTTOM_RIGHT} autoFocus={false}>
-              <Button icon={IconNames.CARET_DOWN} minimal />
-            </Popover>
-          )
-        }
-        {...rest}
-      />
-    );
-  }
-}
+  return (
+    <InputGroup
+      className={classNames('suggestible-input', className)}
+      value={value as string}
+      defaultValue={defaultValue as string}
+      onChange={(e: any) => {
+        onValueChange(e.target.value);
+      }}
+      onFocus={(e: any) => {
+        lastFocusValue.current = e.target.value;
+      }}
+      onBlur={(e: any) => {
+        if (onBlur) onBlur(e);
+        if (lastFocusValue.current === e.target.value) return;
+        if (onFinalize) onFinalize();
+      }}
+      rightElement={
+        suggestions && (
+          <Popover
+            content={
+              <Menu>
+                {suggestions.map(suggestion => {
+                  if (typeof suggestion === 'undefined') {
+                    return (
+                      <MenuItem
+                        key="__undefined__"
+                        text="(none)"
+                        onClick={() => handleSuggestionSelect(suggestion)}
+                      />
+                    );
+                  } else if (typeof suggestion === 'string') {
+                    return (
+                      <MenuItem
+                        key={suggestion}
+                        text={suggestion}
+                        onClick={() => handleSuggestionSelect(suggestion)}
+                      />
+                    );
+                  } else {
+                    return (
+                      <MenuItem key={suggestion.group} text={suggestion.group}>
+                        {suggestion.suggestions.map(suggestion => (
+                          <MenuItem
+                            key={suggestion}
+                            text={suggestion}
+                            onClick={() => handleSuggestionSelect(suggestion)}
+                          />
+                        ))}
+                      </MenuItem>
+                    );
+                  }
+                })}
+              </Menu>
+            }
+            position={Position.BOTTOM_RIGHT}
+            autoFocus={false}
+          >
+            <Button icon={IconNames.CARET_DOWN} minimal />
+          </Popover>
+        )
+      }
+      {...rest}
+    />
+  );
+});
diff --git a/web-console/src/components/supervisor-statistics-table/__snapshots__/supervisor-statistics-table.spec.tsx.snap b/web-console/src/components/supervisor-statistics-table/__snapshots__/supervisor-statistics-table.spec.tsx.snap
index aeaa9c5..b662c59 100644
--- a/web-console/src/components/supervisor-statistics-table/__snapshots__/supervisor-statistics-table.spec.tsx.snap
+++ b/web-console/src/components/supervisor-statistics-table/__snapshots__/supervisor-statistics-table.spec.tsx.snap
@@ -1,65 +1,791 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`rule editor matches snapshot 1`] = `
+exports[`SupervisorStatisticsTable matches snapshot on error 1`] = `
 <div
-  class="supervisor-statistics-table"
+  className="supervisor-statistics-table"
 >
   <div
-    class="top-actions"
+    className="top-actions"
   >
-    <div
-      class="bp3-button-group right-buttons"
+    <Blueprint3.ButtonGroup
+      className="right-buttons"
     >
-      <button
-        class="bp3-button bp3-disabled bp3-minimal"
-        disabled=""
-        tabindex="-1"
-        type="button"
-      >
-        <span
-          class="bp3-button-text"
-        >
-          View raw
-        </span>
-      </button>
-    </div>
+      <Blueprint3.Button
+        disabled={false}
+        minimal={true}
+        onClick={[Function]}
+        text="View raw"
+      />
+    </Blueprint3.ButtonGroup>
   </div>
   <div
-    class="main-area"
+    className="main-area"
   >
-    <div
-      class="loader"
+    <ReactTable
+      AggregatedComponent={[Function]}
+      ExpanderComponent={[Function]}
+      FilterComponent={[Function]}
+      LoadingComponent={[Function]}
+      NoDataComponent={[Function]}
+      PadRowComponent={[Function]}
+      PaginationComponent={[Function]}
+      PivotValueComponent={[Function]}
+      ResizerComponent={[Function]}
+      TableComponent={[Function]}
+      TbodyComponent={[Function]}
+      TdComponent={[Function]}
+      TfootComponent={[Function]}
+      ThComponent={[Function]}
+      TheadComponent={[Function]}
+      TrComponent={[Function]}
+      TrGroupComponent={[Function]}
+      aggregatedKey="_aggregated"
+      className=""
+      collapseOnDataChange={true}
+      collapseOnPageChange={true}
+      collapseOnSortingChange={true}
+      column={
+        Object {
+          "Aggregated": undefined,
+          "Cell": undefined,
+          "Expander": undefined,
+          "Filter": undefined,
+          "Footer": undefined,
+          "Header": undefined,
+          "Pivot": undefined,
+          "PivotValue": undefined,
+          "Placeholder": undefined,
+          "aggregate": undefined,
+          "className": "",
+          "filterAll": false,
+          "filterMethod": undefined,
+          "filterable": undefined,
+          "footerClassName": "",
+          "footerStyle": Object {},
+          "getFooterProps": [Function],
+          "getHeaderProps": [Function],
+          "getProps": [Function],
+          "headerClassName": "",
+          "headerStyle": Object {},
+          "minResizeWidth": 11,
+          "minWidth": 100,
+          "resizable": undefined,
+          "show": true,
+          "sortMethod": undefined,
+          "sortable": undefined,
+          "style": Object {},
+        }
+      }
+      columns={
+        Array [
+          Object {
+            "Header": "Task ID",
+            "accessor": [Function],
+            "id": "task_id",
+          },
+          Object {
+            "Cell": [Function],
+            "Header": "Totals",
+            "accessor": [Function],
+            "id": "total",
+          },
+        ]
+      }
+      data={Array []}
+      defaultExpanded={Object {}}
+      defaultFilterMethod={[Function]}
+      defaultFiltered={Array []}
+      defaultPage={0}
+      defaultPageSize={6}
+      defaultResized={Array []}
+      defaultSortDesc={false}
+      defaultSortMethod={[Function]}
+      defaultSorted={Array []}
+      expanderDefaults={
+        Object {
+          "filterable": false,
+          "resizable": false,
+          "sortable": false,
+          "width": 35,
+        }
+      }
+      filterable={false}
+      freezeWhenExpanded={false}
+      getLoadingProps={[Function]}
+      getNoDataProps={[Function]}
+      getPaginationProps={[Function]}
+      getProps={[Function]}
+      getResizerProps={[Function]}
+      getTableProps={[Function]}
+      getTbodyProps={[Function]}
+      getTdProps={[Function]}
+      getTfootProps={[Function]}
+      getTfootTdProps={[Function]}
+      getTfootTrProps={[Function]}
+      getTheadFilterProps={[Function]}
+      getTheadFilterThProps={[Function]}
+      getTheadFilterTrProps={[Function]}
+      getTheadGroupProps={[Function]}
+      getTheadGroupThProps={[Function]}
+      getTheadGroupTrProps={[Function]}
+      getTheadProps={[Function]}
+      getTheadThProps={[Function]}
+      getTheadTrProps={[Function]}
+      getTrGroupProps={[Function]}
+      getTrProps={[Function]}
+      groupedByPivotKey="_groupedByPivot"
+      indexKey="_index"
+      loading={false}
+      loadingText="Loading..."
+      multiSort={true}
+      nestingLevelKey="_nestingLevel"
+      nextText="Next"
+      noDataText="test error"
+      ofText="of"
+      onFetchData={[Function]}
+      originalKey="_original"
+      pageJumpText="jump to page"
+      pageSizeOptions={
+        Array [
+          5,
+          10,
+          20,
+          25,
+          50,
+          100,
+        ]
+      }
+      pageText="Page"
+      pivotDefaults={Object {}}
+      pivotIDKey="_pivotID"
+      pivotValKey="_pivotVal"
+      previousText="Previous"
+      resizable={true}
+      resolveData={[Function]}
+      rowsSelectorText="rows per page"
+      rowsText="rows"
+      showPageJump={true}
+      showPageSizeOptions={true}
+      showPagination={false}
+      showPaginationBottom={true}
+      showPaginationTop={false}
+      sortable={true}
+      style={Object {}}
+      subRowsKey="_subRows"
+    />
+  </div>
+</div>
+`;
+
+exports[`SupervisorStatisticsTable matches snapshot on init 1`] = `
+<div
+  className="supervisor-statistics-table"
+>
+  <div
+    className="top-actions"
+  >
+    <Blueprint3.ButtonGroup
+      className="right-buttons"
+    >
+      <Blueprint3.Button
+        disabled={false}
+        minimal={true}
+        onClick={[Function]}
+        text="View raw"
+      />
+    </Blueprint3.ButtonGroup>
+  </div>
+  <div
+    className="main-area"
+  >
+    <ReactTable
+      AggregatedComponent={[Function]}
+      ExpanderComponent={[Function]}
+      FilterComponent={[Function]}
+      LoadingComponent={[Function]}
+      NoDataComponent={[Function]}
+      PadRowComponent={[Function]}
+      PaginationComponent={[Function]}
+      PivotValueComponent={[Function]}
+      ResizerComponent={[Function]}
+      TableComponent={[Function]}
+      TbodyComponent={[Function]}
+      TdComponent={[Function]}
+      TfootComponent={[Function]}
+      ThComponent={[Function]}
+      TheadComponent={[Function]}
+      TrComponent={[Function]}
+      TrGroupComponent={[Function]}
+      aggregatedKey="_aggregated"
+      className=""
+      collapseOnDataChange={true}
+      collapseOnPageChange={true}
+      collapseOnSortingChange={true}
+      column={
+        Object {
+          "Aggregated": undefined,
+          "Cell": undefined,
+          "Expander": undefined,
+          "Filter": undefined,
+          "Footer": undefined,
+          "Header": undefined,
+          "Pivot": undefined,
+          "PivotValue": undefined,
+          "Placeholder": undefined,
+          "aggregate": undefined,
+          "className": "",
+          "filterAll": false,
+          "filterMethod": undefined,
+          "filterable": undefined,
+          "footerClassName": "",
+          "footerStyle": Object {},
+          "getFooterProps": [Function],
+          "getHeaderProps": [Function],
+          "getProps": [Function],
+          "headerClassName": "",
+          "headerStyle": Object {},
+          "minResizeWidth": 11,
+          "minWidth": 100,
+          "resizable": undefined,
+          "show": true,
+          "sortMethod": undefined,
+          "sortable": undefined,
+          "style": Object {},
+        }
+      }
+      columns={
+        Array [
+          Object {
+            "Header": "Task ID",
+            "accessor": [Function],
+            "id": "task_id",
+          },
+          Object {
+            "Cell": [Function],
+            "Header": "Totals",
+            "accessor": [Function],
+            "id": "total",
+          },
+        ]
+      }
+      data={Array []}
+      defaultExpanded={Object {}}
+      defaultFilterMethod={[Function]}
+      defaultFiltered={Array []}
+      defaultPage={0}
+      defaultPageSize={6}
+      defaultResized={Array []}
+      defaultSortDesc={false}
+      defaultSortMethod={[Function]}
+      defaultSorted={Array []}
+      expanderDefaults={
+        Object {
+          "filterable": false,
+          "resizable": false,
+          "sortable": false,
+          "width": 35,
+        }
+      }
+      filterable={false}
+      freezeWhenExpanded={false}
+      getLoadingProps={[Function]}
+      getNoDataProps={[Function]}
+      getPaginationProps={[Function]}
+      getProps={[Function]}
+      getResizerProps={[Function]}
+      getTableProps={[Function]}
+      getTbodyProps={[Function]}
+      getTdProps={[Function]}
+      getTfootProps={[Function]}
+      getTfootTdProps={[Function]}
+      getTfootTrProps={[Function]}
+      getTheadFilterProps={[Function]}
+      getTheadFilterThProps={[Function]}
+      getTheadFilterTrProps={[Function]}
+      getTheadGroupProps={[Function]}
+      getTheadGroupThProps={[Function]}
+      getTheadGroupTrProps={[Function]}
+      getTheadProps={[Function]}
+      getTheadThProps={[Function]}
+      getTheadTrProps={[Function]}
+      getTrGroupProps={[Function]}
+      getTrProps={[Function]}
+      groupedByPivotKey="_groupedByPivot"
+      indexKey="_index"
+      loading={false}
+      loadingText="Loading..."
+      multiSort={true}
+      nestingLevelKey="_nestingLevel"
+      nextText="Next"
+      noDataText="No statistics data found"
+      ofText="of"
+      onFetchData={[Function]}
+      originalKey="_original"
+      pageJumpText="jump to page"
+      pageSizeOptions={
+        Array [
+          5,
+          10,
+          20,
+          25,
+          50,
+          100,
+        ]
+      }
+      pageText="Page"
+      pivotDefaults={Object {}}
+      pivotIDKey="_pivotID"
+      pivotValKey="_pivotVal"
+      previousText="Previous"
+      resizable={true}
+      resolveData={[Function]}
+      rowsSelectorText="rows per page"
+      rowsText="rows"
+      showPageJump={true}
+      showPageSizeOptions={true}
+      showPagination={false}
+      showPaginationBottom={true}
+      showPaginationTop={false}
+      sortable={true}
+      style={Object {}}
+      subRowsKey="_subRows"
+    />
+  </div>
+</div>
+`;
+
+exports[`SupervisorStatisticsTable matches snapshot on loading 1`] = `
+<div
+  className="supervisor-statistics-table"
+>
+  <div
+    className="top-actions"
+  >
+    <Blueprint3.ButtonGroup
+      className="right-buttons"
     >
-      <div
-        class="loader-logo"
-      >
-        <svg
-          viewBox="0 0 100 100"
-        >
-          <path
-            class="one"
-            d="M54.2,69.8h-2.7c-0.7,0-1.3-0.6-1.3-1.3c0-0.7,0.6-1.3,1.3-1.3h2.7c11.5,0,23.8-7.4,23.8-23.7
-          c0-9.1-6.9-15.8-16.4-15.8H38c-0.7,0-1.3-0.6-1.3-1.3s0.6-1.3,1.3-1.3h23.6c5.3,0,10.1,1.9,13.6,5.3c3.5,3.4,5.4,8,5.4,13.1
-          c0,6.6-2.3,13-6.3,17.7C69.5,66.8,62.5,69.8,54.2,69.8z"
-          />
-          <path
-            class="two"
-            d="M55.7,59.5h-26c-0.7,0-1.3-0.6-1.3-1.3c0-0.7,0.6-1.3,1.3-1.3h26c7.5,0,11.5-5.8,11.5-11.5
-            c0-4.2-3.2-7.3-7.7-7.3h-26c-0.7,0-1.3-0.6-1.3-1.3s0.6-1.3,1.3-1.3h26c5.9,0,10.3,4.3,10.3,9.9c0,3.7-1.3,7.2-3.7,9.8
-            C63.5,58,59.9,59.5,55.7,59.5z"
-          />
-          <path
-            class="three"
-            d="M27.2,38h-6.3c-0.7,0-1.3-0.6-1.3-1.3s0.6-1.3,1.3-1.3h6.3c0.7,0,1.3,0.6,1.3,1.3S27.9,38,27.2,38z"
-          />
-          <path
-            class="four"
-            d="M45.1,69.8h-5.8c-0.7,0-1.3-0.6-1.3-1.3c0-0.7,0.6-1.3,1.3-1.3h5.8c0.7,0,1.3,0.6,1.3,1.3
-            C46.4,69.2,45.8,69.8,45.1,69.8z"
-          />
-        </svg>
-      </div>
-    </div>
+      <Blueprint3.Button
+        disabled={true}
+        minimal={true}
+        onClick={[Function]}
+        text="View raw"
+      />
+    </Blueprint3.ButtonGroup>
+  </div>
+  <div
+    className="main-area"
+  >
+    <Memo(Loader) />
+  </div>
+</div>
+`;
+
+exports[`SupervisorStatisticsTable matches snapshot on no data 1`] = `
+<div
+  className="supervisor-statistics-table"
+>
+  <div
+    className="top-actions"
+  >
+    <Blueprint3.ButtonGroup
+      className="right-buttons"
+    >
+      <Blueprint3.Button
+        disabled={false}
+        minimal={true}
+        onClick={[Function]}
+        text="View raw"
+      />
+    </Blueprint3.ButtonGroup>
+  </div>
+  <div
+    className="main-area"
+  >
+    <ReactTable
+      AggregatedComponent={[Function]}
+      ExpanderComponent={[Function]}
+      FilterComponent={[Function]}
+      LoadingComponent={[Function]}
+      NoDataComponent={[Function]}
+      PadRowComponent={[Function]}
+      PaginationComponent={[Function]}
+      PivotValueComponent={[Function]}
+      ResizerComponent={[Function]}
+      TableComponent={[Function]}
+      TbodyComponent={[Function]}
+      TdComponent={[Function]}
+      TfootComponent={[Function]}
+      ThComponent={[Function]}
+      TheadComponent={[Function]}
+      TrComponent={[Function]}
+      TrGroupComponent={[Function]}
+      aggregatedKey="_aggregated"
+      className=""
+      collapseOnDataChange={true}
+      collapseOnPageChange={true}
+      collapseOnSortingChange={true}
+      column={
+        Object {
+          "Aggregated": undefined,
+          "Cell": undefined,
+          "Expander": undefined,
+          "Filter": undefined,
+          "Footer": undefined,
+          "Header": undefined,
+          "Pivot": undefined,
+          "PivotValue": undefined,
+          "Placeholder": undefined,
+          "aggregate": undefined,
+          "className": "",
+          "filterAll": false,
+          "filterMethod": undefined,
+          "filterable": undefined,
+          "footerClassName": "",
+          "footerStyle": Object {},
+          "getFooterProps": [Function],
+          "getHeaderProps": [Function],
+          "getProps": [Function],
+          "headerClassName": "",
+          "headerStyle": Object {},
+          "minResizeWidth": 11,
+          "minWidth": 100,
+          "resizable": undefined,
+          "show": true,
+          "sortMethod": undefined,
+          "sortable": undefined,
+          "style": Object {},
+        }
+      }
+      columns={
+        Array [
+          Object {
+            "Header": "Task ID",
+            "accessor": [Function],
+            "id": "task_id",
+          },
+          Object {
+            "Cell": [Function],
+            "Header": "Totals",
+            "accessor": [Function],
+            "id": "total",
+          },
+        ]
+      }
+      data={Array []}
+      defaultExpanded={Object {}}
+      defaultFilterMethod={[Function]}
+      defaultFiltered={Array []}
+      defaultPage={0}
+      defaultPageSize={6}
+      defaultResized={Array []}
+      defaultSortDesc={false}
+      defaultSortMethod={[Function]}
+      defaultSorted={Array []}
+      expanderDefaults={
+        Object {
+          "filterable": false,
+          "resizable": false,
+          "sortable": false,
+          "width": 35,
+        }
+      }
+      filterable={false}
+      freezeWhenExpanded={false}
+      getLoadingProps={[Function]}
+      getNoDataProps={[Function]}
+      getPaginationProps={[Function]}
+      getProps={[Function]}
+      getResizerProps={[Function]}
+      getTableProps={[Function]}
+      getTbodyProps={[Function]}
+      getTdProps={[Function]}
+      getTfootProps={[Function]}
+      getTfootTdProps={[Function]}
+      getTfootTrProps={[Function]}
+      getTheadFilterProps={[Function]}
+      getTheadFilterThProps={[Function]}
+      getTheadFilterTrProps={[Function]}
+      getTheadGroupProps={[Function]}
+      getTheadGroupThProps={[Function]}
+      getTheadGroupTrProps={[Function]}
+      getTheadProps={[Function]}
+      getTheadThProps={[Function]}
+      getTheadTrProps={[Function]}
+      getTrGroupProps={[Function]}
+      getTrProps={[Function]}
+      groupedByPivotKey="_groupedByPivot"
+      indexKey="_index"
+      loading={false}
+      loadingText="Loading..."
+      multiSort={true}
+      nestingLevelKey="_nestingLevel"
+      nextText="Next"
+      noDataText="No statistics data found"
+      ofText="of"
+      onFetchData={[Function]}
+      originalKey="_original"
+      pageJumpText="jump to page"
+      pageSizeOptions={
+        Array [
+          5,
+          10,
+          20,
+          25,
+          50,
+          100,
+        ]
+      }
+      pageText="Page"
+      pivotDefaults={Object {}}
+      pivotIDKey="_pivotID"
+      pivotValKey="_pivotVal"
+      previousText="Previous"
+      resizable={true}
+      resolveData={[Function]}
+      rowsSelectorText="rows per page"
+      rowsText="rows"
+      showPageJump={true}
+      showPageSizeOptions={true}
+      showPagination={false}
+      showPaginationBottom={true}
+      showPaginationTop={false}
+      sortable={true}
+      style={Object {}}
+      subRowsKey="_subRows"
+    />
+  </div>
+</div>
+`;
+
+exports[`SupervisorStatisticsTable matches snapshot on some data 1`] = `
+<div
+  className="supervisor-statistics-table"
+>
+  <div
+    className="top-actions"
+  >
+    <Blueprint3.ButtonGroup
+      className="right-buttons"
+    >
+      <Blueprint3.Button
+        disabled={false}
+        minimal={true}
+        onClick={[Function]}
+        text="View raw"
+      />
+    </Blueprint3.ButtonGroup>
+  </div>
+  <div
+    className="main-area"
+  >
+    <ReactTable
+      AggregatedComponent={[Function]}
+      ExpanderComponent={[Function]}
+      FilterComponent={[Function]}
+      LoadingComponent={[Function]}
+      NoDataComponent={[Function]}
+      PadRowComponent={[Function]}
+      PaginationComponent={[Function]}
+      PivotValueComponent={[Function]}
+      ResizerComponent={[Function]}
+      TableComponent={[Function]}
+      TbodyComponent={[Function]}
+      TdComponent={[Function]}
+      TfootComponent={[Function]}
+      ThComponent={[Function]}
+      TheadComponent={[Function]}
+      TrComponent={[Function]}
+      TrGroupComponent={[Function]}
+      aggregatedKey="_aggregated"
+      className=""
+      collapseOnDataChange={true}
+      collapseOnPageChange={true}
+      collapseOnSortingChange={true}
+      column={
+        Object {
+          "Aggregated": undefined,
+          "Cell": undefined,
+          "Expander": undefined,
+          "Filter": undefined,
+          "Footer": undefined,
+          "Header": undefined,
+          "Pivot": undefined,
+          "PivotValue": undefined,
+          "Placeholder": undefined,
+          "aggregate": undefined,
+          "className": "",
+          "filterAll": false,
+          "filterMethod": undefined,
+          "filterable": undefined,
+          "footerClassName": "",
+          "footerStyle": Object {},
+          "getFooterProps": [Function],
+          "getHeaderProps": [Function],
+          "getProps": [Function],
+          "headerClassName": "",
+          "headerStyle": Object {},
+          "minResizeWidth": 11,
+          "minWidth": 100,
+          "resizable": undefined,
+          "show": true,
+          "sortMethod": undefined,
+          "sortable": undefined,
+          "style": Object {},
+        }
+      }
+      columns={
+        Array [
+          Object {
+            "Header": "Task ID",
+            "accessor": [Function],
+            "id": "task_id",
+          },
+          Object {
+            "Cell": [Function],
+            "Header": "Totals",
+            "accessor": [Function],
+            "id": "total",
+          },
+          Object {
+            "Cell": [Function],
+            "Header": "1m",
+            "accessor": [Function],
+            "id": "1m",
+          },
+          Object {
+            "Cell": [Function],
+            "Header": "5m",
+            "accessor": [Function],
+            "id": "5m",
+          },
+          Object {
+            "Cell": [Function],
+            "Header": "15m",
+            "accessor": [Function],
+            "id": "15m",
+          },
+        ]
+      }
+      data={
+        Array [
+          Object {
+            "summary": Object {
+              "movingAverages": Object {
+                "buildSegments": Object {
+                  "15m": Object {
+                    "processed": 5.544749689510444,
+                    "processedWithError": 0,
+                    "thrownAway": 0,
+                    "unparseable": 0,
+                  },
+                  "1m": Object {
+                    "processed": 4.593670088770785,
+                    "processedWithError": 0,
+                    "thrownAway": 0,
+                    "unparseable": 0,
+                  },
+                  "5m": Object {
+                    "processed": 3.5455993615040584,
+                    "processedWithError": 0,
+                    "thrownAway": 0,
+                    "unparseable": 0,
+                  },
+                },
+              },
+              "totals": Object {
+                "buildSegments": Object {
+                  "processed": 7516,
+                  "processedWithError": 0,
+                  "thrownAway": 0,
+                  "unparseable": 0,
+                },
+              },
+            },
+            "taskId": "index_kafka_github_dfde87f265a8cc9_pnmcaldn",
+          },
+        ]
+      }
+      defaultExpanded={Object {}}
+      defaultFilterMethod={[Function]}
+      defaultFiltered={Array []}
+      defaultPage={0}
+      defaultPageSize={6}
+      defaultResized={Array []}
+      defaultSortDesc={false}
+      defaultSortMethod={[Function]}
+      defaultSorted={Array []}
+      expanderDefaults={
+        Object {
+          "filterable": false,
+          "resizable": false,
+          "sortable": false,
+          "width": 35,
+        }
+      }
+      filterable={false}
+      freezeWhenExpanded={false}
+      getLoadingProps={[Function]}
+      getNoDataProps={[Function]}
+      getPaginationProps={[Function]}
+      getProps={[Function]}
+      getResizerProps={[Function]}
+      getTableProps={[Function]}
+      getTbodyProps={[Function]}
+      getTdProps={[Function]}
+      getTfootProps={[Function]}
+      getTfootTdProps={[Function]}
+      getTfootTrProps={[Function]}
+      getTheadFilterProps={[Function]}
+      getTheadFilterThProps={[Function]}
+      getTheadFilterTrProps={[Function]}
+      getTheadGroupProps={[Function]}
+      getTheadGroupThProps={[Function]}
+      getTheadGroupTrProps={[Function]}
+      getTheadProps={[Function]}
+      getTheadThProps={[Function]}
+      getTheadTrProps={[Function]}
+      getTrGroupProps={[Function]}
+      getTrProps={[Function]}
+      groupedByPivotKey="_groupedByPivot"
+      indexKey="_index"
+      loading={false}
+      loadingText="Loading..."
+      multiSort={true}
+      nestingLevelKey="_nestingLevel"
+      nextText="Next"
+      noDataText="No statistics data found"
+      ofText="of"
+      onFetchData={[Function]}
+      originalKey="_original"
+      pageJumpText="jump to page"
+      pageSizeOptions={
+        Array [
+          5,
+          10,
+          20,
+          25,
+          50,
+          100,
+        ]
+      }
+      pageText="Page"
+      pivotDefaults={Object {}}
+      pivotIDKey="_pivotID"
+      pivotValKey="_pivotVal"
+      previousText="Previous"
+      resizable={true}
+      resolveData={[Function]}
+      rowsSelectorText="rows per page"
+      rowsText="rows"
+      showPageJump={true}
+      showPageSizeOptions={true}
+      showPagination={false}
+      showPaginationBottom={true}
+      showPaginationTop={false}
+      sortable={true}
+      style={Object {}}
+      subRowsKey="_subRows"
+    />
   </div>
 </div>
 `;
diff --git a/web-console/src/components/supervisor-statistics-table/supervisor-statistics-table.spec.tsx b/web-console/src/components/supervisor-statistics-table/supervisor-statistics-table.spec.tsx
index 5f41522..baf3b76 100644
--- a/web-console/src/components/supervisor-statistics-table/supervisor-statistics-table.spec.tsx
+++ b/web-console/src/components/supervisor-statistics-table/supervisor-statistics-table.spec.tsx
@@ -16,15 +16,93 @@
  * limitations under the License.
  */
 
-import { render } from '@testing-library/react';
+import { shallow } from 'enzyme';
 import React from 'react';
 
-import { SupervisorStatisticsTable } from './supervisor-statistics-table';
+import { QueryState } from '../../utils';
 
-describe('rule editor', () => {
-  it('matches snapshot', () => {
-    const showJson = <SupervisorStatisticsTable endpoint={'test'} downloadFilename={'test'} />;
-    const { container } = render(showJson);
-    expect(container.firstChild).toMatchSnapshot();
+import {
+  normalizeSupervisorStatisticsResults,
+  SupervisorStatisticsTable,
+  SupervisorStatisticsTableRow,
+} from './supervisor-statistics-table';
+
+let supervisorStatisticsState: QueryState<SupervisorStatisticsTableRow[]> = QueryState.INIT;
+jest.mock('../../hooks', () => {
+  return {
+    useQueryManager: () => [supervisorStatisticsState],
+  };
+});
+
+describe('SupervisorStatisticsTable', () => {
+  function makeSupervisorStatisticsTable() {
+    return <SupervisorStatisticsTable supervisorId="sup-id" downloadFilename={'test'} />;
+  }
+
+  it('matches snapshot on init', () => {
+    expect(shallow(makeSupervisorStatisticsTable())).toMatchSnapshot();
+  });
+
+  it('matches snapshot on loading', () => {
+    supervisorStatisticsState = QueryState.LOADING;
+
+    expect(shallow(makeSupervisorStatisticsTable())).toMatchSnapshot();
+  });
+
+  it('matches snapshot on error', () => {
+    supervisorStatisticsState = new QueryState({ error: new Error('test error') });
+
+    expect(shallow(makeSupervisorStatisticsTable())).toMatchSnapshot();
+  });
+
+  it('matches snapshot on no data', () => {
+    supervisorStatisticsState = new QueryState({
+      data: normalizeSupervisorStatisticsResults({}),
+    });
+
+    expect(shallow(makeSupervisorStatisticsTable())).toMatchSnapshot();
+  });
+
+  it('matches snapshot on some data', () => {
+    supervisorStatisticsState = new QueryState({
+      data: normalizeSupervisorStatisticsResults({
+        '0': {
+          index_kafka_github_dfde87f265a8cc9_pnmcaldn: {
+            movingAverages: {
+              buildSegments: {
+                '5m': {
+                  processed: 3.5455993615040584,
+                  unparseable: 0,
+                  thrownAway: 0,
+                  processedWithError: 0,
+                },
+                '15m': {
+                  processed: 5.544749689510444,
+                  unparseable: 0,
+                  thrownAway: 0,
+                  processedWithError: 0,
+                },
+                '1m': {
+                  processed: 4.593670088770785,
+                  unparseable: 0,
+                  thrownAway: 0,
+                  processedWithError: 0,
+                },
+              },
+            },
+            totals: {
+              buildSegments: {
+                processed: 7516,
+                processedWithError: 0,
+                thrownAway: 0,
+                unparseable: 0,
+              },
+            },
+          },
+        },
+      }),
+    });
+
+    expect(shallow(makeSupervisorStatisticsTable())).toMatchSnapshot();
   });
 });
diff --git a/web-console/src/components/supervisor-statistics-table/supervisor-statistics-table.tsx b/web-console/src/components/supervisor-statistics-table/supervisor-statistics-table.tsx
index 6f0cc7d..a370592 100644
--- a/web-console/src/components/supervisor-statistics-table/supervisor-statistics-table.tsx
+++ b/web-console/src/components/supervisor-statistics-table/supervisor-statistics-table.tsx
@@ -19,21 +19,21 @@
 import { Button, ButtonGroup } from '@blueprintjs/core';
 import axios from 'axios';
 import React from 'react';
-import ReactTable, { Column } from 'react-table';
+import ReactTable, { CellInfo, Column } from 'react-table';
 
+import { useQueryManager } from '../../hooks';
 import { UrlBaser } from '../../singletons/url-baser';
-import { QueryManager } from '../../utils';
 import { deepGet } from '../../utils/object-change';
 import { Loader } from '../loader/loader';
 
 import './supervisor-statistics-table.scss';
 
-interface TaskSummary {
+export interface TaskSummary {
   totals: Record<string, StatsEntry>;
   movingAverages: Record<string, Record<string, StatsEntry>>;
 }
 
-interface StatsEntry {
+export interface StatsEntry {
   processed?: number;
   processedWithError?: number;
   thrownAway?: number;
@@ -41,71 +41,49 @@ interface StatsEntry {
   [key: string]: number | undefined;
 }
 
-interface TableRow {
+export interface SupervisorStatisticsTableRow {
   taskId: string;
   summary: TaskSummary;
 }
 
-export interface SupervisorStatisticsTableProps {
-  endpoint: string;
-  downloadFilename?: string;
+export function normalizeSupervisorStatisticsResults(
+  data: Record<string, Record<string, TaskSummary>>,
+): SupervisorStatisticsTableRow[] {
+  return Object.values(data).flatMap(v => Object.keys(v).map(k => ({ taskId: k, summary: v[k] })));
 }
 
-export interface SupervisorStatisticsTableState {
-  data?: TableRow[];
-  loading: boolean;
-  error?: string;
+export interface SupervisorStatisticsTableProps {
+  supervisorId: string;
+  downloadFilename?: string;
 }
 
-export class SupervisorStatisticsTable extends React.PureComponent<
-  SupervisorStatisticsTableProps,
-  SupervisorStatisticsTableState
-> {
-  private supervisorStatisticsQueryManager: QueryManager<null, TableRow[]>;
-
-  constructor(props: SupervisorStatisticsTableProps, context: any) {
-    super(props, context);
-    this.state = {
-      loading: true,
-    };
-
-    this.supervisorStatisticsQueryManager = new QueryManager({
-      processQuery: async () => {
-        const { endpoint } = this.props;
-        const resp = await axios.get(endpoint);
-        const data: Record<string, Record<string, TaskSummary>> = resp.data;
-
-        return Object.values(data).flatMap(v =>
-          Object.keys(v).map(k => ({ taskId: k, summary: v[k] })),
-        );
-      },
-      onStateChange: ({ result, loading, error }) => {
-        this.setState({
-          data: result,
-          error,
-          loading,
-        });
-      },
-    });
-  }
-
-  componentDidMount(): void {
-    this.supervisorStatisticsQueryManager.runQuery(null);
-  }
-
-  renderCell(data: StatsEntry | undefined) {
-    if (!data) {
+export const SupervisorStatisticsTable = React.memo(function SupervisorStatisticsTable(
+  props: SupervisorStatisticsTableProps,
+) {
+  const { supervisorId } = props;
+  const endpoint = `/druid/indexer/v1/supervisor/${supervisorId}/stats`;
+
+  const [supervisorStatisticsState] = useQueryManager<null, SupervisorStatisticsTableRow[]>({
+    processQuery: async () => {
+      const resp = await axios.get(endpoint);
+      return normalizeSupervisorStatisticsResults(resp.data);
+    },
+    initQuery: null,
+  });
+
+  function renderCell(cell: CellInfo) {
+    const cellValue = cell.value;
+    if (!cellValue) {
       return <div>No data found</div>;
     }
-    return Object.keys(data)
+
+    return Object.keys(cellValue)
       .sort()
-      .map(key => <div key={key}>{`${key}: ${Number(data[key]).toFixed(1)}`}</div>);
+      .map(key => <div key={key}>{`${key}: ${Number(cellValue[key]).toFixed(1)}`}</div>);
   }
 
-  renderTable(error?: string) {
-    const { data } = this.state;
-
-    let columns: Column<TableRow>[] = [
+  function renderTable() {
+    let columns: Column<SupervisorStatisticsTableRow>[] = [
       {
         Header: 'Task ID',
         id: 'task_id',
@@ -117,14 +95,12 @@ export class SupervisorStatisticsTable extends React.PureComponent<
         accessor: d => {
           return deepGet(d, 'summary.totals.buildSegments') as StatsEntry;
         },
-        Cell: d => {
-          return this.renderCell(d.value ? d.value : undefined);
-        },
+        Cell: renderCell,
       },
     ];
 
     const movingAveragesBuildSegments = deepGet(
-      data as any,
+      supervisorStatisticsState.data as any,
       '0.summary.movingAverages.buildSegments',
     );
     if (movingAveragesBuildSegments) {
@@ -132,16 +108,14 @@ export class SupervisorStatisticsTable extends React.PureComponent<
         Object.keys(movingAveragesBuildSegments)
           .sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
           .map(
-            (interval: string): Column<TableRow> => {
+            (interval: string): Column<SupervisorStatisticsTableRow> => {
               return {
                 Header: interval,
                 id: interval,
                 accessor: d => {
                   return deepGet(d, `summary.movingAverages.buildSegments.${interval}`);
                 },
-                Cell: d => {
-                  return this.renderCell(d.value ? d.value : null);
-                },
+                Cell: renderCell,
               };
             },
           ),
@@ -150,34 +124,30 @@ export class SupervisorStatisticsTable extends React.PureComponent<
 
     return (
       <ReactTable
-        data={this.state.data ? this.state.data : []}
+        data={supervisorStatisticsState.data || []}
         showPagination={false}
         defaultPageSize={6}
         columns={columns}
-        noDataText={error ? error : 'No statistics data found'}
+        noDataText={supervisorStatisticsState.getErrorMessage() || 'No statistics data found'}
       />
     );
   }
 
-  render(): JSX.Element {
-    const { endpoint } = this.props;
-    const { loading, error } = this.state;
-    return (
-      <div className="supervisor-statistics-table">
-        <div className="top-actions">
-          <ButtonGroup className="right-buttons">
-            <Button
-              text="View raw"
-              disabled={loading}
-              minimal
-              onClick={() => window.open(UrlBaser.base(endpoint), '_blank')}
-            />
-          </ButtonGroup>
-        </div>
-        <div className="main-area">
-          {loading ? <Loader loadingText="" loading /> : this.renderTable(error)}
-        </div>
+  return (
+    <div className="supervisor-statistics-table">
+      <div className="top-actions">
+        <ButtonGroup className="right-buttons">
+          <Button
+            text="View raw"
+            disabled={supervisorStatisticsState.loading}
+            minimal
+            onClick={() => window.open(UrlBaser.base(endpoint), '_blank')}
+          />
+        </ButtonGroup>
       </div>
-    );
-  }
-}
+      <div className="main-area">
+        {supervisorStatisticsState.loading ? <Loader /> : renderTable()}
+      </div>
+    </div>
+  );
+});
diff --git a/web-console/src/components/timed-button/timed-button.spec.tsx b/web-console/src/components/timed-button/timed-button.spec.tsx
index 2de4811..e5025fb 100644
--- a/web-console/src/components/timed-button/timed-button.spec.tsx
+++ b/web-console/src/components/timed-button/timed-button.spec.tsx
@@ -25,10 +25,10 @@ describe('Timed button', () => {
   it('matches snapshot', () => {
     const timedButton = (
       <TimedButton
-        intervals={[{ label: 'timeValue', value: 1000 }]}
+        delays={[{ label: 'timeValue', delay: 1000 }]}
         onRefresh={() => null}
         label={'label'}
-        defaultValue={1000}
+        defaultDelay={1000}
       />
     );
     const { container } = render(timedButton);
diff --git a/web-console/src/components/timed-button/timed-button.tsx b/web-console/src/components/timed-button/timed-button.tsx
index 3da95d2..78a0765 100644
--- a/web-console/src/components/timed-button/timed-button.tsx
+++ b/web-console/src/components/timed-button/timed-button.tsx
@@ -18,114 +18,76 @@
 
 import { Button, ButtonGroup, IButtonProps, Popover, Radio, RadioGroup } from '@blueprintjs/core';
 import { IconNames } from '@blueprintjs/icons';
-import React from 'react';
+import React, { useState } from 'react';
 
+import { useInterval } from '../../hooks';
 import { localStorageGet, LocalStorageKeys, localStorageSet } from '../../utils';
 
 import './timed-button.scss';
 
-export interface Interval {
+export interface DelayLabel {
   label: string;
-  value: number;
+  delay: number;
 }
 
 export interface TimedButtonProps extends IButtonProps {
-  intervals: Interval[];
+  delays: DelayLabel[];
   onRefresh: (auto: boolean) => void;
   localStorageKey?: LocalStorageKeys;
   label: string;
-  defaultValue: number;
+  defaultDelay: number;
 }
 
-export interface TimedButtonState {
-  interval: number;
-}
-
-export class TimedButton extends React.PureComponent<TimedButtonProps, TimedButtonState> {
-  constructor(props: TimedButtonProps, context: any) {
-    super(props, context);
-    this.state = {
-      interval:
-        this.props.localStorageKey && localStorageGet(this.props.localStorageKey)
-          ? Number(localStorageGet(this.props.localStorageKey))
-          : this.props.defaultValue,
-    };
-  }
+export const TimedButton = React.memo(function TimedButton(props: TimedButtonProps) {
+  const {
+    label,
+    delays,
+    onRefresh,
+    type,
+    text,
+    icon,
+    defaultDelay,
+    localStorageKey,
+    ...other
+  } = props;
 
-  private timer: any;
+  const [delay, setDelay] = useState(
+    localStorageKey && localStorageGet(localStorageKey)
+      ? Number(localStorageGet(localStorageKey))
+      : defaultDelay,
+  );
 
-  componentDidMount(): void {
-    if (this.state.interval) {
-      this.timer = setTimeout(() => {
-        this.continuousRefresh(this.state.interval);
-      }, this.state.interval);
-    }
-  }
-
-  componentWillUnmount(): void {
-    this.clearTimer();
-  }
+  useInterval(() => {
+    onRefresh(true);
+  }, delay);
 
-  clearTimer() {
-    if (this.timer) {
-      clearTimeout(this.timer);
+  function handleSelection(e: any) {
+    const selectedDelay = Number(e.currentTarget.value);
+    setDelay(selectedDelay);
+    if (localStorageKey) {
+      localStorageSet(localStorageKey, String(selectedDelay));
     }
-    this.timer = undefined;
   }
 
-  continuousRefresh = (selectedInterval: number) => {
-    if (selectedInterval) {
-      this.timer = setTimeout(() => {
-        this.props.onRefresh(true);
-        this.continuousRefresh(selectedInterval);
-      }, selectedInterval);
-    }
-  };
-
-  handleSelection = (e: any) => {
-    const selectedInterval = Number(e.currentTarget.value);
-    this.clearTimer();
-    this.setState({ interval: selectedInterval });
-    if (this.props.localStorageKey) {
-      localStorageSet(this.props.localStorageKey, String(selectedInterval));
-    }
-    this.continuousRefresh(selectedInterval);
-  };
-
-  render(): JSX.Element {
-    const {
-      label,
-      intervals,
-      onRefresh,
-      type,
-      text,
-      icon,
-      defaultValue,
-      localStorageKey,
-      ...other
-    } = this.props;
-    const { interval } = this.state;
-
-    return (
-      <ButtonGroup>
-        <Button {...other} text={text} icon={icon} onClick={() => onRefresh(false)} />
-        <Popover
-          content={
-            <RadioGroup
-              label={label}
-              className="timed-button"
-              onChange={this.handleSelection}
-              selectedValue={interval}
-            >
-              {intervals.map((interval: any) => (
-                <Radio label={interval.label} value={interval.value} key={interval.label} />
-              ))}
-            </RadioGroup>
-          }
-        >
-          <Button {...other} rightIcon={IconNames.CARET_DOWN} />
-        </Popover>
-      </ButtonGroup>
-    );
-  }
-}
+  return (
+    <ButtonGroup>
+      <Button {...other} text={text} icon={icon} onClick={() => onRefresh(false)} />
+      <Popover
+        content={
+          <RadioGroup
+            label={label}
+            className="timed-button"
+            onChange={handleSelection}
+            selectedValue={delay}
+          >
+            {delays.map(({ label, delay }) => (
+              <Radio label={label} value={delay} key={label} />
+            ))}
+          </RadioGroup>
+        }
+      >
+        <Button {...other} rightIcon={IconNames.CARET_DOWN} />
+      </Popover>
+    </ButtonGroup>
+  );
+});
diff --git a/web-console/src/console-application.scss b/web-console/src/console-application.scss
index 8474158..a0dd2d4 100644
--- a/web-console/src/console-application.scss
+++ b/web-console/src/console-application.scss
@@ -19,10 +19,6 @@
 @import './variables';
 
 .console-application {
-  position: absolute;
-  height: 100%;
-  width: 100%;
-
   .view-container {
     position: absolute;
     top: $header-bar-height;
diff --git a/web-console/src/console-application.tsx b/web-console/src/console-application.tsx
index 4791c31..6094148 100644
--- a/web-console/src/console-application.tsx
+++ b/web-console/src/console-application.tsx
@@ -92,9 +92,9 @@ export class ConsoleApplication extends React.PureComponent<
         if (!capabilities) ConsoleApplication.shownNotifications();
         return capabilities || Capabilities.FULL;
       },
-      onStateChange: ({ result, loading }) => {
+      onStateChange: ({ data, loading }) => {
         this.setState({
-          capabilities: result || Capabilities.FULL,
+          capabilities: data || Capabilities.FULL,
           capabilitiesLoading: loading,
         });
       },
@@ -283,7 +283,7 @@ export class ConsoleApplication extends React.PureComponent<
     if (capabilitiesLoading) {
       return (
         <div className="loading-capabilities">
-          <Loader loadingText="" loading />
+          <Loader />
         </div>
       );
     }
diff --git a/web-console/src/dialogs/compaction-dialog/compaction-dialog.tsx b/web-console/src/dialogs/compaction-dialog/compaction-dialog.tsx
index 46dc857..85ce37d 100644
--- a/web-console/src/dialogs/compaction-dialog/compaction-dialog.tsx
+++ b/web-console/src/dialogs/compaction-dialog/compaction-dialog.tsx
@@ -17,13 +17,81 @@
  */
 
 import { Button, Classes, Dialog, Intent } from '@blueprintjs/core';
-import React from 'react';
+import React, { useState } from 'react';
 
-import { AutoForm, ExternalLink } from '../../components';
+import { AutoForm, ExternalLink, Field } from '../../components';
 import { getLink } from '../../links';
 
 import './compaction-dialog.scss';
 
+export const DEFAULT_MAX_ROWS_PER_SEGMENT = 5000000;
+
+const COMPACTION_CONFIG_FIELDS: Field<Record<string, any>>[] = [
+  {
+    name: 'inputSegmentSizeBytes',
+    type: 'number',
+    defaultValue: 419430400,
+    info: (
+      <p>
+        Maximum number of total segment bytes processed per compaction task. Since a time chunk must
+        be processed in its entirety, if the segments for a particular time chunk have a total size
+        in bytes greater than this parameter, compaction will not run for that time chunk. Because
+        each compaction task runs with a single thread, setting this value too far above 1–2GB will
+        result in compaction tasks taking an excessive amount of time.
+      </p>
+    ),
+  },
+  {
+    name: 'skipOffsetFromLatest',
+    type: 'string',
+    defaultValue: 'P1D',
+    info: (
+      <p>
+        The offset for searching segments to be compacted. Strongly recommended to set for realtime
+        dataSources.
+      </p>
+    ),
+  },
+  {
+    name: 'maxRowsPerSegment',
+    type: 'number',
+    defaultValue: DEFAULT_MAX_ROWS_PER_SEGMENT,
+    info: <p>Determines how many rows are in each segment.</p>,
+  },
+  {
+    name: 'taskContext',
+    type: 'json',
+    info: (
+      <p>
+        <ExternalLink href={`${getLink('DOCS')}/ingestion/tasks.html#task-context`}>
+          Task context
+        </ExternalLink>{' '}
+        for compaction tasks.
+      </p>
+    ),
+  },
+  {
+    name: 'taskPriority',
+    type: 'number',
+    defaultValue: 25,
+    info: <p>Priority of the compaction task.</p>,
+  },
+  {
+    name: 'tuningConfig',
+    type: 'json',
+    info: (
+      <p>
+        <ExternalLink
+          href={`${getLink('DOCS')}/configuration/index.html#compact-task-tuningconfig`}
+        >
+          Tuning config
+        </ExternalLink>{' '}
+        for compaction tasks.
+      </p>
+    ),
+  },
+];
+
 export interface CompactionDialogProps {
   onClose: () => void;
   onSave: (config: Record<string, any>) => void;
@@ -32,139 +100,50 @@ export interface CompactionDialogProps {
   compactionConfig?: Record<string, any>;
 }
 
-export interface CompactionDialogState {
-  currentConfig?: Record<string, any>;
-}
+export const CompactionDialog = React.memo(function CompactionDialog(props: CompactionDialogProps) {
+  const { datasource, compactionConfig, onSave, onClose, onDelete } = props;
 
-export class CompactionDialog extends React.PureComponent<
-  CompactionDialogProps,
-  CompactionDialogState
-> {
-  static DEFAULT_MAX_ROWS_PER_SEGMENT = 5000000;
+  const [currentConfig, setCurrentConfig] = useState<Record<string, any>>(
+    compactionConfig || {
+      dataSource: datasource,
+    },
+  );
 
-  constructor(props: CompactionDialogProps) {
-    super(props);
-    this.state = {};
-  }
-
-  componentDidMount(): void {
-    const { datasource, compactionConfig } = this.props;
-
-    this.setState({
-      currentConfig: compactionConfig || {
-        dataSource: datasource,
-      },
-    });
-  }
-
-  private handleSubmit = () => {
-    const { onSave } = this.props;
-    const { currentConfig } = this.state;
+  function handleSubmit() {
     if (!currentConfig) return;
-
     onSave(currentConfig);
-  };
-
-  render(): JSX.Element {
-    const { onClose, onDelete, datasource, compactionConfig } = this.props;
-    const { currentConfig } = this.state;
+  }
 
-    return (
-      <Dialog
-        className="compaction-dialog"
-        isOpen
-        onClose={onClose}
-        canOutsideClickClose={false}
-        title={`Compaction config: ${datasource}`}
-      >
-        <AutoForm
-          fields={[
-            {
-              name: 'inputSegmentSizeBytes',
-              type: 'number',
-              defaultValue: 419430400,
-              info: (
-                <p>
-                  Maximum number of total segment bytes processed per compaction task. Since a time
-                  chunk must be processed in its entirety, if the segments for a particular time
-                  chunk have a total size in bytes greater than this parameter, compaction will not
-                  run for that time chunk. Because each compaction task runs with a single thread,
-                  setting this value too far above 1–2GB will result in compaction tasks taking an
-                  excessive amount of time.
-                </p>
-              ),
-            },
-            {
-              name: 'skipOffsetFromLatest',
-              type: 'string',
-              defaultValue: 'P1D',
-              info: (
-                <p>
-                  The offset for searching segments to be compacted. Strongly recommended to set for
-                  realtime dataSources.
-                </p>
-              ),
-            },
-            {
-              name: 'maxRowsPerSegment',
-              type: 'number',
-              defaultValue: CompactionDialog.DEFAULT_MAX_ROWS_PER_SEGMENT,
-              info: <p>Determines how many rows are in each segment.</p>,
-            },
-            {
-              name: 'taskContext',
-              type: 'json',
-              info: (
-                <p>
-                  <ExternalLink href={`${getLink('DOCS')}/ingestion/tasks.html#task-context`}>
-                    Task context
-                  </ExternalLink>{' '}
-                  for compaction tasks.
-                </p>
-              ),
-            },
-            {
-              name: 'taskPriority',
-              type: 'number',
-              defaultValue: 25,
-              info: <p>Priority of the compaction task.</p>,
-            },
-            {
-              name: 'tuningConfig',
-              type: 'json',
-              info: (
-                <p>
-                  <ExternalLink
-                    href={`${getLink('DOCS')}/configuration/index.html#compact-task-tuningconfig`}
-                  >
-                    Tuning config
-                  </ExternalLink>{' '}
-                  for compaction tasks.
-                </p>
-              ),
-            },
-          ]}
-          model={currentConfig}
-          onChange={m => this.setState({ currentConfig: m })}
-        />
-        <div className={Classes.DIALOG_FOOTER}>
-          <div className={Classes.DIALOG_FOOTER_ACTIONS}>
-            <Button
-              text="Delete"
-              intent={Intent.DANGER}
-              onClick={onDelete}
-              disabled={!compactionConfig}
-            />
-            <Button text="Close" onClick={onClose} />
-            <Button
-              text="Submit"
-              intent={Intent.PRIMARY}
-              onClick={this.handleSubmit}
-              disabled={!currentConfig}
-            />
-          </div>
+  return (
+    <Dialog
+      className="compaction-dialog"
+      isOpen
+      onClose={onClose}
+      canOutsideClickClose={false}
+      title={`Compaction config: ${datasource}`}
+    >
+      <AutoForm
+        fields={COMPACTION_CONFIG_FIELDS}
+        model={currentConfig}
+        onChange={m => setCurrentConfig(m)}
+      />
+      <div className={Classes.DIALOG_FOOTER}>
+        <div className={Classes.DIALOG_FOOTER_ACTIONS}>
+          <Button
+            text="Delete"
+            intent={Intent.DANGER}
+            onClick={onDelete}
+            disabled={!compactionConfig}
+          />
+          <Button text="Close" onClick={onClose} />
+          <Button
+            text="Submit"
+            intent={Intent.PRIMARY}
+            onClick={handleSubmit}
+            disabled={!currentConfig}
+          />
         </div>
-      </Dialog>
-    );
-  }
-}
+      </div>
+    </Dialog>
+  );
+});
diff --git a/web-console/src/dialogs/coordinator-dynamic-config-dialog/__snapshots__/coordinator-dynamic-config-dialog.spec.tsx.snap b/web-console/src/dialogs/coordinator-dynamic-config-dialog/__snapshots__/coordinator-dynamic-config-dialog.spec.tsx.snap
index ecc7bca..dc3cdac 100644
--- a/web-console/src/dialogs/coordinator-dynamic-config-dialog/__snapshots__/coordinator-dynamic-config-dialog.spec.tsx.snap
+++ b/web-console/src/dialogs/coordinator-dynamic-config-dialog/__snapshots__/coordinator-dynamic-config-dialog.spec.tsx.snap
@@ -1,114 +1,174 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
 exports[`coordinator dynamic config matches snapshot 1`] = `
-<div
-  class="bp3-portal"
+<SnitchDialog
+  className="coordinator-dynamic-config-dialog"
+  onClose={[Function]}
+  onSave={[Function]}
+  title="Coordinator dynamic config"
 >
-  <div
-    class="bp3-overlay bp3-overlay-open bp3-overlay-scroll-container"
-  >
-    <div
-      class="bp3-overlay-backdrop bp3-overlay-appear bp3-overlay-appear-active"
-    />
-    <div
-      class="bp3-dialog-container bp3-overlay-content bp3-overlay-appear bp3-overlay-appear-active"
-      tabindex="0"
+  <p>
+    Edit the coordinator dynamic configuration on the fly. For more information please refer to the
+     
+    <Memo(ExternalLink)
+      href="https://druid.apache.org/docs/0.19.0/configuration/index.html#dynamic-configuration"
     >
-      <div
-        class="bp3-dialog snitch-dialog coordinator-dynamic-config-dialog"
-      >
-        <div
-          class="bp3-dialog-header"
-        >
-          <h4
-            class="bp3-heading"
-          >
-            Coordinator dynamic config
-          </h4>
-          <button
-            aria-label="Close"
-            class="bp3-button bp3-minimal bp3-dialog-close-button"
-            type="button"
-          >
-            <span
-              class="bp3-icon bp3-icon-small-cross"
-              icon="small-cross"
-            >
-              <svg
-                data-icon="small-cross"
-                height="20"
-                viewBox="0 0 20 20"
-                width="20"
-              >
-                <desc>
-                  small-cross
-                </desc>
-                <path
-                  d="M11.41 10l3.29-3.29c.19-.18.3-.43.3-.71a1.003 1.003 0 00-1.71-.71L10 8.59l-3.29-3.3a1.003 1.003 0 00-1.42 1.42L8.59 10 5.3 13.29c-.19.18-.3.43-.3.71a1.003 1.003 0 001.71.71l3.29-3.3 3.29 3.29c.18.19.43.3.71.3a1.003 1.003 0 00.71-1.71L11.41 10z"
-                  fill-rule="evenodd"
-                />
-              </svg>
-            </span>
-          </button>
-        </div>
-        <div
-          class="bp3-dialog-body"
-        >
-          <p>
-            Edit the coordinator dynamic configuration on the fly. For more information please refer to the
+      documentation
+    </Memo(ExternalLink)>
+    .
+  </p>
+  <AutoForm
+    fields={
+      Array [
+        Object {
+          "defaultValue": 5,
+          "info": <React.Fragment>
+            The maximum number of segments that can be moved at any given time.
+          </React.Fragment>,
+          "name": "maxSegmentsToMove",
+          "type": "number",
+        },
+        Object {
+          "defaultValue": 1,
+          "info": <React.Fragment>
+            Thread pool size for computing moving cost of segments in segment balancing. Consider increasing this if you have a lot of segments and moving segments starts to get stuck.
+          </React.Fragment>,
+          "name": "balancerComputeThreads",
+          "type": "number",
+        },
+        Object {
+          "defaultValue": false,
+          "info": <React.Fragment>
+            Boolean flag for whether or not we should emit balancing stats. This is an expensive operation.
+          </React.Fragment>,
+          "name": "emitBalancingStats",
+          "type": "boolean",
+        },
+        Object {
+          "defaultValue": false,
+          "info": <React.Fragment>
+            Send kill tasks for ALL dataSources if property
              
-            <a
-              href="https://druid.apache.org/docs/0.19.0/configuration/index.html#dynamic-configuration"
-              rel="noopener noreferrer"
-              target="_blank"
-            >
-              documentation
-            </a>
+            <Unknown>
+              druid.coordinator.kill.on
+            </Unknown>
+             is true. If this is set to true then
+             
+            <Unknown>
+              killDataSourceWhitelist
+            </Unknown>
+             must not be specified or be empty list.
+          </React.Fragment>,
+          "name": "killAllDataSources",
+          "type": "boolean",
+        },
+        Object {
+          "emptyValue": Array [],
+          "info": <React.Fragment>
+            List of dataSources for which kill tasks are sent if property
+             
+            <Unknown>
+              druid.coordinator.kill.on
+            </Unknown>
+             is true. This can be a list of comma-separated dataSources or a JSON array.
+          </React.Fragment>,
+          "name": "killDataSourceWhitelist",
+          "type": "string-array",
+        },
+        Object {
+          "emptyValue": Array [],
+          "info": <React.Fragment>
+            List of dataSources for which pendingSegments are NOT cleaned up if property
+             
+            <Unknown>
+              druid.coordinator.kill.pendingSegments.on
+            </Unknown>
+             is true. This can be a list of comma-separated dataSources or a JSON array.
+          </React.Fragment>,
+          "name": "killPendingSegmentsSkipList",
+          "type": "string-array",
+        },
+        Object {
+          "defaultValue": 0,
+          "info": <React.Fragment>
+            The maximum number of segments that could be queued for loading to any given server. This parameter could be used to speed up segments loading process, especially if there are "slow" nodes in the cluster (with low loading speed) or if too much segments scheduled to be replicated to some particular node (faster loading could be preferred to better segments distribution). Desired value depends on segments loading speed, acceptable replication time and number of nodes. Value 100 [...]
+          </React.Fragment>,
+          "name": "maxSegmentsInNodeLoadingQueue",
+          "type": "number",
+        },
+        Object {
+          "defaultValue": 524288000,
+          "info": <React.Fragment>
+            The maximum total uncompressed size in bytes of segments to merge.
+          </React.Fragment>,
+          "name": "mergeBytesLimit",
+          "type": "size-bytes",
+        },
+        Object {
+          "defaultValue": 100,
+          "info": <React.Fragment>
+            The maximum number of segments that can be in a single append task.
+          </React.Fragment>,
+          "name": "mergeSegmentsLimit",
+          "type": "number",
+        },
+        Object {
+          "defaultValue": 900000,
+          "info": <React.Fragment>
+            How long does the Coordinator need to be active before it can start removing (marking unused) segments in metadata storage.
+          </React.Fragment>,
+          "name": "millisToWaitBeforeDeleting",
+          "type": "number",
+        },
+        Object {
+          "defaultValue": 15,
+          "info": <React.Fragment>
+            The maximum number of Coordinator runs for a segment to be replicated before we start alerting.
+          </React.Fragment>,
+          "name": "replicantLifetime",
+          "type": "number",
+        },
+        Object {
+          "defaultValue": 10,
+          "info": <React.Fragment>
+            The maximum number of segments that can be replicated at one time.
+          </React.Fragment>,
+          "name": "replicationThrottleLimit",
+          "type": "number",
+        },
+        Object {
+          "emptyValue": Array [],
+          "info": <React.Fragment>
+            List of historical services to 'decommission'. Coordinator will not assign new segments to 'decommissioning' services, and segments will be moved away from them to be placed on non-decommissioning services at the maximum rate specified by
+             
+            <Unknown>
+              decommissioningMaxPercentOfMaxSegmentsToMove
+            </Unknown>
             .
-          </p>
-          <div
-            class="auto-form"
-          />
-        </div>
-        <div
-          class="bp3-dialog-footer"
-        >
-          <div
-            class="bp3-dialog-footer-actions"
-          >
-            <button
-              class="bp3-button bp3-intent-primary"
-              type="button"
-            >
-              <span
-                class="bp3-button-text"
-              >
-                Next
-              </span>
-              <span
-                class="bp3-icon bp3-icon-arrow-right"
-                icon="arrow-right"
-              >
-                <svg
-                  data-icon="arrow-right"
-                  height="16"
-                  viewBox="0 0 16 16"
-                  width="16"
-                >
-                  <desc>
-                    arrow-right
-                  </desc>
-                  <path
-                    d="M14.7 7.29l-5-5a.965.965 0 00-.71-.3 1.003 1.003 0 00-.71 1.71l3.29 3.29H1.99c-.55 0-1 .45-1 1s.45 1 1 1h9.59l-3.29 3.29a1.003 1.003 0 001.42 1.42l5-5c.18-.18.29-.43.29-.71s-.12-.52-.3-.7z"
-                    fill-rule="evenodd"
-                  />
-                </svg>
-              </span>
-            </button>
-          </div>
-        </div>
-      </div>
-    </div>
-  </div>
-</div>
+          </React.Fragment>,
+          "name": "decommissioningNodes",
+          "type": "string-array",
+        },
+        Object {
+          "defaultValue": 70,
+          "info": <React.Fragment>
+            The maximum number of segments that may be moved away from 'decommissioning' services to non-decommissioning (that is, active) services during one Coordinator run. This value is relative to the total maximum segment movements allowed during one run which is determined by 
+            <Unknown>
+              maxSegmentsToMove
+            </Unknown>
+            . If
+            <Unknown>
+              decommissioningMaxPercentOfMaxSegmentsToMove
+            </Unknown>
+             is 0, segments will neither be moved from or to 'decommissioning' services, effectively putting them in a sort of "maintenance" mode that will not participate in balancing or assignment by load rules. Decommissioning can also become stalled if there are no available active services to place the segments. By leveraging the maximum percent of decommissioning segment movements, an operator can prevent active services from overload by prioritizing balancing, or decrease decommis [...]
+          </React.Fragment>,
+          "name": "decommissioningMaxPercentOfMaxSegmentsToMove",
+          "type": "number",
+        },
+      ]
+    }
+    model={Object {}}
+    onChange={[Function]}
+  />
+</SnitchDialog>
 `;
diff --git a/web-console/src/dialogs/coordinator-dynamic-config-dialog/coordinator-dynamic-config-dialog.spec.tsx b/web-console/src/dialogs/coordinator-dynamic-config-dialog/coordinator-dynamic-config-dialog.spec.tsx
index 3b865ea..e2b73ce 100644
--- a/web-console/src/dialogs/coordinator-dynamic-config-dialog/coordinator-dynamic-config-dialog.spec.tsx
+++ b/web-console/src/dialogs/coordinator-dynamic-config-dialog/coordinator-dynamic-config-dialog.spec.tsx
@@ -16,15 +16,15 @@
  * limitations under the License.
  */
 
-import { render } from '@testing-library/react';
+import { shallow } from 'enzyme';
 import React from 'react';
 
 import { CoordinatorDynamicConfigDialog } from './coordinator-dynamic-config-dialog';
 
 describe('coordinator dynamic config', () => {
   it('matches snapshot', () => {
-    const coordinatorDynamicConfig = <CoordinatorDynamicConfigDialog onClose={() => {}} />;
-    render(coordinatorDynamicConfig);
-    expect(document.body.lastChild).toMatchSnapshot();
+    const coordinatorDynamicConfig = shallow(<CoordinatorDynamicConfigDialog onClose={() => {}} />);
+
+    expect(coordinatorDynamicConfig).toMatchSnapshot();
   });
 });
diff --git a/web-console/src/dialogs/coordinator-dynamic-config-dialog/coordinator-dynamic-config-dialog.tsx b/web-console/src/dialogs/coordinator-dynamic-config-dialog/coordinator-dynamic-config-dialog.tsx
index 20b2d79..8478ec6 100644
--- a/web-console/src/dialogs/coordinator-dynamic-config-dialog/coordinator-dynamic-config-dialog.tsx
+++ b/web-console/src/dialogs/coordinator-dynamic-config-dialog/coordinator-dynamic-config-dialog.tsx
@@ -19,13 +19,14 @@
 import { Code, Intent } from '@blueprintjs/core';
 import { IconNames } from '@blueprintjs/icons';
 import axios from 'axios';
-import React from 'react';
+import React, { useState } from 'react';
 
 import { SnitchDialog } from '..';
 import { AutoForm, ExternalLink } from '../../components';
+import { useQueryManager } from '../../hooks';
 import { getLink } from '../../links';
 import { AppToaster } from '../../singletons/toaster';
-import { getDruidErrorMessage, QueryManager } from '../../utils';
+import { getDruidErrorMessage } from '../../utils';
 
 import './coordinator-dynamic-config-dialog.scss';
 
@@ -33,65 +34,42 @@ export interface CoordinatorDynamicConfigDialogProps {
   onClose: () => void;
 }
 
-export interface CoordinatorDynamicConfigDialogState {
-  dynamicConfig?: Record<string, any>;
-  historyRecords: any[];
-}
-
-export class CoordinatorDynamicConfigDialog extends React.PureComponent<
-  CoordinatorDynamicConfigDialogProps,
-  CoordinatorDynamicConfigDialogState
-> {
-  private historyQueryManager: QueryManager<null, any>;
+export const CoordinatorDynamicConfigDialog = React.memo(function CoordinatorDynamicConfigDialog(
+  props: CoordinatorDynamicConfigDialogProps,
+) {
+  const { onClose } = props;
+  const [dynamicConfig, setDynamicConfig] = useState<Record<string, any>>({});
 
-  constructor(props: CoordinatorDynamicConfigDialogProps) {
-    super(props);
-    this.state = {
-      historyRecords: [],
-    };
+  const [historyRecordsState] = useQueryManager<null, any[]>({
+    processQuery: async () => {
+      const historyResp = await axios(`/druid/coordinator/v1/config/history?count=100`);
+      return historyResp.data;
+    },
+    initQuery: null,
+  });
 
-    this.historyQueryManager = new QueryManager({
-      processQuery: async () => {
-        const historyResp = await axios(`/druid/coordinator/v1/config/history?count=100`);
-        return historyResp.data;
-      },
-      onStateChange: ({ result }) => {
-        this.setState({
-          historyRecords: result,
+  useQueryManager<null, Record<string, any>>({
+    processQuery: async () => {
+      try {
+        const configResp = await axios.get('/druid/coordinator/v1/config');
+        setDynamicConfig(configResp.data);
+      } catch (e) {
+        AppToaster.show({
+          icon: IconNames.ERROR,
+          intent: Intent.DANGER,
+          message: `Could not load coordinator dynamic config: ${getDruidErrorMessage(e)}`,
         });
-      },
-    });
-  }
-
-  componentDidMount() {
-    this.getClusterConfig();
+        setDynamicConfig({});
+        onClose();
+      }
+      return {};
+    },
+    initQuery: null,
+  });
 
-    this.historyQueryManager.runQuery(null);
-  }
-
-  async getClusterConfig() {
-    let config: Record<string, any> | undefined;
+  async function saveConfig(comment: string) {
     try {
-      const configResp = await axios.get('/druid/coordinator/v1/config');
-      config = configResp.data;
-    } catch (e) {
-      AppToaster.show({
-        icon: IconNames.ERROR,
-        intent: Intent.DANGER,
-        message: `Could not load coordinator dynamic config: ${getDruidErrorMessage(e)}`,
-      });
-      return;
-    }
-    this.setState({
-      dynamicConfig: config,
-    });
-  }
-
-  private saveClusterConfig = async (comment: string) => {
-    const { onClose } = this.props;
-    const newState: any = this.state.dynamicConfig;
-    try {
-      await axios.post('/druid/coordinator/v1/config', newState, {
+      await axios.post('/druid/coordinator/v1/config', dynamicConfig, {
         headers: {
           'X-Druid-Author': 'console',
           'X-Druid-Comment': comment,
@@ -110,191 +88,186 @@ export class CoordinatorDynamicConfigDialog extends React.PureComponent<
       intent: Intent.SUCCESS,
     });
     onClose();
-  };
-
-  render(): JSX.Element {
-    const { onClose } = this.props;
-    const { dynamicConfig, historyRecords } = this.state;
-
-    return (
-      <SnitchDialog
-        className="coordinator-dynamic-config-dialog"
-        onSave={this.saveClusterConfig}
-        onClose={onClose}
-        title="Coordinator dynamic config"
-        historyRecords={historyRecords}
-      >
-        <p>
-          Edit the coordinator dynamic configuration on the fly. For more information please refer
-          to the{' '}
-          <ExternalLink href={`${getLink('DOCS')}/configuration/index.html#dynamic-configuration`}>
-            documentation
-          </ExternalLink>
-          .
-        </p>
-        <AutoForm
-          fields={[
-            {
-              name: 'maxSegmentsToMove',
-              type: 'number',
-              defaultValue: 5,
-              info: <>The maximum number of segments that can be moved at any given time.</>,
-            },
-            {
-              name: 'balancerComputeThreads',
-              type: 'number',
-              defaultValue: 1,
-              info: (
-                <>
-                  Thread pool size for computing moving cost of segments in segment balancing.
-                  Consider increasing this if you have a lot of segments and moving segments starts
-                  to get stuck.
-                </>
-              ),
-            },
-            {
-              name: 'emitBalancingStats',
-              type: 'boolean',
-              defaultValue: false,
-              info: (
-                <>
-                  Boolean flag for whether or not we should emit balancing stats. This is an
-                  expensive operation.
-                </>
-              ),
-            },
-            {
-              name: 'killAllDataSources',
-              type: 'boolean',
-              defaultValue: false,
-              info: (
-                <>
-                  Send kill tasks for ALL dataSources if property{' '}
-                  <Code>druid.coordinator.kill.on</Code> is true. If this is set to true then{' '}
-                  <Code>killDataSourceWhitelist</Code> must not be specified or be empty list.
-                </>
-              ),
-            },
-            {
-              name: 'killDataSourceWhitelist',
-              type: 'string-array',
-              emptyValue: [],
-              info: (
-                <>
-                  List of dataSources for which kill tasks are sent if property{' '}
-                  <Code>druid.coordinator.kill.on</Code> is true. This can be a list of
-                  comma-separated dataSources or a JSON array.
-                </>
-              ),
-            },
-            {
-              name: 'killPendingSegmentsSkipList',
-              type: 'string-array',
-              emptyValue: [],
-              info: (
-                <>
-                  List of dataSources for which pendingSegments are NOT cleaned up if property{' '}
-                  <Code>druid.coordinator.kill.pendingSegments.on</Code> is true. This can be a list
-                  of comma-separated dataSources or a JSON array.
-                </>
-              ),
-            },
-            {
-              name: 'maxSegmentsInNodeLoadingQueue',
-              type: 'number',
-              defaultValue: 0,
-              info: (
-                <>
-                  The maximum number of segments that could be queued for loading to any given
-                  server. This parameter could be used to speed up segments loading process,
-                  especially if there are "slow" nodes in the cluster (with low loading speed) or if
-                  too much segments scheduled to be replicated to some particular node (faster
-                  loading could be preferred to better segments distribution). Desired value depends
-                  on segments loading speed, acceptable replication time and number of nodes. Value
-                  1000 could be a start point for a rather big cluster. Default value is 0 (loading
-                  queue is unbounded)
-                </>
-              ),
-            },
-            {
-              name: 'mergeBytesLimit',
-              type: 'size-bytes',
-              defaultValue: 524288000,
-              info: <>The maximum total uncompressed size in bytes of segments to merge.</>,
-            },
-            {
-              name: 'mergeSegmentsLimit',
-              type: 'number',
-              defaultValue: 100,
-              info: <>The maximum number of segments that can be in a single append task.</>,
-            },
-            {
-              name: 'millisToWaitBeforeDeleting',
-              type: 'number',
-              defaultValue: 900000,
-              info: (
-                <>
-                  How long does the Coordinator need to be active before it can start removing
-                  (marking unused) segments in metadata storage.
-                </>
-              ),
-            },
-            {
-              name: 'replicantLifetime',
-              type: 'number',
-              defaultValue: 15,
-              info: (
-                <>
-                  The maximum number of Coordinator runs for a segment to be replicated before we
-                  start alerting.
-                </>
-              ),
-            },
-            {
-              name: 'replicationThrottleLimit',
-              type: 'number',
-              defaultValue: 10,
-              info: <>The maximum number of segments that can be replicated at one time.</>,
-            },
-            {
-              name: 'decommissioningNodes',
-              type: 'string-array',
-              emptyValue: [],
-              info: (
-                <>
-                  List of historical services to 'decommission'. Coordinator will not assign new
-                  segments to 'decommissioning' services, and segments will be moved away from them
-                  to be placed on non-decommissioning services at the maximum rate specified by{' '}
-                  <Code>decommissioningMaxPercentOfMaxSegmentsToMove</Code>.
-                </>
-              ),
-            },
-            {
-              name: 'decommissioningMaxPercentOfMaxSegmentsToMove',
-              type: 'number',
-              defaultValue: 70,
-              info: (
-                <>
-                  The maximum number of segments that may be moved away from 'decommissioning'
-                  services to non-decommissioning (that is, active) services during one Coordinator
-                  run. This value is relative to the total maximum segment movements allowed during
-                  one run which is determined by <Code>maxSegmentsToMove</Code>. If
-                  <Code>decommissioningMaxPercentOfMaxSegmentsToMove</Code> is 0, segments will
-                  neither be moved from or to 'decommissioning' services, effectively putting them
-                  in a sort of "maintenance" mode that will not participate in balancing or
-                  assignment by load rules. Decommissioning can also become stalled if there are no
-                  available active services to place the segments. By leveraging the maximum percent
-                  of decommissioning segment movements, an operator can prevent active services from
-                  overload by prioritizing balancing, or decrease decommissioning time instead. The
-                  value should be between 0 and 100.
-                </>
-              ),
-            },
-          ]}
-          model={dynamicConfig}
-          onChange={m => this.setState({ dynamicConfig: m })}
-        />
-      </SnitchDialog>
-    );
   }
-}
+
+  return (
+    <SnitchDialog
+      className="coordinator-dynamic-config-dialog"
+      onSave={saveConfig}
+      onClose={onClose}
+      title="Coordinator dynamic config"
+      historyRecords={historyRecordsState.data}
+    >
+      <p>
+        Edit the coordinator dynamic configuration on the fly. For more information please refer to
+        the{' '}
+        <ExternalLink href={`${getLink('DOCS')}/configuration/index.html#dynamic-configuration`}>
+          documentation
+        </ExternalLink>
+        .
+      </p>
+      <AutoForm
+        fields={[
+          {
+            name: 'maxSegmentsToMove',
+            type: 'number',
+            defaultValue: 5,
+            info: <>The maximum number of segments that can be moved at any given time.</>,
+          },
+          {
+            name: 'balancerComputeThreads',
+            type: 'number',
+            defaultValue: 1,
+            info: (
+              <>
+                Thread pool size for computing moving cost of segments in segment balancing.
+                Consider increasing this if you have a lot of segments and moving segments starts to
+                get stuck.
+              </>
+            ),
+          },
+          {
+            name: 'emitBalancingStats',
+            type: 'boolean',
+            defaultValue: false,
+            info: (
+              <>
+                Boolean flag for whether or not we should emit balancing stats. This is an expensive
+                operation.
+              </>
+            ),
+          },
+          {
+            name: 'killAllDataSources',
+            type: 'boolean',
+            defaultValue: false,
+            info: (
+              <>
+                Send kill tasks for ALL dataSources if property{' '}
+                <Code>druid.coordinator.kill.on</Code> is true. If this is set to true then{' '}
+                <Code>killDataSourceWhitelist</Code> must not be specified or be empty list.
+              </>
+            ),
+          },
+          {
+            name: 'killDataSourceWhitelist',
+            type: 'string-array',
+            emptyValue: [],
+            info: (
+              <>
+                List of dataSources for which kill tasks are sent if property{' '}
+                <Code>druid.coordinator.kill.on</Code> is true. This can be a list of
+                comma-separated dataSources or a JSON array.
+              </>
+            ),
+          },
+          {
+            name: 'killPendingSegmentsSkipList',
+            type: 'string-array',
+            emptyValue: [],
+            info: (
+              <>
+                List of dataSources for which pendingSegments are NOT cleaned up if property{' '}
+                <Code>druid.coordinator.kill.pendingSegments.on</Code> is true. This can be a list
+                of comma-separated dataSources or a JSON array.
+              </>
+            ),
+          },
+          {
+            name: 'maxSegmentsInNodeLoadingQueue',
+            type: 'number',
+            defaultValue: 0,
+            info: (
+              <>
+                The maximum number of segments that could be queued for loading to any given server.
+                This parameter could be used to speed up segments loading process, especially if
+                there are "slow" nodes in the cluster (with low loading speed) or if too much
+                segments scheduled to be replicated to some particular node (faster loading could be
+                preferred to better segments distribution). Desired value depends on segments
+                loading speed, acceptable replication time and number of nodes. Value 1000 could be
+                a start point for a rather big cluster. Default value is 0 (loading queue is
+                unbounded)
+              </>
+            ),
+          },
+          {
+            name: 'mergeBytesLimit',
+            type: 'size-bytes',
+            defaultValue: 524288000,
+            info: <>The maximum total uncompressed size in bytes of segments to merge.</>,
+          },
+          {
+            name: 'mergeSegmentsLimit',
+            type: 'number',
+            defaultValue: 100,
+            info: <>The maximum number of segments that can be in a single append task.</>,
+          },
+          {
+            name: 'millisToWaitBeforeDeleting',
+            type: 'number',
+            defaultValue: 900000,
+            info: (
+              <>
+                How long does the Coordinator need to be active before it can start removing
+                (marking unused) segments in metadata storage.
+              </>
+            ),
+          },
+          {
+            name: 'replicantLifetime',
+            type: 'number',
+            defaultValue: 15,
+            info: (
+              <>
+                The maximum number of Coordinator runs for a segment to be replicated before we
+                start alerting.
+              </>
+            ),
+          },
+          {
+            name: 'replicationThrottleLimit',
+            type: 'number',
+            defaultValue: 10,
+            info: <>The maximum number of segments that can be replicated at one time.</>,
+          },
+          {
+            name: 'decommissioningNodes',
+            type: 'string-array',
+            emptyValue: [],
+            info: (
+              <>
+                List of historical services to 'decommission'. Coordinator will not assign new
+                segments to 'decommissioning' services, and segments will be moved away from them to
+                be placed on non-decommissioning services at the maximum rate specified by{' '}
+                <Code>decommissioningMaxPercentOfMaxSegmentsToMove</Code>.
+              </>
+            ),
+          },
+          {
+            name: 'decommissioningMaxPercentOfMaxSegmentsToMove',
+            type: 'number',
+            defaultValue: 70,
+            info: (
+              <>
+                The maximum number of segments that may be moved away from 'decommissioning'
+                services to non-decommissioning (that is, active) services during one Coordinator
+                run. This value is relative to the total maximum segment movements allowed during
+                one run which is determined by <Code>maxSegmentsToMove</Code>. If
+                <Code>decommissioningMaxPercentOfMaxSegmentsToMove</Code> is 0, segments will
+                neither be moved from or to 'decommissioning' services, effectively putting them in
+                a sort of "maintenance" mode that will not participate in balancing or assignment by
+                load rules. Decommissioning can also become stalled if there are no available active
+                services to place the segments. By leveraging the maximum percent of decommissioning
+                segment movements, an operator can prevent active services from overload by
+                prioritizing balancing, or decrease decommissioning time instead. The value should
+                be between 0 and 100.
+              </>
+            ),
+          },
+        ]}
+        model={dynamicConfig}
+        onChange={m => setDynamicConfig(m)}
+      />
+    </SnitchDialog>
+  );
+});
diff --git a/web-console/src/dialogs/lookup-edit-dialog/lookup-edit-dialog.tsx b/web-console/src/dialogs/lookup-edit-dialog/lookup-edit-dialog.tsx
index 513bd8d..f9613f8 100644
--- a/web-console/src/dialogs/lookup-edit-dialog/lookup-edit-dialog.tsx
+++ b/web-console/src/dialogs/lookup-edit-dialog/lookup-edit-dialog.tsx
@@ -72,6 +72,7 @@ export interface LookupSpec {
   firstCacheTimeout?: number;
   injective?: boolean;
 }
+
 export interface LookupEditDialogProps {
   onClose: () => void;
   onSubmit: (updateLookupVersion: boolean) => void;
diff --git a/web-console/src/dialogs/overlord-dynamic-config-dialog/__snapshots__/overload-dynamic-config-dialog.spec.tsx.snap b/web-console/src/dialogs/overlord-dynamic-config-dialog/__snapshots__/overload-dynamic-config-dialog.spec.tsx.snap
index c782312..a5e42b2 100644
--- a/web-console/src/dialogs/overlord-dynamic-config-dialog/__snapshots__/overload-dynamic-config-dialog.spec.tsx.snap
+++ b/web-console/src/dialogs/overlord-dynamic-config-dialog/__snapshots__/overload-dynamic-config-dialog.spec.tsx.snap
@@ -1,114 +1,37 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
 exports[`overload dynamic config matches snapshot 1`] = `
-<div
-  class="bp3-portal"
+<SnitchDialog
+  className="overlord-dynamic-config-dialog"
+  onClose={[Function]}
+  onSave={[Function]}
+  title="Overlord dynamic config"
 >
-  <div
-    class="bp3-overlay bp3-overlay-open bp3-overlay-scroll-container"
-  >
-    <div
-      class="bp3-overlay-backdrop bp3-overlay-appear bp3-overlay-appear-active"
-    />
-    <div
-      class="bp3-dialog-container bp3-overlay-content bp3-overlay-appear bp3-overlay-appear-active"
-      tabindex="0"
+  <p>
+    Edit the overlord dynamic configuration on the fly. For more information please refer to the
+     
+    <Memo(ExternalLink)
+      href="https://druid.apache.org/docs/0.19.0/configuration/index.html#overlord-dynamic-configuration"
     >
-      <div
-        class="bp3-dialog snitch-dialog overlord-dynamic-config-dialog"
-      >
-        <div
-          class="bp3-dialog-header"
-        >
-          <h4
-            class="bp3-heading"
-          >
-            Overlord dynamic config
-          </h4>
-          <button
-            aria-label="Close"
-            class="bp3-button bp3-minimal bp3-dialog-close-button"
-            type="button"
-          >
-            <span
-              class="bp3-icon bp3-icon-small-cross"
-              icon="small-cross"
-            >
-              <svg
-                data-icon="small-cross"
-                height="20"
-                viewBox="0 0 20 20"
-                width="20"
-              >
-                <desc>
-                  small-cross
-                </desc>
-                <path
-                  d="M11.41 10l3.29-3.29c.19-.18.3-.43.3-.71a1.003 1.003 0 00-1.71-.71L10 8.59l-3.29-3.3a1.003 1.003 0 00-1.42 1.42L8.59 10 5.3 13.29c-.19.18-.3.43-.3.71a1.003 1.003 0 001.71.71l3.29-3.3 3.29 3.29c.18.19.43.3.71.3a1.003 1.003 0 00.71-1.71L11.41 10z"
-                  fill-rule="evenodd"
-                />
-              </svg>
-            </span>
-          </button>
-        </div>
-        <div
-          class="bp3-dialog-body"
-        >
-          <p>
-            Edit the overlord dynamic configuration on the fly. For more information please refer to the
-             
-            <a
-              href="https://druid.apache.org/docs/0.19.0/configuration/index.html#overlord-dynamic-configuration"
-              rel="noopener noreferrer"
-              target="_blank"
-            >
-              documentation
-            </a>
-            .
-          </p>
-          <div
-            class="auto-form"
-          />
-        </div>
-        <div
-          class="bp3-dialog-footer"
-        >
-          <div
-            class="bp3-dialog-footer-actions"
-          >
-            <button
-              class="bp3-button bp3-intent-primary"
-              type="button"
-            >
-              <span
-                class="bp3-button-text"
-              >
-                Next
-              </span>
-              <span
-                class="bp3-icon bp3-icon-arrow-right"
-                icon="arrow-right"
-              >
-                <svg
-                  data-icon="arrow-right"
-                  height="16"
-                  viewBox="0 0 16 16"
-                  width="16"
-                >
-                  <desc>
-                    arrow-right
-                  </desc>
-                  <path
-                    d="M14.7 7.29l-5-5a.965.965 0 00-.71-.3 1.003 1.003 0 00-.71 1.71l3.29 3.29H1.99c-.55 0-1 .45-1 1s.45 1 1 1h9.59l-3.29 3.29a1.003 1.003 0 001.42 1.42l5-5c.18-.18.29-.43.29-.71s-.12-.52-.3-.7z"
-                    fill-rule="evenodd"
-                  />
-                </svg>
-              </span>
-            </button>
-          </div>
-        </div>
-      </div>
-    </div>
-  </div>
-</div>
+      documentation
+    </Memo(ExternalLink)>
+    .
+  </p>
+  <AutoForm
+    fields={
+      Array [
+        Object {
+          "name": "selectStrategy",
+          "type": "json",
+        },
+        Object {
+          "name": "autoScaler",
+          "type": "json",
+        },
+      ]
+    }
+    model={Object {}}
+    onChange={[Function]}
+  />
+</SnitchDialog>
 `;
diff --git a/web-console/src/dialogs/overlord-dynamic-config-dialog/overload-dynamic-config-dialog.spec.tsx b/web-console/src/dialogs/overlord-dynamic-config-dialog/overload-dynamic-config-dialog.spec.tsx
index 5b84552..5f2ea38 100644
--- a/web-console/src/dialogs/overlord-dynamic-config-dialog/overload-dynamic-config-dialog.spec.tsx
+++ b/web-console/src/dialogs/overlord-dynamic-config-dialog/overload-dynamic-config-dialog.spec.tsx
@@ -16,16 +16,15 @@
  * limitations under the License.
  */
 
-import { render } from '@testing-library/react';
+import { shallow } from 'enzyme';
 import React from 'react';
 
 import { OverlordDynamicConfigDialog } from './overlord-dynamic-config-dialog';
 
 describe('overload dynamic config', () => {
   it('matches snapshot', () => {
-    const lookupEditDialog = <OverlordDynamicConfigDialog onClose={() => {}} />;
+    const lookupEditDialog = shallow(<OverlordDynamicConfigDialog onClose={() => {}} />);
 
-    render(lookupEditDialog);
-    expect(document.body.lastChild).toMatchSnapshot();
+    expect(lookupEditDialog).toMatchSnapshot();
   });
 });
diff --git a/web-console/src/dialogs/overlord-dynamic-config-dialog/overlord-dynamic-config-dialog.tsx b/web-console/src/dialogs/overlord-dynamic-config-dialog/overlord-dynamic-config-dialog.tsx
index 3828b83..64cea82 100644
--- a/web-console/src/dialogs/overlord-dynamic-config-dialog/overlord-dynamic-config-dialog.tsx
+++ b/web-console/src/dialogs/overlord-dynamic-config-dialog/overlord-dynamic-config-dialog.tsx
@@ -19,13 +19,14 @@
 import { Intent } from '@blueprintjs/core';
 import { IconNames } from '@blueprintjs/icons';
 import axios from 'axios';
-import React from 'react';
+import React, { useState } from 'react';
 
 import { SnitchDialog } from '..';
 import { AutoForm, ExternalLink } from '../../components';
+import { useQueryManager } from '../../hooks';
 import { getLink } from '../../links';
 import { AppToaster } from '../../singletons/toaster';
-import { getDruidErrorMessage, QueryManager } from '../../utils';
+import { getDruidErrorMessage } from '../../utils';
 
 import './overlord-dynamic-config-dialog.scss';
 
@@ -33,65 +34,42 @@ export interface OverlordDynamicConfigDialogProps {
   onClose: () => void;
 }
 
-export interface OverlordDynamicConfigDialogState {
-  dynamicConfig?: Record<string, any>;
-  historyRecords: any[];
-}
-
-export class OverlordDynamicConfigDialog extends React.PureComponent<
-  OverlordDynamicConfigDialogProps,
-  OverlordDynamicConfigDialogState
-> {
-  private historyQueryManager: QueryManager<string, any>;
+export const OverlordDynamicConfigDialog = React.memo(function OverlordDynamicConfigDialog(
+  props: OverlordDynamicConfigDialogProps,
+) {
+  const { onClose } = props;
+  const [dynamicConfig, setDynamicConfig] = useState<Record<string, any>>({});
 
-  constructor(props: OverlordDynamicConfigDialogProps) {
-    super(props);
-    this.state = {
-      historyRecords: [],
-    };
+  const [historyRecordsState] = useQueryManager<null, any[]>({
+    processQuery: async () => {
+      const historyResp = await axios(`/druid/indexer/v1/worker/history?count=100`);
+      return historyResp.data;
+    },
+    initQuery: null,
+  });
 
-    this.historyQueryManager = new QueryManager({
-      processQuery: async () => {
-        const historyResp = await axios(`/druid/indexer/v1/worker/history?count=100`);
-        return historyResp.data;
-      },
-      onStateChange: ({ result }) => {
-        this.setState({
-          historyRecords: result,
+  useQueryManager<null, Record<string, any>>({
+    processQuery: async () => {
+      try {
+        const configResp = await axios(`/druid/indexer/v1/worker`);
+        setDynamicConfig(configResp.data);
+      } catch (e) {
+        AppToaster.show({
+          icon: IconNames.ERROR,
+          intent: Intent.DANGER,
+          message: `Could not load overlord dynamic config: ${getDruidErrorMessage(e)}`,
         });
-      },
-    });
-  }
-
-  componentDidMount() {
-    this.getConfig();
+        setDynamicConfig({});
+        onClose();
+      }
+      return {};
+    },
+    initQuery: null,
+  });
 
-    this.historyQueryManager.runQuery(`dummy`);
-  }
-
-  async getConfig() {
-    let config: Record<string, any> | undefined;
+  async function saveConfig(comment: string) {
     try {
-      const configResp = await axios.get('/druid/indexer/v1/worker');
-      config = configResp.data || {};
-    } catch (e) {
-      AppToaster.show({
-        icon: IconNames.ERROR,
-        intent: Intent.DANGER,
-        message: `Could not load overlord dynamic config: ${getDruidErrorMessage(e)}`,
-      });
-      return;
-    }
-    this.setState({
-      dynamicConfig: config,
-    });
-  }
-
-  private saveConfig = async (comment: string) => {
-    const { onClose } = this.props;
-    const newState: any = this.state.dynamicConfig;
-    try {
-      await axios.post('/druid/indexer/v1/worker', newState, {
+      await axios.post('/druid/indexer/v1/worker', dynamicConfig, {
         headers: {
           'X-Druid-Author': 'console',
           'X-Druid-Comment': comment,
@@ -110,45 +88,39 @@ export class OverlordDynamicConfigDialog extends React.PureComponent<
       intent: Intent.SUCCESS,
     });
     onClose();
-  };
-
-  render(): JSX.Element {
-    const { onClose } = this.props;
-    const { dynamicConfig, historyRecords } = this.state;
-
-    return (
-      <SnitchDialog
-        className="overlord-dynamic-config-dialog"
-        onSave={this.saveConfig}
-        onClose={onClose}
-        title="Overlord dynamic config"
-        historyRecords={historyRecords}
-      >
-        <p>
-          Edit the overlord dynamic configuration on the fly. For more information please refer to
-          the{' '}
-          <ExternalLink
-            href={`${getLink('DOCS')}/configuration/index.html#overlord-dynamic-configuration`}
-          >
-            documentation
-          </ExternalLink>
-          .
-        </p>
-        <AutoForm
-          fields={[
-            {
-              name: 'selectStrategy',
-              type: 'json',
-            },
-            {
-              name: 'autoScaler',
-              type: 'json',
-            },
-          ]}
-          model={dynamicConfig}
-          onChange={m => this.setState({ dynamicConfig: m })}
-        />
-      </SnitchDialog>
-    );
   }
-}
+
+  return (
+    <SnitchDialog
+      className="overlord-dynamic-config-dialog"
+      onSave={saveConfig}
+      onClose={onClose}
+      title="Overlord dynamic config"
+      historyRecords={historyRecordsState.data}
+    >
+      <p>
+        Edit the overlord dynamic configuration on the fly. For more information please refer to the{' '}
+        <ExternalLink
+          href={`${getLink('DOCS')}/configuration/index.html#overlord-dynamic-configuration`}
+        >
+          documentation
+        </ExternalLink>
+        .
+      </p>
+      <AutoForm
+        fields={[
+          {
+            name: 'selectStrategy',
+            type: 'json',
+          },
+          {
+            name: 'autoScaler',
+            type: 'json',
+          },
+        ]}
+        model={dynamicConfig}
+        onChange={m => setDynamicConfig(m)}
+      />
+    </SnitchDialog>
+  );
+});
diff --git a/web-console/src/dialogs/query-plan-dialog/query-plan-dialog.tsx b/web-console/src/dialogs/query-plan-dialog/query-plan-dialog.tsx
index 63cb224..b55df80 100644
--- a/web-console/src/dialogs/query-plan-dialog/query-plan-dialog.tsx
+++ b/web-console/src/dialogs/query-plan-dialog/query-plan-dialog.tsx
@@ -33,7 +33,7 @@ import './query-plan-dialog.scss';
 
 export interface QueryPlanDialogProps {
   explainResult?: BasicQueryExplanation | SemiJoinQueryExplanation | string;
-  explainError?: string;
+  explainError?: Error;
   onClose: () => void;
   setQueryString: (queryString: string) => void;
 }
@@ -45,7 +45,7 @@ export const QueryPlanDialog = React.memo(function QueryPlanDialog(props: QueryP
   let queryString: string | undefined;
 
   if (explainError) {
-    content = <div>{explainError}</div>;
+    content = <div>{explainError.message}</div>;
   } else if (!explainResult) {
     content = <div />;
   } else if ((explainResult as BasicQueryExplanation).query) {
diff --git a/web-console/src/dialogs/retention-dialog/__snapshots__/retention-dialog.spec.tsx.snap b/web-console/src/dialogs/retention-dialog/__snapshots__/retention-dialog.spec.tsx.snap
index 92444e0..9e2d237 100644
--- a/web-console/src/dialogs/retention-dialog/__snapshots__/retention-dialog.spec.tsx.snap
+++ b/web-console/src/dialogs/retention-dialog/__snapshots__/retention-dialog.spec.tsx.snap
@@ -238,7 +238,6 @@ exports[`retention dialog matches snapshot 1`] = `
                                 class="bp3-input"
                                 placeholder="P1D"
                                 style="padding-right: 0px;"
-                                suggestions="P1D,P7D,P1M,P1Y,P1000Y"
                                 type="text"
                                 value="P1000Y"
                               />
@@ -553,18 +552,22 @@ exports[`retention dialog matches snapshot 1`] = `
                 </a>
                 ):
               </p>
-              <button
-                class="bp3-button bp3-disabled"
-                disabled=""
-                tabindex="-1"
-                type="button"
+              <div
+                class="default-rule"
               >
-                <span
-                  class="bp3-button-text"
+                <button
+                  class="bp3-button bp3-disabled"
+                  disabled=""
+                  tabindex="-1"
+                  type="button"
                 >
-                  loadForever
-                </span>
-              </button>
+                  <span
+                    class="bp3-button-text"
+                  >
+                    loadForever
+                  </span>
+                </button>
+              </div>
             </div>
           </div>
         </div>
@@ -575,6 +578,16 @@ exports[`retention dialog matches snapshot 1`] = `
             class="bp3-dialog-footer-actions"
           >
             <button
+              class="bp3-button bp3-minimal left-align-button"
+              type="button"
+            >
+              <span
+                class="bp3-button-text"
+              >
+                History
+              </span>
+            </button>
+            <button
               class="bp3-button"
               type="button"
             >
diff --git a/web-console/src/dialogs/retention-dialog/retention-dialog.scss b/web-console/src/dialogs/retention-dialog/retention-dialog.scss
index 5917962..c4d33ec 100644
--- a/web-console/src/dialogs/retention-dialog/retention-dialog.scss
+++ b/web-console/src/dialogs/retention-dialog/retention-dialog.scss
@@ -39,5 +39,9 @@
         padding: 0 15px;
       }
     }
+
+    .default-rule {
+      margin-top: 10px;
+    }
   }
 }
diff --git a/web-console/src/dialogs/retention-dialog/retention-dialog.spec.tsx b/web-console/src/dialogs/retention-dialog/retention-dialog.spec.tsx
index 950e96d..17fef67 100644
--- a/web-console/src/dialogs/retention-dialog/retention-dialog.spec.tsx
+++ b/web-console/src/dialogs/retention-dialog/retention-dialog.spec.tsx
@@ -19,7 +19,7 @@
 import { render } from '@testing-library/react';
 import React from 'react';
 
-import { reorderArray, RetentionDialog } from './retention-dialog';
+import { RetentionDialog } from './retention-dialog';
 
 describe('retention dialog', () => {
   it('matches snapshot', () => {
@@ -44,39 +44,4 @@ describe('retention dialog', () => {
     render(retentionDialog);
     expect(document.body.lastChild).toMatchSnapshot();
   });
-
-  describe('reorderArray', () => {
-    it('works when nothing changes', () => {
-      const array = ['a', 'b', 'c', 'd', 'e'];
-
-      const newArray = reorderArray(array, 0, 0);
-
-      expect(newArray).toEqual(['a', 'b', 'c', 'd', 'e']);
-      expect(array).toEqual(['a', 'b', 'c', 'd', 'e']);
-    });
-
-    it('works upward', () => {
-      const array = ['a', 'b', 'c', 'd', 'e'];
-
-      let newArray = reorderArray(array, 2, 1);
-      expect(newArray).toEqual(['a', 'c', 'b', 'd', 'e']);
-      expect(array).toEqual(['a', 'b', 'c', 'd', 'e']);
-
-      newArray = reorderArray(array, 2, 0);
-      expect(newArray).toEqual(['c', 'a', 'b', 'd', 'e']);
-      expect(array).toEqual(['a', 'b', 'c', 'd', 'e']);
-    });
-
-    it('works downward', () => {
-      const array = ['a', 'b', 'c', 'd', 'e'];
-
-      let newArray = reorderArray(array, 2, 3);
-      expect(newArray).toEqual(['a', 'b', 'c', 'd', 'e']);
-      expect(array).toEqual(['a', 'b', 'c', 'd', 'e']);
-
-      newArray = reorderArray(array, 2, 4);
-      expect(newArray).toEqual(['a', 'b', 'd', 'c', 'e']);
-      expect(array).toEqual(['a', 'b', 'c', 'd', 'e']);
-    });
-  });
 });
diff --git a/web-console/src/dialogs/retention-dialog/retention-dialog.tsx b/web-console/src/dialogs/retention-dialog/retention-dialog.tsx
index a430e72..76a3b59 100644
--- a/web-console/src/dialogs/retention-dialog/retention-dialog.tsx
+++ b/web-console/src/dialogs/retention-dialog/retention-dialog.tsx
@@ -19,26 +19,17 @@
 import { Button, Divider, FormGroup } from '@blueprintjs/core';
 import { IconNames } from '@blueprintjs/icons';
 import axios from 'axios';
-import React from 'react';
+import React, { useState } from 'react';
 
 import { SnitchDialog } from '..';
 import { ExternalLink, RuleEditor } from '../../components';
+import { useQueryManager } from '../../hooks';
 import { getLink } from '../../links';
-import { QueryManager } from '../../utils';
+import { swapElements } from '../../utils';
 import { Rule, RuleUtil } from '../../utils/load-rule';
 
 import './retention-dialog.scss';
 
-export function reorderArray<T>(items: T[], oldIndex: number, newIndex: number): T[] {
-  const newItems = items.concat();
-
-  if (newIndex > oldIndex) newIndex--;
-
-  newItems.splice(newIndex, 0, newItems.splice(oldIndex, 1)[0]);
-
-  return newItems;
-}
-
 export interface RetentionDialogProps {
   datasource: string;
   rules: Rule[];
@@ -54,178 +45,115 @@ export interface RetentionDialogState {
   historyRecords: any[] | undefined;
 }
 
-export class RetentionDialog extends React.PureComponent<
-  RetentionDialogProps,
-  RetentionDialogState
-> {
-  private historyQueryManager: QueryManager<string, any[]>;
-
-  constructor(props: RetentionDialogProps) {
-    super(props);
-
-    this.state = {
-      currentRules: props.rules,
-      historyRecords: [],
-    };
-
-    this.historyQueryManager = new QueryManager({
-      processQuery: async datasource => {
-        const historyResp = await axios(`/druid/coordinator/v1/rules/${datasource}/history`);
-        return historyResp.data;
-      },
-      onStateChange: ({ result }) => {
-        this.setState({
-          historyRecords: result,
-        });
-      },
-    });
-  }
+export const RetentionDialog = React.memo(function RetentionDialog(props: RetentionDialogProps) {
+  const { datasource, onCancel, onEditDefaults, rules, defaultRules, tiers } = props;
+  const [currentRules, setCurrentRules] = useState(props.rules);
 
-  componentDidMount() {
-    const { datasource } = this.props;
-    this.historyQueryManager.runQuery(datasource);
-  }
+  const [historyQueryState] = useQueryManager<string, any[]>({
+    processQuery: async datasource => {
+      const historyResp = await axios(`/druid/coordinator/v1/rules/${datasource}/history`);
+      return historyResp.data;
+    },
+    initQuery: props.datasource,
+  });
 
-  private save = (comment: string) => {
-    const { datasource, onSave } = this.props;
-    const { currentRules } = this.state;
+  const historyRecords = historyQueryState.data || [];
 
+  function saveHandler(comment: string) {
+    const { datasource, onSave } = props;
     onSave(datasource, currentRules, comment);
-  };
-
-  private changeRule = (newRule: Rule, index: number) => {
-    const { currentRules } = this.state;
-
-    const newRules = (currentRules || []).map((r, i) => {
-      if (i === index) return newRule;
-      return r;
-    });
-
-    this.setState({
-      currentRules: newRules,
-    });
-  };
-
-  onDeleteRule = (index: number) => {
-    const { currentRules } = this.state;
-
-    const newRules = (currentRules || []).filter((_r, i) => i !== index);
-
-    this.setState({
-      currentRules: newRules,
-    });
-  };
+  }
 
-  moveRule(index: number, offset: number) {
-    const { currentRules } = this.state;
-    if (!currentRules) return;
+  function addRule() {
+    setCurrentRules(
+      currentRules.concat({
+        type: 'loadForever',
+        tieredReplicants: { [tiers[0]]: 2 },
+      }),
+    );
+  }
 
-    const newIndex = index + offset;
+  function deleteRule(index: number) {
+    setCurrentRules(currentRules.filter((_r, i) => i !== index));
+  }
 
-    this.setState({
-      currentRules: reorderArray(currentRules, index, newIndex),
-    });
+  function changeRule(newRule: Rule, index: number) {
+    setCurrentRules(currentRules.map((r, i) => (i === index ? newRule : r)));
   }
 
-  renderRule = (rule: Rule, index: number) => {
-    const { tiers } = this.props;
-    const { currentRules } = this.state;
+  function moveRule(index: number, direction: number) {
+    setCurrentRules(swapElements(currentRules, index, index + direction));
+  }
 
+  function renderRule(rule: Rule, index: number) {
     return (
       <RuleEditor
         rule={rule}
         tiers={tiers}
         key={index}
-        onChange={r => this.changeRule(r, index)}
-        onDelete={() => this.onDeleteRule(index)}
-        moveUp={index > 0 ? () => this.moveRule(index, -1) : null}
-        moveDown={index < (currentRules || []).length - 1 ? () => this.moveRule(index, 2) : null}
+        onChange={r => changeRule(r, index)}
+        onDelete={() => deleteRule(index)}
+        moveUp={index > 0 ? () => moveRule(index, -1) : undefined}
+        moveDown={index < currentRules.length - 1 ? () => moveRule(index, 1) : undefined}
       />
     );
-  };
+  }
 
-  renderDefaultRule = (rule: Rule, index: number) => {
+  function renderDefaultRule(rule: Rule, index: number) {
     return (
-      <Button disabled key={index}>
-        {RuleUtil.ruleToString(rule)}
-      </Button>
+      <div className="default-rule" key={index}>
+        <Button disabled>{RuleUtil.ruleToString(rule)}</Button>
+      </div>
     );
-  };
-
-  reset = () => {
-    const { rules } = this.props;
-
-    this.setState({
-      currentRules: rules.concat(),
-    });
-  };
-
-  addRule = () => {
-    const { tiers } = this.props;
-    const { currentRules } = this.state;
-
-    const newRules = (currentRules || []).concat({
-      type: 'loadForever',
-      tieredReplicants: { [tiers[0]]: 2 },
-    });
-
-    this.setState({
-      currentRules: newRules,
-    });
-  };
-
-  render(): JSX.Element {
-    const { datasource, onCancel, onEditDefaults, defaultRules } = this.props;
-    const { currentRules, historyRecords } = this.state;
+  }
 
-    return (
-      <SnitchDialog
-        className="retention-dialog"
-        saveDisabled={false}
-        onClose={onCancel}
-        title={`Edit retention rules: ${datasource}${
-          datasource === '_default' ? ' (cluster defaults)' : ''
-        }`}
-        onReset={this.reset}
-        onSave={this.save}
-        historyRecords={historyRecords}
-      >
-        <p>
-          Druid uses rules to determine what data should be retained in the cluster. The rules are
-          evaluated in order from top to bottom. For more information please refer to the{' '}
-          <ExternalLink href={`${getLink('DOCS')}/operations/rule-configuration.html`}>
-            documentation
-          </ExternalLink>
-          .
-        </p>
-        <FormGroup>
-          {currentRules.length ? (
-            currentRules.map(this.renderRule)
-          ) : datasource !== '_default' ? (
-            <p className="no-rules-message">
-              This datasource currently has no rules, it will use the cluster defaults.
-            </p>
-          ) : (
-            undefined
-          )}
-          <div>
-            <Button icon={IconNames.PLUS} onClick={this.addRule}>
-              New rule
-            </Button>
-          </div>
-        </FormGroup>
-        {datasource !== '_default' && (
-          <>
-            <Divider />
-            <FormGroup>
-              <p>
-                Cluster defaults (<a onClick={onEditDefaults}>edit</a>):
-              </p>
-              {defaultRules.map(this.renderDefaultRule)}
-            </FormGroup>
-          </>
+  return (
+    <SnitchDialog
+      className="retention-dialog"
+      saveDisabled={false}
+      onClose={onCancel}
+      title={`Edit retention rules: ${datasource}${
+        datasource === '_default' ? ' (cluster defaults)' : ''
+      }`}
+      onReset={() => setCurrentRules(rules)}
+      onSave={saveHandler}
+      historyRecords={historyRecords}
+    >
+      <p>
+        Druid uses rules to determine what data should be retained in the cluster. The rules are
+        evaluated in order from top to bottom. For more information please refer to the{' '}
+        <ExternalLink href={`${getLink('DOCS')}/operations/rule-configuration.html`}>
+          documentation
+        </ExternalLink>
+        .
+      </p>
+      <FormGroup>
+        {currentRules.length ? (
+          currentRules.map(renderRule)
+        ) : datasource !== '_default' ? (
+          <p className="no-rules-message">
+            This datasource currently has no rules, it will use the cluster defaults.
+          </p>
+        ) : (
+          undefined
         )}
-      </SnitchDialog>
-    );
-  }
-}
+        <div>
+          <Button icon={IconNames.PLUS} onClick={addRule}>
+            New rule
+          </Button>
+        </div>
+      </FormGroup>
+      {datasource !== '_default' && (
+        <>
+          <Divider />
+          <FormGroup>
+            <p>
+              Cluster defaults (<a onClick={onEditDefaults}>edit</a>):
+            </p>
+            {defaultRules.map(renderDefaultRule)}
+          </FormGroup>
+        </>
+      )}
+    </SnitchDialog>
+  );
+});
diff --git a/web-console/src/dialogs/segments-table-action-dialog/__snapshots__/segment-table-action-dialog.spec.tsx.snap b/web-console/src/dialogs/segments-table-action-dialog/__snapshots__/segment-table-action-dialog.spec.tsx.snap
index ac1bc4d..3a8d369 100644
--- a/web-console/src/dialogs/segments-table-action-dialog/__snapshots__/segment-table-action-dialog.spec.tsx.snap
+++ b/web-console/src/dialogs/segments-table-action-dialog/__snapshots__/segment-table-action-dialog.spec.tsx.snap
@@ -140,7 +140,41 @@ exports[`task table action dialog matches snapshot 1`] = `
               </div>
               <div
                 class="main-area"
-              />
+              >
+                <div
+                  class="loader"
+                >
+                  <div
+                    class="loader-logo"
+                  >
+                    <svg
+                      viewBox="0 0 100 100"
+                    >
+                      <path
+                        class="one"
+                        d="M54.2,69.8h-2.7c-0.7,0-1.3-0.6-1.3-1.3c0-0.7,0.6-1.3,1.3-1.3h2.7c11.5,0,23.8-7.4,23.8-23.7
+          c0-9.1-6.9-15.8-16.4-15.8H38c-0.7,0-1.3-0.6-1.3-1.3s0.6-1.3,1.3-1.3h23.6c5.3,0,10.1,1.9,13.6,5.3c3.5,3.4,5.4,8,5.4,13.1
+          c0,6.6-2.3,13-6.3,17.7C69.5,66.8,62.5,69.8,54.2,69.8z"
+                      />
+                      <path
+                        class="two"
+                        d="M55.7,59.5h-26c-0.7,0-1.3-0.6-1.3-1.3c0-0.7,0.6-1.3,1.3-1.3h26c7.5,0,11.5-5.8,11.5-11.5
+            c0-4.2-3.2-7.3-7.7-7.3h-26c-0.7,0-1.3-0.6-1.3-1.3s0.6-1.3,1.3-1.3h26c5.9,0,10.3,4.3,10.3,9.9c0,3.7-1.3,7.2-3.7,9.8
+            C63.5,58,59.9,59.5,55.7,59.5z"
+                      />
+                      <path
+                        class="three"
+                        d="M27.2,38h-6.3c-0.7,0-1.3-0.6-1.3-1.3s0.6-1.3,1.3-1.3h6.3c0.7,0,1.3,0.6,1.3,1.3S27.9,38,27.2,38z"
+                      />
+                      <path
+                        class="four"
+                        d="M45.1,69.8h-5.8c-0.7,0-1.3-0.6-1.3-1.3c0-0.7,0.6-1.3,1.3-1.3h5.8c0.7,0,1.3,0.6,1.3,1.3
+            C46.4,69.2,45.8,69.8,45.1,69.8z"
+                      />
+                    </svg>
+                  </div>
+                </div>
+              </div>
             </div>
           </div>
         </div>
diff --git a/web-console/src/dialogs/snitch-dialog/__snapshots__/snitch-dialog.spec.tsx.snap b/web-console/src/dialogs/snitch-dialog/__snapshots__/snitch-dialog.spec.tsx.snap
index d223804..a35077d 100644
--- a/web-console/src/dialogs/snitch-dialog/__snapshots__/snitch-dialog.spec.tsx.snap
+++ b/web-console/src/dialogs/snitch-dialog/__snapshots__/snitch-dialog.spec.tsx.snap
@@ -96,3 +96,110 @@ exports[`snitch dialog matches snapshot 1`] = `
   </div>
 </div>
 `;
+
+exports[`snitch dialog matches snapshot with history 1`] = `
+<div
+  class="bp3-portal"
+>
+  <div
+    class="bp3-overlay bp3-overlay-open bp3-overlay-scroll-container"
+  >
+    <div
+      class="bp3-overlay-backdrop bp3-overlay-appear bp3-overlay-appear-active"
+    />
+    <div
+      class="bp3-dialog-container bp3-overlay-content bp3-overlay-appear bp3-overlay-appear-active"
+      tabindex="0"
+    >
+      <div
+        class="bp3-dialog snitch-dialog"
+      >
+        <div
+          class="bp3-dialog-header"
+        >
+          <h4
+            class="bp3-heading"
+          >
+            Be snitchin
+          </h4>
+          <button
+            aria-label="Close"
+            class="bp3-button bp3-minimal bp3-dialog-close-button"
+            type="button"
+          >
+            <span
+              class="bp3-icon bp3-icon-small-cross"
+              icon="small-cross"
+            >
+              <svg
+                data-icon="small-cross"
+                height="20"
+                viewBox="0 0 20 20"
+                width="20"
+              >
+                <desc>
+                  small-cross
+                </desc>
+                <path
+                  d="M11.41 10l3.29-3.29c.19-.18.3-.43.3-.71a1.003 1.003 0 00-1.71-.71L10 8.59l-3.29-3.3a1.003 1.003 0 00-1.42 1.42L8.59 10 5.3 13.29c-.19.18-.3.43-.3.71a1.003 1.003 0 001.71.71l3.29-3.3 3.29 3.29c.18.19.43.3.71.3a1.003 1.003 0 00.71-1.71L11.41 10z"
+                  fill-rule="evenodd"
+                />
+              </svg>
+            </span>
+          </button>
+        </div>
+        <div
+          class="bp3-dialog-body"
+        />
+        <div
+          class="bp3-dialog-footer"
+        >
+          <div
+            class="bp3-dialog-footer-actions"
+          >
+            <button
+              class="bp3-button bp3-minimal left-align-button"
+              type="button"
+            >
+              <span
+                class="bp3-button-text"
+              >
+                History
+              </span>
+            </button>
+            <button
+              class="bp3-button bp3-intent-primary"
+              type="button"
+            >
+              <span
+                class="bp3-button-text"
+              >
+                Next
+              </span>
+              <span
+                class="bp3-icon bp3-icon-arrow-right"
+                icon="arrow-right"
+              >
+                <svg
+                  data-icon="arrow-right"
+                  height="16"
+                  viewBox="0 0 16 16"
+                  width="16"
+                >
+                  <desc>
+                    arrow-right
+                  </desc>
+                  <path
+                    d="M14.7 7.29l-5-5a.965.965 0 00-.71-.3 1.003 1.003 0 00-.71 1.71l3.29 3.29H1.99c-.55 0-1 .45-1 1s.45 1 1 1h9.59l-3.29 3.29a1.003 1.003 0 001.42 1.42l5-5c.18-.18.29-.43.29-.71s-.12-.52-.3-.7z"
+                    fill-rule="evenodd"
+                  />
+                </svg>
+              </span>
+            </button>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
+`;
diff --git a/web-console/src/dialogs/snitch-dialog/snitch-dialog.spec.tsx b/web-console/src/dialogs/snitch-dialog/snitch-dialog.spec.tsx
index ff3a5c6..bb419de 100644
--- a/web-console/src/dialogs/snitch-dialog/snitch-dialog.spec.tsx
+++ b/web-console/src/dialogs/snitch-dialog/snitch-dialog.spec.tsx
@@ -27,4 +27,20 @@ describe('snitch dialog', () => {
     render(snitchDialog);
     expect(document.body.lastChild).toMatchSnapshot();
   });
+
+  it('matches snapshot with history', () => {
+    const snitchDialog = (
+      <SnitchDialog
+        title="Be snitchin"
+        onSave={() => {}}
+        onClose={() => {}}
+        historyRecords={[
+          { auditTime: 'test', auditInfo: 'test', payload: JSON.stringify({ name: 'test' }) },
+          { auditTime: 'test', auditInfo: 'test', payload: JSON.stringify({ name: 'test' }) },
+        ]}
+      />
+    );
+    render(snitchDialog);
+    expect(document.body.lastChild).toMatchSnapshot();
+  });
 });
diff --git a/web-console/src/dialogs/snitch-dialog/snitch-dialog.tsx b/web-console/src/dialogs/snitch-dialog/snitch-dialog.tsx
index fd41b12..5a8ca6f 100644
--- a/web-console/src/dialogs/snitch-dialog/snitch-dialog.tsx
+++ b/web-console/src/dialogs/snitch-dialog/snitch-dialog.tsx
@@ -37,9 +37,7 @@ export interface SnitchDialogProps {
 
 export interface SnitchDialogState {
   comment: string;
-
   showFinalStep?: boolean;
-
   showHistory?: boolean;
 }
 
diff --git a/web-console/src/dialogs/status-dialog/status-dialog.spec.tsx b/web-console/src/dialogs/status-dialog/status-dialog.spec.tsx
index 5a428a7..7cef90f 100644
--- a/web-console/src/dialogs/status-dialog/status-dialog.spec.tsx
+++ b/web-console/src/dialogs/status-dialog/status-dialog.spec.tsx
@@ -19,7 +19,7 @@
 import { render } from '@testing-library/react';
 import React from 'react';
 
-import { StatusDialog } from './status-dialog';
+import { anywhereMatcher, StatusDialog } from './status-dialog';
 
 describe('status dialog', () => {
   it('matches snapshot', () => {
@@ -35,10 +35,10 @@ describe('status dialog', () => {
       'io.imply.druid.UtilityBeltModule',
     ];
 
-    expect(StatusDialog.anywhereMatcher({ id: '0', value: 'common' }, data)).toEqual(true);
-    expect(StatusDialog.anywhereMatcher({ id: '1', value: 'common' }, data)).toEqual(true);
-    expect(StatusDialog.anywhereMatcher({ id: '0', value: 'org' }, data)).toEqual(true);
-    expect(StatusDialog.anywhereMatcher({ id: '1', value: 'org' }, data)).toEqual(true);
-    expect(StatusDialog.anywhereMatcher({ id: '2', value: 'common' }, data)).toEqual(false);
+    expect(anywhereMatcher({ id: '0', value: 'common' }, data)).toEqual(true);
+    expect(anywhereMatcher({ id: '1', value: 'common' }, data)).toEqual(true);
+    expect(anywhereMatcher({ id: '0', value: 'org' }, data)).toEqual(true);
+    expect(anywhereMatcher({ id: '1', value: 'org' }, data)).toEqual(true);
+    expect(anywhereMatcher({ id: '2', value: 'common' }, data)).toEqual(false);
   });
 });
diff --git a/web-console/src/dialogs/status-dialog/status-dialog.tsx b/web-console/src/dialogs/status-dialog/status-dialog.tsx
index b444ce2..5d6aac5 100644
--- a/web-console/src/dialogs/status-dialog/status-dialog.tsx
+++ b/web-console/src/dialogs/status-dialog/status-dialog.tsx
@@ -21,124 +21,101 @@ import axios from 'axios';
 import React from 'react';
 import ReactTable, { Filter } from 'react-table';
 
-import { Loader } from '../../components/loader/loader';
+import { Loader } from '../../components';
+import { useQueryManager } from '../../hooks';
 import { UrlBaser } from '../../singletons/url-baser';
-import { QueryManager } from '../../utils';
 
 import './status-dialog.scss';
 
+export function anywhereMatcher(filter: Filter, row: any): boolean {
+  return String(row[filter.id]).includes(filter.value);
+}
+
+interface StatusModule {
+  artifact: string;
+  name: string;
+  version: string;
+}
+
 interface StatusResponse {
   version: string;
-  modules: any[];
+  modules: StatusModule[];
 }
 
 interface StatusDialogProps {
   onClose: () => void;
 }
 
-interface StatusDialogState {
-  response?: StatusResponse;
-  loading: boolean;
-  error?: string;
-}
-
-export class StatusDialog extends React.PureComponent<StatusDialogProps, StatusDialogState> {
-  static anywhereMatcher(filter: Filter, row: any) {
-    return String(row[filter.id]).includes(filter.value);
-  }
-
-  private showStatusQueryManager: QueryManager<null, any>;
-
-  constructor(props: StatusDialogProps, context: any) {
-    super(props, context);
-    this.state = {
-      loading: false,
-    };
-
-    this.showStatusQueryManager = new QueryManager({
-      processQuery: async () => {
-        const resp = await axios.get(`/status`);
-        return resp.data;
-      },
-      onStateChange: ({ result, loading, error }) => {
-        this.setState({
-          loading,
-          response: result,
-          error,
-        });
-      },
-    });
-  }
-
-  componentDidMount(): void {
-    this.showStatusQueryManager.runQuery(null);
-  }
-
-  renderContent(): JSX.Element | undefined {
-    const { response, loading, error } = this.state;
-
-    if (loading) return <Loader loading />;
-
-    if (error) return <span>{`Error while loading status: ${error}`}</span>;
-
-    if (response) {
-      return (
-        <div className="main-container">
-          <div className="version">
-            Version:&nbsp;<strong>{response.version}</strong>
-          </div>
-          <ReactTable
-            data={response.modules}
-            columns={[
-              {
-                columns: [
-                  {
-                    Header: 'Extension name',
-                    accessor: 'artifact',
-                    width: 200,
-                  },
-                  {
-                    Header: 'Fully qualified name',
-                    accessor: 'name',
-                  },
-                  {
-                    Header: 'Version',
-                    accessor: 'version',
-                    width: 200,
-                  },
-                ],
-              },
-            ]}
-            loading={loading}
-            filterable
-            defaultFilterMethod={StatusDialog.anywhereMatcher}
-          />
-        </div>
-      );
+export const StatusDialog = React.memo(function StatusDialog(props: StatusDialogProps) {
+  const { onClose } = props;
+  const [responseState] = useQueryManager<null, StatusResponse>({
+    processQuery: async () => {
+      const resp = await axios.get(`/status`);
+      return resp.data;
+    },
+    initQuery: null,
+  });
+
+  function renderContent(): JSX.Element | undefined {
+    if (responseState.loading) return <Loader />;
+
+    if (responseState.error) {
+      return <span>{`Error while loading status: ${responseState.error}`}</span>;
     }
 
-    return;
-  }
-
-  render(): JSX.Element {
-    const { onClose } = this.props;
+    const response = responseState.data;
+    if (!response) return;
 
     return (
-      <Dialog className="status-dialog" onClose={onClose} isOpen title="Status">
-        <div className={Classes.DIALOG_BODY}>{this.renderContent()}</div>
-        <div className={Classes.DIALOG_FOOTER}>
-          <div className="view-raw-button">
-            <Button
-              text="View raw"
-              minimal
-              onClick={() => window.open(UrlBaser.base(`/status`), '_blank')}
-            />
-          </div>
-          <div className="close-button">
-            <Button text="Close" intent={Intent.PRIMARY} onClick={onClose} />
-          </div>
+      <div className="main-container">
+        <div className="version">
+          Version:&nbsp;<strong>{response.version}</strong>
         </div>
-      </Dialog>
+        <ReactTable
+          data={response.modules}
+          columns={[
+            {
+              columns: [
+                {
+                  Header: 'Extension name',
+                  accessor: 'artifact',
+                  width: 200,
+                },
+                {
+                  Header: 'Fully qualified name',
+                  accessor: 'name',
+                },
+                {
+                  Header: 'Version',
+                  accessor: 'version',
+                  width: 200,
+                },
+              ],
+            },
+          ]}
+          loading={responseState.loading}
+          filterable
+          defaultFilterMethod={anywhereMatcher}
+        />
+      </div>
     );
   }
-}
+
+  return (
+    <Dialog className="status-dialog" onClose={onClose} isOpen title="Status">
+      <div className={Classes.DIALOG_BODY}>{renderContent()}</div>
+      <div className={Classes.DIALOG_FOOTER}>
+        <div className="view-raw-button">
+          <Button
+            text="View raw"
+            minimal
+            onClick={() => window.open(UrlBaser.base(`/status`), '_blank')}
+          />
+        </div>
+        <div className="close-button">
+          <Button text="Close" intent={Intent.PRIMARY} onClick={onClose} />
+        </div>
+      </div>
+    </Dialog>
+  );
+});
diff --git a/web-console/src/dialogs/supervisor-table-action-dialog/__snapshots__/supervisor-table-action-dialog.spec.tsx.snap b/web-console/src/dialogs/supervisor-table-action-dialog/__snapshots__/supervisor-table-action-dialog.spec.tsx.snap
index 2673cae..bf4752a 100755
--- a/web-console/src/dialogs/supervisor-table-action-dialog/__snapshots__/supervisor-table-action-dialog.spec.tsx.snap
+++ b/web-console/src/dialogs/supervisor-table-action-dialog/__snapshots__/supervisor-table-action-dialog.spec.tsx.snap
@@ -227,7 +227,41 @@ exports[`supervisor table action dialog matches snapshot 1`] = `
               </div>
               <div
                 class="main-area"
-              />
+              >
+                <div
+                  class="loader"
+                >
+                  <div
+                    class="loader-logo"
+                  >
+                    <svg
+                      viewBox="0 0 100 100"
+                    >
+                      <path
+                        class="one"
+                        d="M54.2,69.8h-2.7c-0.7,0-1.3-0.6-1.3-1.3c0-0.7,0.6-1.3,1.3-1.3h2.7c11.5,0,23.8-7.4,23.8-23.7
+          c0-9.1-6.9-15.8-16.4-15.8H38c-0.7,0-1.3-0.6-1.3-1.3s0.6-1.3,1.3-1.3h23.6c5.3,0,10.1,1.9,13.6,5.3c3.5,3.4,5.4,8,5.4,13.1
+          c0,6.6-2.3,13-6.3,17.7C69.5,66.8,62.5,69.8,54.2,69.8z"
+                      />
+                      <path
+                        class="two"
+                        d="M55.7,59.5h-26c-0.7,0-1.3-0.6-1.3-1.3c0-0.7,0.6-1.3,1.3-1.3h26c7.5,0,11.5-5.8,11.5-11.5
+            c0-4.2-3.2-7.3-7.7-7.3h-26c-0.7,0-1.3-0.6-1.3-1.3s0.6-1.3,1.3-1.3h26c5.9,0,10.3,4.3,10.3,9.9c0,3.7-1.3,7.2-3.7,9.8
+            C63.5,58,59.9,59.5,55.7,59.5z"
+                      />
+                      <path
+                        class="three"
+                        d="M27.2,38h-6.3c-0.7,0-1.3-0.6-1.3-1.3s0.6-1.3,1.3-1.3h6.3c0.7,0,1.3,0.6,1.3,1.3S27.9,38,27.2,38z"
+                      />
+                      <path
+                        class="four"
+                        d="M45.1,69.8h-5.8c-0.7,0-1.3-0.6-1.3-1.3c0-0.7,0.6-1.3,1.3-1.3h5.8c0.7,0,1.3,0.6,1.3,1.3
+            C46.4,69.2,45.8,69.8,45.1,69.8z"
+                      />
+                    </svg>
+                  </div>
+                </div>
+              </div>
             </div>
           </div>
         </div>
diff --git a/web-console/src/dialogs/supervisor-table-action-dialog/supervisor-table-action-dialog.tsx b/web-console/src/dialogs/supervisor-table-action-dialog/supervisor-table-action-dialog.tsx
index a243cc9..41897f7 100644
--- a/web-console/src/dialogs/supervisor-table-action-dialog/supervisor-table-action-dialog.tsx
+++ b/web-console/src/dialogs/supervisor-table-action-dialog/supervisor-table-action-dialog.tsx
@@ -80,7 +80,7 @@ export const SupervisorTableActionDialog = React.memo(function SupervisorTableAc
       )}
       {activeTab === 'stats' && (
         <SupervisorStatisticsTable
-          endpoint={`/druid/indexer/v1/supervisor/${supervisorId}/stats`}
+          supervisorId={supervisorId}
           downloadFilename={`supervisor-stats-${supervisorId}.json`}
         />
       )}
diff --git a/web-console/src/dialogs/task-table-action-dialog/__snapshots__/task-table-action-dialog.spec.tsx.snap b/web-console/src/dialogs/task-table-action-dialog/__snapshots__/task-table-action-dialog.spec.tsx.snap
index 927f496..4f2fd88 100644
--- a/web-console/src/dialogs/task-table-action-dialog/__snapshots__/task-table-action-dialog.spec.tsx.snap
+++ b/web-console/src/dialogs/task-table-action-dialog/__snapshots__/task-table-action-dialog.spec.tsx.snap
@@ -227,7 +227,41 @@ exports[`task table action dialog matches snapshot 1`] = `
               </div>
               <div
                 class="main-area"
-              />
+              >
+                <div
+                  class="loader"
+                >
+                  <div
+                    class="loader-logo"
+                  >
+                    <svg
+                      viewBox="0 0 100 100"
+                    >
+                      <path
+                        class="one"
+                        d="M54.2,69.8h-2.7c-0.7,0-1.3-0.6-1.3-1.3c0-0.7,0.6-1.3,1.3-1.3h2.7c11.5,0,23.8-7.4,23.8-23.7
+          c0-9.1-6.9-15.8-16.4-15.8H38c-0.7,0-1.3-0.6-1.3-1.3s0.6-1.3,1.3-1.3h23.6c5.3,0,10.1,1.9,13.6,5.3c3.5,3.4,5.4,8,5.4,13.1
+          c0,6.6-2.3,13-6.3,17.7C69.5,66.8,62.5,69.8,54.2,69.8z"
+                      />
+                      <path
+                        class="two"
+                        d="M55.7,59.5h-26c-0.7,0-1.3-0.6-1.3-1.3c0-0.7,0.6-1.3,1.3-1.3h26c7.5,0,11.5-5.8,11.5-11.5
+            c0-4.2-3.2-7.3-7.7-7.3h-26c-0.7,0-1.3-0.6-1.3-1.3s0.6-1.3,1.3-1.3h26c5.9,0,10.3,4.3,10.3,9.9c0,3.7-1.3,7.2-3.7,9.8
+            C63.5,58,59.9,59.5,55.7,59.5z"
+                      />
+                      <path
+                        class="three"
+                        d="M27.2,38h-6.3c-0.7,0-1.3-0.6-1.3-1.3s0.6-1.3,1.3-1.3h6.3c0.7,0,1.3,0.6,1.3,1.3S27.9,38,27.2,38z"
+                      />
+                      <path
+                        class="four"
+                        d="M45.1,69.8h-5.8c-0.7,0-1.3-0.6-1.3-1.3c0-0.7,0.6-1.3,1.3-1.3h5.8c0.7,0,1.3,0.6,1.3,1.3
+            C46.4,69.2,45.8,69.8,45.1,69.8z"
+                      />
+                    </svg>
+                  </div>
+                </div>
+              </div>
             </div>
           </div>
         </div>
diff --git a/web-console/src/entry.scss b/web-console/src/entry.scss
index a64414d..0fddfa6 100644
--- a/web-console/src/entry.scss
+++ b/web-console/src/entry.scss
@@ -48,4 +48,10 @@ body {
   position: absolute;
   height: 100%;
   width: 100%;
+
+  .console-application {
+    position: absolute;
+    height: 100%;
+    width: 100%;
+  }
 }
diff --git a/web-console/src/utils/index.tsx b/web-console/src/hooks/index.ts
similarity index 80%
copy from web-console/src/utils/index.tsx
copy to web-console/src/hooks/index.ts
index 7e1cca2..38f3081 100644
--- a/web-console/src/utils/index.tsx
+++ b/web-console/src/hooks/index.ts
@@ -16,9 +16,6 @@
  * limitations under the License.
  */
 
-export * from './general';
-export * from './druid-query';
-export * from './query-manager';
-export * from './query-state';
-export * from './query-cursor';
-export * from './local-storage-keys';
+export * from './use-interval';
+export * from './use-global-event-listener';
+export * from './use-query-manager';
diff --git a/web-console/src/console-application.scss b/web-console/src/hooks/use-global-event-listener.ts
similarity index 59%
copy from web-console/src/console-application.scss
copy to web-console/src/hooks/use-global-event-listener.ts
index 8474158..79dc0dd 100644
--- a/web-console/src/console-application.scss
+++ b/web-console/src/hooks/use-global-event-listener.ts
@@ -16,33 +16,26 @@
  * limitations under the License.
  */
 
-@import './variables';
+import { useEffect, useRef } from 'react';
 
-.console-application {
-  position: absolute;
-  height: 100%;
-  width: 100%;
+export function useGlobalEventListener<T extends Event>(
+  eventName: keyof WindowEventMap,
+  handler: (e: T) => void,
+) {
+  const lastHandler = useRef<(e: T) => void>(handler);
 
-  .view-container {
-    position: absolute;
-    top: $header-bar-height;
-    left: 0;
-    right: 0;
-    bottom: 0;
-    padding: $standard-padding;
+  useEffect(() => {
+    lastHandler.current = handler;
+  }, [handler]);
 
-    &.narrow-pad {
-      padding: $standard-padding 10px;
-    }
+  useEffect(() => {
+    const myHandler = (e: Event) => {
+      lastHandler.current(e as T);
+    };
 
-    .app-view {
-      position: relative;
-    }
-  }
-
-  .control-separator {
-    width: 100%;
-    height: 22px;
-    border-top: 2px solid #6d8ea9;
-  }
+    window.addEventListener(eventName, myHandler);
+    return () => {
+      window.removeEventListener(eventName, myHandler);
+    };
+  }, [eventName]);
 }
diff --git a/web-console/src/dialogs/retention-dialog/retention-dialog.scss b/web-console/src/hooks/use-interval.ts
similarity index 64%
copy from web-console/src/dialogs/retention-dialog/retention-dialog.scss
copy to web-console/src/hooks/use-interval.ts
index 5917962..4e39c50 100644
--- a/web-console/src/dialogs/retention-dialog/retention-dialog.scss
+++ b/web-console/src/hooks/use-interval.ts
@@ -16,28 +16,26 @@
  * limitations under the License.
  */
 
-.retention-dialog {
-  &.bp3-dialog {
-    top: 5%;
-    width: 750px;
-  }
+import { useEffect, useRef } from 'react';
 
-  .bp3-dialog-body {
-    .rule-editor {
-      margin-bottom: 15px;
-    }
+export function useInterval(fn: () => void, delay: number) {
+  const lastFn = useRef<() => void>(fn);
 
-    .no-rules-message {
-      font-style: italic;
-    }
+  useEffect(() => {
+    lastFn.current = fn;
+  }, [fn]);
 
-    .comment {
-      margin-top: 10px;
+  useEffect(() => {
+    const intervalId = delay
+      ? setInterval(() => {
+          lastFn.current();
+        }, delay)
+      : undefined;
 
-      textarea {
-        max-width: 200px;
-        padding: 0 15px;
+    return () => {
+      if (intervalId) {
+        clearInterval(intervalId);
       }
-    }
-  }
+    };
+  }, [delay]);
 }
diff --git a/web-console/src/hooks/use-query-manager.ts b/web-console/src/hooks/use-query-manager.ts
new file mode 100644
index 0000000..f55c0cb
--- /dev/null
+++ b/web-console/src/hooks/use-query-manager.ts
@@ -0,0 +1,68 @@
+/*
+ * 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.
+ */
+
+import { CancelToken } from 'axios';
+import { useEffect, useState } from 'react';
+
+import { QueryManager, QueryState } from '../utils';
+
+export interface UseQueryManagerOptions<Q, R> {
+  processQuery: (
+    query: Q,
+    cancelToken: CancelToken,
+    setIntermediateQuery: (intermediateQuery: any) => void,
+  ) => Promise<R>;
+  debounceIdle?: number;
+  debounceLoading?: number;
+  query?: Q;
+  initQuery?: Q;
+}
+
+export function useQueryManager<Q, R>(
+  options: UseQueryManagerOptions<Q, R>,
+): [QueryState<R>, QueryManager<Q, R>] {
+  const { processQuery, debounceIdle, debounceLoading, query, initQuery } = options;
+
+  const [resultState, setResultState] = useState(QueryState.INIT);
+
+  const [queryManager] = useState(() => {
+    return new QueryManager({
+      processQuery,
+      debounceIdle,
+      debounceLoading,
+      onStateChange: setResultState,
+    });
+  });
+
+  useEffect(() => {
+    if (typeof initQuery !== 'undefined') {
+      queryManager.runQuery(initQuery);
+    }
+    return () => {
+      queryManager.terminate();
+    };
+  }, []);
+
+  if (query) {
+    useEffect(() => {
+      queryManager.runQuery(query);
+    }, [query]);
+  }
+
+  return [resultState, queryManager];
+}
diff --git a/web-console/src/utils/index.tsx b/web-console/src/utils/druid-lookup.ts
similarity index 80%
copy from web-console/src/utils/index.tsx
copy to web-console/src/utils/druid-lookup.ts
index 7e1cca2..990881a 100644
--- a/web-console/src/utils/index.tsx
+++ b/web-console/src/utils/druid-lookup.ts
@@ -16,9 +16,6 @@
  * limitations under the License.
  */
 
-export * from './general';
-export * from './druid-query';
-export * from './query-manager';
-export * from './query-state';
-export * from './query-cursor';
-export * from './local-storage-keys';
+export function isLookupsUninitialized(error: Error | undefined) {
+  return error && error.message === 'Request failed with status code 404';
+}
diff --git a/web-console/src/utils/druid-query.spec.ts b/web-console/src/utils/druid-query.spec.ts
new file mode 100644
index 0000000..41105a6
--- /dev/null
+++ b/web-console/src/utils/druid-query.spec.ts
@@ -0,0 +1,55 @@
+/*
+ * 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.
+ */
+
+import { DruidError } from './druid-query';
+
+describe('DruidQuery', () => {
+  describe('DruidError', () => {
+    it('works for single error 1', () => {
+      const message = `Encountered "COUNT" at line 2, column 12. Was expecting one of: <EOF> "AS" ... "EXCEPT" ... "FETCH" ... "FROM" ... "INTERSECT" ... "LIMIT" ...`;
+
+      expect(DruidError.parsePosition(message)).toEqual({
+        match: 'at line 2, column 12',
+        row: 1,
+        column: 11,
+      });
+    });
+
+    it('works for single error 2', () => {
+      const message = `org.apache.calcite.runtime.CalciteContextException: At line 2, column 20: Unknown identifier '*'`;
+
+      expect(DruidError.parsePosition(message)).toEqual({
+        match: 'At line 2, column 20',
+        row: 1,
+        column: 19,
+      });
+    });
+
+    it('works for range', () => {
+      const message = `org.apache.calcite.runtime.CalciteContextException: From line 2, column 13 to line 2, column 25: No match found for function signature SUMP(<NUMERIC>)`;
+
+      expect(DruidError.parsePosition(message)).toEqual({
+        match: 'From line 2, column 13 to line 2, column 25',
+        row: 1,
+        column: 12,
+        endRow: 1,
+        endColumn: 25,
+      });
+    });
+  });
+});
diff --git a/web-console/src/utils/druid-query.ts b/web-console/src/utils/druid-query.ts
index 267bd38..8013f72 100644
--- a/web-console/src/utils/druid-query.ts
+++ b/web-console/src/utils/druid-query.ts
@@ -20,6 +20,16 @@ import axios from 'axios';
 import { AxiosResponse } from 'axios';
 
 import { assemble } from './general';
+import { RowColumn } from './query-cursor';
+
+const CANCELED_MESSAGE = 'Query canceled by user.';
+
+export interface DruidErrorResponse {
+  error?: string;
+  errorMessage?: string;
+  errorClass?: string;
+  host?: string;
+}
 
 export function parseHtmlError(htmlStr: string): string | undefined {
   const startIndex = htmlStr.indexOf('</h3><pre>');
@@ -33,8 +43,8 @@ export function parseHtmlError(htmlStr: string): string | undefined {
     .replace(/&gt;/g, '>');
 }
 
-export function getDruidErrorMessage(e: any) {
-  const data: any = (e.response || {}).data || {};
+export function getDruidErrorMessage(e: any): string {
+  const data: DruidErrorResponse | string = (e.response || {}).data || {};
   switch (typeof data) {
     case 'object':
       return (
@@ -55,6 +65,72 @@ export function getDruidErrorMessage(e: any) {
   }
 }
 
+export class DruidError extends Error {
+  static parsePosition(errorMessage: string): RowColumn | undefined {
+    const range = String(errorMessage).match(
+      /from line (\d+), column (\d+) to line (\d+), column (\d+)/i,
+    );
+    if (range) {
+      return {
+        match: range[0],
+        row: Number(range[1]) - 1,
+        column: Number(range[2]) - 1,
+        endRow: Number(range[3]) - 1,
+        endColumn: Number(range[4]), // No -1 because we need to include the last char
+      };
+    }
+
+    const single = String(errorMessage).match(/at line (\d+), column (\d+)/i);
+    if (single) {
+      return {
+        match: single[0],
+        row: Number(single[1]) - 1,
+        column: Number(single[2]) - 1,
+      };
+    }
+
+    return;
+  }
+
+  public canceled?: boolean;
+  public error?: string;
+  public errorMessage?: string;
+  public position?: RowColumn;
+  public errorClass?: string;
+  public host?: string;
+
+  constructor(e: any) {
+    super(axios.isCancel(e) ? CANCELED_MESSAGE : getDruidErrorMessage(e));
+    if (axios.isCancel(e)) {
+      this.canceled = true;
+    } else {
+      const data: DruidErrorResponse | string = (e.response || {}).data || {};
+
+      let druidErrorResponse: DruidErrorResponse;
+      switch (typeof data) {
+        case 'object':
+          druidErrorResponse = data;
+          break;
+
+        case 'string':
+          druidErrorResponse = {
+            errorClass: 'HTML error',
+          };
+          break;
+
+        default:
+          druidErrorResponse = {};
+          break;
+      }
+      Object.assign(this, druidErrorResponse);
+
+      if (this.errorMessage) {
+        this.position = DruidError.parsePosition(this.errorMessage);
+      }
+    }
+  }
+}
+
 export async function queryDruidRune(runeQuery: Record<string, any>): Promise<any> {
   let runeResultResp: AxiosResponse<any>;
   try {
@@ -65,10 +141,10 @@ export async function queryDruidRune(runeQuery: Record<string, any>): Promise<an
   return runeResultResp.data;
 }
 
-export async function queryDruidSql<T = any>(sqlQuery: Record<string, any>): Promise<T[]> {
+export async function queryDruidSql<T = any>(sqlQueryPayload: Record<string, any>): Promise<T[]> {
   let sqlResultResp: AxiosResponse<any>;
   try {
-    sqlResultResp = await axios.post('/druid/v2/sql', sqlQuery);
+    sqlResultResp = await axios.post('/druid/v2/sql', sqlQueryPayload);
   } catch (e) {
     throw new Error(getDruidErrorMessage(e));
   }
diff --git a/web-console/src/utils/general.spec.ts b/web-console/src/utils/general.spec.ts
index b9f4788..2a327bd 100644
--- a/web-console/src/utils/general.spec.ts
+++ b/web-console/src/utils/general.spec.ts
@@ -16,7 +16,12 @@
  * limitations under the License.
  */
 
-import { alphanumericCompare, sortWithPrefixSuffix, sqlQueryCustomTableFilter } from './general';
+import {
+  alphanumericCompare,
+  sortWithPrefixSuffix,
+  sqlQueryCustomTableFilter,
+  swapElements,
+} from './general';
 
 describe('general', () => {
   describe('sortWithPrefixSuffix', () => {
@@ -60,4 +65,22 @@ describe('general', () => {
       ).toMatchInlineSnapshot(`"\\"datasource\\" = 'hello'"`);
     });
   });
+
+  describe('swapElements', () => {
+    const array = ['a', 'b', 'c', 'd', 'e'];
+
+    it('works when nothing changes', () => {
+      expect(swapElements(array, 0, 0)).toEqual(['a', 'b', 'c', 'd', 'e']);
+    });
+
+    it('works upward', () => {
+      expect(swapElements(array, 2, 1)).toEqual(['a', 'c', 'b', 'd', 'e']);
+      expect(swapElements(array, 2, 0)).toEqual(['c', 'b', 'a', 'd', 'e']);
+    });
+
+    it('works downward', () => {
+      expect(swapElements(array, 2, 3)).toEqual(['a', 'b', 'd', 'c', 'e']);
+      expect(swapElements(array, 2, 4)).toEqual(['a', 'b', 'e', 'd', 'c']);
+    });
+  });
 });
diff --git a/web-console/src/utils/general.tsx b/web-console/src/utils/general.tsx
index 77c44be..ddf40a1 100644
--- a/web-console/src/utils/general.tsx
+++ b/web-console/src/utils/general.tsx
@@ -26,6 +26,7 @@ import React from 'react';
 import { Filter, FilterRender } from 'react-table';
 
 import { AppToaster } from '../singletons/toaster';
+
 export function wait(ms: number): Promise<void> {
   return new Promise(resolve => {
     setTimeout(resolve, ms);
@@ -137,7 +138,7 @@ export function caseInsensitiveContains(testString: string, searchString: string
 // ----------------------------
 
 export function countBy<T>(
-  array: T[],
+  array: readonly T[],
   fn: (x: T, index: number) => string = String,
 ): Record<string, number> {
   const counts: Record<string, number> = {};
@@ -148,20 +149,21 @@ export function countBy<T>(
   return counts;
 }
 
-function identity(x: any): any {
+function identity<T>(x: T): T {
   return x;
 }
 
-export function lookupBy<T, Q>(
-  array: T[],
+export function lookupBy<T, Q = T>(
+  array: readonly T[],
   keyFn: (x: T, index: number) => string = String,
-  valueFn: (x: T, index: number) => Q = identity,
+  valueFn?: (x: T, index: number) => Q,
 ): Record<string, Q> {
+  if (!valueFn) valueFn = identity as any;
   const lookup: Record<string, Q> = {};
   const n = array.length;
   for (let i = 0; i < n; i++) {
     const a = array[i];
-    lookup[keyFn(a, i)] = valueFn(a, i);
+    lookup[keyFn(a, i)] = valueFn!(a, i);
   }
   return lookup;
 }
@@ -260,7 +262,7 @@ export function validJson(json: string): boolean {
   }
 }
 
-export function filterMap<T, Q>(xs: T[], f: (x: T, i: number) => Q | undefined): Q[] {
+export function filterMap<T, Q>(xs: readonly T[], f: (x: T, i: number) => Q | undefined): Q[] {
   return xs.map(f).filter((x: Q | undefined) => typeof x !== 'undefined') as Q[];
 }
 
@@ -277,9 +279,9 @@ export function alphanumericCompare(a: string, b: string): number {
 }
 
 export function sortWithPrefixSuffix(
-  things: string[],
-  prefix: string[],
-  suffix: string[],
+  things: readonly string[],
+  prefix: readonly string[],
+  suffix: readonly string[],
   cmp: null | ((a: string, b: string) => number),
 ): string[] {
   const pre = uniq(prefix.filter(x => things.includes(x)));
@@ -322,3 +324,11 @@ export function delay(ms: number) {
     setTimeout(resolve, ms);
   });
 }
+
+export function swapElements<T>(items: readonly T[], indexA: number, indexB: number): T[] {
+  const newItems = items.concat();
+  const t = newItems[indexA];
+  newItems[indexA] = newItems[indexB];
+  newItems[indexB] = t;
+  return newItems;
+}
diff --git a/web-console/src/utils/index.tsx b/web-console/src/utils/index.tsx
index 7e1cca2..b46d675 100644
--- a/web-console/src/utils/index.tsx
+++ b/web-console/src/utils/index.tsx
@@ -18,7 +18,9 @@
 
 export * from './general';
 export * from './druid-query';
-export * from './query-manager';
+export * from './druid-lookup';
 export * from './query-state';
+export * from './query-manager';
 export * from './query-cursor';
 export * from './local-storage-keys';
+export * from './column-metadata';
diff --git a/web-console/src/utils/local-storage-keys.tsx b/web-console/src/utils/local-storage-keys.tsx
index ed5f2b9..5397606 100644
--- a/web-console/src/utils/local-storage-keys.tsx
+++ b/web-console/src/utils/local-storage-keys.tsx
@@ -36,7 +36,7 @@ export const LocalStorageKeys = {
   SUPERVISORS_REFRESH_RATE: 'supervisors-refresh-rate' as 'supervisors-refresh-rate',
   LOOKUPS_REFRESH_RATE: 'lookups-refresh-rate' as 'lookups-refresh-rate',
   QUERY_HISTORY: 'query-history' as 'query-history',
-  AUTO_RUN: 'auto-run' as 'auto-run',
+  LIVE_QUERY_MODE: 'live-query-mode' as 'live-query-mode',
 };
 export type LocalStorageKeys = typeof LocalStorageKeys[keyof typeof LocalStorageKeys];
 
diff --git a/web-console/src/utils/query-cursor.ts b/web-console/src/utils/query-cursor.ts
index 4eb32fe..c7a4a42 100644
--- a/web-console/src/utils/query-cursor.ts
+++ b/web-console/src/utils/query-cursor.ts
@@ -36,8 +36,11 @@ export function prettyPrintSql(b: SqlBase): string {
 }
 
 export interface RowColumn {
+  match: string;
   row: number;
   column: number;
+  endRow?: number;
+  endColumn?: number;
 }
 
 export function findEmptyLiteralPosition(query: SqlQuery): RowColumn | undefined {
@@ -58,6 +61,7 @@ export function findEmptyLiteralPosition(query: SqlQuery): RowColumn | undefined
   const row = lines.length - 1;
   const lastLine = lines[row];
   return {
+    match: '',
     row,
     column: lastLine.length,
   };
diff --git a/web-console/src/utils/query-manager.tsx b/web-console/src/utils/query-manager.tsx
index d7f0acb..411d054 100644
--- a/web-console/src/utils/query-manager.tsx
+++ b/web-console/src/utils/query-manager.tsx
@@ -16,17 +16,19 @@
  * limitations under the License.
  */
 
+import axios from 'axios';
+import { CancelToken } from 'axios';
 import debounce from 'lodash.debounce';
 
-export interface QueryStateInt<R> {
-  result?: R;
-  loading: boolean;
-  error?: string;
-}
+import { QueryState } from './query-state';
 
 export interface QueryManagerOptions<Q, R> {
-  processQuery: (query: Q, setIntermediateQuery: (intermediateQuery: any) => void) => Promise<R>;
-  onStateChange?: (queryResolve: QueryStateInt<R>) => void;
+  processQuery: (
+    query: Q,
+    cancelToken: CancelToken,
+    setIntermediateQuery: (intermediateQuery: any) => void,
+  ) => Promise<R>;
+  onStateChange?: (queryResolve: QueryState<R>) => void;
   debounceIdle?: number;
   debounceLoading?: number;
 }
@@ -34,18 +36,17 @@ export interface QueryManagerOptions<Q, R> {
 export class QueryManager<Q, R> {
   private processQuery: (
     query: Q,
+    cancelToken: CancelToken,
     setIntermediateQuery: (intermediateQuery: any) => void,
   ) => Promise<R>;
-  private onStateChange?: (queryResolve: QueryStateInt<R>) => void;
+  private onStateChange?: (queryResolve: QueryState<R>) => void;
 
   private terminated = false;
   private nextQuery: Q | undefined;
   private lastQuery: Q | undefined;
   private lastIntermediateQuery: any;
-  private actuallyLoading = false;
-  private state: QueryStateInt<R> = {
-    loading: false,
-  };
+  private currentRunCancelFn: (() => void) | undefined;
+  private state: QueryState<R> = QueryState.INIT;
   private currentQueryId = 0;
 
   private runWhenIdle: () => void;
@@ -66,7 +67,7 @@ export class QueryManager<Q, R> {
     }
   }
 
-  private setState(queryState: QueryStateInt<R>) {
+  private setState(queryState: QueryState<R>) {
     this.state = queryState;
     if (this.onStateChange && !this.terminated) {
       this.onStateChange(queryState);
@@ -79,41 +80,49 @@ export class QueryManager<Q, R> {
     this.currentQueryId++;
     const myQueryId = this.currentQueryId;
 
-    this.actuallyLoading = true;
-    this.processQuery(this.lastQuery, (intermediateQuery: any) => {
+    if (this.currentRunCancelFn) {
+      this.currentRunCancelFn();
+    }
+    const cancelToken = new axios.CancelToken(cancelFn => {
+      this.currentRunCancelFn = cancelFn;
+    });
+    this.processQuery(this.lastQuery, cancelToken, (intermediateQuery: any) => {
       this.lastIntermediateQuery = intermediateQuery;
     }).then(
-      result => {
+      data => {
         if (this.currentQueryId !== myQueryId) return;
-        this.actuallyLoading = false;
-        this.setState({
-          result,
-          loading: false,
-          error: undefined,
-        });
+        this.currentRunCancelFn = undefined;
+        this.setState(
+          new QueryState<R>({
+            data,
+          }),
+        );
       },
-      (e: Error) => {
+      (e: any) => {
         if (this.currentQueryId !== myQueryId) return;
-        this.actuallyLoading = false;
-        this.setState({
-          result: undefined,
-          loading: false,
-          error: e.message,
-        });
+        this.currentRunCancelFn = undefined;
+        if (axios.isCancel(e)) {
+          e = new Error(`canceled.`); // ToDo: fix!
+        }
+        this.setState(
+          new QueryState<R>({
+            error: e,
+          }),
+        );
       },
     );
   }
 
   private trigger() {
-    const currentActuallyLoading = this.actuallyLoading;
+    const currentlyLoading = Boolean(this.currentRunCancelFn);
 
-    this.setState({
-      result: undefined,
-      loading: true,
-      error: undefined,
-    });
+    this.setState(
+      new QueryState<R>({
+        loading: true,
+      }),
+    );
 
-    if (currentActuallyLoading) {
+    if (currentlyLoading) {
       this.runWhenLoading();
     } else {
       this.runWhenIdle();
@@ -136,6 +145,12 @@ export class QueryManager<Q, R> {
     }
   }
 
+  public cancelCurrent(): void {
+    if (!this.currentRunCancelFn) return;
+    this.currentRunCancelFn();
+    this.currentRunCancelFn = undefined;
+  }
+
   public getLastQuery(): Q | undefined {
     return this.lastQuery;
   }
@@ -144,7 +159,7 @@ export class QueryManager<Q, R> {
     return this.lastIntermediateQuery;
   }
 
-  public getState(): QueryStateInt<R> {
+  public getState(): QueryState<R> {
     return this.state;
   }
 
diff --git a/web-console/src/utils/query-state.ts b/web-console/src/utils/query-state.ts
index 60e09fa..e3eeaf2 100644
--- a/web-console/src/utils/query-state.ts
+++ b/web-console/src/utils/query-state.ts
@@ -18,23 +18,25 @@
 
 export type QueryStateState = 'init' | 'loading' | 'data' | 'error';
 
-export class QueryState<T> {
+export class QueryState<T, E extends Error = Error> {
   static INIT: QueryState<any> = new QueryState({});
+  static LOADING: QueryState<any> = new QueryState({ loading: true });
 
   public state: QueryStateState = 'init';
-  public error?: string;
+  public error?: E;
   public data?: T;
 
-  constructor(opts: { loading?: boolean; error?: string; data?: T }) {
-    if (opts.error) {
-      if (opts.data) {
+  constructor(opts: { loading?: boolean; error?: E; data?: T }) {
+    const hasData = typeof opts.data !== 'undefined';
+    if (typeof opts.error !== 'undefined') {
+      if (hasData) {
         throw new Error('can not have both error and data');
       } else {
         this.state = 'error';
         this.error = opts.error;
       }
     } else {
-      if (opts.data) {
+      if (hasData) {
         this.state = 'data';
         this.data = opts.data;
       } else {
@@ -50,4 +52,23 @@ export class QueryState<T> {
   isLoading(): boolean {
     return this.state === 'loading';
   }
+
+  get loading(): boolean {
+    return this.state === 'loading';
+  }
+
+  isError(): boolean {
+    return this.state === 'error';
+  }
+
+  getErrorMessage(): string | undefined {
+    const { error } = this;
+    if (!error) return;
+    return error.message;
+  }
+
+  isEmpty(): boolean {
+    const { data } = this;
+    return Boolean(data && Array.isArray(data) && data.length === 0);
+  }
 }
diff --git a/web-console/src/views/datasource-view/datasource-view.tsx b/web-console/src/views/datasource-view/datasource-view.tsx
index d486f87..aacd854 100644
--- a/web-console/src/views/datasource-view/datasource-view.tsx
+++ b/web-console/src/views/datasource-view/datasource-view.tsx
@@ -32,11 +32,16 @@ import {
   ActionIcon,
   MoreButton,
   RefreshButton,
+  SegmentTimeline,
   TableColumnSelector,
   ViewControlBar,
 } from '../../components';
-import { SegmentTimeline } from '../../components/segment-timeline/segment-timeline';
-import { AsyncActionDialog, CompactionDialog, RetentionDialog } from '../../dialogs';
+import {
+  AsyncActionDialog,
+  CompactionDialog,
+  DEFAULT_MAX_ROWS_PER_SEGMENT,
+  RetentionDialog,
+} from '../../dialogs';
 import { DatasourceTableActionDialog } from '../../dialogs/datasource-table-action-dialog/datasource-table-action-dialog';
 import { AppToaster } from '../../singletons/toaster';
 import {
@@ -50,6 +55,7 @@ import {
   pluralIfNeeded,
   queryDruidSql,
   QueryManager,
+  QueryState,
 } from '../../utils';
 import { BasicAction } from '../../utils/basic-action';
 import { Capabilities, CapabilitiesMode } from '../../utils/capabilities';
@@ -111,6 +117,11 @@ interface Datasource {
   [key: string]: any;
 }
 
+interface DatasourcesAndDefaultRules {
+  datasources: Datasource[];
+  defaultRules: Rule[];
+}
+
 interface DatasourceQueryResultRow {
   datasource: string;
   num_segments: number;
@@ -142,12 +153,10 @@ export interface DatasourcesViewProps {
 }
 
 export interface DatasourcesViewState {
-  datasourcesLoading: boolean;
-  datasources: Datasource[] | null;
-  tiers: string[];
-  defaultRules: Rule[];
-  datasourcesError?: string;
   datasourceFilter: Filter[];
+  datasourcesAndDefaultRulesState: QueryState<DatasourcesAndDefaultRules>;
+
+  tiersState: QueryState<string[]>;
 
   showUnused: boolean;
   retentionDialogOpenOn?: RetentionDialogOpenOn;
@@ -201,10 +210,8 @@ GROUP BY 1`;
     }
   }
 
-  private datasourceQueryManager: QueryManager<
-    Capabilities,
-    { tiers: string[]; defaultRules: any[]; datasources: Datasource[] }
-  >;
+  private datasourceQueryManager: QueryManager<Capabilities, DatasourcesAndDefaultRules>;
+  private tiersQueryManager: QueryManager<Capabilities, string[]>;
 
   constructor(props: DatasourcesViewProps, context: any) {
     super(props, context);
@@ -215,11 +222,10 @@ GROUP BY 1`;
     }
 
     this.state = {
-      datasourcesLoading: true,
-      datasources: null,
-      tiers: [],
-      defaultRules: [],
       datasourceFilter,
+      datasourcesAndDefaultRulesState: QueryState.INIT,
+
+      tiersState: QueryState.INIT,
 
       showUnused: false,
       useUnuseAction: 'unuse',
@@ -272,7 +278,6 @@ GROUP BY 1`;
           });
           return {
             datasources,
-            tiers: [],
             defaultRules: [],
           };
         }
@@ -298,9 +303,6 @@ GROUP BY 1`;
           (c: any) => c.dataSource,
         );
 
-        const tiersResp = await axios.get('/druid/coordinator/v1/tiers');
-        const tiers = tiersResp.data;
-
         const allDatasources = (datasources as any).concat(
           unused.map(d => ({ datasource: d, unused: true })),
         );
@@ -311,20 +313,29 @@ GROUP BY 1`;
 
         return {
           datasources: allDatasources,
-          tiers,
           defaultRules: rules['_default'],
         };
       },
-      onStateChange: ({ result, loading, error }) => {
+      onStateChange: datasourcesAndDefaultRulesState => {
         this.setState({
-          datasourcesLoading: loading,
-          datasources: result ? result.datasources : null,
-          tiers: result ? result.tiers : [],
-          defaultRules: result ? result.defaultRules : [],
-          datasourcesError: error || undefined,
+          datasourcesAndDefaultRulesState,
         });
       },
     });
+
+    this.tiersQueryManager = new QueryManager({
+      processQuery: async capabilities => {
+        if (capabilities.hasCoordinatorAccess()) {
+          const tiersResp = await axios.get('/druid/coordinator/v1/tiers');
+          return tiersResp.data;
+        } else {
+          throw new Error(`must have coordinator access`);
+        }
+      },
+      onStateChange: tiersState => {
+        this.setState({ tiersState });
+      },
+    });
   }
 
   private handleResize = () => {
@@ -336,16 +347,19 @@ GROUP BY 1`;
 
   private refresh = (auto: any): void => {
     this.datasourceQueryManager.rerunLastQuery(auto);
+    this.tiersQueryManager.rerunLastQuery(auto);
   };
 
   componentDidMount(): void {
     const { capabilities } = this.props;
     this.datasourceQueryManager.runQuery(capabilities);
+    this.tiersQueryManager.runQuery(capabilities);
     window.addEventListener('resize', this.handleResize);
   }
 
   componentWillUnmount(): void {
     this.datasourceQueryManager.terminate();
+    this.tiersQueryManager.terminate();
   }
 
   renderUnuseAction() {
@@ -530,16 +544,18 @@ GROUP BY 1`;
   };
 
   private editDefaultRules = () => {
-    const { datasources, defaultRules } = this.state;
-    if (!datasources) return;
-
     this.setState({ retentionDialogOpenOn: undefined });
     setTimeout(() => {
-      this.setState({
-        retentionDialogOpenOn: {
-          datasource: '_default',
-          rules: defaultRules,
-        },
+      this.setState(state => {
+        const datasourcesAndDefaultRules = state.datasourcesAndDefaultRulesState.data;
+        if (!datasourcesAndDefaultRules) return {};
+
+        return {
+          retentionDialogOpenOn: {
+            datasource: '_default',
+            rules: datasourcesAndDefaultRules.defaultRules,
+          },
+        };
       });
     }, 50);
   };
@@ -704,17 +720,21 @@ GROUP BY 1`;
     }
   }
 
-  renderRetentionDialog() {
-    const { retentionDialogOpenOn, tiers, defaultRules } = this.state;
-    if (!retentionDialogOpenOn) return null;
+  renderRetentionDialog(): JSX.Element | undefined {
+    const { retentionDialogOpenOn, tiersState, datasourcesAndDefaultRulesState } = this.state;
+    const { defaultRules } = datasourcesAndDefaultRulesState.data || {
+      datasources: [],
+      defaultRules: [],
+    };
+    if (!retentionDialogOpenOn) return;
 
     return (
       <RetentionDialog
         datasource={retentionDialogOpenOn.datasource}
         rules={retentionDialogOpenOn.rules}
-        defaultRules={defaultRules}
-        tiers={tiers}
+        tiers={tiersState.data || []}
         onEditDefaults={this.editDefaultRules}
+        defaultRules={defaultRules}
         onCancel={() => this.setState({ retentionDialogOpenOn: undefined })}
         onSave={this.saveRules}
       />
@@ -722,9 +742,8 @@ GROUP BY 1`;
   }
 
   renderCompactionDialog() {
-    const { datasources, compactionDialogOpenOn } = this.state;
-
-    if (!compactionDialogOpenOn || !datasources) return;
+    const { datasourcesAndDefaultRulesState, compactionDialogOpenOn } = this.state;
+    if (!compactionDialogOpenOn || !datasourcesAndDefaultRulesState.data) return;
 
     return (
       <CompactionDialog
@@ -740,27 +759,29 @@ GROUP BY 1`;
   renderDatasourceTable() {
     const { goToSegments, capabilities } = this.props;
     const {
-      datasources,
-      defaultRules,
-      datasourcesLoading,
-      datasourcesError,
+      datasourcesAndDefaultRulesState,
       datasourceFilter,
       showUnused,
       hiddenColumns,
     } = this.state;
-    let data = datasources || [];
+
+    let { datasources, defaultRules } = datasourcesAndDefaultRulesState.data
+      ? datasourcesAndDefaultRulesState.data
+      : { datasources: [], defaultRules: [] };
+
     if (!showUnused) {
-      data = data.filter(d => !d.unused);
+      datasources = datasources.filter(d => !d.unused);
     }
+
     return (
       <>
         <ReactTable
-          data={data}
-          loading={datasourcesLoading}
+          data={datasources}
+          loading={datasourcesAndDefaultRulesState.loading}
           noDataText={
-            !datasourcesLoading && datasources && !datasources.length
+            !datasourcesAndDefaultRulesState.loading && datasources && !datasources.length
               ? 'No datasources'
-              : datasourcesError || ''
+              : datasourcesAndDefaultRulesState.getErrorMessage() || ''
           }
           filterable
           filtered={datasourceFilter}
@@ -922,9 +943,7 @@ GROUP BY 1`;
                 let text: string;
                 if (compaction) {
                   if (compaction.maxRowsPerSegment == null) {
-                    text = `Target: Default (${formatNumber(
-                      CompactionDialog.DEFAULT_MAX_ROWS_PER_SEGMENT,
-                    )})`;
+                    text = `Target: Default (${formatNumber(DEFAULT_MAX_ROWS_PER_SEGMENT)})`;
                   } else {
                     text = `Target: ${formatNumber(compaction.maxRowsPerSegment)}`;
                   }
diff --git a/web-console/src/views/home-view/__snapshots__/home-view.spec.tsx.snap b/web-console/src/views/home-view/__snapshots__/home-view.spec.tsx.snap
index 2a61007..210fd0b 100644
--- a/web-console/src/views/home-view/__snapshots__/home-view.spec.tsx.snap
+++ b/web-console/src/views/home-view/__snapshots__/home-view.spec.tsx.snap
@@ -1,11 +1,11 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`home view matches snapshot (coordiantor) 1`] = `
+exports[`home view matches snapshot (coordinator) 1`] = `
 <div
   className="home-view app-view"
 >
-  <StatusCard />
-  <DatasourcesCard
+  <Memo(StatusCard) />
+  <Memo(DatasourcesCard)
     capabilities={
       Capabilities {
         "coordinator": true,
@@ -14,7 +14,7 @@ exports[`home view matches snapshot (coordiantor) 1`] = `
       }
     }
   />
-  <SegmentsCard
+  <Memo(SegmentsCard)
     capabilities={
       Capabilities {
         "coordinator": true,
@@ -23,7 +23,7 @@ exports[`home view matches snapshot (coordiantor) 1`] = `
       }
     }
   />
-  <ServicesCard
+  <Memo(ServicesCard)
     capabilities={
       Capabilities {
         "coordinator": true,
@@ -32,7 +32,7 @@ exports[`home view matches snapshot (coordiantor) 1`] = `
       }
     }
   />
-  <LookupsCard
+  <Memo(LookupsCard)
     capabilities={
       Capabilities {
         "coordinator": true,
@@ -48,8 +48,8 @@ exports[`home view matches snapshot (full) 1`] = `
 <div
   className="home-view app-view"
 >
-  <StatusCard />
-  <DatasourcesCard
+  <Memo(StatusCard) />
+  <Memo(DatasourcesCard)
     capabilities={
       Capabilities {
         "coordinator": true,
@@ -58,7 +58,7 @@ exports[`home view matches snapshot (full) 1`] = `
       }
     }
   />
-  <SegmentsCard
+  <Memo(SegmentsCard)
     capabilities={
       Capabilities {
         "coordinator": true,
@@ -67,7 +67,7 @@ exports[`home view matches snapshot (full) 1`] = `
       }
     }
   />
-  <SupervisorsCard
+  <Memo(SupervisorsCard)
     capabilities={
       Capabilities {
         "coordinator": true,
@@ -76,7 +76,7 @@ exports[`home view matches snapshot (full) 1`] = `
       }
     }
   />
-  <TasksCard
+  <Memo(TasksCard)
     capabilities={
       Capabilities {
         "coordinator": true,
@@ -85,7 +85,7 @@ exports[`home view matches snapshot (full) 1`] = `
       }
     }
   />
-  <ServicesCard
+  <Memo(ServicesCard)
     capabilities={
       Capabilities {
         "coordinator": true,
@@ -94,7 +94,7 @@ exports[`home view matches snapshot (full) 1`] = `
       }
     }
   />
-  <LookupsCard
+  <Memo(LookupsCard)
     capabilities={
       Capabilities {
         "coordinator": true,
@@ -110,8 +110,8 @@ exports[`home view matches snapshot (overlord) 1`] = `
 <div
   className="home-view app-view"
 >
-  <StatusCard />
-  <SupervisorsCard
+  <Memo(StatusCard) />
+  <Memo(SupervisorsCard)
     capabilities={
       Capabilities {
         "coordinator": false,
@@ -120,7 +120,7 @@ exports[`home view matches snapshot (overlord) 1`] = `
       }
     }
   />
-  <TasksCard
+  <Memo(TasksCard)
     capabilities={
       Capabilities {
         "coordinator": false,
diff --git a/web-console/src/views/home-view/datasources-card/datasources-card.tsx b/web-console/src/views/home-view/datasources-card/datasources-card.tsx
index f36bd18c..c8ec31a 100644
--- a/web-console/src/views/home-view/datasources-card/datasources-card.tsx
+++ b/web-console/src/views/home-view/datasources-card/datasources-card.tsx
@@ -20,7 +20,8 @@ import { IconNames } from '@blueprintjs/icons';
 import axios from 'axios';
 import React from 'react';
 
-import { pluralIfNeeded, queryDruidSql, QueryManager } from '../../../utils';
+import { useQueryManager } from '../../../hooks';
+import { pluralIfNeeded, queryDruidSql } from '../../../utils';
 import { Capabilities } from '../../../utils/capabilities';
 import { HomeViewCard } from '../home-view-card/home-view-card';
 
@@ -28,74 +29,36 @@ export interface DatasourcesCardProps {
   capabilities: Capabilities;
 }
 
-export interface DatasourcesCardState {
-  datasourceCountLoading: boolean;
-  datasourceCount: number;
-  datasourceCountError?: string;
-}
-
-export class DatasourcesCard extends React.PureComponent<
-  DatasourcesCardProps,
-  DatasourcesCardState
-> {
-  private datasourceQueryManager: QueryManager<Capabilities, any>;
-
-  constructor(props: DatasourcesCardProps, context: any) {
-    super(props, context);
-    this.state = {
-      datasourceCountLoading: false,
-      datasourceCount: 0,
-    };
-
-    this.datasourceQueryManager = new QueryManager({
-      processQuery: async capabilities => {
-        let datasources: string[];
-        if (capabilities.hasSql()) {
-          datasources = await queryDruidSql({
-            query: `SELECT datasource FROM sys.segments GROUP BY 1`,
-          });
-        } else if (capabilities.hasCoordinatorAccess()) {
-          const datasourcesResp = await axios.get('/druid/coordinator/v1/datasources');
-          datasources = datasourcesResp.data;
-        } else {
-          throw new Error(`must have SQL or coordinator access`);
-        }
-
-        return datasources.length;
-      },
-      onStateChange: ({ result, loading, error }) => {
-        this.setState({
-          datasourceCountLoading: loading,
-          datasourceCount: result,
-          datasourceCountError: error || undefined,
+export const DatasourcesCard = React.memo(function DatasourcesCard(props: DatasourcesCardProps) {
+  const [datasourceCountState] = useQueryManager<Capabilities, number>({
+    processQuery: async capabilities => {
+      let datasources: string[];
+      if (capabilities.hasSql()) {
+        datasources = await queryDruidSql({
+          query: `SELECT datasource FROM sys.segments GROUP BY 1`,
         });
-      },
-    });
-  }
-
-  componentDidMount(): void {
-    const { capabilities } = this.props;
+      } else if (capabilities.hasCoordinatorAccess()) {
+        const datasourcesResp = await axios.get('/druid/coordinator/v1/datasources');
+        datasources = datasourcesResp.data;
+      } else {
+        throw new Error(`must have SQL or coordinator access`);
+      }
 
-    this.datasourceQueryManager.runQuery(capabilities);
-  }
+      return datasources.length;
+    },
+    initQuery: props.capabilities,
+  });
 
-  componentWillUnmount(): void {
-    this.datasourceQueryManager.terminate();
-  }
-
-  render(): JSX.Element {
-    const { datasourceCountLoading, datasourceCountError, datasourceCount } = this.state;
-    return (
-      <HomeViewCard
-        className="datasources-card"
-        href={'#datasources'}
-        icon={IconNames.MULTI_SELECT}
-        title={'Datasources'}
-        loading={datasourceCountLoading}
-        error={datasourceCountError}
-      >
-        <p>{pluralIfNeeded(datasourceCount, 'datasource')}</p>
-      </HomeViewCard>
-    );
-  }
-}
+  return (
+    <HomeViewCard
+      className="datasources-card"
+      href={'#datasources'}
+      icon={IconNames.MULTI_SELECT}
+      title={'Datasources'}
+      loading={datasourceCountState.loading}
+      error={datasourceCountState.error}
+    >
+      <p>{pluralIfNeeded(datasourceCountState.data || 0, 'datasource')}</p>
+    </HomeViewCard>
+  );
+});
diff --git a/web-console/src/views/home-view/home-view-card/home-view-card.tsx b/web-console/src/views/home-view/home-view-card/home-view-card.tsx
index 09f1ab5..e88f256 100644
--- a/web-console/src/views/home-view/home-view-card/home-view-card.tsx
+++ b/web-console/src/views/home-view/home-view-card/home-view-card.tsx
@@ -29,7 +29,7 @@ export interface HomeViewCardProps {
   icon: IconName;
   title: string;
   loading: boolean;
-  error: string | undefined;
+  error: Error | undefined;
   children?: ReactNode;
 }
 
@@ -48,7 +48,7 @@ export const HomeViewCard = React.memo(function HomeViewCard(props: HomeViewCard
           <Icon color="#bfccd5" icon={icon} />
           &nbsp;{title}
         </H5>
-        {loading ? <p>Loading...</p> : error ? `Error: ${error}` : children}
+        {loading ? <p>Loading...</p> : error ? `Error: ${error.message}` : children}
       </Card>
     </a>
   );
diff --git a/web-console/src/views/home-view/home-view.spec.tsx b/web-console/src/views/home-view/home-view.spec.tsx
index da8f05d..a2820e8 100644
--- a/web-console/src/views/home-view/home-view.spec.tsx
+++ b/web-console/src/views/home-view/home-view.spec.tsx
@@ -29,7 +29,7 @@ describe('home view', () => {
     expect(homeView).toMatchSnapshot();
   });
 
-  it('matches snapshot (coordiantor)', () => {
+  it('matches snapshot (coordinator)', () => {
     const homeView = shallow(<HomeView capabilities={Capabilities.COORDINATOR} />);
     expect(homeView).toMatchSnapshot();
   });
diff --git a/web-console/src/views/home-view/lookups-card/lookups-card.tsx b/web-console/src/views/home-view/lookups-card/lookups-card.tsx
index 757bba6..b752d57 100644
--- a/web-console/src/views/home-view/lookups-card/lookups-card.tsx
+++ b/web-console/src/views/home-view/lookups-card/lookups-card.tsx
@@ -21,7 +21,8 @@ import axios from 'axios';
 import { sum } from 'd3-array';
 import React from 'react';
 
-import { pluralIfNeeded, QueryManager } from '../../../utils';
+import { useQueryManager } from '../../../hooks';
+import { isLookupsUninitialized, pluralIfNeeded } from '../../../utils';
 import { Capabilities } from '../../../utils/capabilities';
 import { HomeViewCard } from '../home-view-card/home-view-card';
 
@@ -29,78 +30,34 @@ export interface LookupsCardProps {
   capabilities: Capabilities;
 }
 
-export interface LookupsCardState {
-  lookupsCountLoading: boolean;
-  lookupsCount: number;
-  lookupsUninitialized: boolean;
-  lookupsCountError?: string;
-}
-
-export class LookupsCard extends React.PureComponent<LookupsCardProps, LookupsCardState> {
-  private lookupsQueryManager: QueryManager<Capabilities, any>;
-
-  constructor(props: LookupsCardProps, context: any) {
-    super(props, context);
-    this.state = {
-      lookupsCountLoading: false,
-      lookupsCount: 0,
-      lookupsUninitialized: false,
-    };
-
-    this.lookupsQueryManager = new QueryManager({
-      processQuery: async capabilities => {
-        if (capabilities.hasCoordinatorAccess()) {
-          const resp = await axios.get('/druid/coordinator/v1/lookups/status');
-          const data = resp.data;
-          const lookupsCount = sum(Object.keys(data).map(k => Object.keys(data[k]).length));
-          return {
-            lookupsCount,
-          };
-        } else {
-          throw new Error(`must have coordinator access`);
-        }
-      },
-      onStateChange: ({ result, loading, error }) => {
-        this.setState({
-          lookupsCount: result ? result.lookupsCount : 0,
-          lookupsUninitialized: error === 'Request failed with status code 404',
-          lookupsCountLoading: loading,
-          lookupsCountError: error,
-        });
-      },
-    });
-  }
-
-  componentDidMount(): void {
-    const { capabilities } = this.props;
-    this.lookupsQueryManager.runQuery(capabilities);
-  }
-
-  componentWillUnmount(): void {
-    this.lookupsQueryManager.terminate();
-  }
-
-  render(): JSX.Element {
-    const {
-      lookupsCountLoading,
-      lookupsCount,
-      lookupsUninitialized,
-      lookupsCountError,
-    } = this.state;
-
-    return (
-      <HomeViewCard
-        className="lookups-card"
-        href={'#lookups'}
-        icon={IconNames.PROPERTIES}
-        title={'Lookups'}
-        loading={lookupsCountLoading}
-        error={!lookupsUninitialized ? lookupsCountError : undefined}
-      >
-        <p>
-          {!lookupsUninitialized ? pluralIfNeeded(lookupsCount, 'lookup') : 'Lookups uninitialized'}
-        </p>
-      </HomeViewCard>
-    );
-  }
-}
+export const LookupsCard = React.memo(function LookupsCard(props: LookupsCardProps) {
+  const [lookupsCountState] = useQueryManager<Capabilities, number>({
+    processQuery: async capabilities => {
+      if (capabilities.hasCoordinatorAccess()) {
+        const resp = await axios.get('/druid/coordinator/v1/lookups/status');
+        const data = resp.data;
+        return sum(Object.keys(data).map(k => Object.keys(data[k]).length));
+      } else {
+        throw new Error(`must have coordinator access`);
+      }
+    },
+    initQuery: props.capabilities,
+  });
+
+  return (
+    <HomeViewCard
+      className="lookups-card"
+      href={'#lookups'}
+      icon={IconNames.PROPERTIES}
+      title={'Lookups'}
+      loading={lookupsCountState.loading}
+      error={!isLookupsUninitialized(lookupsCountState.error) ? lookupsCountState.error : undefined}
+    >
+      <p>
+        {!isLookupsUninitialized(lookupsCountState.error)
+          ? pluralIfNeeded(lookupsCountState.data || 0, 'lookup')
+          : 'Lookups uninitialized'}
+      </p>
+    </HomeViewCard>
+  );
+});
diff --git a/web-console/src/views/home-view/segments-card/segments-card.tsx b/web-console/src/views/home-view/segments-card/segments-card.tsx
index 94186a6..d84061e 100644
--- a/web-console/src/views/home-view/segments-card/segments-card.tsx
+++ b/web-console/src/views/home-view/segments-card/segments-card.tsx
@@ -21,105 +21,68 @@ import axios from 'axios';
 import { sum } from 'd3-array';
 import React from 'react';
 
-import { pluralIfNeeded, queryDruidSql, QueryManager } from '../../../utils';
+import { useQueryManager } from '../../../hooks';
+import { pluralIfNeeded, queryDruidSql } from '../../../utils';
 import { Capabilities } from '../../../utils/capabilities';
 import { deepGet } from '../../../utils/object-change';
 import { HomeViewCard } from '../home-view-card/home-view-card';
 
-export interface SegmentsCardProps {
-  capabilities: Capabilities;
+export interface SegmentCounts {
+  available: number;
+  unavailable: number;
 }
 
-export interface SegmentsCardState {
-  segmentCountLoading: boolean;
-  segmentCount: number;
-  unavailableSegmentCount: number;
-  segmentCountError?: string;
+export interface SegmentsCardProps {
+  capabilities: Capabilities;
 }
 
-export class SegmentsCard extends React.PureComponent<SegmentsCardProps, SegmentsCardState> {
-  private segmentQueryManager: QueryManager<Capabilities, any>;
-
-  constructor(props: SegmentsCardProps, context: any) {
-    super(props, context);
-    this.state = {
-      segmentCountLoading: false,
-      segmentCount: 0,
-      unavailableSegmentCount: 0,
-    };
-
-    this.segmentQueryManager = new QueryManager({
-      processQuery: async capabilities => {
-        if (capabilities.hasSql()) {
-          const segments = await queryDruidSql({
-            query: `SELECT
-  COUNT(*) as "count",
+export const SegmentsCard = React.memo(function SegmentsCard(props: SegmentsCardProps) {
+  const [segmentCountState] = useQueryManager<Capabilities, SegmentCounts>({
+    processQuery: async capabilities => {
+      if (capabilities.hasSql()) {
+        const segments = await queryDruidSql({
+          query: `SELECT
+  COUNT(*) as "available",
   COUNT(*) FILTER (WHERE is_available = 0) as "unavailable"
 FROM sys.segments`,
-          });
-          return segments.length === 1 ? segments[0] : null;
-        } else if (capabilities.hasCoordinatorAccess()) {
-          const loadstatusResp = await axios.get('/druid/coordinator/v1/loadstatus?simple');
-          const loadstatus = loadstatusResp.data;
-          const unavailableSegmentNum = sum(Object.keys(loadstatus), key => loadstatus[key]);
-
-          const datasourcesMetaResp = await axios.get('/druid/coordinator/v1/datasources?simple');
-          const datasourcesMeta = datasourcesMetaResp.data;
-          const availableSegmentNum = sum(datasourcesMeta, (curr: any) =>
-            deepGet(curr, 'properties.segments.count'),
-          );
-
-          return {
-            count: availableSegmentNum + unavailableSegmentNum,
-            unavailable: unavailableSegmentNum,
-          };
-        } else {
-          throw new Error(`must have SQL or coordinator access`);
-        }
-      },
-      onStateChange: ({ result, loading, error }) => {
-        this.setState({
-          segmentCountLoading: loading,
-          segmentCount: result ? result.count : 0,
-          unavailableSegmentCount: result ? result.unavailable : 0,
-          segmentCountError: error,
         });
-      },
-    });
-  }
-
-  componentDidMount(): void {
-    const { capabilities } = this.props;
+        return segments.length === 1 ? segments[0] : null;
+      } else if (capabilities.hasCoordinatorAccess()) {
+        const loadstatusResp = await axios.get('/druid/coordinator/v1/loadstatus?simple');
+        const loadstatus = loadstatusResp.data;
+        const unavailableSegmentNum = sum(Object.keys(loadstatus), key => loadstatus[key]);
 
-    this.segmentQueryManager.runQuery(capabilities);
-  }
+        const datasourcesMetaResp = await axios.get('/druid/coordinator/v1/datasources?simple');
+        const datasourcesMeta = datasourcesMetaResp.data;
+        const availableSegmentNum = sum(datasourcesMeta, (curr: any) =>
+          deepGet(curr, 'properties.segments.count'),
+        );
 
-  componentWillUnmount(): void {
-    this.segmentQueryManager.terminate();
-  }
+        return {
+          available: availableSegmentNum + unavailableSegmentNum,
+          unavailable: unavailableSegmentNum,
+        };
+      } else {
+        throw new Error(`must have SQL or coordinator access`);
+      }
+    },
+    initQuery: props.capabilities,
+  });
 
-  render(): JSX.Element {
-    const {
-      segmentCountLoading,
-      segmentCountError,
-      segmentCount,
-      unavailableSegmentCount,
-    } = this.state;
-
-    return (
-      <HomeViewCard
-        className="segments-card"
-        href={'#segments'}
-        icon={IconNames.STACKED_CHART}
-        title={'Segments'}
-        loading={segmentCountLoading}
-        error={segmentCountError}
-      >
-        <p>{pluralIfNeeded(segmentCount, 'segment')}</p>
-        {Boolean(unavailableSegmentCount) && (
-          <p>{pluralIfNeeded(unavailableSegmentCount, 'unavailable segment')}</p>
-        )}
-      </HomeViewCard>
-    );
-  }
-}
+  const segmentCount = segmentCountState.data || { available: 0, unavailable: 0 };
+  return (
+    <HomeViewCard
+      className="segments-card"
+      href={'#segments'}
+      icon={IconNames.STACKED_CHART}
+      title={'Segments'}
+      loading={segmentCountState.loading}
+      error={segmentCountState.error}
+    >
+      <p>{pluralIfNeeded(segmentCount.available, 'segment')}</p>
+      {Boolean(segmentCount.unavailable) && (
+        <p>{pluralIfNeeded(segmentCount.unavailable, 'unavailable segment')}</p>
+      )}
+    </HomeViewCard>
+  );
+});
diff --git a/web-console/src/views/home-view/services-card/services-card.tsx b/web-console/src/views/home-view/services-card/services-card.tsx
index 09d5cf0..3ef5ec9 100644
--- a/web-console/src/views/home-view/services-card/services-card.tsx
+++ b/web-console/src/views/home-view/services-card/services-card.tsx
@@ -21,144 +21,90 @@ import axios from 'axios';
 import React from 'react';
 
 import { PluralPairIfNeeded } from '../../../components/plural-pair-if-needed/plural-pair-if-needed';
-import { lookupBy, queryDruidSql, QueryManager } from '../../../utils';
+import { useQueryManager } from '../../../hooks';
+import { lookupBy, queryDruidSql } from '../../../utils';
 import { Capabilities } from '../../../utils/capabilities';
 import { HomeViewCard } from '../home-view-card/home-view-card';
 
-export interface ServicesCardProps {
-  capabilities: Capabilities;
+export interface ServiceCounts {
+  coordinator?: number;
+  overlord?: number;
+  router?: number;
+  broker?: number;
+  historical?: number;
+  middle_manager?: number;
+  peon?: number;
+  indexer?: number;
 }
 
-export interface ServicesCardState {
-  serviceCountLoading: boolean;
-  coordinatorCount: number;
-  overlordCount: number;
-  routerCount: number;
-  brokerCount: number;
-  historicalCount: number;
-  middleManagerCount: number;
-  peonCount: number;
-  indexerCount: number;
-  serviceCountError?: string;
+export interface ServicesCardProps {
+  capabilities: Capabilities;
 }
 
-export class ServicesCard extends React.PureComponent<ServicesCardProps, ServicesCardState> {
-  private serviceQueryManager: QueryManager<Capabilities, any>;
-
-  constructor(props: ServicesCardProps, context: any) {
-    super(props, context);
-    this.state = {
-      serviceCountLoading: false,
-      coordinatorCount: 0,
-      overlordCount: 0,
-      routerCount: 0,
-      brokerCount: 0,
-      historicalCount: 0,
-      middleManagerCount: 0,
-      peonCount: 0,
-      indexerCount: 0,
-    };
-
-    this.serviceQueryManager = new QueryManager({
-      processQuery: async capabilities => {
-        if (capabilities.hasSql()) {
-          const serviceCountsFromQuery: {
-            service_type: string;
-            count: number;
-          }[] = await queryDruidSql({
-            query: `SELECT server_type AS "service_type", COUNT(*) as "count" FROM sys.servers GROUP BY 1`,
-          });
-          return lookupBy(serviceCountsFromQuery, x => x.service_type, x => x.count);
-        } else if (capabilities.hasCoordinatorAccess()) {
-          const services = (await axios.get('/druid/coordinator/v1/servers?simple')).data;
-
-          const middleManager = capabilities.hasOverlordAccess()
-            ? (await axios.get('/druid/indexer/v1/workers')).data
-            : [];
-
-          return {
-            historical: services.filter((s: any) => s.type === 'historical').length,
-            middle_manager: middleManager.length,
-            peon: services.filter((s: any) => s.type === 'indexer-executor').length,
-          };
-        } else {
-          throw new Error(`must have SQL or coordinator access`);
-        }
-      },
-      onStateChange: ({ result, loading, error }) => {
-        this.setState({
-          serviceCountLoading: loading,
-          coordinatorCount: result ? result.coordinator : 0,
-          overlordCount: result ? result.overlord : 0,
-          routerCount: result ? result.router : 0,
-          brokerCount: result ? result.broker : 0,
-          historicalCount: result ? result.historical : 0,
-          middleManagerCount: result ? result.middle_manager : 0,
-          peonCount: result ? result.peon : 0,
-          indexerCount: result ? result.indexer : 0,
-          serviceCountError: error,
+export const ServicesCard = React.memo(function ServicesCard(props: ServicesCardProps) {
+  const [serviceCountState] = useQueryManager<Capabilities, ServiceCounts>({
+    processQuery: async capabilities => {
+      if (capabilities.hasSql()) {
+        const serviceCountsFromQuery: {
+          service_type: string;
+          count: number;
+        }[] = await queryDruidSql({
+          query: `SELECT server_type AS "service_type", COUNT(*) as "count" FROM sys.servers GROUP BY 1`,
         });
-      },
-    });
-  }
+        return lookupBy(serviceCountsFromQuery, x => x.service_type, x => x.count);
+      } else if (capabilities.hasCoordinatorAccess()) {
+        const services = (await axios.get('/druid/coordinator/v1/servers?simple')).data;
 
-  componentDidMount(): void {
-    const { capabilities } = this.props;
+        const middleManager = capabilities.hasOverlordAccess()
+          ? (await axios.get('/druid/indexer/v1/workers')).data
+          : [];
 
-    this.serviceQueryManager.runQuery(capabilities);
-  }
+        return {
+          historical: services.filter((s: any) => s.type === 'historical').length,
+          middle_manager: middleManager.length,
+          peon: services.filter((s: any) => s.type === 'indexer-executor').length,
+        };
+      } else {
+        throw new Error(`must have SQL or coordinator access`);
+      }
+    },
+    initQuery: props.capabilities,
+  });
 
-  componentWillUnmount(): void {
-    this.serviceQueryManager.terminate();
-  }
-
-  render(): JSX.Element {
-    const {
-      serviceCountLoading,
-      coordinatorCount,
-      overlordCount,
-      routerCount,
-      brokerCount,
-      historicalCount,
-      middleManagerCount,
-      peonCount,
-      indexerCount,
-      serviceCountError,
-    } = this.state;
-    return (
-      <HomeViewCard
-        className="services-card"
-        href={'#services'}
-        icon={IconNames.DATABASE}
-        title={'Services'}
-        loading={serviceCountLoading}
-        error={serviceCountError}
-      >
-        <PluralPairIfNeeded
-          firstCount={overlordCount}
-          firstSingular="overlord"
-          secondCount={coordinatorCount}
-          secondSingular="coordinator"
-        />
-        <PluralPairIfNeeded
-          firstCount={routerCount}
-          firstSingular="router"
-          secondCount={brokerCount}
-          secondSingular="broker"
-        />
-        <PluralPairIfNeeded
-          firstCount={historicalCount}
-          firstSingular="historical"
-          secondCount={middleManagerCount}
-          secondSingular="middle manager"
-        />
-        <PluralPairIfNeeded
-          firstCount={peonCount}
-          firstSingular="peon"
-          secondCount={indexerCount}
-          secondSingular="indexer"
-        />
-      </HomeViewCard>
-    );
-  }
-}
+  const serviceCounts = serviceCountState.data;
+  return (
+    <HomeViewCard
+      className="services-card"
+      href={'#services'}
+      icon={IconNames.DATABASE}
+      title={'Services'}
+      loading={serviceCountState.loading}
+      error={serviceCountState.error}
+    >
+      <PluralPairIfNeeded
+        firstCount={serviceCounts ? serviceCounts.overlord : 0}
+        firstSingular="overlord"
+        secondCount={serviceCounts ? serviceCounts.coordinator : 0}
+        secondSingular="coordinator"
+      />
+      <PluralPairIfNeeded
+        firstCount={serviceCounts ? serviceCounts.router : 0}
+        firstSingular="router"
+        secondCount={serviceCounts ? serviceCounts.broker : 0}
+        secondSingular="broker"
+      />
+      <PluralPairIfNeeded
+        firstCount={serviceCounts ? serviceCounts.historical : 0}
+        firstSingular="historical"
+        secondCount={serviceCounts ? serviceCounts.middle_manager : 0}
+        secondSingular="middle manager"
+      />
+      <PluralPairIfNeeded
+        firstCount={serviceCounts ? serviceCounts.peon : 0}
+        firstSingular="peon"
+        secondCount={serviceCounts ? serviceCounts.indexer : 0}
+        secondSingular="indexer"
+      />
+    </HomeViewCard>
+  );
+});
diff --git a/web-console/src/views/home-view/status-card/status-card.tsx b/web-console/src/views/home-view/status-card/status-card.tsx
index 7b8f907..ed32aef 100644
--- a/web-console/src/views/home-view/status-card/status-card.tsx
+++ b/web-console/src/views/home-view/status-card/status-card.tsx
@@ -18,99 +18,65 @@
 
 import { IconNames } from '@blueprintjs/icons';
 import axios from 'axios';
-import React from 'react';
+import React, { useState } from 'react';
 
 import { StatusDialog } from '../../../dialogs/status-dialog/status-dialog';
-import { pluralIfNeeded, QueryManager } from '../../../utils';
+import { useQueryManager } from '../../../hooks';
+import { pluralIfNeeded, QueryState } from '../../../utils';
 import { HomeViewCard } from '../home-view-card/home-view-card';
 
-export interface StatusCardProps {}
-
-export interface StatusCardState {
-  statusLoading: boolean;
-  version?: string;
-  extensionCount?: number;
-  statusError?: string;
-
-  showStatusDialog: boolean;
-}
-
 interface StatusSummary {
   version: string;
   extensionCount: number;
 }
 
-export class StatusCard extends React.PureComponent<StatusCardProps, StatusCardState> {
-  private versionQueryManager: QueryManager<null, StatusSummary>;
-
-  constructor(props: StatusCardProps, context: any) {
-    super(props, context);
-
-    this.state = {
-      statusLoading: true,
-      showStatusDialog: false,
-    };
-
-    this.versionQueryManager = new QueryManager({
-      processQuery: async () => {
-        const statusResp = await axios.get('/status');
-        return {
-          version: statusResp.data.version,
-          extensionCount: statusResp.data.modules.length,
-        };
-      },
-      onStateChange: ({ result, loading, error }) => {
-        this.setState({
-          statusLoading: loading,
-          version: result ? result.version : undefined,
-          extensionCount: result ? result.extensionCount : undefined,
-          statusError: error,
-        });
-      },
-    });
-  }
-
-  componentDidMount(): void {
-    this.versionQueryManager.runQuery(null);
-  }
-
-  componentWillUnmount(): void {
-    this.versionQueryManager.terminate();
-  }
-
-  private handleStatusDialogOpen = () => {
-    this.setState({ showStatusDialog: true });
-  };
-
-  private handleStatusDialogClose = () => {
-    this.setState({ showStatusDialog: false });
-  };
-
-  renderStatusDialog() {
-    const { showStatusDialog } = this.state;
-    if (!showStatusDialog) return;
-
-    return <StatusDialog onClose={this.handleStatusDialogClose} />;
-  }
-
-  render(): JSX.Element {
-    const { version, extensionCount, statusLoading, statusError } = this.state;
+export interface StatusCardProps {}
 
-    return (
-      <>
-        <HomeViewCard
-          className="status-card"
-          onClick={this.handleStatusDialogOpen}
-          icon={IconNames.GRAPH}
-          title="Status"
-          loading={statusLoading}
-          error={statusError}
-        >
-          {version && <p>{`Apache Druid is running version ${version}`}</p>}
-          {extensionCount && <p>{`${pluralIfNeeded(extensionCount, 'extension')} loaded`}</p>}
-        </HomeViewCard>
-        {this.renderStatusDialog()}
-      </>
-    );
-  }
+export interface StatusCardState {
+  statusSummaryState: QueryState<StatusSummary>;
+  showStatusDialog: boolean;
 }
+
+export const StatusCard = React.memo(function StatusCard(_props: StatusCardProps) {
+  const [showStatusDialog, setShowStatusDialog] = useState(false);
+  const [statusSummaryState] = useQueryManager<null, StatusSummary>({
+    processQuery: async () => {
+      const statusResp = await axios.get('/status');
+      return {
+        version: statusResp.data.version,
+        extensionCount: statusResp.data.modules.length,
+      };
+    },
+    initQuery: null,
+  });
+
+  const statusSummary = statusSummaryState.data;
+  return (
+    <>
+      <HomeViewCard
+        className="status-card"
+        onClick={() => {
+          setShowStatusDialog(true);
+        }}
+        icon={IconNames.GRAPH}
+        title="Status"
+        loading={statusSummaryState.loading}
+        error={statusSummaryState.error}
+      >
+        {statusSummary && (
+          <>
+            <p>{`Apache Druid is running version ${statusSummary.version}`}</p>
+            <p>{`${pluralIfNeeded(statusSummary.extensionCount, 'extension')} loaded`}</p>
+          </>
+        )}
+      </HomeViewCard>
+      {showStatusDialog && (
+        <StatusDialog
+          onClose={() => {
+            setShowStatusDialog(false);
+          }}
+        />
+      )}
+    </>
+  );
+});
diff --git a/web-console/src/views/home-view/supervisors-card/supervisors-card.tsx b/web-console/src/views/home-view/supervisors-card/supervisors-card.tsx
index 97291a9..ce81f87 100644
--- a/web-console/src/views/home-view/supervisors-card/supervisors-card.tsx
+++ b/web-console/src/views/home-view/supervisors-card/supervisors-card.tsx
@@ -20,104 +20,61 @@ import { IconNames } from '@blueprintjs/icons';
 import axios from 'axios';
 import React from 'react';
 
-import { pluralIfNeeded, queryDruidSql, QueryManager } from '../../../utils';
+import { useQueryManager } from '../../../hooks';
+import { pluralIfNeeded, queryDruidSql } from '../../../utils';
 import { Capabilities } from '../../../utils/capabilities';
 import { HomeViewCard } from '../home-view-card/home-view-card';
 
-export interface SupervisorsCardProps {
-  capabilities: Capabilities;
+export interface SupervisorCounts {
+  running: number;
+  suspended: number;
 }
 
-export interface SupervisorsCardState {
-  supervisorCountLoading: boolean;
-  runningSupervisorCount: number;
-  suspendedSupervisorCount: number;
-  supervisorCountError?: string;
+export interface SupervisorsCardProps {
+  capabilities: Capabilities;
 }
 
-export class SupervisorsCard extends React.PureComponent<
-  SupervisorsCardProps,
-  SupervisorsCardState
-> {
-  private supervisorQueryManager: QueryManager<Capabilities, any>;
-
-  constructor(props: SupervisorsCardProps, context: any) {
-    super(props, context);
-    this.state = {
-      supervisorCountLoading: false,
-      runningSupervisorCount: 0,
-      suspendedSupervisorCount: 0,
-    };
-
-    this.supervisorQueryManager = new QueryManager({
-      processQuery: async capabilities => {
-        if (capabilities.hasSql()) {
-          return (await queryDruidSql({
-            query: `SELECT
-  COUNT(*) FILTER (WHERE "suspended" = 0) AS "runningSupervisorCount",
-  COUNT(*) FILTER (WHERE "suspended" = 1) AS "suspendedSupervisorCount"
+export const SupervisorsCard = React.memo(function SupervisorsCard(props: SupervisorsCardProps) {
+  const [supervisorCountState] = useQueryManager<Capabilities, SupervisorCounts>({
+    processQuery: async capabilities => {
+      if (capabilities.hasSql()) {
+        return (await queryDruidSql({
+          query: `SELECT
+  COUNT(*) FILTER (WHERE "suspended" = 0) AS "running",
+  COUNT(*) FILTER (WHERE "suspended" = 1) AS "suspended"
 FROM sys.supervisors`,
-          }))[0];
-        } else if (capabilities.hasOverlordAccess()) {
-          const resp = await axios.get('/druid/indexer/v1/supervisor?full');
-          const data = resp.data;
-          const runningSupervisorCount = data.filter((d: any) => d.spec.suspended === false).length;
-          const suspendedSupervisorCount = data.filter((d: any) => d.spec.suspended === true)
-            .length;
-          return {
-            runningSupervisorCount,
-            suspendedSupervisorCount,
-          };
-        } else {
-          throw new Error(`must have SQL or overlord access`);
-        }
-      },
-      onStateChange: ({ result, loading, error }) => {
-        this.setState({
-          runningSupervisorCount: result ? result.runningSupervisorCount : 0,
-          suspendedSupervisorCount: result ? result.suspendedSupervisorCount : 0,
-          supervisorCountLoading: loading,
-          supervisorCountError: error,
-        });
-      },
-    });
-  }
-
-  componentDidMount(): void {
-    const { capabilities } = this.props;
-
-    this.supervisorQueryManager.runQuery(capabilities);
-  }
+        }))[0];
+      } else if (capabilities.hasOverlordAccess()) {
+        const resp = await axios.get('/druid/indexer/v1/supervisor?full');
+        const data = resp.data;
+        return {
+          running: data.filter((d: any) => d.spec.suspended === false).length,
+          suspended: data.filter((d: any) => d.spec.suspended === true).length,
+        };
+      } else {
+        throw new Error(`must have SQL or overlord access`);
+      }
+    },
+    initQuery: props.capabilities,
+  });
 
-  componentWillUnmount(): void {
-    this.supervisorQueryManager.terminate();
-  }
+  const { running, suspended } = supervisorCountState.data || {
+    running: 0,
+    suspended: 0,
+  };
 
-  render(): JSX.Element {
-    const {
-      supervisorCountLoading,
-      supervisorCountError,
-      runningSupervisorCount,
-      suspendedSupervisorCount,
-    } = this.state;
-
-    return (
-      <HomeViewCard
-        className="supervisors-card"
-        href={'#ingestion'}
-        icon={IconNames.LIST_COLUMNS}
-        title={'Supervisors'}
-        loading={supervisorCountLoading}
-        error={supervisorCountError}
-      >
-        {!Boolean(runningSupervisorCount + suspendedSupervisorCount) && <p>No supervisors</p>}
-        {Boolean(runningSupervisorCount) && (
-          <p>{pluralIfNeeded(runningSupervisorCount, 'running supervisor')}</p>
-        )}
-        {Boolean(suspendedSupervisorCount) && (
-          <p>{pluralIfNeeded(suspendedSupervisorCount, 'suspended supervisor')}</p>
-        )}
-      </HomeViewCard>
-    );
-  }
-}
+  return (
+    <HomeViewCard
+      className="supervisors-card"
+      href={'#ingestion'}
+      icon={IconNames.LIST_COLUMNS}
+      title={'Supervisors'}
+      loading={supervisorCountState.loading}
+      error={supervisorCountState.error}
+    >
+      {!Boolean(running + suspended) && <p>No supervisors</p>}
+      {Boolean(running) && <p>{pluralIfNeeded(running, 'running supervisor')}</p>}
+      {Boolean(suspended) && <p>{pluralIfNeeded(suspended, 'suspended supervisor')}</p>}
+    </HomeViewCard>
+  );
+});
diff --git a/web-console/src/views/home-view/tasks-card/tasks-card.tsx b/web-console/src/views/home-view/tasks-card/tasks-card.tsx
index b0efe6a..3687607 100644
--- a/web-console/src/views/home-view/tasks-card/tasks-card.tsx
+++ b/web-console/src/views/home-view/tasks-card/tasks-card.tsx
@@ -21,7 +21,8 @@ import axios from 'axios';
 import React from 'react';
 
 import { PluralPairIfNeeded } from '../../../components/plural-pair-if-needed/plural-pair-if-needed';
-import { lookupBy, pluralIfNeeded, queryDruidSql, QueryManager } from '../../../utils';
+import { useQueryManager } from '../../../hooks';
+import { lookupBy, pluralIfNeeded, queryDruidSql } from '../../../utils';
 import { Capabilities } from '../../../utils/capabilities';
 import { HomeViewCard } from '../home-view-card/home-view-card';
 
@@ -29,119 +30,80 @@ function getTaskStatus(d: any) {
   return d.statusCode === 'RUNNING' ? d.runnerStatusCode : d.statusCode;
 }
 
-export interface TasksCardProps {
-  capabilities: Capabilities;
+export interface TaskCounts {
+  SUCCESS?: number;
+  FAILED?: number;
+  RUNNING?: number;
+  PENDING?: number;
+  WAITING?: number;
 }
 
-export interface TasksCardState {
-  taskCountLoading: boolean;
-  runningTaskCount: number;
-  pendingTaskCount: number;
-  successTaskCount: number;
-  failedTaskCount: number;
-  waitingTaskCount: number;
-  taskCountError?: string;
+export interface TasksCardProps {
+  capabilities: Capabilities;
 }
 
-export class TasksCard extends React.PureComponent<TasksCardProps, TasksCardState> {
-  private taskQueryManager: QueryManager<Capabilities, any>;
-
-  constructor(props: TasksCardProps, context: any) {
-    super(props, context);
-    this.state = {
-      taskCountLoading: false,
-      runningTaskCount: 0,
-      pendingTaskCount: 0,
-      successTaskCount: 0,
-      failedTaskCount: 0,
-      waitingTaskCount: 0,
-    };
-
-    this.taskQueryManager = new QueryManager({
-      processQuery: async capabilities => {
-        if (capabilities.hasSql()) {
-          const taskCountsFromQuery: { status: string; count: number }[] = await queryDruidSql({
-            query: `SELECT
+export const TasksCard = React.memo(function TasksCard(props: TasksCardProps) {
+  const [taskCountState] = useQueryManager<Capabilities, TaskCounts>({
+    processQuery: async capabilities => {
+      if (capabilities.hasSql()) {
+        const taskCountsFromQuery: { status: string; count: number }[] = await queryDruidSql({
+          query: `SELECT
   CASE WHEN "status" = 'RUNNING' THEN "runner_status" ELSE "status" END AS "status",
   COUNT (*) AS "count"
 FROM sys.tasks
 GROUP BY 1`,
-          });
-          return lookupBy(taskCountsFromQuery, x => x.status, x => x.count);
-        } else if (capabilities.hasOverlordAccess()) {
-          const tasks: any[] = (await axios.get('/druid/indexer/v1/tasks')).data;
-          return {
-            SUCCESS: tasks.filter(d => getTaskStatus(d) === 'SUCCESS').length,
-            FAILED: tasks.filter(d => getTaskStatus(d) === 'FAILED').length,
-            RUNNING: tasks.filter(d => getTaskStatus(d) === 'RUNNING').length,
-            PENDING: tasks.filter(d => getTaskStatus(d) === 'PENDING').length,
-            WAITING: tasks.filter(d => getTaskStatus(d) === 'WAITING').length,
-          };
-        } else {
-          throw new Error(`must have SQL or overlord access`);
-        }
-      },
-      onStateChange: ({ result, loading, error }) => {
-        this.setState({
-          taskCountLoading: loading,
-          successTaskCount: result ? result.SUCCESS : 0,
-          failedTaskCount: result ? result.FAILED : 0,
-          runningTaskCount: result ? result.RUNNING : 0,
-          pendingTaskCount: result ? result.PENDING : 0,
-          waitingTaskCount: result ? result.WAITING : 0,
-          taskCountError: error,
         });
-      },
-    });
-  }
-
-  componentDidMount(): void {
-    const { capabilities } = this.props;
-
-    this.taskQueryManager.runQuery(capabilities);
-  }
+        return lookupBy(taskCountsFromQuery, x => x.status, x => x.count);
+      } else if (capabilities.hasOverlordAccess()) {
+        const tasks: any[] = (await axios.get('/druid/indexer/v1/tasks')).data;
+        return {
+          SUCCESS: tasks.filter(d => getTaskStatus(d) === 'SUCCESS').length,
+          FAILED: tasks.filter(d => getTaskStatus(d) === 'FAILED').length,
+          RUNNING: tasks.filter(d => getTaskStatus(d) === 'RUNNING').length,
+          PENDING: tasks.filter(d => getTaskStatus(d) === 'PENDING').length,
+          WAITING: tasks.filter(d => getTaskStatus(d) === 'WAITING').length,
+        };
+      } else {
+        throw new Error(`must have SQL or overlord access`);
+      }
+    },
+    initQuery: props.capabilities,
+  });
 
-  componentWillUnmount(): void {
-    this.taskQueryManager.terminate();
-  }
+  const taskCounts = taskCountState.data;
+  const successTaskCount = taskCounts ? taskCounts.SUCCESS : 0;
+  const failedTaskCount = taskCounts ? taskCounts.FAILED : 0;
+  const runningTaskCount = taskCounts ? taskCounts.RUNNING : 0;
+  const pendingTaskCount = taskCounts ? taskCounts.PENDING : 0;
+  const waitingTaskCount = taskCounts ? taskCounts.WAITING : 0;
 
-  render(): JSX.Element {
-    const {
-      taskCountError,
-      taskCountLoading,
-      runningTaskCount,
-      pendingTaskCount,
-      successTaskCount,
-      failedTaskCount,
-      waitingTaskCount,
-    } = this.state;
-
-    return (
-      <HomeViewCard
-        className="tasks-card"
-        href={'#ingestion'}
-        icon={IconNames.GANTT_CHART}
-        title={'Tasks'}
-        loading={taskCountLoading}
-        error={taskCountError}
-      >
-        <PluralPairIfNeeded
-          firstCount={runningTaskCount}
-          firstSingular="running task"
-          secondCount={pendingTaskCount}
-          secondSingular="pending task"
-        />
-        {Boolean(successTaskCount) && <p>{pluralIfNeeded(successTaskCount, 'successful task')}</p>}
-        {Boolean(waitingTaskCount) && <p>{pluralIfNeeded(waitingTaskCount, 'waiting task')}</p>}
-        {Boolean(failedTaskCount) && <p>{pluralIfNeeded(failedTaskCount, 'failed task')}</p>}
-        {!(
-          Boolean(runningTaskCount) ||
-          Boolean(pendingTaskCount) ||
-          Boolean(successTaskCount) ||
-          Boolean(waitingTaskCount) ||
-          Boolean(failedTaskCount)
-        ) && <p>There are no tasks</p>}
-      </HomeViewCard>
-    );
-  }
-}
+  return (
+    <HomeViewCard
+      className="tasks-card"
+      href={'#ingestion'}
+      icon={IconNames.GANTT_CHART}
+      title={'Tasks'}
+      loading={taskCountState.loading}
+      error={taskCountState.error}
+    >
+      <PluralPairIfNeeded
+        firstCount={runningTaskCount}
+        firstSingular="running task"
+        secondCount={pendingTaskCount}
+        secondSingular="pending task"
+      />
+      {Boolean(successTaskCount) && (
+        <p>{pluralIfNeeded(successTaskCount || 0, 'successful task')}</p>
+      )}
+      {Boolean(waitingTaskCount) && <p>{pluralIfNeeded(waitingTaskCount || 0, 'waiting task')}</p>}
+      {Boolean(failedTaskCount) && <p>{pluralIfNeeded(failedTaskCount || 0, 'failed task')}</p>}
+      {!(
+        Boolean(runningTaskCount) ||
+        Boolean(pendingTaskCount) ||
+        Boolean(successTaskCount) ||
+        Boolean(waitingTaskCount) ||
+        Boolean(failedTaskCount)
+      ) && <p>There are no tasks</p>}
+    </HomeViewCard>
+  );
+});
diff --git a/web-console/src/views/ingestion-view/ingestion-view.tsx b/web-console/src/views/ingestion-view/ingestion-view.tsx
index cf8d201..380c1cf 100644
--- a/web-console/src/views/ingestion-view/ingestion-view.tsx
+++ b/web-console/src/views/ingestion-view/ingestion-view.tsx
@@ -52,6 +52,7 @@ import {
   localStorageSet,
   queryDruidSql,
   QueryManager,
+  QueryState,
 } from '../../utils';
 import { BasicAction } from '../../utils/basic-action';
 import { Capabilities } from '../../utils/capabilities';
@@ -113,9 +114,7 @@ export interface IngestionViewProps {
 }
 
 export interface IngestionViewState {
-  supervisorsLoading: boolean;
-  supervisors?: SupervisorQueryResultRow[];
-  supervisorsError?: string;
+  supervisorsState: QueryState<SupervisorQueryResultRow[]>;
 
   resumeSupervisorId?: string;
   suspendSupervisorId?: string;
@@ -126,9 +125,7 @@ export interface IngestionViewState {
   showSuspendAllSupervisors: boolean;
   showTerminateAllSupervisors: boolean;
 
-  tasksLoading: boolean;
-  tasks?: TaskQueryResultRow[];
-  tasksError?: string;
+  tasksState: QueryState<TaskQueryResultRow[]>;
 
   taskFilter: Filter[];
   supervisorFilter: Filter[];
@@ -223,14 +220,13 @@ ORDER BY "rank" DESC, "created_time" DESC`;
     if (props.datasourceId) supervisorFilter.push({ id: 'datasource', value: props.datasourceId });
 
     this.state = {
-      supervisorsLoading: true,
-      supervisors: [],
+      supervisorsState: QueryState.INIT,
 
       showResumeAllSupervisors: false,
       showSuspendAllSupervisors: false,
       showTerminateAllSupervisors: false,
 
-      tasksLoading: true,
+      tasksState: QueryState.INIT,
       taskFilter: taskFilter,
       supervisorFilter: supervisorFilter,
 
@@ -274,11 +270,9 @@ ORDER BY "rank" DESC, "created_time" DESC`;
           throw new Error(`must have SQL or overlord access`);
         }
       },
-      onStateChange: ({ result, loading, error }) => {
+      onStateChange: supervisorsState => {
         this.setState({
-          supervisors: result,
-          supervisorsLoading: loading,
-          supervisorsError: error,
+          supervisorsState,
         });
       },
     });
@@ -296,11 +290,9 @@ ORDER BY "rank" DESC, "created_time" DESC`;
           throw new Error(`must have SQL or overlord access`);
         }
       },
-      onStateChange: ({ result, loading, error }) => {
+      onStateChange: tasksState => {
         this.setState({
-          tasks: result,
-          tasksLoading: loading,
-          tasksError: error,
+          tasksState,
         });
       },
     });
@@ -559,23 +551,15 @@ ORDER BY "rank" DESC, "created_time" DESC`;
   }
 
   renderSupervisorTable() {
-    const {
-      supervisors,
-      supervisorsLoading,
-      supervisorsError,
-      hiddenSupervisorColumns,
-      taskFilter,
-      supervisorFilter,
-    } = this.state;
+    const { supervisorsState, hiddenSupervisorColumns, taskFilter, supervisorFilter } = this.state;
+
     return (
       <>
         <ReactTable
-          data={supervisors || []}
-          loading={supervisorsLoading}
+          data={supervisorsState.data || []}
+          loading={supervisorsState.loading}
           noDataText={
-            !supervisorsLoading && supervisors && !supervisors.length
-              ? 'No supervisors'
-              : supervisorsError || ''
+            supervisorsState.isEmpty() ? 'No supervisors' : supervisorsState.getErrorMessage() || ''
           }
           filtered={supervisorFilter}
           onFilteredChange={filtered => {
@@ -722,9 +706,7 @@ ORDER BY "rank" DESC, "created_time" DESC`;
   renderTaskTable() {
     const { goToMiddleManager } = this.props;
     const {
-      tasks,
-      tasksLoading,
-      tasksError,
+      tasksState,
       taskFilter,
       groupTasksBy,
       hiddenTaskColumns,
@@ -733,9 +715,9 @@ ORDER BY "rank" DESC, "created_time" DESC`;
     return (
       <>
         <ReactTable
-          data={tasks || []}
-          loading={tasksLoading}
-          noDataText={!tasksLoading && tasks && !tasks.length ? 'No tasks' : tasksError || ''}
+          data={tasksState.data || []}
+          loading={tasksState.loading}
+          noDataText={tasksState.isEmpty() ? 'No tasks' : tasksState.getErrorMessage() || ''}
           filterable
           filtered={taskFilter}
           onFilteredChange={filtered => {
diff --git a/web-console/src/views/load-data-view/load-data-view.tsx b/web-console/src/views/load-data-view/load-data-view.tsx
index c65b83e..75ba008 100644
--- a/web-console/src/views/load-data-view/load-data-view.tsx
+++ b/web-console/src/views/load-data-view/load-data-view.tsx
@@ -555,7 +555,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
     return (
       <div className={classNames('load-data-view', 'app-view', step)}>
         {this.renderStepNav()}
-        {step === 'loading' && <Loader loading />}
+        {step === 'loading' && <Loader />}
 
         {step === 'welcome' && this.renderWelcomeStep()}
         {step === 'connect' && this.renderConnectStep()}
@@ -1027,7 +1027,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
 
     if (issue) {
       this.setState({
-        inputQueryState: initRun ? QueryState.INIT : new QueryState({ error: issue }),
+        inputQueryState: initRun ? QueryState.INIT : new QueryState({ error: new Error(issue) }),
       });
       return;
     }
@@ -1083,9 +1083,9 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
         </CenterMessage>
       );
     } else if (inputQueryState.isLoading()) {
-      mainFill = <Loader loading />;
+      mainFill = <Loader />;
     } else if (inputQueryState.error) {
-      mainFill = <CenterMessage>{`Error: ${inputQueryState.error}`}</CenterMessage>;
+      mainFill = <CenterMessage>{`Error: ${inputQueryState.error.message}`}</CenterMessage>;
     } else if (inputQueryState.data) {
       const inputData = inputQueryState.data.data;
       mainFill = (
@@ -1260,7 +1260,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
 
     if (issue) {
       this.setState({
-        parserQueryState: initRun ? QueryState.INIT : new QueryState({ error: issue }),
+        parserQueryState: initRun ? QueryState.INIT : new QueryState({ error: new Error(issue) }),
       });
       return;
     }
@@ -1309,9 +1309,9 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
         </CenterMessage>
       );
     } else if (parserQueryState.isLoading()) {
-      mainFill = <Loader loading />;
+      mainFill = <Loader />;
     } else if (parserQueryState.error) {
-      mainFill = <CenterMessage>{`Error: ${parserQueryState.error}`}</CenterMessage>;
+      mainFill = <CenterMessage>{`Error: ${parserQueryState.error.message}`}</CenterMessage>;
     } else if (parserQueryState.data) {
       mainFill = (
         <div className="table-with-control">
@@ -1342,9 +1342,9 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
       );
     }
 
-    let sugestedFlattenFields: FlattenField[] | undefined;
+    let suggestedFlattenFields: FlattenField[] | undefined;
     if (canFlatten && !flattenFields.length && parserQueryState.data) {
-      sugestedFlattenFields = computeFlattenPathsForData(
+      suggestedFlattenFields = computeFlattenPathsForData(
         filterMap(parserQueryState.data.rows, r => r.input),
         'path',
         'ignore-arrays',
@@ -1386,17 +1386,17 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
             </>
           )}
           {this.renderFlattenControls()}
-          {sugestedFlattenFields && sugestedFlattenFields.length ? (
+          {suggestedFlattenFields && suggestedFlattenFields.length ? (
             <FormGroup>
               <Button
                 icon={IconNames.LIGHTBULB}
-                text={`Auto add ${pluralIfNeeded(sugestedFlattenFields.length, 'flatten spec')}`}
+                text={`Auto add ${pluralIfNeeded(suggestedFlattenFields.length, 'flatten spec')}`}
                 onClick={() => {
                   this.updateSpec(
                     deepSet(
                       spec,
                       'spec.ioConfig.inputFormat.flattenSpec.fields',
-                      sugestedFlattenFields,
+                      suggestedFlattenFields,
                     ),
                   );
                 }}
@@ -1531,7 +1531,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
       this.setState({
         timestampQueryState: initRun
           ? QueryState.INIT
-          : new QueryState({ error: 'must complete parse step' }),
+          : new QueryState({ error: new Error('must complete parse step') }),
       });
       return;
     }
@@ -1579,9 +1579,9 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
         </CenterMessage>
       );
     } else if (timestampQueryState.isLoading()) {
-      mainFill = <Loader loading />;
+      mainFill = <Loader />;
     } else if (timestampQueryState.error) {
-      mainFill = <CenterMessage>{`Error: ${timestampQueryState.error}`}</CenterMessage>;
+      mainFill = <CenterMessage>{`Error: ${timestampQueryState.error.message}`}</CenterMessage>;
     } else if (timestampQueryState.data) {
       mainFill = (
         <div className="table-with-control">
@@ -1682,7 +1682,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
       this.setState({
         transformQueryState: initRun
           ? QueryState.INIT
-          : new QueryState({ error: 'must complete parse step' }),
+          : new QueryState({ error: new Error('must complete parse step') }),
       });
       return;
     }
@@ -1728,9 +1728,9 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
     if (transformQueryState.isInit()) {
       mainFill = <CenterMessage>{`Please fill in the previous steps`}</CenterMessage>;
     } else if (transformQueryState.isLoading()) {
-      mainFill = <Loader loading />;
+      mainFill = <Loader />;
     } else if (transformQueryState.error) {
-      mainFill = <CenterMessage>{`Error: ${transformQueryState.error}`}</CenterMessage>;
+      mainFill = <CenterMessage>{`Error: ${transformQueryState.error.message}`}</CenterMessage>;
     } else if (transformQueryState.data) {
       mainFill = (
         <div className="table-with-control">
@@ -1897,7 +1897,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
       this.setState({
         filterQueryState: initRun
           ? QueryState.INIT
-          : new QueryState({ error: 'must complete parse step' }),
+          : new QueryState({ error: new Error('must complete parse step') }),
       });
       return;
     }
@@ -1970,9 +1970,9 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
     if (filterQueryState.isInit()) {
       mainFill = <CenterMessage>Please enter more details for the previous steps</CenterMessage>;
     } else if (filterQueryState.isLoading()) {
-      mainFill = <Loader loading />;
+      mainFill = <Loader />;
     } else if (filterQueryState.error) {
-      mainFill = <CenterMessage>{`Error: ${filterQueryState.error}`}</CenterMessage>;
+      mainFill = <CenterMessage>{`Error: ${filterQueryState.error.message}`}</CenterMessage>;
     } else if (filterQueryState.data) {
       mainFill = (
         <div className="table-with-control">
@@ -2184,7 +2184,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
       this.setState({
         schemaQueryState: initRun
           ? QueryState.INIT
-          : new QueryState({ error: 'must complete parse step' }),
+          : new QueryState({ error: new Error('must complete parse step') }),
       });
       return;
     }
@@ -2236,9 +2236,9 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
     if (schemaQueryState.isInit()) {
       mainFill = <CenterMessage>Please enter more details for the previous steps</CenterMessage>;
     } else if (schemaQueryState.isLoading()) {
-      mainFill = <Loader loading />;
+      mainFill = <Loader />;
     } else if (schemaQueryState.error) {
-      mainFill = <CenterMessage>{`Error: ${schemaQueryState.error}`}</CenterMessage>;
+      mainFill = <CenterMessage>{`Error: ${schemaQueryState.error.message}`}</CenterMessage>;
     } else if (schemaQueryState.data) {
       mainFill = (
         <div className="table-with-control">
diff --git a/web-console/src/views/lookups-view/lookups-view.tsx b/web-console/src/views/lookups-view/lookups-view.tsx
index 659094e..aab310d 100644
--- a/web-console/src/views/lookups-view/lookups-view.tsx
+++ b/web-console/src/views/lookups-view/lookups-view.tsx
@@ -35,7 +35,13 @@ import { AsyncActionDialog, LookupEditDialog } from '../../dialogs/';
 import { LookupSpec } from '../../dialogs/lookup-edit-dialog/lookup-edit-dialog';
 import { LookupTableActionDialog } from '../../dialogs/lookup-table-action-dialog/lookup-table-action-dialog';
 import { AppToaster } from '../../singletons/toaster';
-import { getDruidErrorMessage, LocalStorageKeys, QueryManager } from '../../utils';
+import {
+  getDruidErrorMessage,
+  isLookupsUninitialized,
+  LocalStorageKeys,
+  QueryManager,
+  QueryState,
+} from '../../utils';
 import { BasicAction } from '../../utils/basic-action';
 import { LocalStorageBackedArray } from '../../utils/local-storage-backed-array';
 
@@ -45,20 +51,25 @@ const tableColumns: string[] = ['Lookup name', 'Tier', 'Type', 'Version', ACTION
 
 const DEFAULT_LOOKUP_TIER: string = '__default';
 
+export interface LookupEntriesAndTiers {
+  lookupEntries: any[];
+  tiers: string[];
+}
+
+export interface LookupEditInfo {
+  name: string;
+  tier: string;
+  version: string;
+  spec: LookupSpec;
+}
+
 export interface LookupsViewProps {}
 
 export interface LookupsViewState {
-  lookups?: any[];
-  loadingLookups: boolean;
-  lookupsError?: string;
-  lookupsUninitialized: boolean;
-  lookupEditDialogOpen: boolean;
-  lookupEditName: string;
-  lookupEditTier: string;
-  lookupEditVersion: string;
-  lookupEditSpec: LookupSpec;
+  lookupEntriesAndTiersState: QueryState<LookupEntriesAndTiers>;
+
+  lookupEdit?: LookupEditInfo;
   isEdit: boolean;
-  allLookupTiers: string[];
 
   deleteLookupName?: string;
   deleteLookupTier?: string;
@@ -70,21 +81,13 @@ export interface LookupsViewState {
 }
 
 export class LookupsView extends React.PureComponent<LookupsViewProps, LookupsViewState> {
-  private lookupsQueryManager: QueryManager<null, { lookupEntries: any[]; tiers: string[] }>;
+  private lookupsQueryManager: QueryManager<null, LookupEntriesAndTiers>;
 
   constructor(props: LookupsViewProps, context: any) {
     super(props, context);
     this.state = {
-      lookups: [],
-      loadingLookups: true,
-      lookupsUninitialized: false,
-      lookupEditDialogOpen: false,
-      lookupEditTier: '',
-      lookupEditName: '',
-      lookupEditVersion: '',
-      lookupEditSpec: { type: '' },
+      lookupEntriesAndTiersState: QueryState.INIT,
       isEdit: false,
-      allLookupTiers: [],
       actions: [],
 
       hiddenColumns: new LocalStorageBackedArray<string>(
@@ -118,13 +121,9 @@ export class LookupsView extends React.PureComponent<LookupsViewProps, LookupsVi
           tiers,
         };
       },
-      onStateChange: ({ result, loading, error }) => {
+      onStateChange: lookupEntriesAndTiersState => {
         this.setState({
-          lookups: result ? result.lookupEntries : undefined,
-          loadingLookups: loading,
-          lookupsError: error,
-          lookupsUninitialized: error === 'Request failed with status code 404',
-          allLookupTiers: result ? result.tiers : [],
+          lookupEntriesAndTiersState,
         });
       },
     });
@@ -152,29 +151,36 @@ export class LookupsView extends React.PureComponent<LookupsViewProps, LookupsVi
   }
 
   private async openLookupEditDialog(tier: string, id: string) {
-    const { lookups } = this.state;
-    if (!lookups) return;
+    const { lookupEntriesAndTiersState } = this.state;
+    const lookupEntriesAndTiers = lookupEntriesAndTiersState.data;
+    if (!lookupEntriesAndTiers) return;
 
-    const target: any = lookups.find((lookupEntry: any) => {
+    const target: any = lookupEntriesAndTiers.lookupEntries.find((lookupEntry: any) => {
       return lookupEntry.tier === tier && lookupEntry.id === id;
     });
     if (id === '') {
-      this.setState(prevState => ({
-        lookupEditName: '',
-        lookupEditTier: prevState.allLookupTiers[0],
-        lookupEditDialogOpen: true,
-        lookupEditSpec: { type: '' },
-        lookupEditVersion: new Date().toISOString(),
-        isEdit: false,
-      }));
+      this.setState(prevState => {
+        const { lookupEntriesAndTiersState } = prevState;
+        const loadingEntriesAndTiers = lookupEntriesAndTiersState.data;
+        return {
+          isEdit: false,
+          lookupEdit: {
+            name: '',
+            tier: loadingEntriesAndTiers ? loadingEntriesAndTiers.tiers[0] : '',
+            spec: { type: '' },
+            version: new Date().toISOString(),
+          },
+        };
+      });
     } else {
       this.setState({
-        lookupEditName: id,
-        lookupEditTier: tier,
-        lookupEditDialogOpen: true,
-        lookupEditSpec: target.spec,
-        lookupEditVersion: target.version,
         isEdit: true,
+        lookupEdit: {
+          name: id,
+          tier: tier,
+          spec: target.spec,
+          version: target.version,
+        },
       });
     }
   }
@@ -186,27 +192,23 @@ export class LookupsView extends React.PureComponent<LookupsViewProps, LookupsVi
   };
 
   private async submitLookupEdit(updatelookupEditVersion: boolean) {
-    const {
-      lookupEditTier,
-      lookupEditName,
-      lookupEditSpec,
-      lookupEditVersion,
-      isEdit,
-    } = this.state;
-    const version = updatelookupEditVersion ? new Date().toISOString() : lookupEditVersion;
+    const { lookupEdit, isEdit } = this.state;
+    if (!lookupEdit) return;
+
+    const version = updatelookupEditVersion ? new Date().toISOString() : lookupEdit.version;
     let endpoint = '/druid/coordinator/v1/lookups/config';
-    const specJson: any = lookupEditSpec;
+    const specJson: any = lookupEdit.spec;
     let dataJson: any;
     if (isEdit) {
-      endpoint = `${endpoint}/${lookupEditTier}/${lookupEditName}`;
+      endpoint = `${endpoint}/${lookupEdit.tier}/${lookupEdit.name}`;
       dataJson = {
         version: version,
         lookupExtractorFactory: specJson,
       };
     } else {
       dataJson = {
-        [lookupEditTier]: {
-          [lookupEditName]: {
+        [lookupEdit.tier]: {
+          [lookupEdit.name]: {
             version: version,
             lookupExtractorFactory: specJson,
           },
@@ -216,7 +218,7 @@ export class LookupsView extends React.PureComponent<LookupsViewProps, LookupsVi
     try {
       await axios.post(endpoint, dataJson);
       this.setState({
-        lookupEditDialogOpen: false,
+        lookupEdit: undefined,
       });
       this.lookupsQueryManager.rerunLastQuery();
     } catch (e) {
@@ -272,15 +274,11 @@ export class LookupsView extends React.PureComponent<LookupsViewProps, LookupsVi
   }
 
   renderLookupsTable() {
-    const {
-      lookups,
-      loadingLookups,
-      lookupsError,
-      lookupsUninitialized,
-      hiddenColumns,
-    } = this.state;
+    const { lookupEntriesAndTiersState, hiddenColumns } = this.state;
+    const lookupEntriesAndTiers = lookupEntriesAndTiersState.data;
+    const lookups = lookupEntriesAndTiers ? lookupEntriesAndTiers.lookupEntries : undefined;
 
-    if (lookupsUninitialized) {
+    if (isLookupsUninitialized(lookupEntriesAndTiersState.error)) {
       return (
         <div className="init-div">
           <Button
@@ -296,9 +294,11 @@ export class LookupsView extends React.PureComponent<LookupsViewProps, LookupsVi
       <>
         <ReactTable
           data={lookups || []}
-          loading={loadingLookups}
+          loading={lookupEntriesAndTiersState.loading}
           noDataText={
-            !loadingLookups && lookups && !lookups.length ? 'No lookups' : lookupsError || ''
+            !lookupEntriesAndTiersState.loading && lookups && !lookups.length
+              ? 'No lookups'
+              : lookupEntriesAndTiersState.getErrorMessage() || ''
           }
           filterable
           columns={[
@@ -362,26 +362,21 @@ export class LookupsView extends React.PureComponent<LookupsViewProps, LookupsVi
   }
 
   renderLookupEditDialog() {
-    const {
-      lookupEditDialogOpen,
-      allLookupTiers,
-      lookupEditSpec,
-      lookupEditTier,
-      lookupEditName,
-      lookupEditVersion,
-      isEdit,
-    } = this.state;
-    if (!lookupEditDialogOpen) return;
+    const { lookupEdit, isEdit, lookupEntriesAndTiersState } = this.state;
+    if (!lookupEdit) return;
+    const allLookupTiers = lookupEntriesAndTiersState.data
+      ? lookupEntriesAndTiersState.data.tiers
+      : [];
 
     return (
       <LookupEditDialog
-        onClose={() => this.setState({ lookupEditDialogOpen: false })}
+        onClose={() => this.setState({ lookupEdit: undefined })}
         onSubmit={updateLookupVersion => this.submitLookupEdit(updateLookupVersion)}
         onChange={this.handleChangeLookup}
-        lookupSpec={lookupEditSpec}
-        lookupName={lookupEditName}
-        lookupTier={lookupEditTier}
-        lookupVersion={lookupEditVersion}
+        lookupSpec={lookupEdit.spec}
+        lookupName={lookupEdit.name}
+        lookupTier={lookupEdit.tier}
+        lookupVersion={lookupEdit.version}
         isEdit={isEdit}
         allLookupTiers={allLookupTiers}
       />
@@ -389,7 +384,12 @@ export class LookupsView extends React.PureComponent<LookupsViewProps, LookupsVi
   }
 
   render(): JSX.Element {
-    const { lookupsError, hiddenColumns, lookupTableActionDialogId, actions } = this.state;
+    const {
+      lookupEntriesAndTiersState,
+      hiddenColumns,
+      lookupTableActionDialogId,
+      actions,
+    } = this.state;
 
     return (
       <div className="lookups-view app-view">
@@ -398,7 +398,7 @@ export class LookupsView extends React.PureComponent<LookupsViewProps, LookupsVi
             onRefresh={auto => this.lookupsQueryManager.rerunLastQuery(auto)}
             localStorageKey={LocalStorageKeys.LOOKUPS_REFRESH_RATE}
           />
-          {!lookupsError && (
+          {!lookupEntriesAndTiersState.isError() && (
             <Button
               icon={IconNames.PLUS}
               text="Add lookup"
diff --git a/web-console/src/views/query-view/__snapshots__/query-view.spec.tsx.snap b/web-console/src/views/query-view/__snapshots__/query-view.spec.tsx.snap
index 1f9b7f5..093e326 100644
--- a/web-console/src/views/query-view/__snapshots__/query-view.spec.tsx.snap
+++ b/web-console/src/views/query-view/__snapshots__/query-view.spec.tsx.snap
@@ -35,7 +35,7 @@ exports[`sql view matches snapshot 1`] = `
         className="control-bar"
       >
         <HotkeysTarget(RunButton)
-          loading={false}
+          loading={true}
           onEditContext={[Function]}
           onExplain={[Function]}
           onHistory={[Function]}
@@ -46,19 +46,6 @@ exports[`sql view matches snapshot 1`] = `
           runeMode={false}
         />
         <Blueprint3.Tooltip
-          content="Automatically run queries when modified via helper action menus."
-          hoverCloseDelay={0}
-          hoverOpenDelay={800}
-          transitionDuration={100}
-        >
-          <Blueprint3.Switch
-            checked={true}
-            className="auto-run"
-            label="Auto run"
-            onChange={[Function]}
-          />
-        </Blueprint3.Tooltip>
-        <Blueprint3.Tooltip
           content="Automatically wrap the query with a limit to protect against queries with very large result sets."
           hoverCloseDelay={0}
           hoverOpenDelay={800}
@@ -71,13 +58,21 @@ exports[`sql view matches snapshot 1`] = `
             onChange={[Function]}
           />
         </Blueprint3.Tooltip>
+        <Memo(LiveQueryModeSelector)
+          autoLiveQueryModeShouldRun={true}
+          liveQueryMode="auto"
+          onLiveQueryModeChange={[Function]}
+        />
       </div>
     </div>
-    <Memo(QueryOutput)
-      loading={false}
-      onQueryChange={[Function]}
-      runeMode={false}
-    />
+    <div
+      className="output-pane"
+    >
+      <Memo(Loader)
+        cancelText="Cancel query"
+        onCancel={[Function]}
+      />
+    </div>
   </t>
 </div>
 `;
@@ -117,7 +112,7 @@ exports[`sql view matches snapshot with query 1`] = `
         className="control-bar"
       >
         <HotkeysTarget(RunButton)
-          loading={false}
+          loading={true}
           onEditContext={[Function]}
           onExplain={[Function]}
           onHistory={[Function]}
@@ -128,19 +123,6 @@ exports[`sql view matches snapshot with query 1`] = `
           runeMode={false}
         />
         <Blueprint3.Tooltip
-          content="Automatically run queries when modified via helper action menus."
-          hoverCloseDelay={0}
-          hoverOpenDelay={800}
-          transitionDuration={100}
-        >
-          <Blueprint3.Switch
-            checked={true}
-            className="auto-run"
-            label="Auto run"
-            onChange={[Function]}
-          />
-        </Blueprint3.Tooltip>
-        <Blueprint3.Tooltip
           content="Automatically wrap the query with a limit to protect against queries with very large result sets."
           hoverCloseDelay={0}
           hoverOpenDelay={800}
@@ -153,13 +135,21 @@ exports[`sql view matches snapshot with query 1`] = `
             onChange={[Function]}
           />
         </Blueprint3.Tooltip>
+        <Memo(LiveQueryModeSelector)
+          autoLiveQueryModeShouldRun={true}
+          liveQueryMode="auto"
+          onLiveQueryModeChange={[Function]}
+        />
       </div>
     </div>
-    <Memo(QueryOutput)
-      loading={false}
-      onQueryChange={[Function]}
-      runeMode={false}
-    />
+    <div
+      className="output-pane"
+    >
+      <Memo(Loader)
+        cancelText="Cancel query"
+        onCancel={[Function]}
+      />
+    </div>
   </t>
 </div>
 `;
diff --git a/web-console/src/views/query-view/column-tree/column-tree.tsx b/web-console/src/views/query-view/column-tree/column-tree.tsx
index 5bd4d13..9ae7270 100644
--- a/web-console/src/views/query-view/column-tree/column-tree.tsx
+++ b/web-console/src/views/query-view/column-tree/column-tree.tsx
@@ -16,16 +16,7 @@
  * limitations under the License.
  */
 
-import {
-  HTMLSelect,
-  IconName,
-  ITreeNode,
-  Menu,
-  MenuItem,
-  Popover,
-  Position,
-  Tree,
-} from '@blueprintjs/core';
+import { HTMLSelect, ITreeNode, Menu, MenuItem, Popover, Position, Tree } from '@blueprintjs/core';
 import { IconNames } from '@blueprintjs/icons';
 import {
   SqlAlias,
@@ -42,6 +33,7 @@ import { Loader } from '../../../components';
 import { Deferred } from '../../../components/deferred/deferred';
 import { copyAndAlert, groupBy, prettyPrintSql } from '../../../utils';
 import { ColumnMetadata } from '../../../utils/column-metadata';
+import { dataTypeToIcon } from '../query-utils';
 
 import { NumberMenuItems, StringMenuItems, TimeMenuItems } from './column-tree-menu';
 
@@ -320,7 +312,7 @@ export class ColumnTree extends React.PureComponent<ColumnTreeProps, ColumnTreeS
                 .map(
                   (columnData): ITreeNode => ({
                     id: columnData.COLUMN_NAME,
-                    icon: ColumnTree.dataTypeToIcon(columnData.DATA_TYPE),
+                    icon: dataTypeToIcon(columnData.DATA_TYPE),
                     label: (
                       <Popover
                         boundary={'window'}
@@ -445,20 +437,6 @@ export class ColumnTree extends React.PureComponent<ColumnTreeProps, ColumnTreeS
     return null;
   }
 
-  static dataTypeToIcon(dataType: string): IconName {
-    switch (dataType) {
-      case 'TIMESTAMP':
-        return IconNames.TIME;
-      case 'VARCHAR':
-        return IconNames.FONT;
-      case 'BIGINT':
-      case 'FLOAT':
-        return IconNames.NUMERICAL;
-      default:
-        return IconNames.HELP;
-    }
-  }
-
   constructor(props: ColumnTreeProps, context: any) {
     super(props, context);
     this.state = {
@@ -504,6 +482,24 @@ export class ColumnTree extends React.PureComponent<ColumnTreeProps, ColumnTreeS
     });
   };
 
+  private handleNodeCollapse = (nodeData: ITreeNode) => {
+    nodeData.isExpanded = false;
+    this.bounceState();
+  };
+
+  private handleNodeExpand = (nodeData: ITreeNode) => {
+    nodeData.isExpanded = true;
+    this.bounceState();
+  };
+
+  bounceState() {
+    const { columnTree } = this.state;
+    if (!columnTree) return;
+    this.setState(prevState => ({
+      columnTree: prevState.columnTree ? prevState.columnTree.slice() : undefined,
+    }));
+  }
+
   render(): JSX.Element | null {
     const { columnMetadataLoading } = this.props;
     const { currentSchemaSubtree } = this.state;
@@ -511,7 +507,7 @@ export class ColumnTree extends React.PureComponent<ColumnTreeProps, ColumnTreeS
     if (columnMetadataLoading) {
       return (
         <div className="column-tree">
-          <Loader loading />
+          <Loader />
         </div>
       );
     }
@@ -531,22 +527,4 @@ export class ColumnTree extends React.PureComponent<ColumnTreeProps, ColumnTreeS
       </div>
     );
   }
-
-  private handleNodeCollapse = (nodeData: ITreeNode) => {
-    nodeData.isExpanded = false;
-    this.bounceState();
-  };
-
-  private handleNodeExpand = (nodeData: ITreeNode) => {
-    nodeData.isExpanded = true;
-    this.bounceState();
-  };
-
-  bounceState() {
-    const { columnTree } = this.state;
-    if (!columnTree) return;
-    this.setState(prevState => ({
-      columnTree: prevState.columnTree ? prevState.columnTree.slice() : undefined,
-    }));
-  }
 }
diff --git a/web-console/src/views/query-view/live-query-mode-selector/__snapshots__/live-query-mode-selector.spec.tsx.snap b/web-console/src/views/query-view/live-query-mode-selector/__snapshots__/live-query-mode-selector.spec.tsx.snap
new file mode 100644
index 0000000..2291096
--- /dev/null
+++ b/web-console/src/views/query-view/live-query-mode-selector/__snapshots__/live-query-mode-selector.spec.tsx.snap
@@ -0,0 +1,147 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`LiveQueryModeSelector matches snapshot auto 1`] = `
+<Blueprint3.Popover
+  boundary="scrollParent"
+  captureDismiss={false}
+  content={
+    <Blueprint3.Menu>
+      <Blueprint3.MenuItem
+        className="auto auto-on"
+        disabled={false}
+        icon="tick"
+        multiline={false}
+        onClick={[Function]}
+        popoverProps={Object {}}
+        shouldDismissPopover={true}
+        text="Auto"
+      />
+      <Blueprint3.MenuItem
+        className="on"
+        disabled={false}
+        icon="blank"
+        multiline={false}
+        onClick={[Function]}
+        popoverProps={Object {}}
+        shouldDismissPopover={true}
+        text="On"
+      />
+      <Blueprint3.MenuItem
+        className="off"
+        disabled={false}
+        icon="blank"
+        multiline={false}
+        onClick={[Function]}
+        popoverProps={Object {}}
+        shouldDismissPopover={true}
+        text="Off"
+      />
+    </Blueprint3.Menu>
+  }
+  defaultIsOpen={false}
+  disabled={false}
+  fill={false}
+  hasBackdrop={false}
+  hoverCloseDelay={300}
+  hoverOpenDelay={150}
+  inheritDarkTheme={true}
+  interactionKind="click"
+  minimal={true}
+  modifiers={Object {}}
+  openOnTargetFocus={true}
+  portalClassName="live-query-mode-selector-portal"
+  position="bottom-left"
+  targetTagName="span"
+  transitionDuration={300}
+  usePortal={true}
+  wrapperTagName="span"
+>
+  <Blueprint3.Button
+    className="live-query-mode-selector"
+    minimal={true}
+  >
+    <span>
+      Live query:
+    </span>
+     
+    <span
+      className="auto auto-on"
+    >
+      Auto
+    </span>
+  </Blueprint3.Button>
+</Blueprint3.Popover>
+`;
+
+exports[`LiveQueryModeSelector matches snapshot on 1`] = `
+<Blueprint3.Popover
+  boundary="scrollParent"
+  captureDismiss={false}
+  content={
+    <Blueprint3.Menu>
+      <Blueprint3.MenuItem
+        className="auto auto-on"
+        disabled={false}
+        icon="blank"
+        multiline={false}
+        onClick={[Function]}
+        popoverProps={Object {}}
+        shouldDismissPopover={true}
+        text="Auto"
+      />
+      <Blueprint3.MenuItem
+        className="on"
+        disabled={false}
+        icon="tick"
+        multiline={false}
+        onClick={[Function]}
+        popoverProps={Object {}}
+        shouldDismissPopover={true}
+        text="On"
+      />
+      <Blueprint3.MenuItem
+        className="off"
+        disabled={false}
+        icon="blank"
+        multiline={false}
+        onClick={[Function]}
+        popoverProps={Object {}}
+        shouldDismissPopover={true}
+        text="Off"
+      />
+    </Blueprint3.Menu>
+  }
+  defaultIsOpen={false}
+  disabled={false}
+  fill={false}
+  hasBackdrop={false}
+  hoverCloseDelay={300}
+  hoverOpenDelay={150}
+  inheritDarkTheme={true}
+  interactionKind="click"
+  minimal={true}
+  modifiers={Object {}}
+  openOnTargetFocus={true}
+  portalClassName="live-query-mode-selector-portal"
+  position="bottom-left"
+  targetTagName="span"
+  transitionDuration={300}
+  usePortal={true}
+  wrapperTagName="span"
+>
+  <Blueprint3.Button
+    className="live-query-mode-selector"
+    minimal={true}
+  >
+    <span>
+      Live query:
+    </span>
+     
+    <span
+      className="on auto-on"
+    >
+      On
+    </span>
+  </Blueprint3.Button>
+</Blueprint3.Popover>
+`;
diff --git a/web-console/src/utils/index.tsx b/web-console/src/views/query-view/live-query-mode-selector/live-query-mode-selector.scss
similarity index 74%
copy from web-console/src/utils/index.tsx
copy to web-console/src/views/query-view/live-query-mode-selector/live-query-mode-selector.scss
index 7e1cca2..b524e52 100644
--- a/web-console/src/utils/index.tsx
+++ b/web-console/src/views/query-view/live-query-mode-selector/live-query-mode-selector.scss
@@ -16,9 +16,22 @@
  * limitations under the License.
  */
 
-export * from './general';
-export * from './druid-query';
-export * from './query-manager';
-export * from './query-state';
-export * from './query-cursor';
-export * from './local-storage-keys';
+.live-query-mode-selector {
+  .auto.auto-on {
+    color: #7ce87c;
+  }
+
+  .on {
+    color: #7ce87c;
+  }
+}
+
+.live-query-mode-selector-portal {
+  .auto.auto-on .bp3-text-overflow-ellipsis {
+    color: #7ce87c;
+  }
+
+  .on .bp3-text-overflow-ellipsis {
+    color: #7ce87c;
+  }
+}
diff --git a/web-console/src/views/home-view/home-view.spec.tsx b/web-console/src/views/query-view/live-query-mode-selector/live-query-mode-selector.spec.tsx
similarity index 56%
copy from web-console/src/views/home-view/home-view.spec.tsx
copy to web-console/src/views/query-view/live-query-mode-selector/live-query-mode-selector.spec.tsx
index da8f05d..a8bd391 100644
--- a/web-console/src/views/home-view/home-view.spec.tsx
+++ b/web-console/src/views/query-view/live-query-mode-selector/live-query-mode-selector.spec.tsx
@@ -19,23 +19,30 @@
 import { shallow } from 'enzyme';
 import React from 'react';
 
-import { Capabilities } from '../../utils/capabilities';
+import { LiveQueryModeSelector } from './live-query-mode-selector';
 
-import { HomeView } from './home-view';
+describe('LiveQueryModeSelector', () => {
+  it('matches snapshot on', () => {
+    const liveQueryModeSelector = shallow(
+      <LiveQueryModeSelector
+        liveQueryMode="on"
+        onLiveQueryModeChange={() => {}}
+        autoLiveQueryModeShouldRun
+      />,
+    );
 
-describe('home view', () => {
-  it('matches snapshot (full)', () => {
-    const homeView = shallow(<HomeView capabilities={Capabilities.FULL} />);
-    expect(homeView).toMatchSnapshot();
+    expect(liveQueryModeSelector).toMatchSnapshot();
   });
 
-  it('matches snapshot (coordiantor)', () => {
-    const homeView = shallow(<HomeView capabilities={Capabilities.COORDINATOR} />);
-    expect(homeView).toMatchSnapshot();
-  });
+  it('matches snapshot auto', () => {
+    const liveQueryModeSelector = shallow(
+      <LiveQueryModeSelector
+        liveQueryMode="auto"
+        onLiveQueryModeChange={() => {}}
+        autoLiveQueryModeShouldRun
+      />,
+    );
 
-  it('matches snapshot (overlord)', () => {
-    const homeView = shallow(<HomeView capabilities={Capabilities.OVERLORD} />);
-    expect(homeView).toMatchSnapshot();
+    expect(liveQueryModeSelector).toMatchSnapshot();
   });
 });
diff --git a/web-console/src/views/query-view/live-query-mode-selector/live-query-mode-selector.tsx b/web-console/src/views/query-view/live-query-mode-selector/live-query-mode-selector.tsx
new file mode 100644
index 0000000..c9a6a82
--- /dev/null
+++ b/web-console/src/views/query-view/live-query-mode-selector/live-query-mode-selector.tsx
@@ -0,0 +1,77 @@
+/*
+ * 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.
+ */
+
+import { Button, Menu, MenuItem, Popover, PopoverPosition } from '@blueprintjs/core';
+import { IconNames } from '@blueprintjs/icons';
+import classNames from 'classnames';
+import React from 'react';
+
+import './live-query-mode-selector.scss';
+
+export type LiveQueryMode = 'auto' | 'on' | 'off';
+export const LIVE_QUERY_MODES: LiveQueryMode[] = ['auto', 'on', 'off'];
+export const LIVE_QUERY_MODE_TITLE: Record<LiveQueryMode, string> = {
+  auto: 'Auto',
+  on: 'On',
+  off: 'Off',
+};
+
+export interface LiveQueryModeSelectorProps {
+  liveQueryMode: LiveQueryMode;
+  onLiveQueryModeChange: (liveQueryMode: LiveQueryMode) => void;
+  autoLiveQueryModeShouldRun: boolean;
+}
+
+export const LiveQueryModeSelector = React.memo(function LiveQueryModeSelector(
+  props: LiveQueryModeSelectorProps,
+) {
+  const { liveQueryMode, onLiveQueryModeChange, autoLiveQueryModeShouldRun } = props;
+
+  return (
+    <Popover
+      portalClassName="live-query-mode-selector-portal"
+      minimal
+      position={PopoverPosition.BOTTOM_LEFT}
+      content={
+        <Menu>
+          {LIVE_QUERY_MODES.map(m => (
+            <MenuItem
+              className={classNames(
+                m,
+                m === 'auto' ? (autoLiveQueryModeShouldRun ? 'auto-on' : 'auto-off') : undefined,
+              )}
+              key={m}
+              icon={m === liveQueryMode ? IconNames.TICK : IconNames.BLANK}
+              text={LIVE_QUERY_MODE_TITLE[m]}
+              onClick={() => onLiveQueryModeChange(m)}
+            />
+          ))}
+        </Menu>
+      }
+    >
+      <Button minimal className="live-query-mode-selector">
+        <span>Live query:</span>{' '}
+        <span
+          className={classNames(liveQueryMode, autoLiveQueryModeShouldRun ? 'auto-on' : 'auto-off')}
+        >
+          {LIVE_QUERY_MODE_TITLE[liveQueryMode]}
+        </span>
+      </Button>
+    </Popover>
+  );
+});
diff --git a/web-console/src/views/query-view/query-error/__snapshots__/query-error.spec.tsx.snap b/web-console/src/views/query-view/query-error/__snapshots__/query-error.spec.tsx.snap
new file mode 100644
index 0000000..4c58947
--- /dev/null
+++ b/web-console/src/views/query-view/query-error/__snapshots__/query-error.spec.tsx.snap
@@ -0,0 +1,9 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`query error matches snapshot 1`] = `
+<div
+  className="query-error"
+>
+  something went wrong in line 7, column 8.
+</div>
+`;
diff --git a/web-console/src/utils/index.tsx b/web-console/src/views/query-view/query-error/query-error.scss
similarity index 80%
copy from web-console/src/utils/index.tsx
copy to web-console/src/views/query-view/query-error/query-error.scss
index 7e1cca2..a4511de 100644
--- a/web-console/src/utils/index.tsx
+++ b/web-console/src/views/query-view/query-error/query-error.scss
@@ -16,9 +16,13 @@
  * limitations under the License.
  */
 
-export * from './general';
-export * from './druid-query';
-export * from './query-manager';
-export * from './query-state';
-export * from './query-cursor';
-export * from './local-storage-keys';
+.query-error {
+  background: #232d35;
+  padding: 20px 22px;
+
+  .cursor-link {
+    color: #2aabd2;
+    text-decoration: underline;
+    cursor: pointer;
+  }
+}
diff --git a/web-console/src/dialogs/snitch-dialog/snitch-dialog.spec.tsx b/web-console/src/views/query-view/query-error/query-error.spec.tsx
similarity index 73%
copy from web-console/src/dialogs/snitch-dialog/snitch-dialog.spec.tsx
copy to web-console/src/views/query-view/query-error/query-error.spec.tsx
index ff3a5c6..acec4d1 100644
--- a/web-console/src/dialogs/snitch-dialog/snitch-dialog.spec.tsx
+++ b/web-console/src/views/query-view/query-error/query-error.spec.tsx
@@ -16,15 +16,20 @@
  * limitations under the License.
  */
 
-import { render } from '@testing-library/react';
+import { shallow } from 'enzyme';
 import React from 'react';
 
-import { SnitchDialog } from './snitch-dialog';
+import { QueryError } from './query-error';
 
-describe('snitch dialog', () => {
+describe('query error', () => {
   it('matches snapshot', () => {
-    const snitchDialog = <SnitchDialog title="Be snitchin" onSave={() => {}} onClose={() => {}} />;
-    render(snitchDialog);
-    expect(document.body.lastChild).toMatchSnapshot();
+    const queryError = shallow(
+      <QueryError
+        error={new Error('something went wrong in line 7, column 8.')}
+        moveCursorTo={() => {}}
+      />,
+    );
+
+    expect(queryError).toMatchSnapshot();
   });
... 1433 lines suppressed ...


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@druid.apache.org
For additional commands, e-mail: commits-help@druid.apache.org