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

vogievetsky 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 bbcccd2e1f8 Web console: refactor table filters, show inactive MSQ 
worker count (#18768)
bbcccd2e1f8 is described below

commit bbcccd2e1f8bb6ca58b10e12e4e4f95d78648007
Author: Vadim Ogievetsky <[email protected]>
AuthorDate: Fri Nov 21 23:47:02 2025 +0000

    Web console: refactor table filters, show inactive MSQ worker count (#18768)
    
    * Make TableFilters class instead of using the interface from react-table
    
    * Add refresh button to ShowJson and ShowJsonOrStages
    
    * Show inactive workers
---
 web-console/src/bootstrap/react-table-defaults.tsx |  13 +-
 .../show-json-or-stages.spec.tsx.snap              |  12 ++
 .../show-json-or-stages/show-json-or-stages.tsx    |   8 +-
 .../__snapshots__/show-json.spec.tsx.snap          |  12 ++
 web-console/src/components/show-json/show-json.tsx |   8 +-
 .../table-filterable-cell.tsx                      |  18 +-
 web-console/src/console-application.tsx            | 111 +++--------
 .../segment-table-action-dialog.spec.tsx.snap      |  12 ++
 .../src/dialogs/status-dialog/status-dialog.tsx    |   8 +-
 .../supervisor-table-action-dialog.spec.tsx.snap   |  12 ++
 .../task-table-action-dialog.spec.tsx.snap         |  12 ++
 web-console/src/druid-models/stages/stages.ts      |  23 +++
 .../src/react-table/{index.ts => constants.ts}     |  10 +-
 web-console/src/react-table/index.ts               |   2 +-
 .../src/react-table/react-table-filters.spec.ts    |  83 --------
 web-console/src/react-table/react-table-filters.ts | 195 -------------------
 web-console/src/react-table/react-table-inputs.tsx |  42 ++--
 .../{react-table => utils/table-filters}/index.ts  |   5 +-
 .../src/utils/table-filters/table-filter.spec.ts   |  96 +++++++++
 .../src/utils/table-filters/table-filter.ts        | 214 +++++++++++++++++++++
 .../src/utils/table-filters/table-filters.spec.ts  |  94 +++++++++
 .../src/utils/table-filters/table-filters.ts       | 107 +++++++++++
 web-console/src/utils/table-helpers.ts             |   5 +-
 .../datasources-view/datasources-view.spec.tsx     |   6 +-
 .../views/datasources-view/datasources-view.tsx    |  60 ++++--
 .../views/load-data-view/load-data-view.spec.tsx   |   6 +-
 .../src/views/load-data-view/load-data-view.tsx    |  13 +-
 .../src/views/lookups-view/lookups-view.spec.tsx   |   5 +-
 .../src/views/lookups-view/lookups-view.tsx        |  10 +-
 .../src/views/segments-view/segments-view.spec.tsx |   3 +-
 .../src/views/segments-view/segments-view.tsx      | 133 ++++++-------
 .../src/views/services-view/services-view.spec.tsx |   3 +-
 .../src/views/services-view/services-view.tsx      |  33 ++--
 .../ingestion-progress-dialog.tsx                  |  20 +-
 .../sql-data-loader-view/sql-data-loader-view.tsx  |  11 +-
 .../supervisors-view/supervisors-view.spec.tsx     |   6 +-
 .../views/supervisors-view/supervisors-view.tsx    |  60 +++---
 .../__snapshots__/tasks-view.spec.tsx.snap         |   1 +
 .../src/views/tasks-view/tasks-view.spec.tsx       |   5 +-
 web-console/src/views/tasks-view/tasks-view.tsx    |  40 ++--
 .../execution-stages-pane.spec.tsx.snap            |   1 +
 .../execution-stages-pane.tsx                      |  16 ++
 .../src/views/workbench-view/workbench-view.tsx    |  12 +-
 43 files changed, 934 insertions(+), 612 deletions(-)

diff --git a/web-console/src/bootstrap/react-table-defaults.tsx 
b/web-console/src/bootstrap/react-table-defaults.tsx
index 531bc8127bc..fe5cfe99f5a 100644
--- a/web-console/src/bootstrap/react-table-defaults.tsx
+++ b/web-console/src/bootstrap/react-table-defaults.tsx
@@ -21,13 +21,9 @@ import type { Filter } from 'react-table';
 import { ReactTableDefaults } from 'react-table';
 
 import { Loader } from '../components';
-import {
-  booleanCustomTableFilter,
-  DEFAULT_TABLE_CLASS_NAME,
-  GenericFilterInput,
-  ReactTablePagination,
-} from '../react-table';
+import { DEFAULT_TABLE_CLASS_NAME, GenericFilterInput, ReactTablePagination } 
from '../react-table';
 import { countBy } from '../utils';
+import { TableFilter } from '../utils/table-filters';
 
 const NoData = React.memo(function NoData(props: { children?: React.ReactNode 
}) {
   const { children } = props;
@@ -41,10 +37,11 @@ export function bootstrapReactTable() {
     defaultFilterMethod: (filter: Filter, row: any) => {
       const id = filter.pivotId || filter.id;
       const subRows = row._subRows;
+      const tableFilter = TableFilter.fromFilter(filter);
       if (Array.isArray(subRows)) {
-        return subRows.some(r => booleanCustomTableFilter(filter, r[id]));
+        return subRows.some(r => tableFilter.matches(r[id]));
       } else {
-        return booleanCustomTableFilter(filter, row[id]);
+        return tableFilter.matches(row[id]);
       }
     },
     LoadingComponent: Loader,
diff --git 
a/web-console/src/components/show-json-or-stages/__snapshots__/show-json-or-stages.spec.tsx.snap
 
b/web-console/src/components/show-json-or-stages/__snapshots__/show-json-or-stages.spec.tsx.snap
index 052bb7e9d6c..5110584105c 100644
--- 
a/web-console/src/components/show-json-or-stages/__snapshots__/show-json-or-stages.spec.tsx.snap
+++ 
b/web-console/src/components/show-json-or-stages/__snapshots__/show-json-or-stages.spec.tsx.snap
@@ -10,6 +10,18 @@ exports[`ShowJsonOrStages matches snapshot 1`] = `
     <div
       class="bp5-button-group right-buttons"
     >
+      <button
+        class="bp5-button bp5-disabled bp5-minimal"
+        disabled=""
+        tabindex="-1"
+        type="button"
+      >
+        <span
+          class="bp5-button-text"
+        >
+          Refesh
+        </span>
+      </button>
       <button
         class="bp5-button bp5-disabled bp5-minimal"
         disabled=""
diff --git 
a/web-console/src/components/show-json-or-stages/show-json-or-stages.tsx 
b/web-console/src/components/show-json-or-stages/show-json-or-stages.tsx
index 8010104bcee..6d15330c431 100644
--- a/web-console/src/components/show-json-or-stages/show-json-or-stages.tsx
+++ b/web-console/src/components/show-json-or-stages/show-json-or-stages.tsx
@@ -40,7 +40,7 @@ export interface ShowJsonOrStagesProps {
 export const ShowJsonOrStages = React.memo(function ShowJsonOrStages(props: 
ShowJsonOrStagesProps) {
   const { endpoint, transform, downloadFilename } = props;
 
-  const [jsonState] = useQueryManager<null, [string, Execution | undefined]>({
+  const [jsonState, queryManager] = useQueryManager<null, [string, Execution | 
undefined]>({
     processQuery: async (_, signal) => {
       const resp = await Api.instance.get(endpoint, { signal });
       let data = resp.data;
@@ -68,6 +68,12 @@ export const ShowJsonOrStages = React.memo(function 
ShowJsonOrStages(props: Show
     <div className="show-json-or-stages">
       <div className="top-actions">
         <ButtonGroup className="right-buttons">
+          <Button
+            disabled={jsonState.loading}
+            text="Refesh"
+            minimal
+            onClick={() => queryManager.rerunLastQuery()}
+          />
           {downloadFilename && (
             <Button
               disabled={jsonState.loading}
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 a5f40157462..a52424f9cba 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
@@ -10,6 +10,18 @@ exports[`ShowJson matches snapshot 1`] = `
     <div
       class="bp5-button-group right-buttons"
     >
+      <button
+        class="bp5-button bp5-disabled bp5-minimal"
+        disabled=""
+        tabindex="-1"
+        type="button"
+      >
+        <span
+          class="bp5-button-text"
+        >
+          Refesh
+        </span>
+      </button>
       <button
         class="bp5-button bp5-disabled bp5-minimal"
         disabled=""
diff --git a/web-console/src/components/show-json/show-json.tsx 
b/web-console/src/components/show-json/show-json.tsx
index 3340e5818ec..c54e8938591 100644
--- a/web-console/src/components/show-json/show-json.tsx
+++ b/web-console/src/components/show-json/show-json.tsx
@@ -38,7 +38,7 @@ export interface ShowJsonProps {
 export const ShowJson = React.memo(function ShowJson(props: ShowJsonProps) {
   const { endpoint, transform, downloadFilename } = props;
 
-  const [jsonState] = useQueryManager<null, string>({
+  const [jsonState, queryManager] = useQueryManager<null, string>({
     processQuery: async (_, signal) => {
       const resp = await Api.instance.get(endpoint, { signal });
       let data = resp.data;
@@ -53,6 +53,12 @@ export const ShowJson = React.memo(function ShowJson(props: 
ShowJsonProps) {
     <div className="show-json">
       <div className="top-actions">
         <ButtonGroup className="right-buttons">
+          <Button
+            disabled={jsonState.loading}
+            text="Refesh"
+            minimal
+            onClick={() => queryManager.rerunLastQuery()}
+          />
           {downloadFilename && (
             <Button
               disabled={jsonState.loading}
diff --git 
a/web-console/src/components/table-filterable-cell/table-filterable-cell.tsx 
b/web-console/src/components/table-filterable-cell/table-filterable-cell.tsx
index 1e2f3094ff3..4e27307a1d2 100644
--- a/web-console/src/components/table-filterable-cell/table-filterable-cell.tsx
+++ b/web-console/src/components/table-filterable-cell/table-filterable-cell.tsx
@@ -19,10 +19,9 @@
 import { Menu, MenuDivider, MenuItem, Popover } from '@blueprintjs/core';
 import type { ReactNode } from 'react';
 import React from 'react';
-import type { Filter } from 'react-table';
 
-import type { FilterMode } from '../../react-table';
-import { addOrUpdateFilter, combineModeAndNeedle, filterModeToIcon } from 
'../../react-table';
+import type { FilterMode, TableFilters } from '../../utils/table-filters';
+import { TableFilter } from '../../utils/table-filters';
 import { Deferred } from '../deferred/deferred';
 
 import './table-filterable-cell.scss';
@@ -33,8 +32,8 @@ const FILTER_MODES_NO_COMPARISONS: FilterMode[] = ['=', '!='];
 export interface TableFilterableCellProps {
   field: string;
   value: string;
-  filters: Filter[];
-  onFiltersChange(filters: Filter[]): void;
+  filters: TableFilters;
+  onFiltersChange(filters: TableFilters): void;
   enableComparisons?: boolean;
   children?: ReactNode;
   displayValue?: string;
@@ -57,15 +56,10 @@ export const TableFilterableCell = React.memo(function 
TableFilterableCell(
               {(enableComparisons ? FILTER_MODES : 
FILTER_MODES_NO_COMPARISONS).map((mode, i) => (
                 <MenuItem
                   key={i}
-                  icon={filterModeToIcon(mode)}
+                  icon={TableFilter.modeToIcon(mode)}
                   text={displayValue ?? value}
                   onClick={() =>
-                    onFiltersChange(
-                      addOrUpdateFilter(filters, {
-                        id: field,
-                        value: combineModeAndNeedle(mode, value),
-                      }),
-                    )
+                    onFiltersChange(filters.addOrUpdate(new TableFilter(field, 
mode, value)))
                   }
                 />
               ))}
diff --git a/web-console/src/console-application.tsx 
b/web-console/src/console-application.tsx
index 822429582c9..3c838c25feb 100644
--- a/web-console/src/console-application.tsx
+++ b/web-console/src/console-application.tsx
@@ -24,7 +24,6 @@ import React from 'react';
 import type { RouteComponentProps } from 'react-router';
 import { Redirect } from 'react-router';
 import { HashRouter, Route, Switch } from 'react-router-dom';
-import type { Filter } from 'react-table';
 
 import { initAceDsqlMode } from './ace-modes/dsql';
 import { initAceHjsonMode } from './ace-modes/hjson';
@@ -33,9 +32,9 @@ import { SqlFunctionsProvider } from 
'./contexts/sql-functions-context';
 import type { ConsoleViewId, QueryContext, QueryWithContext } from 
'./druid-models';
 import type { AvailableFunctions } from './helpers';
 import { Capabilities, maybeGetClusterCapacity } from './helpers';
-import { stringToTableFilters, tableFiltersToString } from './react-table';
 import { AppToaster } from './singletons';
-import { compact, localStorageGetJson, LocalStorageKeys, QueryManager } from 
'./utils';
+import { localStorageGetJson, LocalStorageKeys, QueryManager } from './utils';
+import { TableFilters } from './utils/table-filters';
 import {
   DatasourcesView,
   ExploreView,
@@ -54,23 +53,23 @@ import './console-application.scss';
 
 type FiltersRouteMatch = RouteComponentProps<{ filters?: string }>;
 
-function changeTabWithFilter(tab: ConsoleViewId, filters: Filter[]) {
-  const filterString = tableFiltersToString(filters);
+function goToView(tab: ConsoleViewId, filters?: TableFilters) {
+  if (!filters || filters.isEmpty()) {
+    location.hash = tab;
+    return;
+  }
+  const filterString = filters.toString();
   location.hash = tab + (filterString ? `/${filterString}` : '');
 }
 
 function viewFilterChange(tab: ConsoleViewId) {
-  return (filters: Filter[]) => changeTabWithFilter(tab, filters);
+  return (filters: TableFilters) => goToView(tab, filters);
 }
 
 function pathWithFilter(tab: ConsoleViewId) {
   return `/${tab}/:filters?`;
 }
 
-function switchTab(tab: ConsoleViewId) {
-  location.hash = tab;
-}
-
 function switchToWorkbenchTab(tabId: string) {
   location.hash = `workbench/${tabId}`;
 }
@@ -183,79 +182,31 @@ export class ConsoleApplication extends 
React.PureComponent<
 
   private readonly goToStreamingDataLoader = (supervisorId?: string) => {
     if (supervisorId) this.supervisorId = supervisorId;
-    switchTab('streaming-data-loader');
+    goToView('streaming-data-loader');
     this.resetInitialsWithDelay();
   };
 
   private readonly goToClassicBatchDataLoader = (taskId?: string) => {
     if (taskId) this.taskId = taskId;
-    switchTab('classic-batch-data-loader');
+    goToView('classic-batch-data-loader');
     this.resetInitialsWithDelay();
   };
 
-  private readonly goToDatasources = (datasource: string) => {
-    changeTabWithFilter('datasources', [{ id: 'datasource', value: 
`=${datasource}` }]);
-  };
-
-  private readonly goToSegments = ({
-    start,
-    end,
-    datasource,
-    realtime,
-  }: {
-    start?: Date;
-    end?: Date;
-    datasource?: string;
-    realtime?: boolean;
-  }) => {
-    changeTabWithFilter(
-      'segments',
-      compact([
-        start && { id: 'start', value: `>=${start.toISOString()}` },
-        end && { id: 'end', value: `<${end.toISOString()}` },
-        datasource && { id: 'datasource', value: `=${datasource}` },
-        typeof realtime === 'boolean' ? { id: 'is_realtime', value: 
`=${realtime}` } : undefined,
-      ]),
-    );
-  };
-
-  private readonly goToSupervisor = (supervisorId: string) => {
-    changeTabWithFilter('supervisors', [{ id: 'supervisor_id', value: 
`=${supervisorId}` }]);
-  };
-
-  private readonly goToTasksWithTaskId = (taskId: string) => {
-    changeTabWithFilter('tasks', [{ id: 'task_id', value: `=${taskId}` }]);
-  };
-
-  private readonly goToTasksWithTaskGroupId = (taskGroupId: string) => {
-    changeTabWithFilter('tasks', [{ id: 'group_id', value: `=${taskGroupId}` 
}]);
-  };
-
-  private readonly goToTasksWithDatasource = (datasource: string, type?: 
string) => {
-    changeTabWithFilter(
-      'tasks',
-      compact([
-        { id: 'datasource', value: `=${datasource}` },
-        type ? { id: 'type', value: `=${type}` } : undefined,
-      ]),
-    );
-  };
-
   private readonly openSupervisorSubmit = () => {
     this.openSupervisorDialog = true;
-    switchTab('supervisors');
+    goToView('supervisors');
     this.resetInitialsWithDelay();
   };
 
   private readonly openTaskSubmit = () => {
     this.openTaskDialog = true;
-    switchTab('tasks');
+    goToView('tasks');
     this.resetInitialsWithDelay();
   };
 
   private readonly goToQuery = (queryWithContext: QueryWithContext) => {
     this.queryWithContext = queryWithContext;
-    switchTab('workbench');
+    goToView('workbench');
     this.resetInitialsWithDelay();
   };
 
@@ -290,8 +241,7 @@ export class ConsoleApplication extends React.PureComponent<
         mode="all"
         initTaskId={this.taskId}
         initSupervisorId={this.supervisorId}
-        goToSupervisor={this.goToSupervisor}
-        goToTasks={this.goToTasksWithTaskGroupId}
+        goToView={goToView}
         openSupervisorSubmit={this.openSupervisorSubmit}
         openTaskSubmit={this.openTaskSubmit}
       />,
@@ -305,8 +255,7 @@ export class ConsoleApplication extends React.PureComponent<
       <LoadDataView
         mode="streaming"
         initSupervisorId={this.supervisorId}
-        goToSupervisor={this.goToSupervisor}
-        goToTasks={this.goToTasksWithTaskGroupId}
+        goToView={goToView}
         openSupervisorSubmit={this.openSupervisorSubmit}
         openTaskSubmit={this.openTaskSubmit}
       />,
@@ -320,8 +269,7 @@ export class ConsoleApplication extends React.PureComponent<
       <LoadDataView
         mode="batch"
         initTaskId={this.taskId}
-        goToSupervisor={this.goToSupervisor}
-        goToTasks={this.goToTasksWithTaskGroupId}
+        goToView={goToView}
         openSupervisorSubmit={this.openSupervisorSubmit}
         openTaskSubmit={this.openTaskSubmit}
       />,
@@ -346,7 +294,7 @@ export class ConsoleApplication extends React.PureComponent<
         baseQueryContext={baseQueryContext}
         serverQueryContext={serverQueryContext}
         queryEngines={capabilities.getSupportedQueryEngines()}
-        goToTask={this.goToTasksWithTaskId}
+        goToView={goToView}
         getClusterCapacity={maybeGetClusterCapacity}
       />,
       'thin',
@@ -361,8 +309,7 @@ export class ConsoleApplication extends React.PureComponent<
       <SqlDataLoaderView
         capabilities={capabilities}
         goToQuery={this.goToQuery}
-        goToTask={this.goToTasksWithTaskId}
-        goToTaskGroup={this.goToTasksWithTaskGroupId}
+        goToView={goToView}
         getClusterCapacity={maybeGetClusterCapacity}
         serverQueryContext={serverQueryContext}
       />,
@@ -374,11 +321,10 @@ export class ConsoleApplication extends 
React.PureComponent<
     return this.wrapInViewContainer(
       'datasources',
       <DatasourcesView
-        filters={stringToTableFilters(p.match.params.filters)}
+        filters={TableFilters.fromString(p.match.params.filters)}
         onFiltersChange={viewFilterChange('datasources')}
         goToQuery={this.goToQuery}
-        goToTasks={this.goToTasksWithDatasource}
-        goToSegments={this.goToSegments}
+        goToView={goToView}
         capabilities={capabilities}
       />,
     );
@@ -389,7 +335,7 @@ export class ConsoleApplication extends React.PureComponent<
     return this.wrapInViewContainer(
       'segments',
       <SegmentsView
-        filters={stringToTableFilters(p.match.params.filters)}
+        filters={TableFilters.fromString(p.match.params.filters)}
         onFiltersChange={viewFilterChange('segments')}
         goToQuery={this.goToQuery}
         capabilities={capabilities}
@@ -402,13 +348,12 @@ export class ConsoleApplication extends 
React.PureComponent<
     return this.wrapInViewContainer(
       'supervisors',
       <SupervisorsView
-        filters={stringToTableFilters(p.match.params.filters)}
+        filters={TableFilters.fromString(p.match.params.filters)}
         onFiltersChange={viewFilterChange('supervisors')}
         openSupervisorDialog={this.openSupervisorDialog}
-        goToDatasource={this.goToDatasources}
+        goToView={goToView}
         goToQuery={this.goToQuery}
         goToStreamingDataLoader={this.goToStreamingDataLoader}
-        goToTasks={this.goToTasksWithTaskGroupId}
         capabilities={capabilities}
       />,
     );
@@ -419,10 +364,10 @@ export class ConsoleApplication extends 
React.PureComponent<
     return this.wrapInViewContainer(
       'tasks',
       <TasksView
-        filters={stringToTableFilters(p.match.params.filters)}
+        filters={TableFilters.fromString(p.match.params.filters)}
         onFiltersChange={viewFilterChange('tasks')}
         openTaskDialog={this.openTaskDialog}
-        goToDatasource={this.goToDatasources}
+        goToView={goToView}
         goToQuery={this.goToQuery}
         goToClassicBatchDataLoader={this.goToClassicBatchDataLoader}
         capabilities={capabilities}
@@ -435,7 +380,7 @@ export class ConsoleApplication extends React.PureComponent<
     return this.wrapInViewContainer(
       'services',
       <ServicesView
-        filters={stringToTableFilters(p.match.params.filters)}
+        filters={TableFilters.fromString(p.match.params.filters)}
         onFiltersChange={viewFilterChange('services')}
         goToQuery={this.goToQuery}
         capabilities={capabilities}
@@ -447,7 +392,7 @@ export class ConsoleApplication extends React.PureComponent<
     return this.wrapInViewContainer(
       'lookups',
       <LookupsView
-        filters={stringToTableFilters(p.match.params.filters)}
+        filters={TableFilters.fromString(p.match.params.filters)}
         onFiltersChange={viewFilterChange('lookups')}
       />,
     );
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 b5e9297210f..5926054f5c9 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
@@ -133,6 +133,18 @@ exports[`SegmentTableActionDialog matches snapshot 1`] = `
                 <div
                   class="bp5-button-group right-buttons"
                 >
+                  <button
+                    class="bp5-button bp5-disabled bp5-minimal"
+                    disabled=""
+                    tabindex="-1"
+                    type="button"
+                  >
+                    <span
+                      class="bp5-button-text"
+                    >
+                      Refesh
+                    </span>
+                  </button>
                   <button
                     class="bp5-button bp5-disabled bp5-minimal"
                     disabled=""
diff --git a/web-console/src/dialogs/status-dialog/status-dialog.tsx 
b/web-console/src/dialogs/status-dialog/status-dialog.tsx
index babc5e8acf1..de9d4b679f7 100644
--- a/web-console/src/dialogs/status-dialog/status-dialog.tsx
+++ b/web-console/src/dialogs/status-dialog/status-dialog.tsx
@@ -18,13 +18,13 @@
 
 import { Button, Classes, Dialog, Intent } from '@blueprintjs/core';
 import React, { useState } from 'react';
-import type { Filter } from 'react-table';
 import ReactTable from 'react-table';
 
 import { Loader, TableFilterableCell } from '../../components';
 import { useQueryManager } from '../../hooks';
 import { SMALL_TABLE_PAGE_SIZE, SMALL_TABLE_PAGE_SIZE_OPTIONS } from 
'../../react-table';
 import { Api, UrlBaser } from '../../singletons';
+import { TableFilters } from '../../utils/table-filters';
 
 import './status-dialog.scss';
 
@@ -45,7 +45,7 @@ interface StatusDialogProps {
 
 export const StatusDialog = React.memo(function StatusDialog(props: 
StatusDialogProps) {
   const { onClose } = props;
-  const [moduleFilter, setModuleFilter] = useState<Filter[]>([]);
+  const [moduleFilter, setModuleFilter] = 
useState<TableFilters>(TableFilters.empty());
 
   const [responseState] = useQueryManager<null, StatusResponse>({
     initQuery: null,
@@ -86,8 +86,8 @@ export const StatusDialog = React.memo(function 
StatusDialog(props: StatusDialog
           data={response.modules}
           loading={responseState.loading}
           filterable
-          filtered={moduleFilter}
-          onFilteredChange={setModuleFilter}
+          filtered={moduleFilter.toFilters()}
+          onFilteredChange={filters => 
setModuleFilter(TableFilters.fromFilters(filters))}
           defaultPageSize={SMALL_TABLE_PAGE_SIZE}
           pageSizeOptions={SMALL_TABLE_PAGE_SIZE_OPTIONS}
           showPagination={response.modules.length > SMALL_TABLE_PAGE_SIZE}
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 84e7b4d3a5a..b35c08f23cc 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
@@ -187,6 +187,18 @@ exports[`SupervisorTableActionDialog matches snapshot 1`] 
= `
                 <div
                   class="bp5-button-group right-buttons"
                 >
+                  <button
+                    class="bp5-button bp5-disabled bp5-minimal"
+                    disabled=""
+                    tabindex="-1"
+                    type="button"
+                  >
+                    <span
+                      class="bp5-button-text"
+                    >
+                      Refesh
+                    </span>
+                  </button>
                   <button
                     class="bp5-button bp5-disabled bp5-minimal"
                     disabled=""
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 28fa10bb145..0e92f7194af 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
@@ -187,6 +187,18 @@ exports[`TaskTableActionDialog matches snapshot 1`] = `
                 <div
                   class="bp5-button-group right-buttons"
                 >
+                  <button
+                    class="bp5-button bp5-disabled bp5-minimal"
+                    disabled=""
+                    tabindex="-1"
+                    type="button"
+                  >
+                    <span
+                      class="bp5-button-text"
+                    >
+                      Refesh
+                    </span>
+                  </button>
                   <button
                     class="bp5-button bp5-disabled bp5-minimal"
                     disabled=""
diff --git a/web-console/src/druid-models/stages/stages.ts 
b/web-console/src/druid-models/stages/stages.ts
index 8f9638e4e5b..65818c80467 100644
--- a/web-console/src/druid-models/stages/stages.ts
+++ b/web-console/src/druid-models/stages/stages.ts
@@ -617,6 +617,29 @@ export class Stages {
     });
   }
 
+  getInactiveWorkerCount(stage: StageDefinition): number | undefined {
+    const { counters } = this;
+    const { stageNumber, definition } = stage;
+    const forStageCounters = counters?.[stageNumber];
+    if (!forStageCounters) return;
+
+    const inputChannelCounters = definition.input.map((_, i) => `input${i}` as 
ChannelCounterName);
+
+    // Calculate and return the number of workers that have zero count across 
all inputChannelCounters
+    return sum(
+      Object.values(forStageCounters).map(stageCounters =>
+        Number(
+          inputChannelCounters.every(channel => {
+            const c = stageCounters[channel];
+            if (!c) return true;
+            const totalRows = sum(c.rows || []);
+            return totalRows === 0;
+          }),
+        ),
+      ),
+    );
+  }
+
   getPartitionChannelCounterNamesForStage(
     stage: StageDefinition,
     inOut: InOut,
diff --git a/web-console/src/react-table/index.ts 
b/web-console/src/react-table/constants.ts
similarity index 73%
copy from web-console/src/react-table/index.ts
copy to web-console/src/react-table/constants.ts
index 65b62cff5b4..aad0e6f1170 100644
--- a/web-console/src/react-table/index.ts
+++ b/web-console/src/react-table/constants.ts
@@ -16,6 +16,10 @@
  * limitations under the License.
  */
 
-export * from './react-table-filters';
-export * from './react-table-inputs';
-export * from './react-table-pagination/react-table-pagination';
+export const DEFAULT_TABLE_CLASS_NAME = '-striped -highlight padded-header';
+
+export const STANDARD_TABLE_PAGE_SIZE = 50;
+export const STANDARD_TABLE_PAGE_SIZE_OPTIONS = [50, 100, 200];
+
+export const SMALL_TABLE_PAGE_SIZE = 25;
+export const SMALL_TABLE_PAGE_SIZE_OPTIONS = [25, 50, 100];
diff --git a/web-console/src/react-table/index.ts 
b/web-console/src/react-table/index.ts
index 65b62cff5b4..ce293570ad4 100644
--- a/web-console/src/react-table/index.ts
+++ b/web-console/src/react-table/index.ts
@@ -16,6 +16,6 @@
  * limitations under the License.
  */
 
-export * from './react-table-filters';
+export * from './constants';
 export * from './react-table-inputs';
 export * from './react-table-pagination/react-table-pagination';
diff --git a/web-console/src/react-table/react-table-filters.spec.ts 
b/web-console/src/react-table/react-table-filters.spec.ts
deleted file mode 100644
index c0a5476fcde..00000000000
--- a/web-console/src/react-table/react-table-filters.spec.ts
+++ /dev/null
@@ -1,83 +0,0 @@
-/*
- * 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 {
-  sqlQueryCustomTableFilter,
-  stringToTableFilters,
-  tableFiltersToString,
-} from './react-table-filters';
-
-describe('react-table-utils', () => {
-  describe('sqlQueryCustomTableFilter', () => {
-    it('works with contains', () => {
-      expect(
-        String(
-          sqlQueryCustomTableFilter({
-            id: 'datasource',
-            value: `Hello`,
-          }),
-        ),
-      ).toEqual(`LOWER("datasource") LIKE '%hello%'`);
-    });
-
-    it('works with exact', () => {
-      expect(
-        String(
-          sqlQueryCustomTableFilter({
-            id: 'datasource',
-            value: `=Hello`,
-          }),
-        ),
-      ).toEqual(`"datasource" = 'Hello'`);
-    });
-
-    it('works with less than or equal', () => {
-      expect(
-        String(
-          sqlQueryCustomTableFilter({
-            id: 'datasource',
-            value: `<=Hello`,
-          }),
-        ),
-      ).toEqual(`"datasource" <= 'Hello'`);
-    });
-  });
-
-  it('tableFiltersToString', () => {
-    expect(
-      tableFiltersToString([
-        { id: 'x', value: '~y' },
-        { id: 'z', value: '=w&%/' },
-      ]),
-    ).toEqual('x~y&z=w%26%25%2F');
-  });
-
-  it('stringToTableFilters', () => {
-    expect(stringToTableFilters(undefined)).toEqual([]);
-    expect(stringToTableFilters('')).toEqual([]);
-    expect(stringToTableFilters('x~y')).toEqual([{ id: 'x', value: '~y' }]);
-    expect(stringToTableFilters('x~y&z=w%26%25%2F')).toEqual([
-      { id: 'x', value: '~y' },
-      { id: 'z', value: '=w&%/' },
-    ]);
-    expect(stringToTableFilters('x<3&y<=3')).toEqual([
-      { id: 'x', value: '<3' },
-      { id: 'y', value: '<=3' },
-    ]);
-  });
-});
diff --git a/web-console/src/react-table/react-table-filters.ts 
b/web-console/src/react-table/react-table-filters.ts
deleted file mode 100644
index 3d446a210e7..00000000000
--- a/web-console/src/react-table/react-table-filters.ts
+++ /dev/null
@@ -1,195 +0,0 @@
-/*
- * 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 type { IconName } from '@blueprintjs/core';
-import { IconNames } from '@blueprintjs/icons';
-import { C, F, SqlExpression } from 'druid-query-toolkit';
-import type { Filter } from 'react-table';
-
-import { addOrUpdate, caseInsensitiveContains, filterMap } from '../utils';
-
-export const DEFAULT_TABLE_CLASS_NAME = '-striped -highlight padded-header';
-
-export const STANDARD_TABLE_PAGE_SIZE = 50;
-export const STANDARD_TABLE_PAGE_SIZE_OPTIONS = [50, 100, 200];
-
-export const SMALL_TABLE_PAGE_SIZE = 25;
-export const SMALL_TABLE_PAGE_SIZE_OPTIONS = [25, 50, 100];
-
-export type FilterMode = '~' | '=' | '!=' | '<' | '<=' | '>' | '>=';
-
-export const FILTER_MODES: FilterMode[] = ['~', '=', '!=', '<', '<=', '>', 
'>='];
-export const FILTER_MODES_NO_COMPARISON: FilterMode[] = ['~', '=', '!='];
-
-export function filterModeToIcon(mode: FilterMode): IconName {
-  switch (mode) {
-    case '~':
-      return IconNames.SEARCH;
-    case '=':
-      return IconNames.EQUALS;
-    case '!=':
-      return IconNames.NOT_EQUAL_TO;
-    case '<':
-      return IconNames.LESS_THAN;
-    case '<=':
-      return IconNames.LESS_THAN_OR_EQUAL_TO;
-    case '>':
-      return IconNames.GREATER_THAN;
-    case '>=':
-      return IconNames.GREATER_THAN_OR_EQUAL_TO;
-    default:
-      return IconNames.BLANK;
-  }
-}
-
-export function filterModeToTitle(mode: FilterMode): string {
-  switch (mode) {
-    case '~':
-      return 'Search';
-    case '=':
-      return 'Equals';
-    case '!=':
-      return 'Not equals';
-    case '<':
-      return 'Less than';
-    case '<=':
-      return 'Less than or equal';
-    case '>':
-      return 'Greater than';
-    case '>=':
-      return 'Greater than or equal';
-    default:
-      return '?';
-  }
-}
-
-interface FilterModeAndNeedle {
-  mode: FilterMode;
-  needle: string;
-  needleParts: string[];
-}
-
-export function parseFilterModeAndNeedle(
-  filter: Filter,
-  loose = false,
-): FilterModeAndNeedle | undefined {
-  const m = /^(~|=|!=|<(?!=)|<=|>(?!=)|>=)?(.*)$/.exec(String(filter.value));
-  if (!m) return;
-  if (!loose && !m[2]) return;
-  const mode = (m[1] as FilterMode) || '~';
-  const needle = m[2] || '';
-  return {
-    mode,
-    needle,
-    needleParts: needle.split('|'),
-  };
-}
-
-export function combineModeAndNeedle(mode: FilterMode, needle: string, cleanup 
= false): string {
-  if (cleanup && needle === '') return '';
-  return `${mode}${needle}`;
-}
-
-export function addOrUpdateFilter(filters: readonly Filter[], filter: Filter): 
Filter[] {
-  return addOrUpdate(filters, filter, f => f.id);
-}
-
-export function booleanCustomTableFilter(filter: Filter, value: unknown): 
boolean {
-  if (value == null) return false;
-  const modeAndNeedles = parseFilterModeAndNeedle(filter);
-  if (!modeAndNeedles) return true;
-  const { mode, needleParts } = modeAndNeedles;
-  const strValue = String(value);
-  switch (mode) {
-    case '=':
-      return needleParts.some(needle => strValue === needle);
-
-    case '!=':
-      return needleParts.every(needle => strValue !== needle);
-
-    case '<':
-      return needleParts.some(needle => strValue < needle);
-
-    case '<=':
-      return needleParts.some(needle => strValue <= needle);
-
-    case '>':
-      return needleParts.some(needle => strValue > needle);
-
-    case '>=':
-      return needleParts.some(needle => strValue >= needle);
-
-    default:
-      return needleParts.some(needle => caseInsensitiveContains(strValue, 
needle));
-  }
-}
-
-export function sqlQueryCustomTableFilter(filter: Filter): SqlExpression | 
undefined {
-  const modeAndNeedles = parseFilterModeAndNeedle(filter);
-  if (!modeAndNeedles) return;
-  const { mode, needleParts } = modeAndNeedles;
-  const column = C(filter.id);
-  switch (mode) {
-    case '=': {
-      return SqlExpression.or(...needleParts.map(needle => 
column.equal(needle)));
-    }
-
-    case '!=': {
-      return SqlExpression.and(...needleParts.map(needle => 
column.unequal(needle)));
-    }
-
-    case '<':
-      return SqlExpression.or(...needleParts.map(needle => 
column.lessThan(needle)));
-
-    case '<=':
-      return SqlExpression.or(...needleParts.map(needle => 
column.lessThanOrEqual(needle)));
-
-    case '>':
-      return SqlExpression.or(...needleParts.map(needle => 
column.greaterThan(needle)));
-
-    case '>=':
-      return SqlExpression.or(...needleParts.map(needle => 
column.greaterThanOrEqual(needle)));
-
-    default:
-      return SqlExpression.or(
-        ...needleParts.map(needle => F('LOWER', 
column).like(`%${needle.toLowerCase()}%`)),
-      );
-  }
-}
-
-export function sqlQueryCustomTableFilters(filters: Filter[]): SqlExpression {
-  return SqlExpression.and(...filterMap(filters, sqlQueryCustomTableFilter));
-}
-
-export function tableFiltersToString(tableFilters: Filter[]): string {
-  return tableFilters
-    .map(({ id, value }) => `${id}${value.replace(/[&%/]/g, 
encodeURIComponent)}`)
-    .join('&');
-}
-
-export function stringToTableFilters(str: string | undefined): Filter[] {
-  if (!str) return [];
-  // '~' | '=' | '!=' | '<' | '<=' | '>' | '>=';
-  return filterMap(str.split('&'), clause => {
-    const m = /^(\w+)((?:~|=|!=|<(?!=)|<=|>(?!=)|>=).*)$/.exec(
-      clause.replace(/%2[56F]/g, decodeURIComponent),
-    );
-    if (!m) return;
-    return { id: m[1], value: m[2] };
-  });
-}
diff --git a/web-console/src/react-table/react-table-inputs.tsx 
b/web-console/src/react-table/react-table-inputs.tsx
index 6794cff4282..59662e509e8 100644
--- a/web-console/src/react-table/react-table-inputs.tsx
+++ b/web-console/src/react-table/react-table-inputs.tsx
@@ -23,15 +23,7 @@ import { useEffect, useState } from 'react';
 import type { Column, ReactTableFunction } from 'react-table';
 
 import { filterMap, toggle } from '../utils';
-
-import {
-  combineModeAndNeedle,
-  FILTER_MODES,
-  FILTER_MODES_NO_COMPARISON,
-  filterModeToIcon,
-  filterModeToTitle,
-  parseFilterModeAndNeedle,
-} from './react-table-filters';
+import { TableFilter } from '../utils/table-filters';
 
 interface FilterRendererProps {
   column: Column;
@@ -48,7 +40,7 @@ export function GenericFilterInput({ column, filter, 
onChange, key }: FilterRend
 
   const enableComparisons = 
String(column.headerClassName).includes('enable-comparisons');
 
-  const { mode, needle } = (filter ? parseFilterModeAndNeedle(filter, true) : 
undefined) || {
+  const { mode, needle } = (filter ? TableFilter.parseModeAndNeedle(filter, 
true) : undefined) || {
     mode: '~',
     needle: '',
   };
@@ -56,7 +48,7 @@ export function GenericFilterInput({ column, filter, 
onChange, key }: FilterRend
   useEffect(() => {
     const handler = setTimeout(() => {
       if (focusedText !== undefined && focusedText !== debouncedValue) {
-        onChange(combineModeAndNeedle(mode, focusedText));
+        onChange(TableFilter.combineModeAndNeedle(mode, focusedText));
         setDebouncedValue(focusedText);
       }
     }, INPUT_DEBOUNCE_TIME_IN_MILLISECONDS);
@@ -80,19 +72,21 @@ export function GenericFilterInput({ column, filter, 
onChange, key }: FilterRend
           onInteraction={setMenuOpen}
           content={
             <Menu>
-              {(enableComparisons ? FILTER_MODES : 
FILTER_MODES_NO_COMPARISON).map((m, i) => (
-                <MenuItem
-                  key={i}
-                  icon={filterModeToIcon(m)}
-                  text={filterModeToTitle(m)}
-                  onClick={() => onChange(combineModeAndNeedle(m, needle))}
-                  labelElement={m === mode ? <Icon icon={IconNames.TICK} /> : 
undefined}
-                />
-              ))}
+              {(enableComparisons ? TableFilter.MODES : 
TableFilter.MODES_NO_COMPARISON).map(
+                (m, i) => (
+                  <MenuItem
+                    key={i}
+                    icon={TableFilter.modeToIcon(m)}
+                    text={TableFilter.modeToTitle(m)}
+                    onClick={() => 
onChange(TableFilter.combineModeAndNeedle(m, needle))}
+                    labelElement={m === mode ? <Icon icon={IconNames.TICK} /> 
: undefined}
+                  />
+                ),
+              )}
             </Menu>
           }
         >
-          <Button className="filter-mode-button" icon={filterModeToIcon(mode)} 
minimal />
+          <Button className="filter-mode-button" 
icon={TableFilter.modeToIcon(mode)} minimal />
         </Popover>
       }
       value={focusedText ?? needle}
@@ -101,7 +95,7 @@ export function GenericFilterInput({ column, filter, 
onChange, key }: FilterRend
         if (e.key === 'Enter') {
           const inputValue = (e.target as HTMLInputElement).value;
           setDebouncedValue(undefined); // Reset debounce to avoid duplicate 
triggers
-          onChange(combineModeAndNeedle(mode, inputValue));
+          onChange(TableFilter.combineModeAndNeedle(mode, inputValue));
         }
       }}
       rightElement={
@@ -119,7 +113,7 @@ export function suggestibleFilterInput(suggestions: 
string[]) {
   return function SuggestibleFilterInput({ filter, onChange, key, ...rest }: 
FilterRendererProps) {
     let valuesFilteredOn: string[] | undefined;
     if (filter) {
-      const modeAndNeedle = parseFilterModeAndNeedle(filter, true);
+      const modeAndNeedle = TableFilter.parseModeAndNeedle(filter, true);
       if (modeAndNeedle && modeAndNeedle.mode === '=') {
         valuesFilteredOn = modeAndNeedle.needleParts;
       }
@@ -145,7 +139,7 @@ export function suggestibleFilterInput(suggestions: 
string[]) {
                   text={suggestion}
                   onClick={() =>
                     onChange(
-                      combineModeAndNeedle(
+                      TableFilter.combineModeAndNeedle(
                         '=',
                         valuesFilteredOn
                           ? toggle(valuesFilteredOn, suggestion).join('|')
diff --git a/web-console/src/react-table/index.ts 
b/web-console/src/utils/table-filters/index.ts
similarity index 85%
copy from web-console/src/react-table/index.ts
copy to web-console/src/utils/table-filters/index.ts
index 65b62cff5b4..c41a8055052 100644
--- a/web-console/src/react-table/index.ts
+++ b/web-console/src/utils/table-filters/index.ts
@@ -16,6 +16,5 @@
  * limitations under the License.
  */
 
-export * from './react-table-filters';
-export * from './react-table-inputs';
-export * from './react-table-pagination/react-table-pagination';
+export * from './table-filter';
+export * from './table-filters';
diff --git a/web-console/src/utils/table-filters/table-filter.spec.ts 
b/web-console/src/utils/table-filters/table-filter.spec.ts
new file mode 100644
index 00000000000..59fd22ec05a
--- /dev/null
+++ b/web-console/src/utils/table-filters/table-filter.spec.ts
@@ -0,0 +1,96 @@
+/*
+ * 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 { TableFilter } from './table-filter';
+
+describe('TableFilter', () => {
+  describe('sqlQueryCustomTableFilter', () => {
+    it('works with contains', () => {
+      expect(String(new TableFilter('datasource', '~', 
'Hello').toSqlExpression())).toEqual(
+        `LOWER("datasource") LIKE '%hello%'`,
+      );
+    });
+
+    it('works with exact', () => {
+      expect(String(new TableFilter('datasource', '=', 
'Hello').toSqlExpression())).toEqual(
+        `"datasource" = 'Hello'`,
+      );
+    });
+
+    it('works with less than or equal', () => {
+      expect(String(new TableFilter('datasource', '<=', 
'Hello').toSqlExpression())).toEqual(
+        `"datasource" <= 'Hello'`,
+      );
+    });
+  });
+
+  describe('toFilter', () => {
+    it('converts to react-table Filter format', () => {
+      const filter = new TableFilter('datasource', '=', 'test');
+      expect(filter.toFilter()).toEqual({ id: 'datasource', value: '=test' });
+    });
+  });
+
+  describe('fromFilter', () => {
+    it('converts from react-table Filter format', () => {
+      const filter = TableFilter.fromFilter({ id: 'datasource', value: '=test' 
});
+      expect(filter.key).toBe('datasource');
+      expect(filter.mode).toBe('=');
+      expect(filter.value).toBe('test');
+    });
+
+    it('defaults to contains mode when no mode specified', () => {
+      const filter = TableFilter.fromFilter({ id: 'datasource', value: 'test' 
});
+      expect(filter.key).toBe('datasource');
+      expect(filter.mode).toBe('~');
+      expect(filter.value).toBe('test');
+    });
+  });
+
+  describe('matches', () => {
+    it('works with equals', () => {
+      const filter = new TableFilter('status', '=', 'active');
+      expect(filter.matches('active')).toBe(true);
+      expect(filter.matches('inactive')).toBe(false);
+    });
+
+    it('works with contains', () => {
+      const filter = new TableFilter('name', '~', 'test');
+      expect(filter.matches('testing')).toBe(true);
+      expect(filter.matches('TEST')).toBe(true);
+      expect(filter.matches('notest')).toBe(true);
+      expect(filter.matches('other')).toBe(false);
+    });
+
+    it('works with comparisons', () => {
+      const filter = new TableFilter('count', '<', '10');
+      expect(filter.matches('05')).toBe(true); // String comparison: '05' < 
'10'
+      expect(filter.matches('2')).toBe(false); // String comparison: '2' > '10'
+    });
+  });
+
+  describe('equals', () => {
+    it('compares two filters', () => {
+      const filter1 = new TableFilter('x', '=', 'y');
+      const filter2 = new TableFilter('x', '=', 'y');
+      const filter3 = new TableFilter('x', '=', 'z');
+      expect(filter1.equals(filter2)).toBe(true);
+      expect(filter1.equals(filter3)).toBe(false);
+    });
+  });
+});
diff --git a/web-console/src/utils/table-filters/table-filter.ts 
b/web-console/src/utils/table-filters/table-filter.ts
new file mode 100644
index 00000000000..f29e4d94258
--- /dev/null
+++ b/web-console/src/utils/table-filters/table-filter.ts
@@ -0,0 +1,214 @@
+/*
+ * 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 type { IconName } from '@blueprintjs/core';
+import { IconNames } from '@blueprintjs/icons';
+import { C, F, SqlExpression } from 'druid-query-toolkit';
+import type { Filter } from 'react-table';
+
+import { caseInsensitiveContains } from '../index';
+
+export type FilterMode = '~' | '=' | '!=' | '<' | '<=' | '>' | '>=';
+
+export class TableFilter {
+  static readonly MODES: FilterMode[] = ['~', '=', '!=', '<', '<=', '>', '>='];
+  static readonly MODES_NO_COMPARISON: FilterMode[] = ['~', '=', '!='];
+
+  public readonly key: string;
+  public readonly mode: FilterMode;
+  public readonly values: string[];
+
+  static fromSingleTableFilterString(str: string): TableFilter | undefined {
+    const m = /^(\w+)((?:~|=|!=|<(?!=)|<=|>(?!=)|>=).*)$/.exec(
+      str.replace(/%2[56F]/g, decodeURIComponent),
+    );
+    if (!m) return;
+
+    const key = m[1];
+    const modeAndValue = m[2];
+
+    // Parse mode from the value
+    const modeMatch = /^(~|=|!=|<(?!=)|<=|>(?!=)|>=)?(.*)$/.exec(modeAndValue);
+    if (!modeMatch) return;
+
+    const mode = (modeMatch[1] as FilterMode) || '~';
+    const value = modeMatch[2] || '';
+
+    return new TableFilter(key, mode, value);
+  }
+
+  static fromFilter(filter: Filter): TableFilter {
+    const modeMatch = 
/^(~|=|!=|<(?!=)|<=|>(?!=)|>=)?(.*)$/.exec(String(filter.value));
+    if (!modeMatch) {
+      return new TableFilter(filter.id, '~', String(filter.value));
+    }
+
+    const mode = (modeMatch[1] as FilterMode) || '~';
+    const value = modeMatch[2] || '';
+
+    return new TableFilter(filter.id, mode, value);
+  }
+
+  constructor(key: string, mode: FilterMode, value: string | string[]) {
+    this.key = key;
+    this.mode = mode;
+    this.values = typeof value === 'string' ? value.split('|') : value;
+  }
+
+  public get value(): string {
+    return this.values.join('|');
+  }
+
+  public toFilter(): Filter {
+    return {
+      id: this.key,
+      value: `${this.mode}${this.value}`,
+    };
+  }
+
+  public equals(other: TableFilter): boolean {
+    return (
+      this.key === other.key &&
+      this.mode === other.mode &&
+      this.values.length === other.values.length &&
+      this.values.every((v, i) => v === other.values[i])
+    );
+  }
+
+  public matches(value: unknown): boolean {
+    if (value == null) return false;
+    const strValue = String(value);
+
+    switch (this.mode) {
+      case '=':
+        return this.values.some(needle => strValue === needle);
+
+      case '!=':
+        return this.values.every(needle => strValue !== needle);
+
+      case '<':
+        return this.values.some(needle => strValue < needle);
+
+      case '<=':
+        return this.values.some(needle => strValue <= needle);
+
+      case '>':
+        return this.values.some(needle => strValue > needle);
+
+      case '>=':
+        return this.values.some(needle => strValue >= needle);
+
+      default:
+        return this.values.some(needle => caseInsensitiveContains(strValue, 
needle));
+    }
+  }
+
+  public toSqlExpression(): SqlExpression {
+    const column = C(this.key);
+
+    switch (this.mode) {
+      case '=': {
+        return SqlExpression.or(...this.values.map(needle => 
column.equal(needle)));
+      }
+
+      case '!=': {
+        return SqlExpression.and(...this.values.map(needle => 
column.unequal(needle)));
+      }
+
+      case '<':
+        return SqlExpression.or(...this.values.map(needle => 
column.lessThan(needle)));
+
+      case '<=':
+        return SqlExpression.or(...this.values.map(needle => 
column.lessThanOrEqual(needle)));
+
+      case '>':
+        return SqlExpression.or(...this.values.map(needle => 
column.greaterThan(needle)));
+
+      case '>=':
+        return SqlExpression.or(...this.values.map(needle => 
column.greaterThanOrEqual(needle)));
+
+      default:
+        return SqlExpression.or(
+          ...this.values.map(needle => F('LOWER', 
column).like(`%${needle.toLowerCase()}%`)),
+        );
+    }
+  }
+
+  static modeToIcon(mode: FilterMode): IconName {
+    switch (mode) {
+      case '~':
+        return IconNames.SEARCH;
+      case '=':
+        return IconNames.EQUALS;
+      case '!=':
+        return IconNames.NOT_EQUAL_TO;
+      case '<':
+        return IconNames.LESS_THAN;
+      case '<=':
+        return IconNames.LESS_THAN_OR_EQUAL_TO;
+      case '>':
+        return IconNames.GREATER_THAN;
+      case '>=':
+        return IconNames.GREATER_THAN_OR_EQUAL_TO;
+      default:
+        return IconNames.BLANK;
+    }
+  }
+
+  static modeToTitle(mode: FilterMode): string {
+    switch (mode) {
+      case '~':
+        return 'Search';
+      case '=':
+        return 'Equals';
+      case '!=':
+        return 'Not equals';
+      case '<':
+        return 'Less than';
+      case '<=':
+        return 'Less than or equal';
+      case '>':
+        return 'Greater than';
+      case '>=':
+        return 'Greater than or equal';
+      default:
+        return '?';
+    }
+  }
+
+  static combineModeAndNeedle(mode: FilterMode, needle: string, cleanup = 
false): string {
+    if (cleanup && needle === '') return '';
+    return `${mode}${needle}`;
+  }
+
+  static parseModeAndNeedle(
+    filter: Filter,
+    loose = false,
+  ): { mode: FilterMode; needle: string; needleParts: string[] } | undefined {
+    const m = /^(~|=|!=|<(?!=)|<=|>(?!=)|>=)?(.*)$/.exec(String(filter.value));
+    if (!m) return;
+    if (!loose && !m[2]) return;
+    const mode = (m[1] as FilterMode) || '~';
+    const needle = m[2] || '';
+    return {
+      mode,
+      needle,
+      needleParts: needle.split('|'),
+    };
+  }
+}
diff --git a/web-console/src/utils/table-filters/table-filters.spec.ts 
b/web-console/src/utils/table-filters/table-filters.spec.ts
new file mode 100644
index 00000000000..1a9dfa2ef77
--- /dev/null
+++ b/web-console/src/utils/table-filters/table-filters.spec.ts
@@ -0,0 +1,94 @@
+/*
+ * 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 { TableFilter } from './table-filter';
+import { TableFilters } from './table-filters';
+
+describe('TableFilters', () => {
+  describe('toString', () => {
+    it('converts filters to string', () => {
+      const filters = new TableFilters([
+        new TableFilter('x', '~', 'y'),
+        new TableFilter('z', '=', 'w&%/'),
+      ]);
+      expect(filters.toString()).toEqual('x~y&z=w%26%25%2F');
+    });
+  });
+
+  describe('fromString', () => {
+    it('handles empty strings', () => {
+      expect(TableFilters.fromString(undefined).isEmpty()).toBe(true);
+      expect(TableFilters.fromString('').isEmpty()).toBe(true);
+    });
+
+    it('parses single filter', () => {
+      const filters = TableFilters.fromString('x~y');
+      expect(filters.size()).toBe(1);
+      const filterArray = filters.toArray();
+      expect(filterArray[0].key).toBe('x');
+      expect(filterArray[0].mode).toBe('~');
+      expect(filterArray[0].value).toBe('y');
+    });
+
+    it('parses multiple filters with encoded characters', () => {
+      const filters = TableFilters.fromString('x~y&z=w%26%25%2F');
+      expect(filters.size()).toBe(2);
+      const filterArray = filters.toArray();
+      expect(filterArray[0].key).toBe('x');
+      expect(filterArray[0].mode).toBe('~');
+      expect(filterArray[0].value).toBe('y');
+      expect(filterArray[1].key).toBe('z');
+      expect(filterArray[1].mode).toBe('=');
+      expect(filterArray[1].value).toBe('w&%/');
+    });
+
+    it('parses comparison filters', () => {
+      const filters = TableFilters.fromString('x<3&y<=3');
+      expect(filters.size()).toBe(2);
+      const filterArray = filters.toArray();
+      expect(filterArray[0].key).toBe('x');
+      expect(filterArray[0].mode).toBe('<');
+      expect(filterArray[0].value).toBe('3');
+      expect(filterArray[1].key).toBe('y');
+      expect(filterArray[1].mode).toBe('<=');
+      expect(filterArray[1].value).toBe('3');
+    });
+  });
+
+  describe('eq', () => {
+    it('creates filters from key-value pairs', () => {
+      const filters = TableFilters.eq({ datasource: 'test', type: 'index' });
+      expect(filters.size()).toBe(2);
+      const filterArray = filters.toArray();
+      expect(filterArray[0].key).toBe('datasource');
+      expect(filterArray[0].mode).toBe('=');
+      expect(filterArray[0].value).toBe('test');
+      expect(filterArray[1].key).toBe('type');
+      expect(filterArray[1].mode).toBe('=');
+      expect(filterArray[1].value).toBe('index');
+    });
+  });
+
+  describe('empty', () => {
+    it('creates an empty TableFilters', () => {
+      const filters = TableFilters.empty();
+      expect(filters.isEmpty()).toBe(true);
+      expect(filters.size()).toBe(0);
+    });
+  });
+});
diff --git a/web-console/src/utils/table-filters/table-filters.ts 
b/web-console/src/utils/table-filters/table-filters.ts
new file mode 100644
index 00000000000..d219e927978
--- /dev/null
+++ b/web-console/src/utils/table-filters/table-filters.ts
@@ -0,0 +1,107 @@
+/*
+ * 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 { SqlExpression } from 'druid-query-toolkit';
+import type { Filter } from 'react-table';
+
+import { addOrUpdate, filterMap } from '../index';
+
+import { TableFilter } from './table-filter';
+
+export class TableFilters {
+  private readonly filters: readonly TableFilter[];
+
+  static fromString(str: string | undefined): TableFilters {
+    if (!str) return new TableFilters([]);
+    const filters = filterMap(str.split('&'), clause => {
+      return TableFilter.fromSingleTableFilterString(clause);
+    });
+    return new TableFilters(filters);
+  }
+
+  static fromFilters(filters: Filter[]): TableFilters {
+    return new TableFilters(filters.map(TableFilter.fromFilter));
+  }
+
+  static eq(keyValue: Record<string, string>): TableFilters {
+    const filters = Object.entries(keyValue).map(
+      ([key, value]) => new TableFilter(key, '=', value),
+    );
+    return new TableFilters(filters);
+  }
+
+  static empty(): TableFilters {
+    return new TableFilters([]);
+  }
+
+  constructor(filters?: TableFilter | TableFilter[] | TableFilters) {
+    if (!filters) {
+      this.filters = [];
+    } else if (filters instanceof TableFilters) {
+      this.filters = filters.filters;
+    } else if (Array.isArray(filters)) {
+      this.filters = filters;
+    } else {
+      this.filters = [filters];
+    }
+  }
+
+  toString(): string {
+    return this.filters
+      .map(
+        filter =>
+          `${filter.key}${filter.mode}${filter.values
+            .join('|')
+            .replace(/[&%/]/g, encodeURIComponent)}`,
+      )
+      .join('&');
+  }
+
+  toFilters(): Filter[] {
+    return this.filters.map(f => f.toFilter());
+  }
+
+  addOrUpdate(filter: TableFilter): TableFilters {
+    return new TableFilters(addOrUpdate(this.filters, filter, f => f.key));
+  }
+
+  toSqlExpression(): SqlExpression {
+    return SqlExpression.and(
+      ...filterMap(this.filters, function (filter: TableFilter): SqlExpression 
| undefined {
+        return filter.toSqlExpression();
+      }),
+    );
+  }
+
+  toArray(): readonly TableFilter[] {
+    return this.filters;
+  }
+
+  isEmpty(): boolean {
+    return this.filters.length === 0;
+  }
+
+  size(): number {
+    return this.filters.length;
+  }
+
+  equals(other: TableFilters): boolean {
+    if (this.filters.length !== other.filters.length) return false;
+    return this.filters.every((f, i) => f.equals(other.filters[i]));
+  }
+}
diff --git a/web-console/src/utils/table-helpers.ts 
b/web-console/src/utils/table-helpers.ts
index a4ba4fce7dd..30328f32752 100644
--- a/web-console/src/utils/table-helpers.ts
+++ b/web-console/src/utils/table-helpers.ts
@@ -19,10 +19,11 @@
 import { ascending, descending, sort } from 'd3-array';
 import type { QueryResult, SqlExpression } from 'druid-query-toolkit';
 import { C } from 'druid-query-toolkit';
-import type { Filter, SortingRule } from 'react-table';
+import type { SortingRule } from 'react-table';
 
 import { filterMap, formatNumber, isNumberLike, oneOf } from './general';
 import { deepSet } from './object-change';
+import type { TableFilters } from './table-filters';
 
 export interface Pagination {
   page: number;
@@ -71,7 +72,7 @@ export function getNumericColumnBraces(
 export interface TableState {
   page: number;
   pageSize: number;
-  filtered: Filter[];
+  filtered: TableFilters;
   sorted: SortingRule[];
 }
 
diff --git a/web-console/src/views/datasources-view/datasources-view.spec.tsx 
b/web-console/src/views/datasources-view/datasources-view.spec.tsx
index 196085369a8..f915a3dc7cd 100644
--- a/web-console/src/views/datasources-view/datasources-view.spec.tsx
+++ b/web-console/src/views/datasources-view/datasources-view.spec.tsx
@@ -18,6 +18,7 @@
 
 import { Capabilities } from '../../helpers';
 import { shallow } from '../../utils/shallow-renderer';
+import { TableFilters } from '../../utils/table-filters';
 
 import { DatasourcesView } from './datasources-view';
 
@@ -25,11 +26,10 @@ describe('DatasourcesView', () => {
   it('matches snapshot', () => {
     const dataSourceView = shallow(
       <DatasourcesView
-        filters={[]}
+        filters={TableFilters.empty()}
         onFiltersChange={() => {}}
         goToQuery={() => {}}
-        goToTasks={() => null}
-        goToSegments={() => {}}
+        goToView={() => {}}
         capabilities={Capabilities.FULL}
       />,
     );
diff --git a/web-console/src/views/datasources-view/datasources-view.tsx 
b/web-console/src/views/datasources-view/datasources-view.tsx
index b1d08522ee3..dc3f9ef45b4 100644
--- a/web-console/src/views/datasources-view/datasources-view.tsx
+++ b/web-console/src/views/datasources-view/datasources-view.tsx
@@ -21,7 +21,6 @@ import { IconNames } from '@blueprintjs/icons';
 import { sum } from 'd3-array';
 import { SqlQuery, T } from 'druid-query-toolkit';
 import React from 'react';
-import type { Filter } from 'react-table';
 import ReactTable from 'react-table';
 
 import {
@@ -51,6 +50,7 @@ import type {
   CompactionConfigs,
   CompactionInfo,
   CompactionStatus,
+  ConsoleViewId,
   QueryWithContext,
   Rule,
 } from '../../druid-models';
@@ -94,6 +94,7 @@ import {
   twoLines,
 } from '../../utils';
 import type { BasicAction } from '../../utils/basic-action';
+import { TableFilter, TableFilters } from '../../utils/table-filters';
 
 import './datasources-view.scss';
 
@@ -304,16 +305,10 @@ function normalizeTaskType(taskType: string): string {
 }
 
 export interface DatasourcesViewProps {
-  filters: Filter[];
-  onFiltersChange(filters: Filter[]): void;
+  filters: TableFilters;
+  onFiltersChange(filters: TableFilters): void;
   goToQuery(queryWithContext: QueryWithContext): void;
-  goToTasks(datasource?: string): void;
-  goToSegments(options: {
-    start?: Date;
-    end?: Date;
-    datasource?: string;
-    realtime?: boolean;
-  }): void;
+  goToView(tab: ConsoleViewId, filters?: TableFilters): void;
   capabilities: Capabilities;
 }
 
@@ -1002,7 +997,7 @@ GROUP BY 1, 2`;
     rules: Rule[] | undefined,
     compactionInfo: CompactionInfo | undefined,
   ): BasicAction[] {
-    const { goToQuery, goToSegments, capabilities } = this.props;
+    const { goToQuery, goToView, capabilities } = this.props;
 
     if (unused) {
       if (!capabilities.hasOverlordAccess()) return [];
@@ -1045,7 +1040,7 @@ GROUP BY 1, 2`;
           icon: getConsoleViewIcon('segments'),
           title: 'Go to segments',
           onAction: () => {
-            goToSegments({ datasource });
+            goToView('segments', TableFilters.eq({ datasource }));
           },
         },
         capabilities.hasCoordinatorAccess()
@@ -1174,7 +1169,7 @@ GROUP BY 1, 2`;
   }
 
   private renderDatasourcesTable() {
-    const { goToTasks, capabilities, filters, onFiltersChange } = this.props;
+    const { goToView, capabilities, filters, onFiltersChange } = this.props;
     const { datasourcesAndDefaultRulesState, showUnused, visibleColumns, 
showSegmentTimeline } =
       this.state;
 
@@ -1219,8 +1214,8 @@ GROUP BY 1, 2`;
             : '')
         }
         filterable
-        filtered={filters}
-        onFilteredChange={onFiltersChange}
+        filtered={filters.toFilters()}
+        onFilteredChange={filters => 
onFiltersChange(TableFilters.fromFilters(filters))}
         defaultPageSize={STANDARD_TABLE_PAGE_SIZE}
         pageSizeOptions={STANDARD_TABLE_PAGE_SIZE_OPTIONS}
         showPagination={datasources.length > STANDARD_TABLE_PAGE_SIZE}
@@ -1364,7 +1359,9 @@ GROUP BY 1, 2`;
               if (!runningTasks) return;
               return (
                 <TableClickableCell
-                  onClick={() => goToTasks(original.datasource)}
+                  onClick={() =>
+                    goToView('tasks', TableFilters.eq({ datasource: 
original.datasource }))
+                  }
                   hoverIcon={IconNames.ARROW_TOP_RIGHT}
                   tooltip="Go to tasks"
                 >
@@ -1719,7 +1716,7 @@ GROUP BY 1, 2`;
   }
 
   render() {
-    const { capabilities, filters, goToSegments } = this.props;
+    const { capabilities, filters, goToView } = this.props;
     const {
       showUnused,
       visibleColumns,
@@ -1751,9 +1748,9 @@ GROUP BY 1, 2`;
                   ? undefined
                   : {
                       capabilities,
-                      datasource: findMap(filters, filter =>
-                        filter.id === 'datasource' && 
/^=[^=|]+$/.exec(String(filter.value))
-                          ? filter.value.slice(1)
+                      datasource: findMap(filters.toArray(), filter =>
+                        filter.key === 'datasource' && filter.mode === '='
+                          ? filter.value
                           : undefined,
                       ),
                     },
@@ -1791,7 +1788,28 @@ GROUP BY 1, 2`;
                     text="Open in segments view"
                     small
                     rightIcon={IconNames.ARROW_TOP_RIGHT}
-                    onClick={() => goToSegments({ start, end, datasource, 
realtime })}
+                    onClick={() => {
+                      let filters = TableFilters.empty();
+                      if (datasource) {
+                        filters = filters.addOrUpdate(
+                          new TableFilter('datasource', '=', datasource),
+                        );
+                      }
+                      if (realtime !== undefined) {
+                        filters = filters.addOrUpdate(
+                          new TableFilter('is_realtime', '=', String(realtime 
? 1 : 0)),
+                        );
+                      }
+                      if (start && end) {
+                        filters = filters.addOrUpdate(
+                          new TableFilter('start', '>=', start.toISOString()),
+                        );
+                        filters = filters.addOrUpdate(
+                          new TableFilter('end', '<=', end.toISOString()),
+                        );
+                      }
+                      goToView('segments', filters);
+                    }}
                   />
                 );
               }}
diff --git a/web-console/src/views/load-data-view/load-data-view.spec.tsx 
b/web-console/src/views/load-data-view/load-data-view.spec.tsx
index 3c7e443a9cb..8e808046081 100644
--- a/web-console/src/views/load-data-view/load-data-view.spec.tsx
+++ b/web-console/src/views/load-data-view/load-data-view.spec.tsx
@@ -25,8 +25,7 @@ describe('LoadDataView', () => {
     const loadDataView = shallow(
       <LoadDataView
         mode="streaming"
-        goToSupervisor={() => {}}
-        goToTasks={() => {}}
+        goToView={() => {}}
         openSupervisorSubmit={() => {}}
         openTaskSubmit={() => {}}
       />,
@@ -38,8 +37,7 @@ describe('LoadDataView', () => {
     const loadDataView = shallow(
       <LoadDataView
         mode="batch"
-        goToSupervisor={() => {}}
-        goToTasks={() => {}}
+        goToView={() => {}}
         openSupervisorSubmit={() => {}}
         openTaskSubmit={() => {}}
       />,
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 cb42359878f..88694367baa 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
@@ -59,6 +59,7 @@ import {
 import { AlertDialog, AsyncActionDialog, DiffDialog } from '../../dialogs';
 import type {
   ArrayIngestMode,
+  ConsoleViewId,
   DimensionSpec,
   DruidFilter,
   FlattenField,
@@ -186,6 +187,7 @@ import {
   sampleForTimestamp,
   sampleForTransform,
 } from '../../utils/sampler';
+import { TableFilters } from '../../utils/table-filters';
 
 import { ExamplePicker } from './example-picker/example-picker';
 import { EXAMPLE_SPECS } from './example-specs';
@@ -389,9 +391,8 @@ export interface LoadDataViewProps {
   mode: LoadDataViewMode;
   initSupervisorId?: string;
   initTaskId?: string;
-  goToSupervisor: (supervisorId: string) => void;
+  goToView: (tab: ConsoleViewId, filters?: TableFilters) => void;
   openSupervisorSubmit: () => void;
-  goToTasks: (taskGroupId: string) => void;
   openTaskSubmit: () => void;
 }
 
@@ -3704,7 +3705,7 @@ export class LoadDataView extends 
React.PureComponent<LoadDataViewProps, LoadDat
   }
 
   private readonly handleSubmitSupervisor = async () => {
-    const { goToSupervisor } = this.props;
+    const { goToView } = this.props;
     const { spec, submitting } = this.state;
     if (submitting) return;
 
@@ -3728,13 +3729,13 @@ export class LoadDataView extends 
React.PureComponent<LoadDataViewProps, LoadDat
     const supervisorId = getSpecDatasourceName(spec);
     if (supervisorId) {
       setTimeout(() => {
-        goToSupervisor(supervisorId);
+        goToView('supervisors', TableFilters.eq({ supervisor_id: supervisorId 
}));
       }, 1000);
     }
   };
 
   private readonly handleSubmitTask = async () => {
-    const { goToTasks } = this.props;
+    const { goToView } = this.props;
     const { spec, submitting } = this.state;
     if (submitting) return;
 
@@ -3757,7 +3758,7 @@ export class LoadDataView extends 
React.PureComponent<LoadDataViewProps, LoadDat
     });
 
     setTimeout(() => {
-      goToTasks(taskResp.data.task);
+      goToView('tasks', TableFilters.eq({ task_id: taskResp.data.task }));
     }, 1000);
   };
 }
diff --git a/web-console/src/views/lookups-view/lookups-view.spec.tsx 
b/web-console/src/views/lookups-view/lookups-view.spec.tsx
index b4f38247cc3..f71047d4ee0 100644
--- a/web-console/src/views/lookups-view/lookups-view.spec.tsx
+++ b/web-console/src/views/lookups-view/lookups-view.spec.tsx
@@ -17,12 +17,15 @@
  */
 
 import { shallow } from '../../utils/shallow-renderer';
+import { TableFilters } from '../../utils/table-filters';
 
 import { LookupsView } from './lookups-view';
 
 describe('LookupsView', () => {
   it('matches snapshot', () => {
-    const lookupsView = shallow(<LookupsView filters={[]} onFiltersChange={() 
=> {}} />);
+    const lookupsView = shallow(
+      <LookupsView filters={TableFilters.empty()} onFiltersChange={() => {}} 
/>,
+    );
     expect(lookupsView).toMatchSnapshot();
   });
 });
diff --git a/web-console/src/views/lookups-view/lookups-view.tsx 
b/web-console/src/views/lookups-view/lookups-view.tsx
index e66efbed062..e0dffa50c3a 100644
--- a/web-console/src/views/lookups-view/lookups-view.tsx
+++ b/web-console/src/views/lookups-view/lookups-view.tsx
@@ -19,7 +19,6 @@
 import { Button, Icon, Intent, Tag } from '@blueprintjs/core';
 import { IconNames } from '@blueprintjs/icons';
 import React from 'react';
-import type { Filter } from 'react-table';
 import ReactTable from 'react-table';
 
 import {
@@ -51,6 +50,7 @@ import {
   QueryState,
 } from '../../utils';
 import type { BasicAction } from '../../utils/basic-action';
+import { TableFilters } from '../../utils/table-filters';
 
 import './lookups-view.scss';
 
@@ -89,8 +89,8 @@ export interface LookupEditInfo {
 }
 
 export interface LookupsViewProps {
-  filters: Filter[];
-  onFiltersChange(filters: Filter[]): void;
+  filters: TableFilters;
+  onFiltersChange(filters: TableFilters): void;
 }
 
 export interface LookupsViewState {
@@ -370,8 +370,8 @@ export class LookupsView extends 
React.PureComponent<LookupsViewProps, LookupsVi
         loading={lookupEntriesAndTiersState.loading}
         noDataText={lookupEntriesAndTiersState.getErrorMessage() || 'No 
lookups'}
         filterable
-        filtered={filters}
-        onFilteredChange={onFiltersChange}
+        filtered={filters.toFilters()}
+        onFilteredChange={filters => 
onFiltersChange(TableFilters.fromFilters(filters))}
         defaultSorted={[{ id: 'lookup_name', desc: false }]}
         defaultPageSize={STANDARD_TABLE_PAGE_SIZE}
         pageSizeOptions={STANDARD_TABLE_PAGE_SIZE_OPTIONS}
diff --git a/web-console/src/views/segments-view/segments-view.spec.tsx 
b/web-console/src/views/segments-view/segments-view.spec.tsx
index 7f78bb6d53f..3b30275e1d1 100644
--- a/web-console/src/views/segments-view/segments-view.spec.tsx
+++ b/web-console/src/views/segments-view/segments-view.spec.tsx
@@ -18,13 +18,14 @@
 
 import { Capabilities } from '../../helpers';
 import { shallow } from '../../utils/shallow-renderer';
+import { TableFilters } from '../../utils/table-filters';
 import { SegmentsView } from '../segments-view/segments-view';
 
 describe('SegmentsView', () => {
   it('matches snapshot', () => {
     const segmentsView = shallow(
       <SegmentsView
-        filters={[]}
+        filters={TableFilters.empty()}
         onFiltersChange={() => {}}
         goToQuery={() => {}}
         capabilities={Capabilities.FULL}
diff --git a/web-console/src/views/segments-view/segments-view.tsx 
b/web-console/src/views/segments-view/segments-view.tsx
index 33510736c5c..770577a2ec0 100644
--- a/web-console/src/views/segments-view/segments-view.tsx
+++ b/web-console/src/views/segments-view/segments-view.tsx
@@ -23,7 +23,7 @@ import { C, L, SqlComparison, SqlExpression } from 
'druid-query-toolkit';
 import * as JSONBig from 'json-bigint-native';
 import type { ReactNode } from 'react';
 import React from 'react';
-import type { Filter, SortingRule } from 'react-table';
+import type { SortingRule } from 'react-table';
 import ReactTable from 'react-table';
 
 import {
@@ -49,11 +49,7 @@ import type { QueryContext, QueryWithContext, ShardSpec } 
from '../../druid-mode
 import { computeSegmentTimeSpan, getConsoleViewIcon, getDatasourceColor } from 
'../../druid-models';
 import type { Capabilities, CapabilitiesMode } from '../../helpers';
 import {
-  booleanCustomTableFilter,
   BooleanFilterInput,
-  combineModeAndNeedle,
-  parseFilterModeAndNeedle,
-  sqlQueryCustomTableFilter,
   STANDARD_TABLE_PAGE_SIZE,
   STANDARD_TABLE_PAGE_SIZE_OPTIONS,
 } from '../../react-table';
@@ -83,6 +79,7 @@ import {
   twoLines,
 } from '../../utils';
 import type { BasicAction } from '../../utils/basic-action';
+import { TableFilter, TableFilters } from '../../utils/table-filters';
 
 import './segments-view.scss';
 
@@ -158,56 +155,47 @@ function formatRangeDimensionValue(dimension: any, value: 
any): string {
   return `${C(String(dimension))}=${L(String(value))}`;
 }
 
-function segmentFiltersToExpression(filters: Filter[]): SqlExpression {
+function segmentFiltersToExpression(filters: TableFilters): SqlExpression {
   return SqlExpression.and(
-    ...filterMap(filters, filter => {
-      if (filter.id === 'start' || filter.id === 'end') {
+    ...filterMap(filters.toArray(), filter => {
+      if (filter.key === 'start' || filter.key === 'end') {
         // Dates need to be converted to ISO string for the SQL query
-        const modeAndNeedle = parseFilterModeAndNeedle(filter);
-        if (!modeAndNeedle) return;
-        if (modeAndNeedle.mode === '~') {
-          return sqlQueryCustomTableFilter(filter);
+        if (filter.mode === '~') {
+          return filter.toSqlExpression();
         }
         try {
-          const internalFilter = { ...filter };
-          const formattedDate = formatDate(modeAndNeedle.needle);
+          const formattedDate = formatDate(filter.value);
           const filterDate = dayjs(formattedDate).toISOString();
-          filter.value = combineModeAndNeedle(modeAndNeedle.mode, 
formattedDate);
-          internalFilter.value = combineModeAndNeedle(modeAndNeedle.mode, 
filterDate);
-          return sqlQueryCustomTableFilter(internalFilter);
+          const internalFilter = new TableFilter(filter.key, filter.mode, 
filterDate);
+          return internalFilter.toSqlExpression();
         } catch {
-          return sqlQueryCustomTableFilter(filter);
+          return filter.toSqlExpression();
         }
       }
-      if (filter.id === 'shard_type') {
+      if (filter.key === 'shard_type') {
         // Special handling for shard_type that needs to be searched for in 
the shard_spec
         // Creates filters like `shard_spec LIKE '%"type":"numbered"%'`
-        const modeAndNeedle = parseFilterModeAndNeedle(filter);
-        if (!modeAndNeedle) return;
         const shardSpecColumn = C('shard_spec');
-        switch (modeAndNeedle.mode) {
+        switch (filter.mode) {
           case '=':
-            return SqlComparison.like(shardSpecColumn, 
`%"type":"${modeAndNeedle.needle}"%`);
+            return SqlComparison.like(shardSpecColumn, 
`%"type":"${filter.value}"%`);
 
           case '!=':
-            return SqlComparison.notLike(shardSpecColumn, 
`%"type":"${modeAndNeedle.needle}"%`);
+            return SqlComparison.notLike(shardSpecColumn, 
`%"type":"${filter.value}"%`);
 
           default:
-            return SqlComparison.like(shardSpecColumn, 
`%"type":"${modeAndNeedle.needle}%`);
+            return SqlComparison.like(shardSpecColumn, 
`%"type":"${filter.value}%`);
         }
-      } else if (filter.id.startsWith('is_')) {
-        switch (filter.value) {
-          case '=false':
-            return C(filter.id).equal(0);
-
-          case '=true':
-            return C(filter.id).equal(1);
-
-          default:
-            return;
+      } else if (filter.key.startsWith('is_')) {
+        if (filter.mode === '=' && filter.value === 'false') {
+          return C(filter.key).equal(0);
+        } else if (filter.mode === '=' && filter.value === 'true') {
+          return C(filter.key).equal(1);
+        } else {
+          return;
         }
       } else {
-        return sqlQueryCustomTableFilter(filter);
+        return filter.toSqlExpression();
       }
     }),
   );
@@ -246,8 +234,8 @@ interface SegmentsWithAuxiliaryInfo {
 }
 
 export interface SegmentsViewProps {
-  filters: Filter[];
-  onFiltersChange(filters: Filter[]): void;
+  filters: TableFilters;
+  onFiltersChange(filters: TableFilters): void;
   goToQuery(queryWithContext: QueryWithContext): void;
   capabilities: Capabilities;
 }
@@ -426,13 +414,11 @@ export class SegmentsView extends 
React.PureComponent<SegmentsViewProps, Segment
           });
         } else if (capabilities.hasCoordinatorAccess()) {
           let datasourceList: string[] = [];
-          const datasourceFilter = filtered.find(({ id }) => id === 
'datasource');
+          const datasourceFilter = filtered.toArray().find(({ key }) => key 
=== 'datasource');
           if (datasourceFilter) {
             datasourceList = (
               await getApiArray('/druid/coordinator/v1/metadata/datasources', 
signal)
-            ).filter((datasource: string) =>
-              booleanCustomTableFilter(datasourceFilter, datasource),
-            );
+            ).filter((datasource: string) => 
datasourceFilter.matches(datasource));
           }
 
           let results = (
@@ -466,13 +452,10 @@ export class SegmentsView extends 
React.PureComponent<SegmentsViewProps, Segment
             };
           });
 
-          if (filtered.length) {
+          if (filtered.toArray().length) {
             results = results.filter((d: SegmentQueryResultRow) => {
-              return filtered.every(filter => {
-                return booleanCustomTableFilter(
-                  filter,
-                  d[filter.id as keyof SegmentQueryResultRow],
-                );
+              return filtered.toArray().every(filter => {
+                return filter.matches(d[filter.key as keyof 
SegmentQueryResultRow]);
               });
             });
           }
@@ -552,7 +535,7 @@ export class SegmentsView extends 
React.PureComponent<SegmentsViewProps, Segment
     });
   };
 
-  private readonly handleFilterChange = (filters: Filter[]) => {
+  private readonly handleFilterChange = (filters: TableFilters) => {
     this.goToFirstPage();
     this.props.onFiltersChange(filters);
   };
@@ -641,9 +624,7 @@ export class SegmentsView extends 
React.PureComponent<SegmentsViewProps, Segment
     // Only allow filtering of columns other than datasource if in SQL mode, 
or if we are filtering on an exact datasource
     const allowGeneralFilter =
       hasSql ||
-      filters.some(
-        filter => filter.id === 'datasource' && 
parseFilterModeAndNeedle(filter)?.mode === '=',
-      );
+      filters.toArray().some(filter => filter.key === 'datasource' && 
filter.mode === '=');
 
     return (
       <ReactTable
@@ -652,13 +633,13 @@ export class SegmentsView extends 
React.PureComponent<SegmentsViewProps, Segment
         loading={segmentsState.loading}
         noDataText={
           segmentsState.isEmpty()
-            ? `No segments${filters.length ? ' matching filter' : ''}`
+            ? `No segments${filters.toArray().length ? ' matching filter' : 
''}`
             : segmentsState.getErrorMessage() || ''
         }
         manual
         filterable
-        filtered={filters}
-        onFilteredChange={this.handleFilterChange}
+        filtered={filters.toFilters()}
+        onFilteredChange={filters => 
this.handleFilterChange(TableFilters.fromFilters(filters))}
         sorted={sorted}
         onSortedChange={sorted => this.setState({ sorted })}
         page={page}
@@ -1144,9 +1125,9 @@ export class SegmentsView extends 
React.PureComponent<SegmentsViewProps, Segment
                   ? undefined
                   : {
                       capabilities,
-                      datasource: findMap(filters, filter =>
-                        filter.id === 'datasource' && 
/^=[^=|]+$/.exec(String(filter.value))
-                          ? filter.value.slice(1)
+                      datasource: findMap(filters.toArray(), filter =>
+                        filter.key === 'datasource' && filter.mode === '='
+                          ? filter.value
                           : undefined,
                       ),
                     },
@@ -1187,18 +1168,30 @@ export class SegmentsView extends 
React.PureComponent<SegmentsViewProps, Segment
                     text="Apply fitler to table"
                     small
                     rightIcon={IconNames.ARROW_DOWN}
-                    onClick={() =>
-                      this.handleFilterChange(
-                        compact([
-                          start && { id: 'start', value: 
`>=${start.toISOString()}` },
-                          end && { id: 'end', value: `<${end.toISOString()}` },
-                          datasource && { id: 'datasource', value: 
`=${datasource}` },
-                          typeof realtime === 'boolean'
-                            ? { id: 'is_realtime', value: `=${realtime}` }
-                            : undefined,
-                        ]),
-                      )
-                    }
+                    onClick={() => {
+                      let filters = TableFilters.empty();
+                      if (start) {
+                        filters = filters.addOrUpdate(
+                          new TableFilter('start', '>=', start.toISOString()),
+                        );
+                      }
+                      if (end) {
+                        filters = filters.addOrUpdate(
+                          new TableFilter('end', '<', end.toISOString()),
+                        );
+                      }
+                      if (datasource) {
+                        filters = filters.addOrUpdate(
+                          new TableFilter('datasource', '=', datasource),
+                        );
+                      }
+                      if (typeof realtime === 'boolean') {
+                        filters = filters.addOrUpdate(
+                          new TableFilter('is_realtime', '=', 
String(realtime)),
+                        );
+                      }
+                      this.handleFilterChange(filters);
+                    }}
                   />
                 );
               }}
diff --git a/web-console/src/views/services-view/services-view.spec.tsx 
b/web-console/src/views/services-view/services-view.spec.tsx
index 35a147e6a2e..c05ab10e358 100644
--- a/web-console/src/views/services-view/services-view.spec.tsx
+++ b/web-console/src/views/services-view/services-view.spec.tsx
@@ -19,6 +19,7 @@
 import { Capabilities } from '../../helpers';
 import { QueryState } from '../../utils';
 import { shallow } from '../../utils/shallow-renderer';
+import { TableFilters } from '../../utils/table-filters';
 
 import { ServicesView } from './services-view';
 
@@ -88,7 +89,7 @@ describe('ServicesView', () => {
   it('renders data', () => {
     const comp = (
       <ServicesView
-        filters={[]}
+        filters={TableFilters.empty()}
         onFiltersChange={() => {}}
         goToQuery={() => {}}
         capabilities={Capabilities.FULL}
diff --git a/web-console/src/views/services-view/services-view.tsx 
b/web-console/src/views/services-view/services-view.tsx
index f0d97f35612..7eec92ef5e0 100644
--- a/web-console/src/views/services-view/services-view.tsx
+++ b/web-console/src/views/services-view/services-view.tsx
@@ -41,10 +41,7 @@ import type { QueryWithContext } from '../../druid-models';
 import { getConsoleViewIcon } from '../../druid-models';
 import type { Capabilities, CapabilitiesMode } from '../../helpers';
 import {
-  booleanCustomTableFilter,
-  combineModeAndNeedle,
   DEFAULT_TABLE_CLASS_NAME,
-  parseFilterModeAndNeedle,
   STANDARD_TABLE_PAGE_SIZE,
   STANDARD_TABLE_PAGE_SIZE_OPTIONS,
   suggestibleFilterInput,
@@ -72,6 +69,7 @@ import {
   ResultWithAuxiliaryWork,
 } from '../../utils';
 import type { BasicAction } from '../../utils/basic-action';
+import { TableFilter, TableFilters } from '../../utils/table-filters';
 
 import { FillIndicator } from './fill-indicator/fill-indicator';
 
@@ -123,8 +121,8 @@ interface ServicesQuery {
 }
 
 export interface ServicesViewProps {
-  filters: Filter[];
-  onFiltersChange(filters: Filter[]): void;
+  filters: TableFilters;
+  onFiltersChange(filters: TableFilters): void;
   goToQuery(queryWithContext: QueryWithContext): void;
   capabilities: Capabilities;
 }
@@ -438,9 +436,9 @@ ORDER BY
             servicesState.isEmpty() ? 'No services' : 
servicesState.getErrorMessage() || ''
           }
           filterable
-          filtered={filters}
+          filtered={filters.toFilters()}
           className={`centered-table ${DEFAULT_TABLE_CLASS_NAME}`}
-          onFilteredChange={onFiltersChange}
+          onFilteredChange={filters => 
onFiltersChange(TableFilters.fromFilters(filters))}
           pivotBy={groupServicesBy ? [groupServicesBy] : []}
           defaultPageSize={STANDARD_TABLE_PAGE_SIZE}
           pageSizeOptions={STANDARD_TABLE_PAGE_SIZE_OPTIONS}
@@ -454,8 +452,8 @@ ORDER BY
   private readonly getTableColumns = memoize(
     (
       visibleColumns: LocalStorageBackedVisibility,
-      _filters: Filter[],
-      _onFiltersChange: (filters: Filter[]) => void,
+      _filters: TableFilters,
+      _onFiltersChange: (filters: TableFilters) => void,
       workerInfoLookup: Record<string, WorkerInfo>,
     ): Column<ServiceResultRow>[] => {
       const { capabilities } = this.props;
@@ -643,15 +641,18 @@ ORDER BY
           Cell: this.renderFilterableCell('start_time', formatDate),
           Aggregated: () => '',
           filterMethod: (filter: Filter, row: ServiceResultRow) => {
-            const modeAndNeedle = parseFilterModeAndNeedle(filter);
-            if (!modeAndNeedle) return true;
+            const tableFilter = TableFilter.fromFilter(filter);
             const parsedRowTime = formatDate(row.start_time);
-            if (modeAndNeedle.mode === '~') {
-              return booleanCustomTableFilter(filter, parsedRowTime);
+            if (tableFilter.mode === '~') {
+              return tableFilter.matches(parsedRowTime);
             }
-            const parsedFilterTime = formatDate(modeAndNeedle.needle);
-            filter.value = combineModeAndNeedle(modeAndNeedle.mode, 
parsedFilterTime);
-            return booleanCustomTableFilter(filter, parsedRowTime);
+            const parsedFilterTime = formatDate(tableFilter.value);
+            const updatedFilter = new TableFilter(
+              tableFilter.key,
+              tableFilter.mode,
+              parsedFilterTime,
+            );
+            return updatedFilter.matches(parsedRowTime);
           },
         },
         {
diff --git 
a/web-console/src/views/sql-data-loader-view/ingestion-progress-dialog/ingestion-progress-dialog.tsx
 
b/web-console/src/views/sql-data-loader-view/ingestion-progress-dialog/ingestion-progress-dialog.tsx
index d383a5d3459..6167af06433 100644
--- 
a/web-console/src/views/sql-data-loader-view/ingestion-progress-dialog/ingestion-progress-dialog.tsx
+++ 
b/web-console/src/views/sql-data-loader-view/ingestion-progress-dialog/ingestion-progress-dialog.tsx
@@ -22,10 +22,11 @@ import classNames from 'classnames';
 import { T } from 'druid-query-toolkit';
 import React, { useState } from 'react';
 
-import type { Execution, QueryWithContext } from '../../../druid-models';
+import type { ConsoleViewId, Execution, QueryWithContext } from 
'../../../druid-models';
 import { getConsoleViewIcon } from '../../../druid-models';
 import { executionBackgroundStatusCheck, reattachTaskExecution } from 
'../../../helpers';
 import { useQueryManager } from '../../../hooks';
+import { TableFilters } from '../../../utils/table-filters';
 import { ExecutionProgressBarPane } from 
'../../workbench-view/execution-progress-bar-pane/execution-progress-bar-pane';
 import { ExecutionStagesPane } from 
'../../workbench-view/execution-stages-pane/execution-stages-pane';
 
@@ -34,8 +35,7 @@ import './ingestion-progress-dialog.scss';
 interface IngestionProgressDialogProps {
   taskId: string;
   goToQuery(queryWithContext: QueryWithContext): void;
-  goToTask(taskGroupId: string): void;
-  goToTaskGroup(taskGroupId: string): void;
+  goToView(tab: ConsoleViewId, filters?: TableFilters): void;
   onReset(): void;
   onClose(): void;
 }
@@ -43,7 +43,7 @@ interface IngestionProgressDialogProps {
 export const IngestionProgressDialog = React.memo(function 
IngestionProgressDialog(
   props: IngestionProgressDialogProps,
 ) {
-  const { taskId, goToQuery, goToTask, goToTaskGroup, onReset, onClose } = 
props;
+  const { taskId, goToQuery, goToView, onReset, onClose } = props;
   const [showLiveReports, setShowLiveReports] = useState(false);
 
   const [insertResultState, ingestQueryManager] = useQueryManager<string, 
Execution, Execution>({
@@ -80,7 +80,12 @@ export const IngestionProgressDialog = React.memo(function 
IngestionProgressDial
               showLiveReports={showLiveReports}
             />
             {insertResultState.intermediate?.stages && showLiveReports && (
-              <ExecutionStagesPane execution={insertResultState.intermediate} 
goToTask={goToTask} />
+              <ExecutionStagesPane
+                execution={insertResultState.intermediate}
+                goToTask={(taskId: string) =>
+                  goToView('tasks', TableFilters.eq({ task_id: taskId }))
+                }
+              />
             )}
           </>
         )}
@@ -106,7 +111,10 @@ export const IngestionProgressDialog = React.memo(function 
IngestionProgressDial
                 rightIcon={IconNames.ARROW_TOP_RIGHT}
                 onClick={() => {
                   if (!insertResultState.intermediate) return;
-                  goToTaskGroup(insertResultState.intermediate.id);
+                  goToView(
+                    'tasks',
+                    TableFilters.eq({ group_id: 
insertResultState.intermediate.id }),
+                  );
                 }}
               />
             </>
diff --git 
a/web-console/src/views/sql-data-loader-view/sql-data-loader-view.tsx 
b/web-console/src/views/sql-data-loader-view/sql-data-loader-view.tsx
index d692c229441..2ce732cd5ba 100644
--- a/web-console/src/views/sql-data-loader-view/sql-data-loader-view.tsx
+++ b/web-console/src/views/sql-data-loader-view/sql-data-loader-view.tsx
@@ -25,6 +25,7 @@ import React, { useState } from 'react';
 
 import type {
   CapacityInfo,
+  ConsoleViewId,
   ExternalConfig,
   QueryContext,
   QueryWithContext,
@@ -40,6 +41,7 @@ import { submitTaskQuery } from '../../helpers';
 import { useLocalStorageState } from '../../hooks';
 import { AppToaster } from '../../singletons';
 import { deepDelete, LocalStorageKeys } from '../../utils';
+import type { TableFilters } from '../../utils/table-filters';
 import { CapacityAlert } from 
'../workbench-view/capacity-alert/capacity-alert';
 import { InputFormatStep } from 
'../workbench-view/input-format-step/input-format-step';
 import { InputSourceStep } from 
'../workbench-view/input-source-step/input-source-step';
@@ -58,8 +60,7 @@ interface LoaderContent extends QueryWithContext {
 export interface SqlDataLoaderViewProps {
   capabilities: Capabilities;
   goToQuery(queryWithContext: QueryWithContext): void;
-  goToTask(taskId: string): void;
-  goToTaskGroup(taskGroupId: string): void;
+  goToView(tab: ConsoleViewId, filters?: TableFilters): void;
   getClusterCapacity: (() => Promise<CapacityInfo | undefined>) | undefined;
   serverQueryContext?: QueryContext;
 }
@@ -70,8 +71,7 @@ export const SqlDataLoaderView = React.memo(function 
SqlDataLoaderView(
   const {
     capabilities,
     goToQuery,
-    goToTask,
-    goToTaskGroup,
+    goToView,
     getClusterCapacity,
     serverQueryContext = DEFAULT_SERVER_QUERY_CONTEXT,
   } = props;
@@ -246,8 +246,7 @@ export const SqlDataLoaderView = React.memo(function 
SqlDataLoaderView(
         <IngestionProgressDialog
           taskId={content.id}
           goToQuery={goToQuery}
-          goToTask={goToTask}
-          goToTaskGroup={goToTaskGroup}
+          goToView={goToView}
           onReset={() => {
             setExternalConfigStep({});
             setContent(undefined);
diff --git a/web-console/src/views/supervisors-view/supervisors-view.spec.tsx 
b/web-console/src/views/supervisors-view/supervisors-view.spec.tsx
index 66c518b9100..9a03fca45b8 100644
--- a/web-console/src/views/supervisors-view/supervisors-view.spec.tsx
+++ b/web-console/src/views/supervisors-view/supervisors-view.spec.tsx
@@ -18,6 +18,7 @@
 
 import { Capabilities } from '../../helpers';
 import { shallow } from '../../utils/shallow-renderer';
+import { TableFilters } from '../../utils/table-filters';
 
 import { SupervisorsView } from './supervisors-view';
 
@@ -25,13 +26,12 @@ describe('SupervisorsView', () => {
   it('matches snapshot', () => {
     const taskView = shallow(
       <SupervisorsView
-        filters={[]}
+        filters={TableFilters.empty()}
         onFiltersChange={() => {}}
         openSupervisorDialog={undefined}
-        goToDatasource={() => {}}
+        goToView={() => {}}
         goToQuery={() => {}}
         goToStreamingDataLoader={() => {}}
-        goToTasks={() => {}}
         capabilities={Capabilities.FULL}
       />,
     );
diff --git a/web-console/src/views/supervisors-view/supervisors-view.tsx 
b/web-console/src/views/supervisors-view/supervisors-view.tsx
index 3da20ee8ec3..a56ac816c38 100644
--- a/web-console/src/views/supervisors-view/supervisors-view.tsx
+++ b/web-console/src/views/supervisors-view/supervisors-view.tsx
@@ -22,7 +22,7 @@ import * as JSONBig from 'json-bigint-native';
 import memoize from 'memoize-one';
 import type { JSX } from 'react';
 import React, { createContext, useContext } from 'react';
-import type { Column, Filter, SortingRule } from 'react-table';
+import type { Column, SortingRule } from 'react-table';
 import ReactTable from 'react-table';
 
 import type { TableColumnSelectorColumn } from '../../components';
@@ -47,6 +47,7 @@ import {
   TaskGroupHandoffDialog,
 } from '../../dialogs';
 import type {
+  ConsoleViewId,
   IngestionSpec,
   QueryWithContext,
   RowStatsKey,
@@ -59,7 +60,6 @@ import type { Capabilities } from '../../helpers';
 import {
   SMALL_TABLE_PAGE_SIZE,
   SMALL_TABLE_PAGE_SIZE_OPTIONS,
-  sqlQueryCustomTableFilters,
   suggestibleFilterInput,
 } from '../../react-table';
 import { Api, AppToaster } from '../../singletons';
@@ -90,6 +90,7 @@ import {
   twoLines,
 } from '../../utils';
 import type { BasicAction } from '../../utils/basic-action';
+import { TableFilters } from '../../utils/table-filters';
 
 import './supervisors-view.scss';
 
@@ -176,13 +177,12 @@ function HeaderStatsKeySelector({ changeStatsKey }: 
HeaderStatsKeySelectorProps)
 }
 
 export interface SupervisorsViewProps {
-  filters: Filter[];
-  onFiltersChange(filters: Filter[]): void;
+  filters: TableFilters;
+  onFiltersChange(filters: TableFilters): void;
   openSupervisorDialog: boolean | undefined;
-  goToDatasource(datasource: string): void;
+  goToView(tab: ConsoleViewId, filters?: TableFilters): void;
   goToQuery(queryWithContext: QueryWithContext): void;
   goToStreamingDataLoader(supervisorId: string): void;
-  goToTasks(supervisorId: string, type: string | undefined): void;
   capabilities: Capabilities;
 }
 
@@ -288,7 +288,7 @@ export class SupervisorsView extends React.PureComponent<
         let count = -1;
         const auxiliaryQueries: 
AuxiliaryQueryFn<SupervisorsWithAuxiliaryInfo>[] = [];
         if (capabilities.hasSql()) {
-          const whereExpression = sqlQueryCustomTableFilters(filtered);
+          const whereExpression = filtered.toSqlExpression();
 
           let filterClause = '';
           if (whereExpression.toString() !== 'TRUE') {
@@ -462,7 +462,7 @@ export class SupervisorsView extends React.PureComponent<
     const { filters } = this.props;
     const { page, pageSize, sorted } = this.state;
     if (
-      
!sqlQueryCustomTableFilters(filters).equals(sqlQueryCustomTableFilters(prevProps.filters))
 ||
+      !filters.toSqlExpression().equals(prevProps.filters.toSqlExpression()) ||
       page !== prevState.page ||
       pageSize !== prevState.pageSize ||
       sortedToOrderByClause(sorted) !== sortedToOrderByClause(prevState.sorted)
@@ -484,7 +484,7 @@ export class SupervisorsView extends React.PureComponent<
     });
   };
 
-  private readonly handleFilterChange = (filters: Filter[]) => {
+  private readonly handleFilterChange = (filters: TableFilters) => {
     this.goToFirstPage();
     this.props.onFiltersChange(filters);
   };
@@ -521,7 +521,7 @@ export class SupervisorsView extends React.PureComponent<
 
   private getSupervisorActions(supervisor: SupervisorQueryResultRow): 
BasicAction[] {
     const { supervisor_id, datasource, suspended, type } = supervisor;
-    const { goToDatasource, goToStreamingDataLoader } = this.props;
+    const { goToView, goToStreamingDataLoader } = this.props;
 
     const actions: BasicAction[] = [];
     if (oneOf(type, 'kafka', 'kinesis')) {
@@ -529,7 +529,7 @@ export class SupervisorsView extends React.PureComponent<
         {
           icon: IconNames.MULTI_SELECT,
           title: 'Go to datasource',
-          onAction: () => goToDatasource(datasource),
+          onAction: () => goToView('datasources', TableFilters.eq({ datasource 
})),
         },
         {
           icon: IconNames.CLOUD_UPLOAD,
@@ -778,29 +778,43 @@ export class SupervisorsView extends React.PureComponent<
   }
 
   private goToTasksForSupervisor(supervisor: SupervisorQueryResultRow) {
-    const { goToTasks } = this.props;
+    const { goToView } = this.props;
     switch (supervisor.type) {
       case 'kafka':
       case 'kinesis':
-        goToTasks(
-          `index_${supervisor.type}_${supervisor.supervisor_id}`,
-          `index_${supervisor.type}`,
+        goToView(
+          'tasks',
+          TableFilters.eq({
+            group_id: `index_${supervisor.type}_${supervisor.supervisor_id}`,
+            type: `index_${supervisor.type}`,
+          }),
         );
         return;
 
       case 'autocompact':
-        goToTasks(supervisor.supervisor_id.replace(/^autocompact__/, ''), 
'compact');
+        goToView(
+          'tasks',
+          TableFilters.eq({
+            datasource: supervisor.supervisor_id.replace(/^autocompact__/, ''),
+            type: 'compact',
+          }),
+        );
         return;
 
       case 'scheduled_batch':
-        goToTasks(
-          supervisor.supervisor_id.replace(/^scheduled_batch__/, 
'').replace(/__[0-9a-f-]+$/, ''),
-          'query_controller',
+        goToView(
+          'tasks',
+          TableFilters.eq({
+            datasource: supervisor.supervisor_id
+              .replace(/^scheduled_batch__/, '')
+              .replace(/__[0-9a-f-]+$/, ''),
+            type: 'query_controller',
+          }),
         );
         return;
 
       default:
-        goToTasks(supervisor.supervisor_id, undefined);
+        goToView('tasks', TableFilters.eq({ group_id: supervisor.supervisor_id 
}));
         return;
     }
   }
@@ -829,8 +843,8 @@ export class SupervisorsView extends React.PureComponent<
             }
             manual
             filterable
-            filtered={filters}
-            onFilteredChange={this.handleFilterChange}
+            filtered={filters.toFilters()}
+            onFilteredChange={filters => 
this.handleFilterChange(TableFilters.fromFilters(filters))}
             sorted={sorted}
             onSortedChange={sorted => this.setState({ sorted })}
             page={page}
@@ -851,7 +865,7 @@ export class SupervisorsView extends React.PureComponent<
   private readonly getTableColumns = memoize(
     (
       visibleColumns: LocalStorageBackedVisibility,
-      filters: Filter[],
+      filters: TableFilters,
     ): Column<SupervisorQueryResultRow>[] => {
       return [
         {
diff --git 
a/web-console/src/views/tasks-view/__snapshots__/tasks-view.spec.tsx.snap 
b/web-console/src/views/tasks-view/__snapshots__/tasks-view.spec.tsx.snap
index 87744c0ac83..5ab6f043c73 100644
--- a/web-console/src/views/tasks-view/__snapshots__/tasks-view.spec.tsx.snap
+++ b/web-console/src/views/tasks-view/__snapshots__/tasks-view.spec.tsx.snap
@@ -202,6 +202,7 @@ exports[`TasksView matches snapshot 1`] = `
           "Header": "Created time",
           "accessor": "created_time",
           "filterMethod": [Function],
+          "headerClassName": "enable-comparisons",
           "show": true,
           "width": 220,
         },
diff --git a/web-console/src/views/tasks-view/tasks-view.spec.tsx 
b/web-console/src/views/tasks-view/tasks-view.spec.tsx
index 88759d9e76c..84d93e2078e 100644
--- a/web-console/src/views/tasks-view/tasks-view.spec.tsx
+++ b/web-console/src/views/tasks-view/tasks-view.spec.tsx
@@ -18,6 +18,7 @@
 
 import { Capabilities } from '../../helpers';
 import { shallow } from '../../utils/shallow-renderer';
+import { TableFilters } from '../../utils/table-filters';
 
 import { TasksView } from './tasks-view';
 
@@ -25,10 +26,10 @@ describe('TasksView', () => {
   it('matches snapshot', () => {
     const taskView = shallow(
       <TasksView
-        filters={[]}
+        filters={TableFilters.empty()}
         onFiltersChange={() => {}}
         openTaskDialog={undefined}
-        goToDatasource={() => {}}
+        goToView={() => {}}
         goToQuery={() => {}}
         goToClassicBatchDataLoader={() => {}}
         capabilities={Capabilities.FULL}
diff --git a/web-console/src/views/tasks-view/tasks-view.tsx 
b/web-console/src/views/tasks-view/tasks-view.tsx
index 543a746d51e..23a8d2d52f0 100644
--- a/web-console/src/views/tasks-view/tasks-view.tsx
+++ b/web-console/src/views/tasks-view/tasks-view.tsx
@@ -37,7 +37,7 @@ import {
   ViewControlBar,
 } from '../../components';
 import { AlertDialog, AsyncActionDialog, SpecDialog, TaskTableActionDialog } 
from '../../dialogs';
-import type { QueryWithContext } from '../../druid-models';
+import type { ConsoleViewId, QueryWithContext } from '../../druid-models';
 import {
   getConsoleViewIcon,
   TASK_CANCELED_ERROR_MESSAGES,
@@ -45,9 +45,6 @@ import {
 } from '../../druid-models';
 import type { Capabilities } from '../../helpers';
 import {
-  booleanCustomTableFilter,
-  combineModeAndNeedle,
-  parseFilterModeAndNeedle,
   SMALL_TABLE_PAGE_SIZE,
   SMALL_TABLE_PAGE_SIZE_OPTIONS,
   suggestibleFilterInput,
@@ -68,6 +65,7 @@ import {
   QueryState,
 } from '../../utils';
 import type { BasicAction } from '../../utils/basic-action';
+import { TableFilter, TableFilters } from '../../utils/table-filters';
 import { ExecutionDetailsDialog } from 
'../workbench-view/execution-details-dialog/execution-details-dialog';
 
 import './tasks-view.scss';
@@ -99,10 +97,10 @@ interface TaskQueryResultRow {
 }
 
 export interface TasksViewProps {
-  filters: Filter[];
-  onFiltersChange(filters: Filter[]): void;
+  filters: TableFilters;
+  onFiltersChange(filters: TableFilters): void;
   openTaskDialog: boolean | undefined;
-  goToDatasource(datasource: string): void;
+  goToView(tab: ConsoleViewId, filters?: TableFilters): void;
   goToQuery(queryWithContext: QueryWithContext): void;
   goToClassicBatchDataLoader(taskId?: string): void;
   capabilities: Capabilities;
@@ -260,7 +258,7 @@ ORDER BY
     type: string,
     fromTable?: boolean,
   ): BasicAction[] {
-    const { goToDatasource, goToClassicBatchDataLoader } = this.props;
+    const { goToView, goToClassicBatchDataLoader } = this.props;
 
     const actions: BasicAction[] = [];
     if (fromTable) {
@@ -282,7 +280,7 @@ ORDER BY
       actions.push({
         icon: IconNames.MULTI_SELECT,
         title: 'Go to datasource',
-        onAction: () => goToDatasource(datasource),
+        onAction: () => goToView('datasources', TableFilters.eq({ datasource 
})),
       });
     }
     if (oneOf(type, 'index', 'index_parallel')) {
@@ -385,8 +383,8 @@ ORDER BY
         loading={tasksState.loading}
         noDataText={tasksState.isEmpty() ? 'No tasks' : 
tasksState.getErrorMessage() || ''}
         filterable
-        filtered={filters}
-        onFilteredChange={onFiltersChange}
+        filtered={filters.toFilters()}
+        onFilteredChange={filters => 
onFiltersChange(TableFilters.fromFilters(filters))}
         defaultSorted={[{ id: 'status', desc: true }]}
         pivotBy={groupTasksBy ? [groupTasksBy] : []}
         defaultPageSize={SMALL_TABLE_PAGE_SIZE}
@@ -504,6 +502,7 @@ ORDER BY
           {
             Header: 'Created time',
             accessor: 'created_time',
+            headerClassName: 'enable-comparisons',
             width: 220,
             Cell: this.renderTaskFilterableCell(
               'created_time',
@@ -521,15 +520,18 @@ ORDER BY
             Aggregated: () => '',
             show: visibleColumns.shown('Created time'),
             filterMethod: (filter: Filter, row: TaskQueryResultRow) => {
-              const modeAndNeedle = parseFilterModeAndNeedle(filter);
-              if (!modeAndNeedle) return true;
+              const tableFilter = TableFilter.fromFilter(filter);
               const parsedRowDate = formatDate(row.created_time);
-              if (modeAndNeedle.mode === '~') {
-                return booleanCustomTableFilter(filter, parsedRowDate);
+              if (tableFilter.mode === '~') {
+                return tableFilter.matches(parsedRowDate);
               }
-              const parsedFilterDate = formatDate(modeAndNeedle.needle);
-              filter.value = combineModeAndNeedle(modeAndNeedle.mode, 
parsedFilterDate);
-              return booleanCustomTableFilter(filter, parsedRowDate);
+              const parsedFilterDate = formatDate(tableFilter.value);
+              const updatedFilter = new TableFilter(
+                tableFilter.key,
+                tableFilter.mode,
+                parsedFilterDate,
+              );
+              return updatedFilter.matches(parsedRowDate);
             },
           },
           {
@@ -714,7 +716,7 @@ ORDER BY
           <ExecutionDetailsDialog
             id={executionDialogOpen}
             goToTask={taskId => {
-              onFiltersChange([{ id: 'task_id', value: `=${taskId}` }]);
+              onFiltersChange(TableFilters.eq({ task_id: taskId }));
               this.setState({ executionDialogOpen: undefined });
             }}
             onClose={() => this.setState({ executionDialogOpen: undefined })}
diff --git 
a/web-console/src/views/workbench-view/execution-stages-pane/__snapshots__/execution-stages-pane.spec.tsx.snap
 
b/web-console/src/views/workbench-view/execution-stages-pane/__snapshots__/execution-stages-pane.spec.tsx.snap
index 331e8235998..0faac636e38 100644
--- 
a/web-console/src/views/workbench-view/execution-stages-pane/__snapshots__/execution-stages-pane.spec.tsx.snap
+++ 
b/web-console/src/views/workbench-view/execution-stages-pane/__snapshots__/execution-stages-pane.spec.tsx.snap
@@ -150,6 +150,7 @@ exports[`ExecutionStagesPane matches snapshot 1`] = `
         "width": 240,
       },
       {
+        "Cell": [Function],
         "Header": <React.Fragment>
           Num
           <br />
diff --git 
a/web-console/src/views/workbench-view/execution-stages-pane/execution-stages-pane.tsx
 
b/web-console/src/views/workbench-view/execution-stages-pane/execution-stages-pane.tsx
index e4717c6d691..8dfa73f1c29 100644
--- 
a/web-console/src/views/workbench-view/execution-stages-pane/execution-stages-pane.tsx
+++ 
b/web-console/src/views/workbench-view/execution-stages-pane/execution-stages-pane.tsx
@@ -917,6 +917,22 @@ ${title} uncompressed size: ${formatBytesCompact(
           accessor: 'workerCount',
           className: 'padded',
           width: 75,
+          Cell({ value, original }) {
+            const inactiveWorkers = stages.getInactiveWorkerCount(original);
+            if (inactiveWorkers) {
+              return (
+                <div>
+                  <div>{formatInteger(value)}</div>
+                  <div
+                    className="detail-line"
+                    data-tooltip="Workers are counted as inactive until they 
reprot starting to read rows from their input."
+                  >{`${formatInteger(inactiveWorkers)} inactive`}</div>
+                </div>
+              );
+            } else {
+              return formatInteger(value);
+            }
+          },
         },
         {
           Header: 'Output',
diff --git a/web-console/src/views/workbench-view/workbench-view.tsx 
b/web-console/src/views/workbench-view/workbench-view.tsx
index 0f4f02edfc1..fe7bf1c6a71 100644
--- a/web-console/src/views/workbench-view/workbench-view.tsx
+++ b/web-console/src/views/workbench-view/workbench-view.tsx
@@ -36,6 +36,7 @@ import { MenuCheckbox, SplitterLayout } from 
'../../components';
 import { SpecDialog, StringInputDialog } from '../../dialogs';
 import type {
   CapacityInfo,
+  ConsoleViewId,
   DruidEngine,
   Execution,
   QueryContext,
@@ -67,6 +68,7 @@ import {
   QueryManager,
   QueryState,
 } from '../../utils';
+import { TableFilters } from '../../utils/table-filters';
 
 import { ColumnTree } from './column-tree/column-tree';
 import { ConnectExternalDataDialog } from 
'./connect-external-data-dialog/connect-external-data-dialog';
@@ -131,7 +133,7 @@ export interface WorkbenchViewProps
   serverQueryContext?: QueryContext;
   queryEngines: DruidEngine[];
   hiddenMoreMenuItems?: MoreMenuItem[] | ((engine: DruidEngine) => 
MoreMenuItem[]);
-  goToTask(taskId: string): void;
+  goToView(tab: ConsoleViewId, filters?: TableFilters): void;
   getClusterCapacity: (() => Promise<CapacityInfo | undefined>) | undefined;
   hideToolbar?: boolean;
   maxTasksOptions?:
@@ -334,7 +336,7 @@ export class WorkbenchView extends 
React.PureComponent<WorkbenchViewProps, Workb
   }
 
   private renderExecutionDetailsDialog() {
-    const { goToTask } = this.props;
+    const { goToView } = this.props;
     const { details } = this.state;
     if (!details) return;
 
@@ -343,7 +345,7 @@ export class WorkbenchView extends 
React.PureComponent<WorkbenchViewProps, Workb
         id={details.id}
         initTab={details.initTab}
         initExecution={details.initExecution}
-        goToTask={goToTask}
+        goToTask={(taskId: string) => goToView('tasks', TableFilters.eq({ 
task_id: taskId }))}
         onClose={() => this.setState({ details: undefined })}
       />
     );
@@ -734,7 +736,7 @@ export class WorkbenchView extends 
React.PureComponent<WorkbenchViewProps, Workb
       baseQueryContext,
       serverQueryContext = DEFAULT_SERVER_QUERY_CONTEXT,
       queryEngines,
-      goToTask,
+      goToView,
       getClusterCapacity,
       maxTasksMenuHeader,
       enginesLabelFn,
@@ -771,7 +773,7 @@ export class WorkbenchView extends 
React.PureComponent<WorkbenchViewProps, Workb
           onDetails={this.handleDetailsWithExecution}
           queryEngines={queryEngines}
           clusterCapacity={capabilities.getMaxTaskSlots()}
-          goToTask={goToTask}
+          goToTask={(taskId: string) => goToView('tasks', TableFilters.eq({ 
task_id: taskId }))}
           getClusterCapacity={getClusterCapacity}
           maxTasksMenuHeader={maxTasksMenuHeader}
           enginesLabelFn={enginesLabelFn}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to