Web console: Display compaction status (#10438)

* init compaction status

* % compacted

* final UI tweaks

* extracted utils, added tests

* add tests to general foramt functions
diff --git a/web-console/src/components/json-input/json-input.tsx b/web-console/src/components/json-input/json-input.tsx
index f1dd512..eba0620 100644
--- a/web-console/src/components/json-input/json-input.tsx
+++ b/web-console/src/components/json-input/json-input.tsx
@@ -80,12 +80,11 @@
   const aceEditor = useRef<Editor | undefined>();
 
   useEffect(() => {
-    if (!deepEqual(value, internalValue.value)) {
-      setInternalValue({
-        value,
-        stringified: stringifyJson(value),
-      });
-    }
+    if (deepEqual(value, internalValue.value)) return;
+    setInternalValue({
+      value,
+      stringified: stringifyJson(value),
+    });
   }, [value]);
 
   const internalValueError = internalValue.error;
@@ -149,8 +148,8 @@
             const rc = extractRowColumnFromHjsonError(internalValueError);
             if (!rc) return;
 
+            aceEditor.current.focus(); // Grab the focus
             aceEditor.current.getSelection().moveCursorTo(rc.row, rc.column);
-            aceEditor.current.focus(); // Grab the focus also
           }}
         >
           {internalValueError.message}
diff --git a/web-console/src/components/more-button/more-button.tsx b/web-console/src/components/more-button/more-button.tsx
index 7a161b7..4bcef07 100644
--- a/web-console/src/components/more-button/more-button.tsx
+++ b/web-console/src/components/more-button/more-button.tsx
@@ -18,14 +18,19 @@
 
 import { Button, Menu, Popover, Position } from '@blueprintjs/core';
 import { IconNames } from '@blueprintjs/icons';
-import React from 'react';
+import React, { useState } from 'react';
+
+type OpenState = 'open' | 'alt-open';
 
 export interface MoreButtonProps {
-  children: React.ReactNode;
+  children: React.ReactNode | React.ReactNode[];
+  altExtra?: React.ReactNode;
 }
 
 export const MoreButton = React.memo(function MoreButton(props: MoreButtonProps) {
-  const { children } = props;
+  const { children, altExtra } = props;
+
+  const [openState, setOpenState] = useState<OpenState | undefined>();
 
   let childCount = 0;
   // Sadly React.Children.count does not ignore nulls correctly
@@ -36,8 +41,18 @@
   return (
     <Popover
       className="more-button"
-      content={<Menu>{children}</Menu>}
+      isOpen={Boolean(openState)}
+      content={
+        <Menu>
+          {children}
+          {openState === 'alt-open' && altExtra}
+        </Menu>
+      }
       position={Position.BOTTOM_LEFT}
+      onInteraction={(nextOpenState, e: any) => {
+        if (!e) return; // For some reason this function is always called twice once with e and once without
+        setOpenState(nextOpenState ? (e.altKey ? 'alt-open' : 'open') : undefined);
+      }}
     >
       <Button icon={IconNames.MORE} disabled={!childCount} />
     </Popover>
diff --git a/web-console/src/dialogs/compaction-dialog/__snapshots__/compaction-dialog.spec.tsx.snap b/web-console/src/dialogs/compaction-dialog/__snapshots__/compaction-dialog.spec.tsx.snap
index 4ff3b51..2b1635e 100644
--- a/web-console/src/dialogs/compaction-dialog/__snapshots__/compaction-dialog.spec.tsx.snap
+++ b/web-console/src/dialogs/compaction-dialog/__snapshots__/compaction-dialog.spec.tsx.snap
@@ -38,6 +38,12 @@
               The offset for searching segments to be compacted. Strongly recommended to set for realtime dataSources.
             </p>,
             "name": "skipOffsetFromLatest",
+            "suggestions": Array [
+              "PT0H",
+              "PT1H",
+              "P1D",
+              "P3D",
+            ],
             "type": "string",
           },
           Object {
@@ -264,6 +270,12 @@
               The offset for searching segments to be compacted. Strongly recommended to set for realtime dataSources.
             </p>,
             "name": "skipOffsetFromLatest",
+            "suggestions": Array [
+              "PT0H",
+              "PT1H",
+              "P1D",
+              "P3D",
+            ],
             "type": "string",
           },
           Object {
@@ -490,6 +502,12 @@
               The offset for searching segments to be compacted. Strongly recommended to set for realtime dataSources.
             </p>,
             "name": "skipOffsetFromLatest",
+            "suggestions": Array [
+              "PT0H",
+              "PT1H",
+              "P1D",
+              "P3D",
+            ],
             "type": "string",
           },
           Object {
@@ -716,6 +734,12 @@
               The offset for searching segments to be compacted. Strongly recommended to set for realtime dataSources.
             </p>,
             "name": "skipOffsetFromLatest",
+            "suggestions": Array [
+              "PT0H",
+              "PT1H",
+              "P1D",
+              "P3D",
+            ],
             "type": "string",
           },
           Object {
diff --git a/web-console/src/dialogs/compaction-dialog/compaction-dialog.tsx b/web-console/src/dialogs/compaction-dialog/compaction-dialog.tsx
index 2c0f704a..4a2611b 100644
--- a/web-console/src/dialogs/compaction-dialog/compaction-dialog.tsx
+++ b/web-console/src/dialogs/compaction-dialog/compaction-dialog.tsx
@@ -24,8 +24,6 @@
 
 import './compaction-dialog.scss';
 
-export const DEFAULT_MAX_ROWS_PER_SEGMENT = 5000000;
-
 type Tabs = 'form' | 'json';
 
 type CompactionConfig = Record<string, any>;
@@ -35,6 +33,7 @@
     name: 'skipOffsetFromLatest',
     type: 'string',
     defaultValue: 'P1D',
+    suggestions: ['PT0H', 'PT1H', 'P1D', 'P3D'],
     info: (
       <p>
         The offset for searching segments to be compacted. Strongly recommended to set for realtime
diff --git a/web-console/src/utils/compaction.spec.ts b/web-console/src/utils/compaction.spec.ts
new file mode 100644
index 0000000..452cfbe
--- /dev/null
+++ b/web-console/src/utils/compaction.spec.ts
@@ -0,0 +1,87 @@
+/*
+ * 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 {
+  CompactionConfig,
+  CompactionStatus,
+  formatCompactionConfigAndStatus,
+  zeroCompactionStatus,
+} from './compaction';
+
+describe('compaction', () => {
+  const BASIC_CONFIG: CompactionConfig = {};
+  const ZERO_STATUS: CompactionStatus = {
+    dataSource: 'tbl',
+    scheduleStatus: 'RUNNING',
+    bytesAwaitingCompaction: 0,
+    bytesCompacted: 0,
+    bytesSkipped: 0,
+    segmentCountAwaitingCompaction: 0,
+    segmentCountCompacted: 0,
+    segmentCountSkipped: 0,
+    intervalCountAwaitingCompaction: 0,
+    intervalCountCompacted: 0,
+    intervalCountSkipped: 0,
+  };
+
+  it('zeroCompactionStatus', () => {
+    expect(zeroCompactionStatus(ZERO_STATUS)).toEqual(true);
+
+    expect(
+      zeroCompactionStatus({
+        dataSource: 'tbl',
+        scheduleStatus: 'RUNNING',
+        bytesAwaitingCompaction: 1,
+        bytesCompacted: 0,
+        bytesSkipped: 0,
+        segmentCountAwaitingCompaction: 0,
+        segmentCountCompacted: 0,
+        segmentCountSkipped: 0,
+        intervalCountAwaitingCompaction: 0,
+        intervalCountCompacted: 0,
+        intervalCountSkipped: 0,
+      }),
+    ).toEqual(false);
+  });
+
+  it('formatCompactionConfigAndStatus', () => {
+    expect(formatCompactionConfigAndStatus(undefined, undefined)).toEqual('Not enabled');
+
+    expect(formatCompactionConfigAndStatus(BASIC_CONFIG, undefined)).toEqual('Awaiting first run');
+
+    expect(formatCompactionConfigAndStatus(undefined, ZERO_STATUS)).toEqual('Running');
+
+    expect(formatCompactionConfigAndStatus(BASIC_CONFIG, ZERO_STATUS)).toEqual('Running');
+
+    expect(
+      formatCompactionConfigAndStatus(BASIC_CONFIG, {
+        dataSource: 'tbl',
+        scheduleStatus: 'RUNNING',
+        bytesAwaitingCompaction: 0,
+        bytesCompacted: 100,
+        bytesSkipped: 0,
+        segmentCountAwaitingCompaction: 0,
+        segmentCountCompacted: 10,
+        segmentCountSkipped: 0,
+        intervalCountAwaitingCompaction: 0,
+        intervalCountCompacted: 10,
+        intervalCountSkipped: 0,
+      }),
+    ).toEqual('Fully compacted');
+  });
+});
diff --git a/web-console/src/utils/compaction.ts b/web-console/src/utils/compaction.ts
new file mode 100644
index 0000000..34634a1
--- /dev/null
+++ b/web-console/src/utils/compaction.ts
@@ -0,0 +1,68 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+function capitalizeFirst(str: string): string {
+  return str.slice(0, 1).toUpperCase() + str.slice(1).toLowerCase();
+}
+
+export interface CompactionStatus {
+  dataSource: string;
+  scheduleStatus: string;
+  bytesAwaitingCompaction: number;
+  bytesCompacted: number;
+  bytesSkipped: number;
+  segmentCountAwaitingCompaction: number;
+  segmentCountCompacted: number;
+  segmentCountSkipped: number;
+  intervalCountAwaitingCompaction: number;
+  intervalCountCompacted: number;
+  intervalCountSkipped: number;
+}
+
+export type CompactionConfig = Record<string, any>;
+
+export function zeroCompactionStatus(compactionStatus: CompactionStatus): boolean {
+  return (
+    !compactionStatus.bytesAwaitingCompaction &&
+    !compactionStatus.bytesCompacted &&
+    !compactionStatus.bytesSkipped &&
+    !compactionStatus.segmentCountAwaitingCompaction &&
+    !compactionStatus.segmentCountCompacted &&
+    !compactionStatus.segmentCountSkipped &&
+    !compactionStatus.intervalCountAwaitingCompaction &&
+    !compactionStatus.intervalCountCompacted &&
+    !compactionStatus.intervalCountSkipped
+  );
+}
+
+export function formatCompactionConfigAndStatus(
+  compactionConfig: CompactionConfig | undefined,
+  compactionStatus: CompactionStatus | undefined,
+) {
+  if (compactionStatus) {
+    if (compactionStatus.bytesAwaitingCompaction === 0 && !zeroCompactionStatus(compactionStatus)) {
+      return 'Fully compacted';
+    } else {
+      return capitalizeFirst(compactionStatus.scheduleStatus);
+    }
+  } else if (compactionConfig) {
+    return 'Awaiting first run';
+  } else {
+    return 'Not enabled';
+  }
+}
diff --git a/web-console/src/utils/general.spec.ts b/web-console/src/utils/general.spec.ts
index 2a327bd..9b2398b 100644
--- a/web-console/src/utils/general.spec.ts
+++ b/web-console/src/utils/general.spec.ts
@@ -18,6 +18,11 @@
 
 import {
   alphanumericCompare,
+  formatBytes,
+  formatBytesCompact,
+  formatInteger,
+  formatMegabytes,
+  formatPercent,
   sortWithPrefixSuffix,
   sqlQueryCustomTableFilter,
   swapElements,
@@ -83,4 +88,34 @@
       expect(swapElements(array, 2, 4)).toEqual(['a', 'b', 'e', 'd', 'c']);
     });
   });
+
+  describe('formatInteger', () => {
+    it('works', () => {
+      expect(formatInteger(10000)).toEqual('10,000');
+    });
+  });
+
+  describe('formatBytes', () => {
+    it('works', () => {
+      expect(formatBytes(10000)).toEqual('10.00 KB');
+    });
+  });
+
+  describe('formatBytesCompact', () => {
+    it('works', () => {
+      expect(formatBytesCompact(10000)).toEqual('10.00KB');
+    });
+  });
+
+  describe('formatMegabytes', () => {
+    it('works', () => {
+      expect(formatMegabytes(30000000)).toEqual('28.6');
+    });
+  });
+
+  describe('formatPercent', () => {
+    it('works', () => {
+      expect(formatPercent(2 / 3)).toEqual('66.67%');
+    });
+  });
 });
diff --git a/web-console/src/utils/general.tsx b/web-console/src/utils/general.tsx
index 47c7e17..7afe385 100644
--- a/web-console/src/utils/general.tsx
+++ b/web-console/src/utils/general.tsx
@@ -231,6 +231,10 @@
   return numeral(n / 1048576).format('0,0.0');
 }
 
+export function formatPercent(n: number): string {
+  return (n * 100).toFixed(2) + '%';
+}
+
 function pad2(str: string | number): string {
   return ('00' + str).substr(-2);
 }
diff --git a/web-console/src/utils/index.tsx b/web-console/src/utils/index.tsx
index b46d675..2bcf661 100644
--- a/web-console/src/utils/index.tsx
+++ b/web-console/src/utils/index.tsx
@@ -24,3 +24,4 @@
 export * from './query-cursor';
 export * from './local-storage-keys';
 export * from './column-metadata';
+export * from './compaction';
diff --git a/web-console/src/views/datasource-view/__snapshots__/datasource-view.spec.tsx.snap b/web-console/src/views/datasource-view/__snapshots__/datasource-view.spec.tsx.snap
index 1d5ce02..2364a8a 100755
--- a/web-console/src/views/datasource-view/__snapshots__/datasource-view.spec.tsx.snap
+++ b/web-console/src/views/datasource-view/__snapshots__/datasource-view.spec.tsx.snap
@@ -11,7 +11,20 @@
       localStorageKey="datasources-refresh-rate"
       onRefresh={[Function]}
     />
-    <Memo(MoreButton)>
+    <Memo(MoreButton)
+      altExtra={
+        <Blueprint3.MenuItem
+          disabled={false}
+          icon="compressed"
+          intent="danger"
+          multiline={false}
+          onClick={[Function]}
+          popoverProps={Object {}}
+          shouldDismissPopover={true}
+          text="Force compaction run (debug)"
+        />
+      }
+    >
       <Blueprint3.MenuItem
         disabled={false}
         icon="application"
@@ -55,6 +68,8 @@
           "Avg. row size",
           "Replicated size",
           "Compaction",
+          "% Compacted",
+          "Left to be compacted",
           "Retention",
           "Actions",
         ]
@@ -137,6 +152,7 @@
           "accessor": [Function],
           "filterable": false,
           "id": "availability",
+          "minWidth": 200,
           "show": true,
           "sortMethod": [Function],
         },
@@ -150,6 +166,7 @@
           "accessor": "num_segments_to_load",
           "filterable": false,
           "id": "load-drop",
+          "minWidth": 100,
           "show": true,
         },
         Object {
@@ -174,7 +191,7 @@
           "accessor": "avg_segment_size",
           "filterable": false,
           "show": true,
-          "width": 200,
+          "width": 150,
         },
         Object {
           "Cell": [Function],
@@ -217,8 +234,35 @@
           "Header": "Compaction",
           "accessor": [Function],
           "filterable": false,
-          "id": "compaction",
+          "id": "compactionStatus",
           "show": true,
+          "width": 150,
+        },
+        Object {
+          "Cell": [Function],
+          "Header": <React.Fragment>
+            % Compacted
+            <br />
+            bytes / segments / intervals
+          </React.Fragment>,
+          "accessor": [Function],
+          "filterable": false,
+          "id": "percentCompacted",
+          "show": true,
+          "width": 200,
+        },
+        Object {
+          "Cell": [Function],
+          "Header": <React.Fragment>
+            Left to be
+            <br />
+            compacted
+          </React.Fragment>,
+          "accessor": [Function],
+          "filterable": false,
+          "id": "leftToBeCompacted",
+          "show": true,
+          "width": 100,
         },
         Object {
           "Cell": [Function],
@@ -226,6 +270,7 @@
           "accessor": [Function],
           "filterable": false,
           "id": "retention",
+          "minWidth": 100,
           "show": true,
         },
         Object {
diff --git a/web-console/src/views/datasource-view/datasource-view.tsx b/web-console/src/views/datasource-view/datasource-view.tsx
index 20a38e7..59688b0 100644
--- a/web-console/src/views/datasource-view/datasource-view.tsx
+++ b/web-console/src/views/datasource-view/datasource-view.tsx
@@ -37,20 +37,19 @@
   TableColumnSelector,
   ViewControlBar,
 } from '../../components';
-import {
-  AsyncActionDialog,
-  CompactionDialog,
-  DEFAULT_MAX_ROWS_PER_SEGMENT,
-  RetentionDialog,
-} from '../../dialogs';
+import { AsyncActionDialog, CompactionDialog, RetentionDialog } from '../../dialogs';
 import { DatasourceTableActionDialog } from '../../dialogs/datasource-table-action-dialog/datasource-table-action-dialog';
 import { AppToaster } from '../../singletons/toaster';
 import {
   addFilter,
+  CompactionConfig,
+  CompactionStatus,
   countBy,
   formatBytes,
+  formatCompactionConfigAndStatus,
   formatInteger,
   formatMegabytes,
+  formatPercent,
   getDruidErrorMessage,
   LocalStorageKeys,
   lookupBy,
@@ -58,6 +57,7 @@
   queryDruidSql,
   QueryManager,
   QueryState,
+  zeroCompactionStatus,
 } from '../../utils';
 import { BasicAction } from '../../utils/basic-action';
 import { Capabilities, CapabilitiesMode } from '../../utils/capabilities';
@@ -78,6 +78,8 @@
     'Avg. row size',
     'Replicated size',
     'Compaction',
+    '% Compacted',
+    'Left to be compacted',
     'Retention',
     ACTION_COLUMN_LABEL,
   ],
@@ -88,6 +90,8 @@
     'Total data size',
     'Segment size',
     'Compaction',
+    '% Compacted',
+    'Left to be compacted',
     'Retention',
     ACTION_COLUMN_LABEL,
   ],
@@ -107,10 +111,10 @@
 function formatLoadDrop(segmentsToLoad: number, segmentsToDrop: number): string {
   const loadDrop: string[] = [];
   if (segmentsToLoad) {
-    loadDrop.push(`${segmentsToLoad} segments to load`);
+    loadDrop.push(`${pluralIfNeeded(segmentsToLoad, 'segment')} to load`);
   }
   if (segmentsToDrop) {
-    loadDrop.push(`${segmentsToDrop} segments to drop`);
+    loadDrop.push(`${pluralIfNeeded(segmentsToDrop, 'segment')} to drop`);
   }
   return loadDrop.join(', ') || 'No segments to load/drop';
 }
@@ -120,6 +124,7 @@
 const formatTotalRows = formatInteger;
 const formatAvgRowSize = formatInteger;
 const formatReplicatedSize = formatBytes;
+const formatLeftToBeCompacted = formatBytes;
 
 function twoLines(line1: string, line2: string) {
   return (
@@ -131,9 +136,19 @@
   );
 }
 
+function progress(done: number, awaiting: number): number {
+  const d = done + awaiting;
+  if (!d) return 0;
+  return done / d;
+}
+
+const PERCENT_BRACES = [formatPercent(1)];
+
 interface Datasource {
   datasource: string;
   rules: Rule[];
+  compactionConfig?: CompactionConfig;
+  compactionStatus?: CompactionStatus;
   [key: string]: any;
 }
 
@@ -164,7 +179,7 @@
 
 interface CompactionDialogOpenOn {
   datasource: string;
-  compactionConfig: Record<string, any>;
+  compactionConfig: CompactionConfig;
 }
 
 export interface DatasourcesViewProps {
@@ -190,6 +205,7 @@
   datasourceToMarkSegmentsByIntervalIn?: string;
   useUnuseAction: 'use' | 'unuse';
   useUnuseInterval: string;
+  showForceCompact: boolean;
   hiddenColumns: LocalStorageBackedArray<string>;
   showChart: boolean;
   chartWidth: number;
@@ -229,7 +245,7 @@
 FROM sys.segments
 GROUP BY 1`;
 
-  static formatRules(rules: any[]): string {
+  static formatRules(rules: Rule[]): string {
     if (rules.length === 0) {
       return 'No rules';
     } else if (rules.length <= 2) {
@@ -259,6 +275,7 @@
       showUnused: false,
       useUnuseAction: 'unuse',
       useUnuseInterval: '',
+      showForceCompact: false,
       hiddenColumns: new LocalStorageBackedArray<string>(
         LocalStorageKeys.DATASOURCE_TABLE_COLUMN_SELECTION,
       ),
@@ -314,7 +331,7 @@
           };
         }
 
-        const seen = countBy(datasources, (x: any) => x.datasource);
+        const seen = countBy(datasources, x => x.datasource);
 
         let unused: string[] = [];
         if (this.state.showUnused) {
@@ -329,18 +346,25 @@
         const rulesResp = await axios.get('/druid/coordinator/v1/rules');
         const rules = rulesResp.data;
 
-        const compactionResp = await axios.get('/druid/coordinator/v1/config/compaction');
-        const compaction = lookupBy(
-          compactionResp.data.compactionConfigs,
-          (c: any) => c.dataSource,
+        const compactionConfigsResp = await axios.get('/druid/coordinator/v1/config/compaction');
+        const compactionConfigs = lookupBy(
+          compactionConfigsResp.data.compactionConfigs || [],
+          (c: CompactionConfig) => c.dataSource,
+        );
+
+        const compactionStatusesResp = await axios.get('/druid/coordinator/v1/compaction/status');
+        const compactionStatuses = lookupBy(
+          compactionStatusesResp.data.latestStatus || [],
+          (c: CompactionStatus) => c.dataSource,
         );
 
         const allDatasources = (datasources as any).concat(
           unused.map(d => ({ datasource: d, unused: true })),
         );
-        allDatasources.forEach((ds: any) => {
+        allDatasources.forEach((ds: Datasource) => {
           ds.rules = rules[ds.datasource] || [];
-          ds.compaction = compaction[ds.datasource];
+          ds.compactionConfig = compactionConfigs[ds.datasource];
+          ds.compactionStatus = compactionStatuses[ds.datasource];
         });
 
         return {
@@ -535,7 +559,18 @@
     const { goToQuery, capabilities } = this.props;
 
     return (
-      <MoreButton>
+      <MoreButton
+        altExtra={
+          <MenuItem
+            icon={IconNames.COMPRESSED}
+            text="Force compaction run (debug)"
+            intent={Intent.DANGER}
+            onClick={() => {
+              this.setState({ showForceCompact: true });
+            }}
+          />
+        }
+      >
         {capabilities.hasSql() && (
           <MenuItem
             icon={IconNames.APPLICATION}
@@ -552,7 +587,32 @@
     );
   }
 
-  private saveRules = async (datasource: string, rules: any[], comment: string) => {
+  renderForceCompactAction() {
+    const { showForceCompact } = this.state;
+    if (!showForceCompact) return;
+
+    return (
+      <AsyncActionDialog
+        action={async () => {
+          const resp = await axios.post(`/druid/coordinator/v1/compaction/compact`, {});
+          return resp.data;
+        }}
+        confirmButtonText="Force compaction run"
+        successText="Out of band compaction run has been initiated"
+        failText="Could not force compaction"
+        intent={Intent.DANGER}
+        onClose={() => {
+          this.setState({ showForceCompact: false });
+        }}
+      >
+        <p>Are you sure you want to force a compaction run?</p>
+        <p>This functionality only exists for debugging and testing reasons.</p>
+        <p>If you are running it in production you are doing something wrong.</p>
+      </AsyncActionDialog>
+    );
+  }
+
+  private saveRules = async (datasource: string, rules: Rule[], comment: string) => {
     try {
       await axios.post(`/druid/coordinator/v1/rules/${datasource}`, rules, {
         headers: {
@@ -642,8 +702,8 @@
   getDatasourceActions(
     datasource: string,
     unused: boolean,
-    rules: any[],
-    compactionConfig: Record<string, any>,
+    rules: Rule[],
+    compactionConfig: CompactionConfig,
   ): BasicAction[] {
     const { goToQuery, goToTask, capabilities } = this.props;
 
@@ -821,6 +881,12 @@
 
     const replicatedSizeValues = datasources.map(d => formatReplicatedSize(d.replicated_size));
 
+    const leftToBeCompactedValues = datasources.map(d =>
+      d.compactionStatus
+        ? formatLeftToBeCompacted(d.compactionStatus.bytesAwaitingCompaction)
+        : '-',
+    );
+
     return (
       <>
         <ReactTable
@@ -842,8 +908,7 @@
               show: hiddenColumns.exists('Datasource name'),
               accessor: 'datasource',
               width: 150,
-              Cell: row => {
-                const value = row.value;
+              Cell: ({ value }) => {
                 return (
                   <a
                     onClick={() => {
@@ -862,14 +927,15 @@
               show: hiddenColumns.exists('Availability'),
               id: 'availability',
               filterable: false,
+              minWidth: 200,
               accessor: row => {
                 return {
                   num_available: row.num_available_segments,
                   num_total: row.num_segments,
                 };
               },
-              Cell: row => {
-                const { datasource, num_available_segments, num_segments, unused } = row.original;
+              Cell: ({ original }) => {
+                const { datasource, num_available_segments, num_segments, unused } = original;
 
                 if (unused) {
                   return (
@@ -927,8 +993,9 @@
               id: 'load-drop',
               accessor: 'num_segments_to_load',
               filterable: false,
-              Cell: row => {
-                const { num_segments_to_load, num_segments_to_drop } = row.original;
+              minWidth: 100,
+              Cell: ({ original }) => {
+                const { num_segments_to_load, num_segments_to_drop } = original;
                 return formatLoadDrop(num_segments_to_load, num_segments_to_drop);
               },
             },
@@ -938,8 +1005,8 @@
               accessor: 'total_data_size',
               filterable: false,
               width: 100,
-              Cell: row => (
-                <BracedText text={formatTotalDataSize(row.value)} braces={totalDataSizeValues} />
+              Cell: ({ value }) => (
+                <BracedText text={formatTotalDataSize(value)} braces={totalDataSizeValues} />
               ),
             },
             {
@@ -947,18 +1014,18 @@
               show: hiddenColumns.exists('Segment size'),
               accessor: 'avg_segment_size',
               filterable: false,
-              width: 200,
-              Cell: row => (
+              width: 150,
+              Cell: ({ value, original }) => (
                 <>
                   <BracedText
-                    text={formatSegmentSize(row.original.min_segment_size)}
+                    text={formatSegmentSize(original.min_segment_size)}
                     braces={minSegmentSizeValues}
                   />{' '}
                   &nbsp;{' '}
-                  <BracedText text={formatSegmentSize(row.value)} braces={avgSegmentSizeValues} />{' '}
+                  <BracedText text={formatSegmentSize(value)} braces={avgSegmentSizeValues} />{' '}
                   &nbsp;{' '}
                   <BracedText
-                    text={formatSegmentSize(row.original.max_segment_size)}
+                    text={formatSegmentSize(original.max_segment_size)}
                     braces={maxSegmentSizeValues}
                   />
                 </>
@@ -970,8 +1037,8 @@
               accessor: 'total_rows',
               filterable: false,
               width: 100,
-              Cell: row => (
-                <BracedText text={formatTotalRows(row.value)} braces={totalRowsValues} />
+              Cell: ({ value }) => (
+                <BracedText text={formatTotalRows(value)} braces={totalRowsValues} />
               ),
             },
             {
@@ -980,8 +1047,8 @@
               accessor: 'avg_row_size',
               filterable: false,
               width: 100,
-              Cell: row => (
-                <BracedText text={formatAvgRowSize(row.value)} braces={avgRowSizeValues} />
+              Cell: ({ value }) => (
+                <BracedText text={formatAvgRowSize(value)} braces={avgRowSizeValues} />
               ),
             },
             {
@@ -990,74 +1057,145 @@
               accessor: 'replicated_size',
               filterable: false,
               width: 100,
-              Cell: row => (
-                <BracedText text={formatReplicatedSize(row.value)} braces={replicatedSizeValues} />
+              Cell: ({ value }) => (
+                <BracedText text={formatReplicatedSize(value)} braces={replicatedSizeValues} />
               ),
             },
             {
               Header: 'Compaction',
               show: capabilities.hasCoordinatorAccess() && hiddenColumns.exists('Compaction'),
-              id: 'compaction',
-              accessor: row => Boolean(row.compaction),
+              id: 'compactionStatus',
+              accessor: row => Boolean(row.compactionStatus),
               filterable: false,
-              Cell: row => {
-                const { compaction } = row.original;
-                let text: string;
-                if (compaction) {
-                  if (compaction.maxRowsPerSegment == null) {
-                    text = `Target: Default (${formatInteger(DEFAULT_MAX_ROWS_PER_SEGMENT)})`;
-                  } else {
-                    text = `Target: ${formatInteger(compaction.maxRowsPerSegment)}`;
-                  }
-                } else {
-                  text = 'Not enabled';
-                }
+              width: 150,
+              Cell: ({ original }) => {
+                const { datasource, compactionConfig, compactionStatus } = original;
                 return (
                   <span
                     className="clickable-cell"
                     onClick={() =>
                       this.setState({
                         compactionDialogOpenOn: {
-                          datasource: row.original.datasource,
-                          compactionConfig: compaction,
+                          datasource,
+                          compactionConfig,
                         },
                       })
                     }
                   >
-                    {text}&nbsp;
+                    {formatCompactionConfigAndStatus(compactionConfig, compactionStatus)}&nbsp;
                     <ActionIcon icon={IconNames.EDIT} />
                   </span>
                 );
               },
             },
             {
+              Header: twoLines('% Compacted', 'bytes / segments / intervals'),
+              show: capabilities.hasCoordinatorAccess() && hiddenColumns.exists('% Compacted'),
+              id: 'percentCompacted',
+              width: 200,
+              accessor: ({ compactionStatus }) =>
+                compactionStatus && compactionStatus.bytesCompacted
+                  ? compactionStatus.bytesCompacted /
+                    (compactionStatus.bytesAwaitingCompaction + compactionStatus.bytesCompacted)
+                  : 0,
+              filterable: false,
+              Cell: ({ original }) => {
+                const { compactionStatus } = original;
+
+                if (!compactionStatus || zeroCompactionStatus(compactionStatus)) {
+                  return (
+                    <>
+                      <BracedText text="-" braces={PERCENT_BRACES} /> &nbsp;{' '}
+                      <BracedText text="-" braces={PERCENT_BRACES} /> &nbsp;{' '}
+                      <BracedText text="-" braces={PERCENT_BRACES} />
+                    </>
+                  );
+                }
+
+                return (
+                  <>
+                    <BracedText
+                      text={formatPercent(
+                        progress(
+                          compactionStatus.bytesCompacted,
+                          compactionStatus.bytesAwaitingCompaction,
+                        ),
+                      )}
+                      braces={PERCENT_BRACES}
+                    />{' '}
+                    &nbsp;{' '}
+                    <BracedText
+                      text={formatPercent(
+                        progress(
+                          compactionStatus.segmentCountCompacted,
+                          compactionStatus.segmentCountAwaitingCompaction,
+                        ),
+                      )}
+                      braces={PERCENT_BRACES}
+                    />{' '}
+                    &nbsp;{' '}
+                    <BracedText
+                      text={formatPercent(
+                        progress(
+                          compactionStatus.intervalCountCompacted,
+                          compactionStatus.intervalCountAwaitingCompaction,
+                        ),
+                      )}
+                      braces={PERCENT_BRACES}
+                    />
+                  </>
+                );
+              },
+            },
+            {
+              Header: twoLines('Left to be', 'compacted'),
+              show:
+                capabilities.hasCoordinatorAccess() && hiddenColumns.exists('Left to be compacted'),
+              id: 'leftToBeCompacted',
+              width: 100,
+              accessor: ({ compactionStatus }) =>
+                (compactionStatus && compactionStatus.bytesAwaitingCompaction) || 0,
+              filterable: false,
+              Cell: ({ original }) => {
+                const { compactionStatus } = original;
+
+                if (!compactionStatus) {
+                  return <BracedText text="-" braces={leftToBeCompactedValues} />;
+                }
+
+                return (
+                  <BracedText
+                    text={formatLeftToBeCompacted(compactionStatus.bytesAwaitingCompaction)}
+                    braces={leftToBeCompactedValues}
+                  />
+                );
+              },
+            },
+            {
               Header: 'Retention',
               show: capabilities.hasCoordinatorAccess() && hiddenColumns.exists('Retention'),
               id: 'retention',
               accessor: row => row.rules.length,
               filterable: false,
-              Cell: row => {
-                const { rules } = row.original;
-                let text: string;
-                if (rules.length === 0) {
-                  text = 'Cluster default: ' + DatasourcesView.formatRules(defaultRules);
-                } else {
-                  text = DatasourcesView.formatRules(rules);
-                }
-
+              minWidth: 100,
+              Cell: ({ original }) => {
+                const { datasource, rules } = original;
                 return (
                   <span
                     onClick={() =>
                       this.setState({
                         retentionDialogOpenOn: {
-                          datasource: row.original.datasource,
-                          rules: row.original.rules,
+                          datasource,
+                          rules,
                         },
                       })
                     }
                     className="clickable-cell"
                   >
-                    {text}&nbsp;
+                    {rules.length
+                      ? DatasourcesView.formatRules(rules)
+                      : `Cluster default: ${DatasourcesView.formatRules(defaultRules)}`}
+                    &nbsp;
                     <ActionIcon icon={IconNames.EDIT} />
                   </span>
                 );
@@ -1070,9 +1208,8 @@
               id: ACTION_COLUMN_ID,
               width: ACTION_COLUMN_WIDTH,
               filterable: false,
-              Cell: row => {
-                const datasource = row.value;
-                const { unused, rules, compaction } = row.original;
+              Cell: ({ value: datasource, original }) => {
+                const { unused, rules, compaction } = original;
                 const datasourceActions = this.getDatasourceActions(
                   datasource,
                   unused,
@@ -1101,6 +1238,7 @@
         {this.renderKillAction()}
         {this.renderRetentionDialog()}
         {this.renderCompactionDialog()}
+        {this.renderForceCompactAction()}
       </>
     );
   }
diff --git a/web-console/src/views/query-view/query-input/query-input.tsx b/web-console/src/views/query-view/query-input/query-input.tsx
index 7897953..12862a3 100644
--- a/web-console/src/views/query-view/query-input/query-input.tsx
+++ b/web-console/src/views/query-view/query-input/query-input.tsx
@@ -214,7 +214,7 @@
   public goToPosition(rowColumn: RowColumn) {
     const { aceEditor } = this;
     if (!aceEditor) return;
-    aceEditor.focus(); // Grab the focus also
+    aceEditor.focus(); // Grab the focus
     aceEditor.getSelection().moveCursorTo(rowColumn.row, rowColumn.column);
     if (rowColumn.endRow && rowColumn.endColumn) {
       aceEditor