This is an automated email from the ASF dual-hosted git repository.
mchades pushed a commit to branch branch-1.3
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/branch-1.3 by this push:
new afb99db8b3 [Cherry-pick to branch-1.3] [#11245] web-v2(Improvement):
UI supports hierarchical schemasupport (#11257) (#11437)
afb99db8b3 is described below
commit afb99db8b3f4c6be415a4811251a0566f9f59f4f
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Fri Jun 5 09:50:41 2026 +0800
[Cherry-pick to branch-1.3] [#11245] web-v2(Improvement): UI supports
hierarchical schemasupport (#11257) (#11437)
**Cherry-pick Information:**
- Original commit: 578355911b8af551c160a21558c43425ffe410aa
- Target branch: `branch-1.3`
- Status: ✅ Clean cherry-pick (no conflicts)
Co-authored-by: Qian Xia <[email protected]>
---
.../catalogs/rightContent/CreateSchemaDialog.js | 21 +-
.../entitiesContent/CatalogDetailsPage.js | 122 ++---------
.../entitiesContent/SchemaDetailsPage.js | 240 ++++++++++++++++++---
.../entitiesContent/SharedSchemaColumns.js | 133 ++++++++++++
web-v2/web/src/lib/api/schemas/index.js | 5 +-
web-v2/web/src/lib/store/metalakes/index.js | 87 ++++++--
6 files changed, 447 insertions(+), 161 deletions(-)
diff --git a/web-v2/web/src/app/catalogs/rightContent/CreateSchemaDialog.js
b/web-v2/web/src/app/catalogs/rightContent/CreateSchemaDialog.js
index 31f7065f2c..2cfa8b3b47 100644
--- a/web-v2/web/src/app/catalogs/rightContent/CreateSchemaDialog.js
+++ b/web-v2/web/src/app/catalogs/rightContent/CreateSchemaDialog.js
@@ -32,7 +32,7 @@ import { nameRegex } from '@/lib/utils/regex'
import { useResetFormOnCloseModal } from '@/lib/hooks/use-reset'
import { genUpdates } from '@/lib/utils'
import { cn } from '@/lib/utils/tailwind'
-import { useAppDispatch } from '@/lib/hooks/useStore'
+import { useAppDispatch, useAppSelector } from '@/lib/hooks/useStore'
import { createSchema, updateSchema, getSchemaDetails } from
'@/lib/store/metalakes'
const { Paragraph } = Typography
@@ -71,6 +71,14 @@ export default function CreateSchemaDialog({ ...props }) {
const dispatch = useAppDispatch()
const paimonCatalogBackend = provider === 'lakehouse-paimon' && ['hive',
'jdbc'].includes(catalogBackend)
+ const isIcebergJdbcCatalog = provider === 'lakehouse-iceberg' &&
catalogBackend === 'jdbc'
+
+ const auth = useAppSelector(state => state.auth)
+ const { systemConfig } = auth || {}
+ const separator = (systemConfig &&
systemConfig['gravitino.schema.separator']) || ':'
+ const escapeForRegex = s => s.replace(/[-\\/\^$*+?.()|[\]{}]/g, '\\$&')
+ const escSep = escapeForRegex(separator)
+ const dynamicSchemaNameRegex = new
RegExp(`^\\w(?!.*${escSep}${escSep})(?!.*${escSep}$)[\\w\\/${escSep}=-]{0,63}$`)
const [form] = Form.useForm()
const values = Form.useWatch([], form)
@@ -226,8 +234,17 @@ export default function CreateSchemaDialog({ ...props }) {
<Form.Item
name='name'
label='Schema Name'
+ extra={
+ isIcebergJdbcCatalog
+ ? `For nested schemas, separate each level with
'${separator}' (for example: a${separator}b${separator}c).`
+ : undefined
+ }
data-refer='schema-name-field'
- rules={[{ required: true }, { type: 'string', max: 64 }, {
pattern: new RegExp(nameRegex) }]}
+ rules={[
+ { required: true },
+ { type: 'string', max: 64 },
+ { pattern: isIcebergJdbcCatalog ? dynamicSchemaNameRegex :
new RegExp(nameRegex) }
+ ]}
messageVariables={{ label: 'schema name' }}
>
<Input placeholder={mismatchName} disabled={editSchema} />
diff --git
a/web-v2/web/src/app/catalogs/rightContent/entitiesContent/CatalogDetailsPage.js
b/web-v2/web/src/app/catalogs/rightContent/entitiesContent/CatalogDetailsPage.js
index 4314c6e73b..afbd387513 100644
---
a/web-v2/web/src/app/catalogs/rightContent/entitiesContent/CatalogDetailsPage.js
+++
b/web-v2/web/src/app/catalogs/rightContent/entitiesContent/CatalogDetailsPage.js
@@ -55,6 +55,7 @@ import { useSearchParams } from 'next/navigation'
import { deleteSchema, getCurrentEntityOwner } from '@/lib/store/metalakes'
import Loading from '@/components/Loading'
import CreateSchemaDialog from '../CreateSchemaDialog'
+import buildSchemaColumns from './SharedSchemaColumns'
const CreateCatalogDialog = dynamic(() => import('../CreateCatalogDialog'), {
loading: () => <Loading />,
@@ -245,113 +246,21 @@ export default function CatalogDetailsPage() {
})
}
- const columns = useMemo(
- () => [
- {
- title: 'Schema Name',
- dataIndex: 'name',
- key: 'name',
- ellipsis: true,
- sorter: (a, b) =>
a.name.toLowerCase().localeCompare(b.name.toLowerCase()),
- width: 200,
- render: name => (
- <Link
- data-refer={`schema-link-${name}`}
-
href={`/catalogs?metalake=${encodeURIComponent(currentMetalake)}&catalogType=${catalogType}&catalog=${encodeURIComponent(catalog)}&schema=${encodeURIComponent(name)}`}
- >
- {name}
- </Link>
- )
- },
- {
- title: 'Tags',
- dataIndex: 'tags',
- key: 'tags',
- ellipsis: true,
- render: (_, record) =>
- record?.node === 'schema' ? (
- <Tags
- metadataObjectType={'schema'}
-
metadataObjectFullName={`${record.namespace.at(-1)}.${record.name}`}
- key={`schema-${record.namespace.at(-1)}.${record.name}-tags`}
- />
- ) : null
- },
- {
- title: 'Policies',
- dataIndex: 'policies',
- key: 'policies',
- ellipsis: true,
- render: (_, record) =>
- record?.node === 'schema' ? (
- <Policies
- metadataObjectType={'schema'}
-
metadataObjectFullName={`${record.namespace.at(-1)}.${record.name}`}
- key={`schema-${record.namespace.at(-1)}.${record.name}-policies`}
- />
- ) : null
- },
- ...(store.activatedDetails?.provider !== 'kafka'
- ? [
- {
- title: 'Actions',
- dataIndex: 'action',
- key: 'action',
- width: 100,
- render: (_, record) => {
- const NameContext = createContext(record.name)
+ const columns = useMemo(() => {
+ // wrap showDeleteConfirm to accept name only for shared builder
+ const showDeleteForShared = name => showDeleteConfirm(createContext(name),
name, 'schema')
- return (
- <div className='flex gap-2'>
- <NameContext.Provider
value={record.name}>{contextHolder}</NameContext.Provider>
- <a>
- <Tooltip title='Edit'>
- <Icons.Pencil className='size-4' onClick={() =>
handleEditSchema(record.name)} />
- </Tooltip>
- </a>
- <a>
- <Tooltip title='Delete'>
- <Icons.Trash2Icon
- className='size-4'
- onClick={() => showDeleteConfirm(NameContext,
record.name, 'schema')}
- />
- </Tooltip>
- </a>
- {anthEnable && (
- <Dropdown
- menu={{
- items: [
- {
- label: 'Set Owner',
- key: 'setOwner'
- }
- ],
- onClick: ({ key }) => {
- switch (key) {
- case 'setOwner':
- handleSetOwner('schema',
`${catalog}.${record.name}`)
- break
- }
- }
- }}
- trigger={['hover']}
- >
- <Tooltip title='Settings'>
- <a onClick={e => e.preventDefault()}>
- <Icons.Settings className='size-4' />
- </a>
- </Tooltip>
- </Dropdown>
- )}
- </div>
- )
- }
- }
- ]
- : [])
- ],
- [catalogType, anthEnable]
- )
+ return buildSchemaColumns({
+ currentMetalake,
+ catalog,
+ catalogType,
+ anthEnable,
+ provider: store.activatedDetails?.provider,
+ handleEditSchema: name => handleEditSchema(name),
+ showDeleteConfirm: name => showDeleteForShared(name),
+ handleSetOwner
+ })
+ }, [currentMetalake, catalog, catalogType, anthEnable,
store.activatedDetails?.provider])
const { resizableColumns, components, tableWidth } = useAntdColumnResize(()
=> {
return { columns, minWidth: 100 }
@@ -359,6 +268,7 @@ export default function CatalogDetailsPage() {
return (
<div ref={ref}>
+ {contextHolder}
<Spin spinning={store.activatedDetailsLoading}>
<Flex className='mb-2' gap='small' align='flex-start'>
<div className='size-8'>{renderIcon(store.activatedDetails)}</div>
diff --git
a/web-v2/web/src/app/catalogs/rightContent/entitiesContent/SchemaDetailsPage.js
b/web-v2/web/src/app/catalogs/rightContent/entitiesContent/SchemaDetailsPage.js
index cc73dc6bee..ab897be47c 100644
---
a/web-v2/web/src/app/catalogs/rightContent/entitiesContent/SchemaDetailsPage.js
+++
b/web-v2/web/src/app/catalogs/rightContent/entitiesContent/SchemaDetailsPage.js
@@ -49,8 +49,11 @@ import Icons from '@/components/Icons'
import Policies from '@/components/PolicyTag'
import TableActions from '@/components/TableActions'
import PropertiesContent from '@/components/PropertiesContent'
+import buildSchemaColumns from './SharedSchemaColumns'
import {
getCatalogDetails,
+ fetchSchemas,
+ deleteSchema,
deleteFileset,
deleteModel,
deleteTopic,
@@ -61,6 +64,7 @@ import {
} from '@/lib/store/metalakes'
import Link from 'next/link'
import { cn } from '@/lib/utils/tailwind'
+import { to } from '@/lib/utils'
import Loading from '@/components/Loading'
import CreateSchemaDialog from '../CreateSchemaDialog'
import CreateFilesetDialog from '../CreateFilesetDialog'
@@ -79,6 +83,8 @@ const { Search } = Input
export default function SchemaDetailsPage() {
const [openSchema, setOpenSchema] = useState(false)
+ const [editSchemaName, setEditSchemaName] = useState('')
+ const [editSchemaInit, setEditSchemaInit] = useState(true)
const [openTable, setOpenTable] = useState(false)
const [openFileset, setOpenFileset] = useState(false)
const [openTopic, setOpenTopic] = useState(false)
@@ -105,10 +111,12 @@ export default function SchemaDetailsPage() {
const [entityType, setEntityType] = useState('')
const [tabKey, setTabKey] = useState('')
const [nameCol, setNameCol] = useState('')
+ const [subSchemas, setSubSchemas] = useState([])
const { ref, width } = useResizeObserver()
const treeRef = useContext(TreeRefContext)
const [catalogData, setCatalogData] = useState(null)
const [ownerData, setOwnerData] = useState(null)
+ const [modal, contextHolder] = Modal.useModal()
useEffect(() => {
if (!currentMetalake || !catalog) return
@@ -135,9 +143,62 @@ export default function SchemaDetailsPage() {
}, [anthEnable])
useEffect(() => {
+ if (!currentMetalake || !catalog || !schema) {
+ setSubSchemas([])
+
+ return
+ }
+
+ const provider = catalogData?.provider
+ const catalogBackend = catalogData?.properties?.['catalog-backend']
+ const isIcebergJdbcCatalog = provider === 'lakehouse-iceberg' &&
catalogBackend === 'jdbc'
+
+ if (!isIcebergJdbcCatalog) {
+ // only iceberg jdbc catalogs support subschemas
+ setSubSchemas([])
+
+ return
+ }
+
+ const loadSubSchemas = async () => {
+ const [err, res] = await to(
+ dispatch(fetchSchemas({ metalake: currentMetalake, catalog,
catalogType, parentSchema: schema }))
+ )
+ if (err || !res) {
+ setSubSchemas([])
+
+ return
+ }
+
+ const { schemas = [] } = res?.payload || {}
+
+ const nextSubSchemas = schemas.map(item => {
+ return {
+ ...item,
+ name: item.name,
+ key: item.name,
+ title: item.name
+ }
+ })
+
+ setSubSchemas(nextSubSchemas)
+ }
+
+ loadSubSchemas()
+ }, [currentMetalake, catalog, schema, catalogData?.provider,
catalogData?.properties?.['catalog-backend']])
+
+ useEffect(() => {
+ const hasSubSchemas = subSchemas.length > 0
+ const withSubSchemas = tabs => (hasSubSchemas ? [{ label: 'Subschemas',
key: 'Subschemas' }, ...tabs] : tabs)
+
+ let nextTabOptions = []
+ let nextCreateBtn = ''
+ let nextNameCol = ''
+ let nextEntityType = ''
+
switch (catalogType) {
case 'relational':
- setTabOptions(
+ nextTabOptions = withSubSchemas(
anthEnable
? [
{ label: 'Tables', key: 'Tables' },
@@ -151,13 +212,12 @@ export default function SchemaDetailsPage() {
{ label: 'Functions', key: 'Functions' }
]
)
- setTabKey('Tables')
- setCreateBtn('Create Table')
- setNameCol('Table Name')
- setEntityType('table')
+ nextCreateBtn = 'Create Table'
+ nextNameCol = 'Table Name'
+ nextEntityType = 'table'
break
case 'messaging':
- setTabOptions(
+ nextTabOptions = withSubSchemas(
anthEnable
? [
{ label: 'Topics', key: 'Topics' },
@@ -169,13 +229,12 @@ export default function SchemaDetailsPage() {
{ label: 'Functions', key: 'Functions' }
]
)
- setTabKey('Topics')
- setCreateBtn('Create Topic')
- setNameCol('Topic Name')
- setEntityType('topic')
+ nextCreateBtn = 'Create Topic'
+ nextNameCol = 'Topic Name'
+ nextEntityType = 'topic'
break
case 'fileset':
- setTabOptions(
+ nextTabOptions = withSubSchemas(
anthEnable
? [
{ label: 'Filesets', key: 'Filesets' },
@@ -187,13 +246,12 @@ export default function SchemaDetailsPage() {
{ label: 'Functions', key: 'Functions' }
]
)
- setTabKey('Filesets')
- setCreateBtn('Create Fileset')
- setNameCol('Fileset Name')
- setEntityType('fileset')
+ nextCreateBtn = 'Create Fileset'
+ nextNameCol = 'Fileset Name'
+ nextEntityType = 'fileset'
break
case 'model':
- setTabOptions(
+ nextTabOptions = withSubSchemas(
anthEnable
? [
{ label: 'Models', key: 'Models' },
@@ -205,22 +263,73 @@ export default function SchemaDetailsPage() {
{ label: 'Functions', key: 'Functions' }
]
)
- setTabKey('Models')
- setCreateBtn('Register Model')
- setNameCol('Model Name')
- setEntityType('model')
+ nextCreateBtn = 'Register Model'
+ nextNameCol = 'Model Name'
+ nextEntityType = 'model'
break
default:
- setTabOptions([])
- setCreateBtn('')
- setEntityType('')
+ nextTabOptions = []
+ nextCreateBtn = ''
+ nextEntityType = ''
+ }
+
+ setTabOptions(nextTabOptions)
+ setCreateBtn(nextCreateBtn)
+ setNameCol(nextNameCol)
+ setEntityType(nextEntityType)
+ }, [catalogType, anthEnable, subSchemas])
+
+ useEffect(() => {
+ const hasSubSchemas = subSchemas.length > 0
+ if (hasSubSchemas) {
+ setTabKey('Subschemas')
+ } else {
+ switch (catalogType) {
+ case 'relational':
+ setTabKey('Tables')
+ break
+ case 'messaging':
+ setTabKey('Topics')
+ break
+ case 'fileset':
+ setTabKey('Filesets')
+ break
+ case 'model':
+ setTabKey('Models')
+ break
+ default:
+ setTabKey('')
+ }
}
- }, [catalogType, anthEnable])
+ }, [subSchemas, catalogType])
const onChangeTab = key => {
setTabKey(key)
}
+ const showDeleteSchemaConfirm = name => {
+ modal.confirm({
+ title: `Are you sure to delete the schema ${name}?`,
+ icon: <ExclamationCircleFilled />,
+ okText: 'Delete',
+ okType: 'danger',
+ cancelText: 'Cancel',
+ onOk: async () => {
+ await dispatch(deleteSchema({ metalake: currentMetalake, catalog,
catalogType, schema: name }))
+
+ const [err, res] = await to(
+ dispatch(fetchSchemas({ metalake: currentMetalake, catalog,
catalogType, parentSchema: schema }))
+ )
+ if (!err && res) {
+ const { schemas = [] } = res.payload || {}
+ const nextSubSchemas = schemas.map(item => ({ ...item, name:
item.name, key: item.name, title: item.name }))
+ setSubSchemas(nextSubSchemas)
+ }
+ treeRef.current.onLoadData({ key: catalog, nodeType: 'catalog', inUse:
'true' }, true)
+ }
+ })
+ }
+
const tableData = [...store.tableData]
.filter(c => {
if (search === '') return true
@@ -247,6 +356,18 @@ export default function SchemaDetailsPage() {
children: undefined
}))
+ const subSchemaData = [...subSchemas]
+ .filter(item => {
+ if (search === '') return true
+
+ return item.name.includes(search)
+ })
+ .map(item => ({
+ ...item,
+ key: item.name,
+ children: undefined
+ }))
+
const tagContent = (
<div>
<Tags readOnly={true} metadataObjectType={'schema'}
metadataObjectFullName={`${catalog}.${schema}`} />
@@ -290,6 +411,8 @@ export default function SchemaDetailsPage() {
}
const handleEditSchema = () => {
+ setEditSchemaName(schema)
+ setEditSchemaInit(true)
setOpenSchema(true)
}
@@ -334,7 +457,20 @@ export default function SchemaDetailsPage() {
setOpenOwner(true)
}
- const showDeleteConfirm = async (modal, entityObj, type) => {
+ const showDeleteConfirm = async (maybeModalOrEntityObj, maybeEntityObj,
maybeType) => {
+ // support two call signatures:
+ // 1) showDeleteConfirm(modalInstance, entityObj, type)
+ // 2) showDeleteConfirm(entityObj, type)
+ let modalToUse = modal
+ let entityObj = maybeModalOrEntityObj
+ let type = maybeEntityObj
+
+ if (maybeModalOrEntityObj && typeof maybeModalOrEntityObj.confirm ===
'function') {
+ modalToUse = maybeModalOrEntityObj
+ entityObj = maybeEntityObj
+ type = maybeType
+ }
+
const { name: entity, storageLocation, type: managedOrExtenalType } =
entityObj
let isManaged = false
let location = ''
@@ -360,7 +496,7 @@ export default function SchemaDetailsPage() {
validateFn = fn
}
- modal.confirm({
+ modalToUse.confirm({
title: `Are you sure to delete the ${type} ${entity}?`,
icon: <ExclamationCircleFilled />,
content: (
@@ -500,7 +636,7 @@ export default function SchemaDetailsPage() {
render: (_, record) => (
<a data-refer={`delete-view-${record.name}`}>
<Tooltip title='Delete'>
- <Icons.Trash2Icon className='size-4' onClick={() =>
showDeleteConfirm(Modal, record, 'view')} />
+ <Icons.Trash2Icon className='size-4' onClick={() =>
showDeleteConfirm(record, 'view')} />
</Tooltip>
</a>
)
@@ -509,6 +645,23 @@ export default function SchemaDetailsPage() {
[currentMetalake, catalogType, catalog, schema, catalogData?.provider]
)
+ const subSchemaColumns = useMemo(() => {
+ return buildSchemaColumns({
+ currentMetalake,
+ catalog,
+ catalogType,
+ anthEnable,
+ provider: catalogData?.provider,
+ handleEditSchema: name => {
+ setEditSchemaName(name)
+ setEditSchemaInit(false)
+ setOpenSchema(true)
+ },
+ showDeleteConfirm: name => showDeleteSchemaConfirm(name),
+ handleSetOwner
+ })
+ }, [currentMetalake, catalog, catalogType, anthEnable,
catalogData?.provider])
+
const { resizableColumns, components, tableWidth } = useAntdColumnResize(()
=> {
return { columns, minWidth: 100 }
}, [columns])
@@ -521,8 +674,17 @@ export default function SchemaDetailsPage() {
return { columns: viewColumns, minWidth: 100 }
}, [viewColumns])
+ const {
+ resizableColumns: subSchemaResizableColumns,
+ components: subSchemaComponents,
+ tableWidth: subSchemaTableWidth
+ } = useAntdColumnResize(() => {
+ return { columns: subSchemaColumns, minWidth: 100 }
+ }, [subSchemaColumns])
+
return (
<div ref={ref}>
+ {contextHolder}
<Spin spinning={store.activatedDetailsLoading}>
<Flex className='mb-2' gap='small' align='flex-start'>
<div className='size-8'>
@@ -611,13 +773,31 @@ export default function SchemaDetailsPage() {
)}
</Space>
</Spin>
- <Tabs data-refer='details-tabs' defaultActiveKey={tabKey}
onChange={onChangeTab} items={tabOptions} />
+ <Tabs data-refer='details-tabs' activeKey={tabKey}
onChange={onChangeTab} items={tabOptions} />
{tabKey === 'Associated Roles' ? (
<AssociatedTable
metalake={currentMetalake}
metadataObjectType={'schema'}
metadataObjectFullName={`${catalog}.${schema}`}
/>
+ ) : tabKey === 'Subschemas' ? (
+ <>
+ <Flex justify='flex-end' className='mb-4'>
+ <div className='flex w-1/3 gap-4'>
+ <Search name='searchSubSchemaInput' placeholder='Search...'
value={search} onChange={onSearchTable} />
+ </div>
+ </Flex>
+ <Table
+ data-refer='subschema-list-grid'
+ size='small'
+ style={{ maxHeight: 'calc(100vh - 30rem)' }}
+ scroll={{ x: subSchemaTableWidth, y: 'calc(100vh - 37rem)' }}
+ dataSource={subSchemaData}
+ pagination={{ position: ['bottomCenter'], showSizeChanger: true }}
+ columns={subSchemaResizableColumns}
+ components={subSchemaComponents}
+ />
+ </>
) : tabKey === 'Functions' ? (
<Functions metalake={currentMetalake} catalog={catalog}
schema={schema} />
) : tabKey === 'Views' ? (
@@ -738,8 +918,8 @@ export default function SchemaDetailsPage() {
catalogType={catalogType}
provider={catalogData?.provider}
locationProviders={catalogData?.properties?.['filesystem-providers']?.split(',')
|| []}
- editSchema={schema}
- init={true}
+ editSchema={editSchemaName || schema}
+ init={editSchemaInit}
/>
)}
{openOwner && (
diff --git
a/web-v2/web/src/app/catalogs/rightContent/entitiesContent/SharedSchemaColumns.js
b/web-v2/web/src/app/catalogs/rightContent/entitiesContent/SharedSchemaColumns.js
new file mode 100644
index 0000000000..f8e29cfe11
--- /dev/null
+++
b/web-v2/web/src/app/catalogs/rightContent/entitiesContent/SharedSchemaColumns.js
@@ -0,0 +1,133 @@
+/*
+ * 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.
+ */
+'use client'
+
+import React from 'react'
+import Link from 'next/link'
+import { Tooltip, Dropdown } from 'antd'
+import Tags from '@/components/CustomTags'
+import Policies from '@/components/PolicyTag'
+import Icons from '@/components/Icons'
+
+export function buildSchemaColumns({
+ currentMetalake,
+ catalog,
+ catalogType,
+ anthEnable,
+ provider,
+ handleEditSchema,
+ showDeleteConfirm,
+ handleSetOwner
+}) {
+ return [
+ {
+ title: 'Schema Name',
+ dataIndex: 'name',
+ key: 'name',
+ ellipsis: true,
+ sorter: (a, b) =>
a.name.toLowerCase().localeCompare(b.name.toLowerCase()),
+ width: 200,
+ render: name => (
+ <Link
+ data-refer={`schema-link-${name}`}
+
href={`/catalogs?metalake=${encodeURIComponent(currentMetalake)}&catalogType=${catalogType}&catalog=${encodeURIComponent(catalog)}&schema=${encodeURIComponent(name)}`}
+ >
+ {name}
+ </Link>
+ )
+ },
+ {
+ title: 'Tags',
+ dataIndex: 'tags',
+ key: 'tags',
+ ellipsis: true,
+ render: (_, record) =>
+ record?.node === 'schema' ? (
+ <Tags metadataObjectType={'schema'}
metadataObjectFullName={`${record.namespace?.at(-1)}.${record.name}`} />
+ ) : null
+ },
+ {
+ title: 'Policies',
+ dataIndex: 'policies',
+ key: 'policies',
+ ellipsis: true,
+ render: (_, record) =>
+ record?.node === 'schema' ? (
+ <Policies
+ metadataObjectType={'schema'}
+
metadataObjectFullName={`${record.namespace?.at(-1)}.${record.name}`}
+ />
+ ) : null
+ },
+ ...(provider !== 'kafka'
+ ? [
+ {
+ title: 'Actions',
+ dataIndex: 'action',
+ key: 'action',
+ width: 120,
+ render: (_, record) => {
+ return (
+ <div className='flex gap-2'>
+ <a>
+ <Tooltip title='Edit'>
+ <Icons.Pencil className='size-4' onClick={() =>
handleEditSchema(record.name)} />
+ </Tooltip>
+ </a>
+ <a>
+ <Tooltip title='Delete'>
+ <Icons.Trash2Icon className='size-4' onClick={() =>
showDeleteConfirm(record.name)} />
+ </Tooltip>
+ </a>
+ {anthEnable && (
+ <Dropdown
+ menu={{
+ items: [
+ {
+ label: 'Set Owner',
+ key: 'setOwner'
+ }
+ ],
+ onClick: ({ key }) => {
+ switch (key) {
+ case 'setOwner':
+ handleSetOwner('schema',
`${catalog}.${record.name}`)
+ break
+ }
+ }
+ }}
+ trigger={['hover']}
+ >
+ <Tooltip title='Settings'>
+ <a onClick={e => e.preventDefault()}>
+ <Icons.Settings className='size-4' />
+ </a>
+ </Tooltip>
+ </Dropdown>
+ )}
+ </div>
+ )
+ }
+ }
+ ]
+ : [])
+ ]
+}
+
+export default buildSchemaColumns
diff --git a/web-v2/web/src/lib/api/schemas/index.js
b/web-v2/web/src/lib/api/schemas/index.js
index 0848c1e545..79a5f6016c 100644
--- a/web-v2/web/src/lib/api/schemas/index.js
+++ b/web-v2/web/src/lib/api/schemas/index.js
@@ -34,9 +34,10 @@ const Apis = {
`/api/metalakes/${encodeURIComponent(metalake)}/catalogs/${encodeURIComponent(catalog)}/schemas/${encodeURIComponent(schema)}`
}
-export const getSchemasApi = params => {
+export const getSchemasApi = ({ metalake, catalog, parentSchema }) => {
return defHttp.get({
- url: `${Apis.GET(params)}`
+ url: `${Apis.GET({ metalake, catalog })}`,
+ params: parentSchema ? { parentSchema } : undefined
})
}
diff --git a/web-v2/web/src/lib/store/metalakes/index.js
b/web-v2/web/src/lib/store/metalakes/index.js
index 0654be50a7..59cc872fd7 100644
--- a/web-v2/web/src/lib/store/metalakes/index.js
+++ b/web-v2/web/src/lib/store/metalakes/index.js
@@ -92,18 +92,20 @@ const remapExpandedAndLoadedNodes = ({ getState, mapNode })
=> {
const mergeWithFunctionNodes = ({ tree, key, entities }) => {
const existingNode = findInTree(tree, 'key', key)
+ const subSchemas = existingNode?.children?.filter(child => child?.node ===
'schema') || []
const functions = existingNode?.children?.filter(child => child?.node ===
'function') || []
const views = existingNode?.children?.filter(child => child?.node ===
'view') || []
- return _.uniqBy([...entities, ...functions, ...views], 'key')
+ return _.uniqBy([...subSchemas, ...entities, ...functions, ...views], 'key')
}
const mergeWithViewNodes = ({ tree, key, entities }) => {
const existingNode = findInTree(tree, 'key', key)
+ const subSchemas = existingNode?.children?.filter(child => child?.node ===
'schema') || []
const tables = existingNode?.children?.filter(child => child?.node ===
'table') || []
const functions = existingNode?.children?.filter(child => child?.node ===
'function') || []
- return _.uniqBy([...tables, ...functions, ...entities], 'key')
+ return _.uniqBy([...subSchemas, ...tables, ...functions, ...entities], 'key')
}
export const fetchMetalakes = createAsyncThunk('appMetalakes/fetchMetalakes',
async (params, { getState }) => {
@@ -276,6 +278,19 @@ export const setIntoTreeNodeWithFetch = createAsyncThunk(
dispatch(setLoadedNodes(loaded))
}
} else if (pathArr.length === 4) {
+ // Only fetch subschemas for iceberg catalog with jdbc backend
+ const catalogNode = findInTree(
+ getState().metalakes.metalakeTree,
+ 'key',
+ `{{${metalake}}}{{${catalog}}}{{${type}}}`
+ )
+ const provider = catalogNode?.provider || catalogNode?.catalogType ||
null
+ const catalogBackend = catalogNode?.properties?.['catalog-backend']
+ const isIcebergJdbcCatalog = provider === 'lakehouse-iceberg' &&
catalogBackend === 'jdbc'
+
+ const childSchemasPromise = isIcebergJdbcCatalog
+ ? getSchemasApi({ metalake, catalog, parentSchema: schema })
+ : Promise.resolve(null)
let entityPromise = Promise.resolve(null)
switch (type) {
case 'relational':
@@ -299,12 +314,32 @@ export const setIntoTreeNodeWithFetch = createAsyncThunk(
? getViewsApi({ metalake, catalog, schema }, { errorMessageMode:
'none' })
: Promise.resolve(null)
- const [funcResult, entityResult, viewResult] = await Promise.allSettled([
+ const [funcResult, entityResult, viewResult, childSchemasResult] = await
Promise.allSettled([
getFunctionsApi({ metalake, catalog, schema, details: false }),
entityPromise,
- viewsPromise
+ viewsPromise,
+ childSchemasPromise
])
+ const childSchemas =
+ childSchemasResult.status === 'fulfilled' && childSchemasResult.value
+ ? (childSchemasResult.value?.identifiers || []).map(schemaItem => {
+ const schemaName = schemaItem.name
+
+ return {
+ ...schemaItem,
+ node: 'schema',
+ id:
`{{${metalake}}}{{${catalog}}}{{${type}}}{{${schemaName}}}`,
+ key:
`{{${metalake}}}{{${catalog}}}{{${type}}}{{${schemaName}}}`,
+ path: `?${new URLSearchParams({ metalake, catalog,
catalogType: type, schema: schemaName }).toString()}`,
+ name: schemaName,
+ title: schemaName,
+ tables: [],
+ children: []
+ }
+ })
+ : []
+
const functions =
funcResult.status === 'fulfilled'
? (funcResult.value?.identifiers || []).map(functionItem => {
@@ -415,7 +450,7 @@ export const setIntoTreeNodeWithFetch = createAsyncThunk(
}
}
- result.data = [...entities, ...functions, ...views]
+ result.data = [...childSchemas, ...entities, ...functions, ...views]
}
return result
@@ -830,12 +865,12 @@ export const switchInUseCatalog = createAsyncThunk(
export const fetchSchemas = createAsyncThunk(
'appMetalakes/fetchSchemas',
- async ({ init, page, metalake, catalog, catalogType }, { getState, dispatch
}) => {
+ async ({ init, page, metalake, catalog, catalogType, parentSchema }, {
getState, dispatch }) => {
if (init) {
await dispatch(resetTableData())
await dispatch(setTableLoading(true))
}
- const [err, res] = await to(getSchemasApi({ metalake, catalog }))
+ const [err, res] = await to(getSchemasApi({ metalake, catalog,
parentSchema }))
await dispatch(setTableLoading(false))
if (err || !res) {
@@ -847,11 +882,17 @@ export const fetchSchemas = createAsyncThunk(
if (isCanceledRequest) {
const catalogKey = `{{${metalake}}}{{${catalog}}}{{${catalogType}}}`
- const catalogNode = findInTree(getState().metalakes.metalakeTree,
'key', catalogKey)
- const cachedSchemas = (catalogNode?.children || []).filter(item =>
item?.node === 'schema')
+
+ const parentKey = parentSchema
+ ?
`{{${metalake}}}{{${catalog}}}{{${catalogType}}}{{${parentSchema}}}`
+ : catalogKey
+ const parentNode = findInTree(getState().metalakes.metalakeTree,
'key', parentKey)
+ const cachedSchemas = (parentNode?.children || []).filter(item =>
item?.node === 'schema')
if (cachedSchemas.length > 0) {
- dispatch(setExpandedNodes([`{{${metalake}}}`, catalogKey]))
+ const expanded =
+ parentKey === catalogKey ? [`{{${metalake}}}`, catalogKey] :
[`{{${metalake}}}`, catalogKey, parentKey]
+ dispatch(setExpandedNodes(expanded))
return { schemas: cachedSchemas, page, init }
}
@@ -863,39 +904,43 @@ export const fetchSchemas = createAsyncThunk(
const { identifiers = [] } = res || {}
const schemas = identifiers.map(schema => {
+ const schemaName = schema.name
+
const schemaItem = findInTree(
getState().metalakes.metalakeTree,
'key',
- `{{${metalake}}}{{${catalog}}}{{${catalogType}}}{{${schema.name}}}`
+ `{{${metalake}}}{{${catalog}}}{{${catalogType}}}{{${schemaName}}}`
)
return {
...schema,
+ name: schemaName,
node: 'schema',
- id:
`{{${metalake}}}{{${catalog}}}{{${catalogType}}}{{${schema.name}}}`,
- key:
`{{${metalake}}}{{${catalog}}}{{${catalogType}}}{{${schema.name}}}`,
- path: `?${new URLSearchParams({ metalake, catalog, catalogType,
schema: schema.name }).toString()}`,
- name: schema.name,
- title: schema.name,
+ id: `{{${metalake}}}{{${catalog}}}{{${catalogType}}}{{${schemaName}}}`,
+ key:
`{{${metalake}}}{{${catalog}}}{{${catalogType}}}{{${schemaName}}}`,
+ path: `?${new URLSearchParams({ metalake, catalog, catalogType,
schema: schemaName }).toString()}`,
+ title: schemaName,
tables: schemaItem ? schemaItem.children : [],
children: schemaItem ? schemaItem.children : []
}
})
if (init) {
- const catalogKey = `{{${metalake}}}{{${catalog}}}{{${catalogType}}}`
+ const parentKey = parentSchema
+ ? `{{${metalake}}}{{${catalog}}}{{${catalogType}}}{{${parentSchema}}}`
+ : `{{${metalake}}}{{${catalog}}}{{${catalogType}}}`
// Always update tree nodes when init is true
dispatch(
setIntoTreeNodes({
- key: catalogKey,
+ key: parentKey,
data: schemas
})
)
- // Add catalog to loadedNodes if not already present
- if (!getState().metalakes.loadedNodes.includes(catalogKey)) {
- dispatch(setLoadedNodes([...getState().metalakes.loadedNodes,
catalogKey]))
+ // Add parent node to loadedNodes if not already present
+ if (!getState().metalakes.loadedNodes.includes(parentKey)) {
+ dispatch(setLoadedNodes([...getState().metalakes.loadedNodes,
parentKey]))
}
}