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]