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

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


The following commit(s) were added to refs/heads/master by this push:
     new a3c27e2f31f Add logical table management UI (#17878)
a3c27e2f31f is described below

commit a3c27e2f31fa0d229c4e4bad861f599a3c6cdf31
Author: Shaurya Chaturvedi <[email protected]>
AuthorDate: Sun Mar 15 07:54:01 2026 +0530

    Add logical table management UI (#17878)
    
    * Logical table UI built
    
    * Apply suggestions from code review
    
    Co-authored-by: Copilot Autofix powered by AI 
<[email protected]>
    
    ---------
    
    Co-authored-by: Copilot Autofix powered by AI 
<[email protected]>
---
 .../app/components/AsyncLogicalTables.tsx          |  51 +++
 .../main/resources/app/components/Breadcrumbs.tsx  |  10 +-
 .../resources/app/pages/LogicalTableDetails.tsx    | 347 +++++++++++++++++++++
 .../main/resources/app/pages/TablesListingPage.tsx |   2 +
 .../src/main/resources/app/requests/index.ts       |   9 +
 pinot-controller/src/main/resources/app/router.tsx |   2 +
 .../main/resources/app/utils/PinotMethodUtils.ts   |  38 ++-
 7 files changed, 455 insertions(+), 4 deletions(-)

diff --git 
a/pinot-controller/src/main/resources/app/components/AsyncLogicalTables.tsx 
b/pinot-controller/src/main/resources/app/components/AsyncLogicalTables.tsx
new file mode 100644
index 00000000000..bdcbafb7e14
--- /dev/null
+++ b/pinot-controller/src/main/resources/app/components/AsyncLogicalTables.tsx
@@ -0,0 +1,51 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import React, { useState, useEffect } from 'react';
+import { TableData } from 'Models';
+import PinotMethodUtils from '../utils/PinotMethodUtils';
+import CustomizedTables from './Table';
+import Utils from '../utils/Utils';
+
+const columnHeaders = ['Logical Table Name'];
+
+export const AsyncLogicalTables = () => {
+  const [tableData, setTableData] = useState<TableData>(
+    Utils.getLoadingTableData(columnHeaders)
+  );
+
+  useEffect(() => {
+    PinotMethodUtils.getLogicalTablesList().then((result) => {
+      setTableData(result);
+    });
+  }, []);
+
+  return (
+    <CustomizedTables
+      title="Logical Tables"
+      data={tableData}
+      showSearchBox={true}
+      inAccordionFormat={true}
+      addLinks
+      baseURL="/logical-tables/"
+    />
+  );
+};
+
+export default AsyncLogicalTables;
diff --git a/pinot-controller/src/main/resources/app/components/Breadcrumbs.tsx 
b/pinot-controller/src/main/resources/app/components/Breadcrumbs.tsx
index e8c3892cab3..2316e551d10 100644
--- a/pinot-controller/src/main/resources/app/components/Breadcrumbs.tsx
+++ b/pinot-controller/src/main/resources/app/components/Breadcrumbs.tsx
@@ -58,6 +58,7 @@ const breadcrumbNameMap: { [key: string]: string } = {
   '/minions': 'Minions',
   '/minion-task-manager': 'Minion Task Manager',
   '/tables': 'Tables',
+  '/logical-tables': 'Logical Tables',
   '/query': 'Query Console',
   '/cluster': 'Cluster Manager',
   '/zookeeper': 'Zookeeper Browser',
@@ -98,7 +99,12 @@ const BreadcrumbsComponent = ({ ...props }) => {
     const breadcrumbs = [getClickableLabel(breadcrumbNameMap['/'], '/')];
     const paramsKeys = keys(props.match.params);
     if(paramsKeys.length){
-      const {tenantName, tableName, segmentName, instanceName, schemaName, 
query, taskType, queueTableName, taskID, subTaskID} = props.match.params;
+      const {tenantName, tableName, segmentName, instanceName, schemaName, 
query, taskType, queueTableName, taskID, subTaskID, logicalTableName} = 
props.match.params;
+      if (logicalTableName) {
+        breadcrumbs.push(
+          getClickableLabel(breadcrumbNameMap['/logical-tables'], '/tables'),
+        );
+      }
       if((tenantName || instanceName) && tableName){
         breadcrumbs.push(
           getClickableLabel(
@@ -143,7 +149,7 @@ const BreadcrumbsComponent = ({ ...props }) => {
       if (subTaskID) {
         breadcrumbs.push(getClickableLabel('Sub Tasks', 
`/task-queue/${taskType}/tables/${queueTableName}/task/${taskID}`));
       }
-      breadcrumbs.push(getLabel(segmentName || tableName || tenantName || 
instanceName || schemaName || subTaskID || taskID || queueTableName || taskType 
|| 'Query Console'));
+      breadcrumbs.push(getLabel(segmentName || tableName || tenantName || 
instanceName || schemaName || logicalTableName || subTaskID || taskID || 
queueTableName || taskType || 'Query Console'));
     } else {
       breadcrumbs.push(getLabel(breadcrumbNameMap[location.pathname]));
     }
diff --git 
a/pinot-controller/src/main/resources/app/pages/LogicalTableDetails.tsx 
b/pinot-controller/src/main/resources/app/pages/LogicalTableDetails.tsx
new file mode 100644
index 00000000000..be0ad0d2e59
--- /dev/null
+++ b/pinot-controller/src/main/resources/app/pages/LogicalTableDetails.tsx
@@ -0,0 +1,347 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import React, { useState, useEffect } from 'react';
+import { makeStyles } from '@material-ui/core/styles';
+import { Grid } from '@material-ui/core';
+import { RouteComponentProps, useHistory } from 'react-router-dom';
+import { UnControlled as CodeMirror } from 'react-codemirror2';
+import { Link } from 'react-router-dom';
+import AppLoader from '../components/AppLoader';
+import CustomizedTables from '../components/Table';
+import TableToolbar from '../components/TableToolbar';
+import SimpleAccordion from '../components/SimpleAccordion';
+import PinotMethodUtils from '../utils/PinotMethodUtils';
+import CustomButton from '../components/CustomButton';
+import EditConfigOp from '../components/Homepage/Operations/EditConfigOp';
+import Confirm from '../components/Confirm';
+import { NotificationContext } from 
'../components/Notification/NotificationContext';
+import NotFound from '../components/NotFound';
+import 'codemirror/lib/codemirror.css';
+import 'codemirror/theme/material.css';
+import 'codemirror/mode/javascript/javascript';
+
+const useStyles = makeStyles((theme) => ({
+  highlightBackground: {
+    border: '1px #4285f4 solid',
+    backgroundColor: 'rgba(66, 133, 244, 0.05)',
+    borderRadius: 4,
+    marginBottom: '20px',
+  },
+  body: {
+    borderTop: '1px solid #BDCCD9',
+    fontSize: '16px',
+    lineHeight: '3rem',
+    paddingLeft: '15px',
+  },
+  queryOutput: {
+    border: '1px solid #BDCCD9',
+    '& .CodeMirror': { height: 532 },
+  },
+  sqlDiv: {
+    border: '1px #BDCCD9 solid',
+    borderRadius: 4,
+    marginBottom: '20px',
+  },
+  operationDiv: {
+    border: '1px #BDCCD9 solid',
+    borderRadius: 4,
+    marginBottom: 20,
+  },
+  link: {
+    color: '#4285f4',
+    textDecoration: 'none',
+    '&:hover': {
+      textDecoration: 'underline',
+    },
+  },
+}));
+
+const jsonoptions = {
+  lineNumbers: true,
+  mode: 'application/json',
+  styleActiveLine: true,
+  gutters: ['CodeMirror-lint-markers'],
+  theme: 'default',
+  readOnly: true,
+};
+
+type Props = {
+  logicalTableName: string;
+};
+
+const ConfigSection = ({ title, data, classes }: { title: string; data: any; 
classes: any }) => {
+  if (!data) return null;
+  return (
+    <div className={classes.sqlDiv}>
+      <SimpleAccordion headerTitle={title} showSearchBox={false}>
+        <CodeMirror
+          options={jsonoptions}
+          value={JSON.stringify(data, null, 2)}
+          className={classes.queryOutput}
+          autoCursor={false}
+        />
+      </SimpleAccordion>
+    </div>
+  );
+};
+
+const LogicalTableDetails = ({ match }: RouteComponentProps<Props>) => {
+  const { logicalTableName } = match.params;
+  const classes = useStyles();
+  const history = useHistory();
+  const [fetching, setFetching] = useState(true);
+  const [notFound, setNotFound] = useState(false);
+  const [logicalTableConfig, setLogicalTableConfig] = useState<any>(null);
+  const [configJSON, setConfigJSON] = useState('');
+
+  const [showEditConfig, setShowEditConfig] = useState(false);
+  const [config, setConfig] = useState('{}');
+  const [confirmDialog, setConfirmDialog] = useState(false);
+  const [dialogDetails, setDialogDetails] = useState(null);
+  const { dispatch } = React.useContext(NotificationContext);
+
+  const fetchData = async () => {
+    setFetching(true);
+    setNotFound(false);
+    try {
+      const result = await 
PinotMethodUtils.getLogicalTableConfig(logicalTableName);
+      if (result.error || !result) {
+        setNotFound(true);
+      } else {
+        const parsed = typeof result === 'string' ? JSON.parse(result) : 
result;
+        setLogicalTableConfig(parsed);
+        setConfigJSON(JSON.stringify(parsed, null, 2));
+      }
+    } catch (e) {
+      setNotFound(true);
+    }
+    setFetching(false);
+  };
+
+  useEffect(() => {
+    fetchData();
+  }, [logicalTableName]);
+
+  const handleConfigChange = (value: string) => {
+    setConfig(value);
+  };
+
+  const saveConfigAction = async () => {
+    try {
+      const configObj = JSON.parse(config);
+      const result = await 
PinotMethodUtils.updateLogicalTableConfig(logicalTableName, configObj);
+      if (result.status) {
+        dispatch({ type: 'success', message: result.status, show: true });
+        fetchData();
+        setShowEditConfig(false);
+      } else {
+        dispatch({ type: 'error', message: result.error || 'Failed to update', 
show: true });
+      }
+    } catch (e) {
+      dispatch({ type: 'error', message: e.toString(), show: true });
+    }
+  };
+
+  const handleDeleteAction = () => {
+    setDialogDetails({
+      title: 'Delete Logical Table',
+      content: `Are you sure you want to delete the logical table 
"${logicalTableName}"?`,
+      successCb: () => deleteLogicalTable(),
+    });
+    setConfirmDialog(true);
+  };
+
+  const deleteLogicalTable = async () => {
+    try {
+      const result = await 
PinotMethodUtils.deleteLogicalTableOp(logicalTableName);
+      if (result.status) {
+        dispatch({ type: 'success', message: result.status, show: true });
+        closeDialog();
+        setTimeout(() => {
+          history.push('/tables');
+        }, 1000);
+      } else {
+        dispatch({ type: 'error', message: result.error || 'Failed to delete', 
show: true });
+        closeDialog();
+      }
+    } catch (e) {
+      dispatch({ type: 'error', message: e.toString(), show: true });
+      closeDialog();
+    }
+  };
+
+  const closeDialog = () => {
+    setConfirmDialog(false);
+    setDialogDetails(null);
+  };
+
+  const buildPhysicalTablesData = () => {
+    if (!logicalTableConfig?.physicalTableConfigMap) {
+      return { columns: ['Physical Table Name', 'Multi-Cluster'], records: [] 
};
+    }
+    const records = 
Object.entries(logicalTableConfig.physicalTableConfigMap).map(
+      ([tableName, config]: [string, any]) => {
+        const isMultiCluster = config?.multiCluster === true;
+        const nameCell = isMultiCluster
+          ? tableName
+          : {
+              customRenderer: (
+                <Link to={`/tenants/table/${tableName}`} 
className={classes.link}>
+                  {tableName}
+                </Link>
+              ),
+            };
+        return [nameCell, isMultiCluster ? 'Yes' : 'No'];
+      }
+    );
+    return {
+      columns: ['Physical Table Name', 'Multi-Cluster'],
+      records,
+    };
+  };
+
+  if (fetching) {
+    return <AppLoader />;
+  }
+
+  if (notFound) {
+    return <NotFound message={`Logical table "${logicalTableName}" not found`} 
/>;
+  }
+
+  const physicalTablesData = buildPhysicalTablesData();
+
+  return (
+    <Grid
+      item
+      xs
+      style={{
+        padding: 20,
+        backgroundColor: 'white',
+        maxHeight: 'calc(100vh - 70px)',
+        overflowY: 'auto',
+      }}
+    >
+      <div className={classes.operationDiv}>
+        <SimpleAccordion headerTitle="Operations" showSearchBox={false}>
+          <div>
+            <CustomButton
+              onClick={() => {
+                setConfig(configJSON);
+                setShowEditConfig(true);
+              }}
+              tooltipTitle="Edit the logical table configuration"
+              enableTooltip={true}
+            >
+              Edit Config
+            </CustomButton>
+            <CustomButton
+              onClick={handleDeleteAction}
+              tooltipTitle="Delete this logical table"
+              enableTooltip={true}
+            >
+              Delete Logical Table
+            </CustomButton>
+          </div>
+        </SimpleAccordion>
+      </div>
+
+      <div className={classes.highlightBackground}>
+        <TableToolbar name="Summary" showSearchBox={false} />
+        <Grid container spacing={2} className={classes.body}>
+          <Grid item xs={4}>
+            <strong>Table Name:</strong> {logicalTableConfig.tableName}
+          </Grid>
+          <Grid item xs={4}>
+            <strong>Broker Tenant:</strong> {logicalTableConfig.brokerTenant 
|| 'N/A'}
+          </Grid>
+          <Grid item xs={4}>
+            <strong>Physical Tables:</strong>{' '}
+            {logicalTableConfig.physicalTableConfigMap
+              ? Object.keys(logicalTableConfig.physicalTableConfigMap).length
+              : 0}
+          </Grid>
+          {logicalTableConfig.refOfflineTableName && (
+            <Grid item xs={4}>
+              <strong>Ref Offline Table:</strong>{' '}
+              <Link 
to={`/tenants/table/${logicalTableConfig.refOfflineTableName}`} 
className={classes.link}>
+                {logicalTableConfig.refOfflineTableName}
+              </Link>
+            </Grid>
+          )}
+          {logicalTableConfig.refRealtimeTableName && (
+            <Grid item xs={4}>
+              <strong>Ref Realtime Table:</strong>{' '}
+              <Link 
to={`/tenants/table/${logicalTableConfig.refRealtimeTableName}`} 
className={classes.link}>
+                {logicalTableConfig.refRealtimeTableName}
+              </Link>
+            </Grid>
+          )}
+        </Grid>
+      </div>
+
+      <Grid container spacing={2}>
+        <Grid item xs={6}>
+          <CustomizedTables
+            title="Physical Tables"
+            data={physicalTablesData}
+            showSearchBox={true}
+            inAccordionFormat={true}
+          />
+          <ConfigSection title="Query Config" data={logicalTableConfig.query} 
classes={classes} />
+          <ConfigSection title="Quota Config" data={logicalTableConfig.quota} 
classes={classes} />
+          <ConfigSection title="Time Boundary Config" 
data={logicalTableConfig.timeBoundaryConfig} classes={classes} />
+        </Grid>
+        <Grid item xs={6}>
+          <div className={classes.sqlDiv}>
+            <SimpleAccordion headerTitle="Logical Table Config" 
showSearchBox={false}>
+              <CodeMirror
+                options={jsonoptions}
+                value={configJSON}
+                className={classes.queryOutput}
+                autoCursor={false}
+              />
+            </SimpleAccordion>
+          </div>
+        </Grid>
+      </Grid>
+
+      <EditConfigOp
+        showModal={showEditConfig}
+        hideModal={() => setShowEditConfig(false)}
+        saveConfig={saveConfigAction}
+        config={config}
+        handleConfigChange={handleConfigChange}
+      />
+      {confirmDialog && dialogDetails && (
+        <Confirm
+          openDialog={confirmDialog}
+          dialogTitle={dialogDetails.title}
+          dialogContent={dialogDetails.content}
+          successCallback={dialogDetails.successCb}
+          closeDialog={closeDialog}
+          dialogYesLabel="Yes"
+          dialogNoLabel="No"
+        />
+      )}
+    </Grid>
+  );
+};
+
+export default LogicalTableDetails;
diff --git 
a/pinot-controller/src/main/resources/app/pages/TablesListingPage.tsx 
b/pinot-controller/src/main/resources/app/pages/TablesListingPage.tsx
index 0e586662d10..661bea20e62 100644
--- a/pinot-controller/src/main/resources/app/pages/TablesListingPage.tsx
+++ b/pinot-controller/src/main/resources/app/pages/TablesListingPage.tsx
@@ -26,6 +26,7 @@ import AddOfflineTableOp from 
'../components/Homepage/Operations/AddOfflineTable
 import AddRealtimeTableOp from 
'../components/Homepage/Operations/AddRealtimeTableOp';
 import AsyncPinotTables from '../components/AsyncPinotTables';
 import { AsyncPinotSchemas } from '../components/AsyncPinotSchemas';
+import AsyncLogicalTables from '../components/AsyncLogicalTables';
 
 const useStyles = makeStyles(() => ({
   gridContainer: {
@@ -97,6 +98,7 @@ const TablesListingPage = () => {
         title="Tables"
         baseUrl="/tenants/table/"
       />
+      <AsyncLogicalTables />
       <AsyncPinotSchemas key={`schema-${schemasKey}`} />
       {showSchemaModal && (
         <AddSchemaOp
diff --git a/pinot-controller/src/main/resources/app/requests/index.ts 
b/pinot-controller/src/main/resources/app/requests/index.ts
index 921fa9f9520..d3dae2b8bd1 100644
--- a/pinot-controller/src/main/resources/app/requests/index.ts
+++ b/pinot-controller/src/main/resources/app/requests/index.ts
@@ -233,6 +233,15 @@ export const getQueryTables = (type?: string): 
Promise<AxiosResponse<QueryTables
 export const getLogicalTables = (): Promise<AxiosResponse<string[]>> =>
   baseApi.get(`/logicalTables`);
 
+export const getLogicalTable = (name: string): 
Promise<AxiosResponse<OperationResponse>> =>
+  baseApi.get(`/logicalTables/${name}`);
+
+export const putLogicalTable = (name: string, params: string): 
Promise<AxiosResponse<OperationResponse>> =>
+  baseApi.put(`/logicalTables/${name}`, params, { headers });
+
+export const deleteLogicalTable = (name: string): 
Promise<AxiosResponse<OperationResponse>> =>
+  baseApi.delete(`/logicalTables/${name}`, { headers });
+
 export const getTableSchema = (name: string): 
Promise<AxiosResponse<TableSchema>> =>
   baseApi.get(`/tables/${name}/schema`);
 
diff --git a/pinot-controller/src/main/resources/app/router.tsx 
b/pinot-controller/src/main/resources/app/router.tsx
index 14175c1919a..4ec3814f0f5 100644
--- a/pinot-controller/src/main/resources/app/router.tsx
+++ b/pinot-controller/src/main/resources/app/router.tsx
@@ -36,6 +36,7 @@ import ZookeeperPage from './pages/ZookeeperPage';
 import SchemaPageDetails from './pages/SchemaPageDetails';
 import LoginPage from './pages/LoginPage';
 import UserPage from "./pages/UserPage";
+import LogicalTableDetails from './pages/LogicalTableDetails';
 
 export default [
   // TODO: make async
@@ -48,6 +49,7 @@ export default [
   { path: '/servers', Component: InstanceListingPage },
   { path: '/minions', Component: InstanceListingPage },
   { path: '/tables', Component: TablesListingPage },
+  { path: '/logical-tables/:logicalTableName', Component: LogicalTableDetails 
},
   { path: '/minion-task-manager', Component: MinionTaskManager },
   { path: '/task-queue/:taskType', Component: TaskQueue },
   { path: '/task-queue/:taskType/tables/:queueTableName', Component: 
TaskQueueTable },
diff --git a/pinot-controller/src/main/resources/app/utils/PinotMethodUtils.ts 
b/pinot-controller/src/main/resources/app/utils/PinotMethodUtils.ts
index 3739cecdc31..4fb582c076f 100644
--- a/pinot-controller/src/main/resources/app/utils/PinotMethodUtils.ts
+++ b/pinot-controller/src/main/resources/app/utils/PinotMethodUtils.ts
@@ -118,7 +118,10 @@ import {
   resumeConsumption,
   getPauseStatus,
   getVersions,
-  getLogicalTables
+  getLogicalTables,
+  getLogicalTable,
+  putLogicalTable,
+  deleteLogicalTable
 } from '../requests';
 import { baseApi } from './axios-config';
 import Utils from './Utils';
@@ -1458,6 +1461,33 @@ const getPackageVersionsData = () => {
   });
 };
 
+const getLogicalTablesData = async (columnHeader: string) => {
+  const { data } = await getLogicalTables();
+  return {
+    columns: [columnHeader],
+    records: data.map((name) => [name])
+  };
+};
+
+const getLogicalTablesList = async () => {
+  return getLogicalTablesData('Logical Table Name');
+};
+
+const getLogicalTableConfig = async (tableName: string) => {
+  const { data } = await getLogicalTable(tableName);
+  return data;
+};
+
+const updateLogicalTableConfig = async (tableName: string, config: string) => {
+  const { data } = await putLogicalTable(tableName, config);
+  return data;
+};
+
+const deleteLogicalTableOp = async (tableName: string) => {
+  const { data } = await deleteLogicalTable(tableName);
+  return data;
+};
+
 export default {
   getTenantsData,
   getAllInstances,
@@ -1558,7 +1588,11 @@ export default {
   getPauseStatusData,
   fetchServerToSegmentsCountData,
   getConsumingSegmentsInfoData,
-  getPackageVersionsData
+  getPackageVersionsData,
+  getLogicalTablesList,
+  getLogicalTableConfig,
+  updateLogicalTableConfig,
+  deleteLogicalTableOp
 };
 
 // Named exports for shared constants and utilities


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

Reply via email to