This is an automated email from the ASF dual-hosted git repository.
mchades pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/main by this push:
new e2bf628cf0 [#9537] web(ui): UI support for UDF management (#9923)
e2bf628cf0 is described below
commit e2bf628cf0d158bf9cf9ab572a09975378b7e60f
Author: Qian Xia <[email protected]>
AuthorDate: Wed Feb 11 19:17:48 2026 +0800
[#9537] web(ui): UI support for UDF management (#9923)
### What changes were proposed in this pull request?
<img width="2884" height="2032" alt="9f5da7c9c89ba65916e61af4d3f21aba"
src="https://github.com/user-attachments/assets/02620f4d-77af-4dac-a56f-176895dd5ef9"
/>
<img width="2892" height="1866" alt="0d7058b726f21ef8e359127d3ed3dc9b"
src="https://github.com/user-attachments/assets/f95f36bf-d39c-4883-98b2-2798cdc9b3c2"
/>
### Why are the changes needed?
UI support for UDF management
Fix: #9537
### Does this PR introduce _any_ user-facing change?
N/A
### How was this patch tested?
manually
---
web-v2/web/src/app/catalogs/TreeComponent.js | 16 +-
web-v2/web/src/app/catalogs/page.js | 2 +
.../src/app/catalogs/rightContent/RightContent.js | 38 ++-
.../entitiesContent/FunctionDetailsPage.js | 274 +++++++++++++++++++++
.../rightContent/entitiesContent/Functions.js | 106 ++++++++
.../entitiesContent/SchemaDetailsPage.js | 23 +-
web-v2/web/src/lib/api/functions/index.js | 44 ++++
web-v2/web/src/lib/store/metalakes/index.js | 238 ++++++++++++------
8 files changed, 641 insertions(+), 100 deletions(-)
diff --git a/web-v2/web/src/app/catalogs/TreeComponent.js
b/web-v2/web/src/app/catalogs/TreeComponent.js
index 2a7e0cce48..f89a3e8167 100644
--- a/web-v2/web/src/app/catalogs/TreeComponent.js
+++ b/web-v2/web/src/app/catalogs/TreeComponent.js
@@ -255,7 +255,7 @@ export const TreeComponent = forwardRef(function
TreeComponent(props, ref) {
return (
<span
role='img'
- aria-label='frown'
+ aria-label='schema'
className='anticon anticon-frown'
onMouseEnter={e => onMouseEnter(e, nodeProps.data)}
onMouseLeave={e => onMouseLeave(e, nodeProps.data)}
@@ -270,28 +270,34 @@ export const TreeComponent = forwardRef(function
TreeComponent(props, ref) {
)
case 'table':
return (
- <span role='img' aria-label='frown' className='anticon
anticon-frown'>
+ <span role='img' aria-label='table' className='anticon
anticon-frown'>
<Icons.iconify icon='bx:table' className='my-icon-small' />
</span>
)
case 'fileset':
return (
- <span role='img' aria-label='frown' className='anticon
anticon-frown'>
+ <span role='img' aria-label='fileset' className='anticon
anticon-frown'>
<Icons.iconify icon='bx:file' className='my-icon-small' />
</span>
)
case 'topic':
return (
- <span role='img' aria-label='frown' className='anticon
anticon-frown'>
+ <span role='img' aria-label='topic' className='anticon
anticon-frown'>
<Icons.iconify icon='material-symbols:topic-outline'
className='my-icon-small' />
</span>
)
case 'model':
return (
- <span role='img' aria-label='frown' className='anticon
anticon-frown'>
+ <span role='img' aria-label='model' className='anticon
anticon-frown'>
<Icons.iconify icon='mdi:globe-model' className='my-icon-small' />
</span>
)
+ case 'function':
+ return (
+ <span role='img' aria-label='function' className='anticon
anticon-frown'>
+ <Icons.iconify icon='material-symbols:function'
className='my-icon-small' />
+ </span>
+ )
default:
return <></>
}
diff --git a/web-v2/web/src/app/catalogs/page.js
b/web-v2/web/src/app/catalogs/page.js
index a4738affd2..2356e9a469 100644
--- a/web-v2/web/src/app/catalogs/page.js
+++ b/web-v2/web/src/app/catalogs/page.js
@@ -39,6 +39,7 @@ import {
fetchTopics,
fetchModels,
fetchModelVersions,
+ fetchFunctions,
getMetalakeDetails,
getCatalogDetails,
getSchemaDetails,
@@ -129,6 +130,7 @@ const CatalogsListPage = () => {
await dispatch(fetchCatalogs({ metalake }))
await dispatch(fetchSchemas({ metalake, catalog, catalogType }))
}
+ dispatch(fetchFunctions({ init: true, metalake, catalog, schema }))
switch (catalogType) {
case 'relational':
dispatch(fetchTables({ init: true, page: 'schemas', metalake,
catalog, schema }))
diff --git a/web-v2/web/src/app/catalogs/rightContent/RightContent.js
b/web-v2/web/src/app/catalogs/rightContent/RightContent.js
index 19caf27f78..5b9d881709 100644
--- a/web-v2/web/src/app/catalogs/rightContent/RightContent.js
+++ b/web-v2/web/src/app/catalogs/rightContent/RightContent.js
@@ -57,6 +57,11 @@ const TableDetailsPage = dynamic(() =>
import('./entitiesContent/TableDetailsPag
ssr: false
})
+const FunctionDetailsPage = dynamic(() =>
import('./entitiesContent/FunctionDetailsPage'), {
+ loading: () => <Loading height={'200px'} />,
+ ssr: false
+})
+
const RightContent = () => {
const searchParams = useSearchParams()
@@ -64,18 +69,15 @@ const RightContent = () => {
const catalogType = searchParams.get('catalogType')
const catalog = searchParams.get('catalog')
const schema = searchParams.get('schema')
+ const functionName = searchParams.get('function')
- const entity = searchParams.get(
- catalogType === 'relational'
- ? 'table'
- : catalogType === 'fileset'
- ? 'fileset'
- : catalogType === 'messaging'
- ? 'topic'
- : catalogType === 'model'
- ? 'model'
- : ''
- )
+ const entity =
+ functionName ||
+ searchParams.get('table') ||
+ searchParams.get('fileset') ||
+ searchParams.get('topic') ||
+ searchParams.get('model') ||
+ ''
const paramsSize = [...searchParams.keys()].length
const { ref, width } = useResizeObserver()
@@ -87,6 +89,9 @@ const RightContent = () => {
} else if (paramsSize === 4 && catalog && schema && !entity) {
return <SchemaDetailsPage />
} else {
+ if (functionName) {
+ return <FunctionDetailsPage />
+ }
switch (catalogType) {
case 'relational':
return (
@@ -220,7 +225,16 @@ const RightContent = () => {
...(entity
? [
{
- title: (
+ title: functionName ? (
+ <Link
+
href={`/catalogs?metalake=${currentMetalake}&catalogType=${catalogType}&catalog=${decodeURIComponent(catalog)}&schema=${decodeURIComponent(schema)}`}
+ title={entity}
+ className='inline-block min-w-10 truncate'
+ style={{ maxWidth: `calc((${width}px - 160px)/4)` }}
+ >
+ {decodeURIComponent(entity)}
+ </Link>
+ ) : (
<span
title={entity}
className='inline-block min-w-10 truncate'
diff --git
a/web-v2/web/src/app/catalogs/rightContent/entitiesContent/FunctionDetailsPage.js
b/web-v2/web/src/app/catalogs/rightContent/entitiesContent/FunctionDetailsPage.js
new file mode 100644
index 0000000000..6101684da6
--- /dev/null
+++
b/web-v2/web/src/app/catalogs/rightContent/entitiesContent/FunctionDetailsPage.js
@@ -0,0 +1,274 @@
+/*
+ * 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 { useEffect, useState } from 'react'
+import { Collapse, Descriptions, Space, Spin, Tag, Typography, Flex, Tooltip,
Divider, Tabs } from 'antd'
+import { useSearchParams } from 'next/navigation'
+import { formatToDateTime, isValidDate } from '@/lib/utils/date'
+import { getFunctionDetailsApi } from '@/lib/api/functions'
+import { to } from '@/lib/utils'
+import Icons from '@/components/Icons'
+
+const { Title, Paragraph } = Typography
+
+const formatType = type => {
+ if (!type) return '-'
+ if (typeof type === 'string') return type
+ if (type?.type === 'unparsed') return type.unparsedType
+
+ return JSON.stringify(type)
+}
+
+const renderParameters = parameters => {
+ if (!parameters?.length) return null
+
+ return parameters.map((param, index) => {
+ const comment = typeof param?.comment === 'string' ? param.comment.trim()
: ''
+
+ return (
+ <span key={`${param.name || 'param'}-${index}`}>
+ <span>{param.name || '-'}</span>
+ {comment && (
+ <Tooltip title={comment}>
+ <span className='mx-1 inline-flex cursor-help text-slate-400
align-middle'>
+ <Icons.iconify icon='material-symbols:info-outline' />
+ </span>
+ </Tooltip>
+ )}
+ <span>{`: ${formatType(param.dataType)}`}</span>
+ {index < parameters.length - 1 && <span>, </span>}
+ </span>
+ )
+ })
+}
+
+const formatReturnColumns = returnColumns => {
+ if (!returnColumns?.length) return ''
+
+ return returnColumns.map(col => `${col.name}:
${formatType(col.dataType)}`).join(', ')
+}
+
+const buildSignature = (name, definition) => {
+ const params = renderParameters(definition?.parameters)
+ const returnType = formatType(definition?.returnType)
+ const returnColumns = formatReturnColumns(definition?.returnColumns)
+ const returnText = returnColumns || returnType || '-'
+
+ return (
+ <span className='font-mono'>
+ <span>{`${name || '-'}(`}</span>
+ {params}
+ <span>{`) => ${returnText}`}</span>
+ </span>
+ )
+}
+
+const buildImplDetails = impl => {
+ const details = [
+ { label: 'Language', value: impl?.language || '-' },
+ { label: 'Runtime', value: impl?.runtime || '-' }
+ ]
+
+ if (impl?.sql) {
+ details.push({ label: 'SQL', value: impl.sql })
+ }
+ if (impl?.className) {
+ details.push({ label: 'Class Name', value: impl.className })
+ }
+ if (impl?.handler) {
+ details.push({ label: 'Handler', value: impl.handler })
+ }
+ if (impl?.codeBlock) {
+ details.push({ label: 'Code Block', value: impl.codeBlock })
+ }
+ if (impl?.resources) {
+ const { jars = [], files = [], archives = [] } = impl.resources || {}
+ details.push({ label: 'Resources (Jars)', value: jars.length ?
jars.join(', ') : '-' })
+ details.push({ label: 'Resources (Files)', value: files.length ?
files.join(', ') : '-' })
+ details.push({ label: 'Resources (Archives)', value: archives.length ?
archives.join(', ') : '-' })
+ }
+ if (impl?.properties) {
+ details.push({
+ label: 'Properties',
+ value: Object.keys(impl.properties).length ?
JSON.stringify(impl.properties) : '-'
+ })
+ }
+
+ return details
+}
+
+const FunctionImplTabs = ({ impls }) => {
+ const [activeKey, setActiveKey] = useState(impls[0]?.runtime || 'runtime-0')
+
+ const tabItems = impls.map((impl, implIndex) => {
+ const runtimeKey = impl?.runtime || `runtime-${implIndex}`
+ const tabLabel = impl?.runtime || `Runtime ${implIndex + 1}`
+
+ return {
+ key: runtimeKey,
+ label: tabLabel
+ }
+ })
+
+ const activeImpl = impls.find((impl, index) => {
+ const key = impl?.runtime || `runtime-${index}`
+
+ return key === activeKey
+ })
+ const activeDetails = activeImpl ? buildImplDetails(activeImpl) : []
+
+ return (
+ <div className='flex flex-col gap-2 w-full'>
+ <Tabs size='small' className='w-full' items={tabItems}
activeKey={activeKey} onChange={setActiveKey} />
+ <Descriptions layout='horizontal' column={1} size='small' bordered
style={{ width: '100%' }}>
+ {activeDetails.map(detail => (
+ <Descriptions.Item key={`${activeKey}-${detail.label}`}
label={detail.label}>
+ {detail.value}
+ </Descriptions.Item>
+ ))}
+ </Descriptions>
+ </div>
+ )
+}
+
+export default function FunctionDetailsPage() {
+ const searchParams = useSearchParams()
+ const metalake = searchParams.get('metalake')
+ const catalog = searchParams.get('catalog')
+ const schema = searchParams.get('schema')
+ const functionName = searchParams.get('function')
+ const [loading, setLoading] = useState(false)
+ const [functionData, setFunctionData] = useState(null)
+
+ useEffect(() => {
+ const loadDetails = async () => {
+ if (!metalake || !catalog || !schema || !functionName) return
+ setLoading(true)
+
+ const [err, res] = await to(
+ getFunctionDetailsApi({
+ metalake,
+ catalog,
+ schema,
+ functionName
+ })
+ )
+ setLoading(false)
+ if (err || !res) {
+ setFunctionData(null)
+
+ return
+ }
+ setFunctionData(res.function)
+ }
+
+ loadDetails()
+ }, [metalake, catalog, schema, functionName])
+
+ const definitions = functionData?.definitions || []
+ const createdAt = functionData?.audit?.createTime
+ const createdAtText = !createdAt || !isValidDate(createdAt) ? '-' :
formatToDateTime(createdAt)
+
+ const definitionItems = definitions.map((definition, index) => {
+ const signature = buildSignature(functionData?.name, definition)
+ const impls = definition?.impls || []
+
+ return {
+ key: `definition-${index}`,
+ label: (
+ <div className='flex flex-col gap-1'>
+ <div className='font-mono truncate'>{signature}</div>
+ </div>
+ ),
+ children: (
+ <div className='flex flex-col gap-2 w-full'>
+ {impls.length === 0 ? (
+ <span className='text-slate-400'>No implementations</span>
+ ) : (
+ <FunctionImplTabs impls={impls} />
+ )}
+ </div>
+ )
+ }
+ })
+
+ return (
+ <Spin spinning={loading}>
+ <div className='bg-white min-h-[calc(100vh-24rem)]'>
+ <Space direction='vertical' size='small' style={{ width: '100%' }}>
+ <Flex className='mb-2' gap='small' align='flex-start'>
+ <div className='size-8'>
+ <Icons.iconify icon='material-symbols:function'
className='my-icon-large' />
+ </div>
+ <div className='grow-1 relative bottom-1'>
+ <Title level={3} style={{ marginBottom: '0.125rem' }}>
+ {functionData?.name || '-'}
+ </Title>
+ <Paragraph type='secondary' style={{ marginBottom: 0 }}>
+ {functionData?.comment || '-'}
+ </Paragraph>
+ </div>
+ </Flex>
+ <Space split={<Divider type='vertical' />} wrap={true}
className='mb-2'>
+ <Space size={4}>
+ <Tooltip title='Type'>
+ <Icons.Type className='size-4' color='grey' />
+ </Tooltip>
+ <span>{functionData?.functionType || '-'}</span>
+ </Space>
+ <Space size={4}>
+ <Tooltip title='Deterministic'>
+ <Icons.CircleCheckBig className='size-4' color='grey' />
+ </Tooltip>
+ {typeof functionData?.deterministic === 'boolean' ? (
+ <Tag color={functionData?.deterministic ? 'green' : 'orange'}>
+ {functionData?.deterministic ? 'true' : 'false'}
+ </Tag>
+ ) : (
+ <span>-</span>
+ )}
+ </Space>
+ <Space size={4}>
+ <Tooltip title='Creator'>
+ <Icons.User className='size-4' color='grey' />
+ </Tooltip>
+ <span>{functionData?.audit?.creator || '-'}</span>
+ </Space>
+ <Space size={4}>
+ <Tooltip title='Created At'>
+ <Icons.Clock className='size-4' color='grey' />
+ </Tooltip>
+ <span>{createdAtText}</span>
+ </Space>
+ </Space>
+ <div className='max-h-[60vh] overflow-auto'>
+ <div className='mb-2 font-medium'>Definitions</div>
+ {definitions.length === 0 ? (
+ <span className='text-slate-400'>No definitions</span>
+ ) : (
+ <Collapse items={definitionItems} defaultActiveKey={[]} />
+ )}
+ </div>
+ </Space>
+ </div>
+ </Spin>
+ )
+}
diff --git
a/web-v2/web/src/app/catalogs/rightContent/entitiesContent/Functions.js
b/web-v2/web/src/app/catalogs/rightContent/entitiesContent/Functions.js
new file mode 100644
index 0000000000..af8280ca06
--- /dev/null
+++ b/web-v2/web/src/app/catalogs/rightContent/entitiesContent/Functions.js
@@ -0,0 +1,106 @@
+/*
+ * 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 { useEffect, useMemo } from 'react'
+import { Spin, Table } from 'antd'
+import { useAntdColumnResize } from 'react-antd-column-resize'
+import { useAppDispatch, useAppSelector } from '@/lib/hooks/useStore'
+import { fetchFunctions } from '@/lib/store/metalakes'
+import { formatToDateTime, isValidDate } from '@/lib/utils/date'
+import Link from 'next/link'
+import { useSearchParams } from 'next/navigation'
+
+export default function Functions({ metalake, catalog, schema }) {
+ const dispatch = useAppDispatch()
+ const store = useAppSelector(state => state.metalakes)
+ const searchParams = useSearchParams()
+ const catalogType = searchParams.get('catalogType')
+
+ useEffect(() => {
+ if (!metalake || !catalog || !schema) return
+ dispatch(fetchFunctions({ metalake, catalog, schema, init: true }))
+ }, [dispatch, metalake, catalog, schema])
+
+ const columns = useMemo(
+ () => [
+ {
+ title: 'Name',
+ dataIndex: 'name',
+ key: 'name',
+ width: 240,
+ ellipsis: true,
+ sorter: (a, b) =>
+ (a?.name ?? '')
+ .toString()
+ .toLowerCase()
+ .localeCompare((b?.name ?? '').toString().toLowerCase()),
+ render: name => (
+ <Link
+ data-refer={`function-link-${name}`}
+
href={`/catalogs?metalake=${encodeURIComponent(metalake)}&catalogType=${catalogType}&catalog=${encodeURIComponent(catalog)}&schema=${encodeURIComponent(schema)}&function=${encodeURIComponent(name)}`}
+ >
+ {name}
+ </Link>
+ )
+ },
+ {
+ title: 'Creator',
+ dataIndex: ['audit', 'creator'],
+ key: 'creator',
+ width: 180,
+ render: (_, record) => record?.audit?.creator || '-'
+ },
+ {
+ title: 'Created At',
+ dataIndex: ['audit', 'createTime'],
+ key: 'createdAt',
+ width: 200,
+ render: (_, record) => {
+ const createTime = record?.audit?.createTime
+ if (!createTime || !isValidDate(createTime)) return '-'
+
+ return formatToDateTime(createTime)
+ }
+ }
+ ],
+ []
+ )
+
+ const { resizableColumns, components, tableWidth } = useAntdColumnResize(()
=> {
+ return { columns, minWidth: 100 }
+ }, [columns])
+
+ return (
+ <Spin spinning={store.tableLoading}>
+ <Table
+ data-refer='functions-table'
+ size='small'
+ style={{ maxHeight: 'calc(100vh - 30rem)' }}
+ scroll={{ x: tableWidth, y: 'calc(100vh - 37rem)' }}
+ dataSource={store.functions}
+ pagination={{ position: ['bottomCenter'], showSizeChanger: true }}
+ columns={resizableColumns}
+ components={components}
+ rowKey={record => record?.name}
+ />
+ </Spin>
+ )
+}
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 aa69ee9184..aad47d5d25 100644
---
a/web-v2/web/src/app/catalogs/rightContent/entitiesContent/SchemaDetailsPage.js
+++
b/web-v2/web/src/app/catalogs/rightContent/entitiesContent/SchemaDetailsPage.js
@@ -52,6 +52,7 @@ import CreateFilesetDialog from '../CreateFilesetDialog'
import RegisterModelDialog from '../RegisterModelDialog'
import CreateTopicDialog from '../CreateTopicDialog'
import CreateTableDialog from '../CreateTableDialog'
+import Functions from './Functions'
const SetOwnerDialog = dynamic(() => import('@/components/SetOwnerDialog'), {
loading: () => <Loading />,
@@ -125,9 +126,13 @@ export default function SchemaDetailsPage() {
anthEnable
? [
{ label: 'Tables', key: 'Tables' },
+ { label: 'Functions', key: 'Functions' },
{ label: 'Associated roles', key: 'Associated roles' }
]
- : [{ label: 'Tables', key: 'Tables' }]
+ : [
+ { label: 'Tables', key: 'Tables' },
+ { label: 'Functions', key: 'Functions' }
+ ]
)
setTabKey('Tables')
setCreateBtn('Create Table')
@@ -519,7 +524,15 @@ export default function SchemaDetailsPage() {
</Space>
</Spin>
<Tabs data-refer='details-tabs' defaultActiveKey={tabKey}
onChange={onChangeTab} items={tabOptions} />
- {tabKey !== 'Associated roles' ? (
+ {tabKey === 'Associated roles' ? (
+ <AssociatedTable
+ metalake={currentMetalake}
+ metadataObjectType={'schema'}
+ metadataObjectFullName={`${catalog}.${store.activatedDetails.name}`}
+ />
+ ) : tabKey === 'Functions' ? (
+ <Functions metalake={currentMetalake} catalog={catalog}
schema={schema} />
+ ) : (
<>
<Flex justify='flex-end' className='mb-4'>
<div
@@ -609,12 +622,6 @@ export default function SchemaDetailsPage() {
/>
)}
</>
- ) : (
- <AssociatedTable
- metalake={currentMetalake}
- metadataObjectType={'schema'}
- metadataObjectFullName={`${catalog}.${store.activatedDetails.name}`}
- />
)}
{openSchema && (
<CreateSchemaDialog
diff --git a/web-v2/web/src/lib/api/functions/index.js
b/web-v2/web/src/lib/api/functions/index.js
new file mode 100644
index 0000000000..1aa29b4129
--- /dev/null
+++ b/web-v2/web/src/lib/api/functions/index.js
@@ -0,0 +1,44 @@
+/*
+ * 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 { defHttp } from '@/lib/utils/axios'
+
+const Apis = {
+ GET: ({ metalake, catalog, schema }) =>
+
`/api/metalakes/${encodeURIComponent(metalake)}/catalogs/${encodeURIComponent(
+ catalog
+ )}/schemas/${encodeURIComponent(schema)}/functions`,
+ GET_DETAIL: ({ metalake, catalog, schema, functionName }) =>
+
`/api/metalakes/${encodeURIComponent(metalake)}/catalogs/${encodeURIComponent(
+ catalog
+
)}/schemas/${encodeURIComponent(schema)}/functions/${encodeURIComponent(functionName)}`
+}
+
+export const getFunctionsApi = ({ metalake, catalog, schema, details = true })
=> {
+ return defHttp.get({
+ url: `${Apis.GET({ metalake, catalog, schema })}`,
+ params: { details }
+ })
+}
+
+export const getFunctionDetailsApi = ({ metalake, catalog, schema,
functionName }) => {
+ return defHttp.get({
+ url: `${Apis.GET_DETAIL({ metalake, catalog, schema, functionName })}`
+ })
+}
diff --git a/web-v2/web/src/lib/store/metalakes/index.js
b/web-v2/web/src/lib/store/metalakes/index.js
index 37f64f3326..a6d068baab 100644
--- a/web-v2/web/src/lib/store/metalakes/index.js
+++ b/web-v2/web/src/lib/store/metalakes/index.js
@@ -77,6 +77,7 @@ import {
updateVersionApi,
deleteVersionApi
} from '@/lib/api/models'
+import { getFunctionsApi } from '@/lib/api/functions'
export const fetchMetalakes = createAsyncThunk('appMetalakes/fetchMetalakes',
async (params, { getState }) => {
const [err, res] = await to(getMetalakesApi())
@@ -247,92 +248,147 @@ export const setIntoTreeNodeWithFetch = createAsyncThunk(
const loaded = loadedNodes.filter(key => !reloadedKeys.includes(key))
dispatch(setLoadedNodes(loaded))
}
- } else if (pathArr.length === 4 && type === 'relational') {
- const [err, res] = await to(getTablesApi({ metalake, catalog, schema }))
-
- if (err || !res) {
- throw new Error(err)
+ } else if (pathArr.length === 4) {
+ let entityPromise = Promise.resolve(null)
+ switch (type) {
+ case 'relational':
+ entityPromise = getTablesApi({ metalake, catalog, schema })
+ break
+ case 'fileset':
+ entityPromise = getFilesetsApi({ metalake, catalog, schema })
+ break
+ case 'messaging':
+ entityPromise = getTopicsApi({ metalake, catalog, schema })
+ break
+ case 'model':
+ entityPromise = getModelsApi({ metalake, catalog, schema })
+ break
+ default:
+ break
}
- const { identifiers = [] } = res || {}
-
- result.data = identifiers.map(tableItem => {
- return {
- ...tableItem,
- node: 'table',
- id:
`{{${metalake}}}{{${catalog}}}{{${type}}}{{${schema}}}{{${tableItem.name}}}`,
- key:
`{{${metalake}}}{{${catalog}}}{{${type}}}{{${schema}}}{{${tableItem.name}}}`,
- path: `?${new URLSearchParams({ metalake, catalog, catalogType:
type, schema, table: tableItem.name }).toString()}`,
- name: tableItem.name,
- title: tableItem.name,
- isLeaf: true,
- columns: [],
- children: []
- }
- })
- } else if (pathArr.length === 4 && type === 'fileset') {
- const [err, res] = await to(getFilesetsApi({ metalake, catalog, schema
}))
-
- if (err || !res) {
- throw new Error(err)
- }
+ const [funcResult, entityResult] = await Promise.allSettled([
+ getFunctionsApi({ metalake, catalog, schema, details: false }),
+ entityPromise
+ ])
- const { identifiers = [] } = res || {}
+ const functions =
+ funcResult.status === 'fulfilled'
+ ? (funcResult.value?.identifiers || []).map(functionItem => {
+ const functionName = functionItem?.name ||
functionItem?.identifier?.name || functionItem
- result.data = identifiers.map(filesetItem => {
- return {
- ...filesetItem,
- node: 'fileset',
- id:
`{{${metalake}}}{{${catalog}}}{{${type}}}{{${schema}}}{{${filesetItem.name}}}`,
- key:
`{{${metalake}}}{{${catalog}}}{{${type}}}{{${schema}}}{{${filesetItem.name}}}`,
- path: `?${new URLSearchParams({ metalake, catalog, catalogType:
type, schema, fileset: filesetItem.name }).toString()}`,
- name: filesetItem.name,
- title: filesetItem.name,
- isLeaf: true
- }
- })
- } else if (pathArr.length === 4 && type === 'messaging') {
- const [err, res] = await to(getTopicsApi({ metalake, catalog, schema }))
+ return {
+ ...functionItem,
+ node: 'function',
+ id:
`{{${metalake}}}{{${catalog}}}{{${type}}}{{${schema}}}{{${functionName}}}`,
+ key:
`{{${metalake}}}{{${catalog}}}{{${type}}}{{${schema}}}{{${functionName}}}`,
+ path: `?${new URLSearchParams({ metalake, catalog,
catalogType: type, schema, function: functionName }).toString()}`,
+ name: functionName,
+ title: functionName,
+ isLeaf: true,
+ children: []
+ }
+ })
+ : []
- if (err || !res) {
- throw new Error(err)
+ if (funcResult.status === 'rejected') {
+ console.warn('Failed to load functions for schema tree node', {
+ metalake,
+ catalog,
+ schema,
+ error: funcResult.reason
+ })
}
- const { identifiers = [] } = res || {}
-
- result.data = identifiers.map(topicItem => {
- return {
- ...topicItem,
- node: 'topic',
- id:
`{{${metalake}}}{{${catalog}}}{{${type}}}{{${schema}}}{{${topicItem.name}}}`,
- key:
`{{${metalake}}}{{${catalog}}}{{${type}}}{{${schema}}}{{${topicItem.name}}}`,
- path: `?${new URLSearchParams({ metalake, catalog, catalogType:
type, schema, topic: topicItem.name }).toString()}`,
- name: topicItem.name,
- title: topicItem.name,
- isLeaf: true
+ let entities = []
+ if (entityResult.status === 'fulfilled' && entityResult.value) {
+ switch (type) {
+ case 'relational': {
+ const { identifiers: tableIdentifiers = [] } = entityResult.value
|| {}
+ entities = tableIdentifiers.map(tableItem => {
+ return {
+ ...tableItem,
+ node: 'table',
+ id:
`{{${metalake}}}{{${catalog}}}{{${type}}}{{${schema}}}{{${tableItem.name}}}`,
+ key:
`{{${metalake}}}{{${catalog}}}{{${type}}}{{${schema}}}{{${tableItem.name}}}`,
+ path: `?${new URLSearchParams({ metalake, catalog,
catalogType: type, schema, table: tableItem.name }).toString()}`,
+ name: tableItem.name,
+ title: tableItem.name,
+ isLeaf: true,
+ columns: [],
+ children: []
+ }
+ })
+ break
+ }
+ case 'fileset': {
+ const { identifiers: filesetIdentifiers = [] } =
entityResult.value || {}
+ entities = filesetIdentifiers.map(filesetItem => {
+ return {
+ ...filesetItem,
+ node: 'fileset',
+ id:
`{{${metalake}}}{{${catalog}}}{{${type}}}{{${schema}}}{{${filesetItem.name}}}`,
+ key:
`{{${metalake}}}{{${catalog}}}{{${type}}}{{${schema}}}{{${filesetItem.name}}}`,
+ path: `?${new URLSearchParams({ metalake, catalog,
catalogType: type, schema, fileset: filesetItem.name }).toString()}`,
+ name: filesetItem.name,
+ title: filesetItem.name,
+ isLeaf: true
+ }
+ })
+ break
+ }
+ case 'messaging': {
+ const { identifiers: topicIdentifiers = [] } = entityResult.value
|| {}
+ entities = topicIdentifiers.map(topicItem => {
+ return {
+ ...topicItem,
+ node: 'topic',
+ id:
`{{${metalake}}}{{${catalog}}}{{${type}}}{{${schema}}}{{${topicItem.name}}}`,
+ key:
`{{${metalake}}}{{${catalog}}}{{${type}}}{{${schema}}}{{${topicItem.name}}}`,
+ path: `?${new URLSearchParams({ metalake, catalog,
catalogType: type, schema, topic: topicItem.name }).toString()}`,
+ name: topicItem.name,
+ title: topicItem.name,
+ isLeaf: true
+ }
+ })
+ break
+ }
+ case 'model': {
+ const { identifiers: modelIdentifiers = [] } = entityResult.value
|| {}
+ entities = modelIdentifiers.map(modelItem => {
+ return {
+ ...modelItem,
+ node: 'model',
+ id:
`{{${metalake}}}{{${catalog}}}{{${type}}}{{${schema}}}{{${modelItem.name}}}`,
+ key:
`{{${metalake}}}{{${catalog}}}{{${type}}}{{${schema}}}{{${modelItem.name}}}`,
+ path: `?${new URLSearchParams({ metalake, catalog,
catalogType: type, schema, model: modelItem.name }).toString()}`,
+ name: modelItem.name,
+ title: modelItem.name,
+ isLeaf: true
+ }
+ })
+ break
+ }
+ default:
+ break
}
- })
- } else if (pathArr.length === 4 && type === 'model') {
- const [err, res] = await to(getModelsApi({ metalake, catalog, schema }))
-
- if (err || !res) {
- throw new Error(err)
+ } else if (entityResult.status === 'rejected') {
+ console.warn('Failed to load entities for schema tree node', {
+ metalake,
+ catalog,
+ schema,
+ type,
+ error: entityResult.reason
+ })
}
- const { identifiers = [] } = res || {}
+ if (funcResult.status === 'rejected' && entityResult.status ===
'rejected') {
+ const funcMessage = funcResult.reason?.message || funcResult.reason
+ const entityMessage = entityResult.reason?.message ||
entityResult.reason
+ throw new Error(funcMessage || entityMessage)
+ }
- result.data = identifiers.map(modelItem => {
- return {
- ...modelItem,
- node: 'model',
- id:
`{{${metalake}}}{{${catalog}}}{{${type}}}{{${schema}}}{{${modelItem.name}}}`,
- key:
`{{${metalake}}}{{${catalog}}}{{${type}}}{{${schema}}}{{${modelItem.name}}}`,
- path: `?${new URLSearchParams({ metalake, catalog, catalogType:
type, schema, model: modelItem.name }).toString()}`,
- name: modelItem.name,
- title: modelItem.name,
- isLeaf: true
- }
- })
+ result.data = [...entities, ...functions]
}
return result
@@ -1613,6 +1669,28 @@ export const deleteVersion = createAsyncThunk(
}
)
+export const fetchFunctions = createAsyncThunk(
+ 'appMetalakes/fetchFunctions',
+ async ({ init, metalake, catalog, schema }, { dispatch }) => {
+ const [err, res] = await to(getFunctionsApi({ metalake, catalog, schema,
details: true }))
+
+ if (init && (err || !res)) {
+ throw new Error(err)
+ }
+
+ const { functions = [], identifiers = [] } = res || {}
+
+ const normalized = (functions.length ? functions : identifiers).map(item
=> {
+ return {
+ ...item,
+ name: item?.name || item?.identifier?.name || item?.identifier || ''
+ }
+ })
+
+ return { functions: normalized, init }
+ }
+)
+
export const appMetalakesSlice = createSlice({
name: 'appMetalakes',
initialState: {
@@ -1623,6 +1701,7 @@ export const appMetalakesSlice = createSlice({
catalogs: [],
schemas: [],
tables: [],
+ functions: [],
columns: [],
filesets: [],
topics: [],
@@ -1686,6 +1765,7 @@ export const appMetalakesSlice = createSlice({
state.catalogs = []
state.schemas = []
state.tables = []
+ state.functions = []
state.columns = []
state.filesets = []
state.topics = []
@@ -2103,6 +2183,14 @@ export const appMetalakesSlice = createSlice({
toast.error(action.error.message)
}
})
+ builder.addCase(fetchFunctions.fulfilled, (state, action) => {
+ state.functions = action.payload.functions
+ })
+ builder.addCase(fetchFunctions.rejected, (state, action) => {
+ if (!action.error.message.includes('CanceledError')) {
+ toast.error(action.error.message)
+ }
+ })
builder.addCase(getVersionDetails.rejected, (state, action) => {
if (!action.error.message.includes('CanceledError')) {
toast.error(action.error.message)