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)


Reply via email to