This is an automated email from the ASF dual-hosted git repository.
jshao 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 cfc55ae053 [#7477] feat(web): Implement FilesetView component for file
browser interface (#7846)
cfc55ae053 is described below
commit cfc55ae0536e4cdfd36033ac1c68077d053ff347
Author: Kyle Lin <[email protected]>
AuthorDate: Thu Aug 14 11:38:16 2025 +0800
[#7477] feat(web): Implement FilesetView component for file browser
interface (#7846)
### What changes were proposed in this pull request?
Implements the `FilesetView.js` component to provide a file browser
interface for viewing and navigating fileset in the Gravitino Web UI.
### Why are the changes needed?
- Fixes #7477
- Parent: #6860
### Does this PR introduce any user-facing change?
add the UI to view files and directories within filesets.
<img width="1012" height="532" alt="7477-1"
src="https://github.com/user-attachments/assets/48b21c89-861a-45ca-9f18-38ece4fb3243"
/>
User can use navigation for easy path traversal.
<img width="1013" height="531" alt="7477-2"
src="https://github.com/user-attachments/assets/cae899c1-181b-4fd2-aaab-3270f2d31221"
/>
### How was this patch tested?
- `./gradlew build`
- User test
---
.../integration/test/web/ui/CatalogsPageTest.java | 4 +-
.../test/web/ui/pages/CatalogsPage.java | 16 ++
.../rightContent/tabsContent/TabsContent.js | 15 +-
.../tabsContent/filesetView/FilesetView.js | 200 +++++++++++++++++++++
web/web/src/lib/api/filesets/index.js | 16 ++
web/web/src/lib/store/metalakes/index.js | 37 ++++
6 files changed, 286 insertions(+), 2 deletions(-)
diff --git
a/web/integration-test/src/test/java/org/apache/gravitino/integration/test/web/ui/CatalogsPageTest.java
b/web/integration-test/src/test/java/org/apache/gravitino/integration/test/web/ui/CatalogsPageTest.java
index bf9a996c49..9f7e4df89c 100644
---
a/web/integration-test/src/test/java/org/apache/gravitino/integration/test/web/ui/CatalogsPageTest.java
+++
b/web/integration-test/src/test/java/org/apache/gravitino/integration/test/web/ui/CatalogsPageTest.java
@@ -667,7 +667,9 @@ public class CatalogsPageTest extends BaseWebIT {
SCHEMA_NAME_FILESET,
FILESET_NAME);
catalogsPage.clickTreeNode(filesetNode);
- // 5. verify show tab details
+ // 5. verify Files tab is shown by default, then switch to Details and
verify details content
+ Assertions.assertTrue(catalogsPage.verifyShowFilesContent());
+ clickAndWait(catalogsPage.tabDetailsBtn);
Assertions.assertTrue(catalogsPage.verifyShowDetailsContent());
Assertions.assertTrue(
catalogsPage.verifyShowPropertiesItemInList(
diff --git
a/web/integration-test/src/test/java/org/apache/gravitino/integration/test/web/ui/pages/CatalogsPage.java
b/web/integration-test/src/test/java/org/apache/gravitino/integration/test/web/ui/pages/CatalogsPage.java
index 0c004f4a6a..5ac01e1e7a 100644
---
a/web/integration-test/src/test/java/org/apache/gravitino/integration/test/web/ui/pages/CatalogsPage.java
+++
b/web/integration-test/src/test/java/org/apache/gravitino/integration/test/web/ui/pages/CatalogsPage.java
@@ -134,6 +134,12 @@ public class CatalogsPage extends BaseWebIT {
@FindBy(xpath = "//div[@data-refer='tab-details-panel']")
public WebElement tabDetailsContent;
+ @FindBy(xpath = "//button[@data-refer='tab-files']")
+ public WebElement tabFilesBtn;
+
+ @FindBy(xpath = "//div[@data-refer='tab-files-panel']")
+ public WebElement tabFilesContent;
+
@FindBy(xpath = "//div[@data-refer='details-drawer']")
public WebElement detailsDrawer;
@@ -574,6 +580,16 @@ public class CatalogsPage extends BaseWebIT {
}
}
+ public boolean verifyShowFilesContent() {
+ try {
+ String files = tabFilesContent.getAttribute("hidden");
+ return Objects.equals(files, null);
+ } catch (Exception e) {
+ LOG.error(e.getMessage(), e);
+ return false;
+ }
+ }
+
public boolean verifyShowCatalogDetails(String name, String
hiveMetastoreUris)
throws InterruptedException {
try {
diff --git
a/web/web/src/app/metalakes/metalake/rightContent/tabsContent/TabsContent.js
b/web/web/src/app/metalakes/metalake/rightContent/tabsContent/TabsContent.js
index 55f2db690f..36f478aeda 100644
--- a/web/web/src/app/metalakes/metalake/rightContent/tabsContent/TabsContent.js
+++ b/web/web/src/app/metalakes/metalake/rightContent/tabsContent/TabsContent.js
@@ -34,6 +34,7 @@ import { useAppSelector } from '@/lib/hooks/useStore'
import { useSearchParams } from 'next/navigation'
import TableView from './tableView/TableView'
import DetailsView from './detailsView/DetailsView'
+import FilesetView from './filesetView/FilesetView'
import Icon from '@/components/Icon'
@@ -89,6 +90,7 @@ const TabsContent = () => {
const isNotNeedTableTab =
(type && ['fileset', 'messaging'].includes(type) && paramsSize === 5) ||
(paramsSize === 6 && searchParams.get('version'))
+ const isFilesetFilesView = type === 'fileset' && paramsSize === 5
const isShowTableProps = paramsSize === 5 && !['fileset',
'messaging'].includes(type)
const handleChangeTab = (event, newValue) => {
@@ -133,7 +135,9 @@ const TabsContent = () => {
}
useEffect(() => {
- if (isNotNeedTableTab) {
+ if (isFilesetFilesView) {
+ setTab('files')
+ } else if (isNotNeedTableTab) {
setTab('details')
} else {
setTab('table')
@@ -159,6 +163,9 @@ const TabsContent = () => {
{!isNotNeedTableTab ? (
<CustomTab icon='mdi:list-box-outline' label={tableTitle}
value='table' data-refer='tab-table' />
) : null}
+ {isFilesetFilesView && (
+ <CustomTab icon='mdi:folder-multiple' label='Files' value='files'
data-refer='tab-files' />
+ )}
<CustomTab icon='mdi:clipboard-text-outline' label='Details'
value='details' data-refer='tab-details' />
</TabList>
{isShowTableProps && (
@@ -309,6 +316,12 @@ const TabsContent = () => {
</CustomTabPanel>
) : null}
+ {isFilesetFilesView && (
+ <CustomTabPanel value='files' data-refer='tab-files-panel'>
+ <FilesetView />
+ </CustomTabPanel>
+ )}
+
<CustomTabPanel value='details' data-refer='tab-details-panel'>
<DetailsView />
</CustomTabPanel>
diff --git
a/web/web/src/app/metalakes/metalake/rightContent/tabsContent/filesetView/FilesetView.js
b/web/web/src/app/metalakes/metalake/rightContent/tabsContent/filesetView/FilesetView.js
new file mode 100644
index 0000000000..a16dc1f272
--- /dev/null
+++
b/web/web/src/app/metalakes/metalake/rightContent/tabsContent/filesetView/FilesetView.js
@@ -0,0 +1,200 @@
+/*
+ * 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 { useState, useEffect } from 'react'
+import { useSearchParams } from 'next/navigation'
+import { useAppDispatch, useAppSelector } from '@/lib/hooks/useStore'
+import { getFilesetFiles } from '@/lib/store/metalakes'
+
+import {
+ Box,
+ Typography,
+ Breadcrumbs,
+ Link,
+ Paper,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ IconButton,
+ Tooltip,
+ Chip
+} from '@mui/material'
+import Icon from '@/components/Icon'
+
+const FilesetView = () => {
+ const dispatch = useAppDispatch()
+ const searchParams = useSearchParams()
+ const store = useAppSelector(state => state.metalakes)
+
+ const metalake = searchParams.get('metalake')
+ const catalog = searchParams.get('catalog')
+ const schema = searchParams.get('schema')
+ const fileset = searchParams.get('fileset')
+
+ const [currentPath, setCurrentPath] = useState('/')
+ const [pathHistory, setPathHistory] = useState(['/'])
+
+ useEffect(() => {
+ if (metalake && catalog && schema && fileset) {
+ dispatch(
+ getFilesetFiles({
+ metalake,
+ catalog,
+ schema,
+ fileset,
+ subPath: currentPath
+ })
+ )
+ }
+ }, [dispatch, metalake, catalog, schema, fileset, currentPath])
+
+ const handlePathClick = path => {
+ setCurrentPath(path)
+ if (!pathHistory.includes(path)) {
+ setPathHistory([...pathHistory, path])
+ }
+ }
+
+ const handleFolderClick = file => {
+ if (file.isDir) {
+ const newPath = currentPath === '/' ? `/${file.name}` :
`${currentPath}/${file.name}`
+ handlePathClick(newPath)
+ }
+ }
+
+ const handleBackClick = () => {
+ if (pathHistory.length > 1) {
+ const newHistory = pathHistory.slice(0, -1)
+ const newPath = newHistory[newHistory.length - 1]
+ setPathHistory(newHistory)
+ setCurrentPath(newPath)
+ }
+ }
+
+ const formatFileSize = size => {
+ if (size === 0) return '0 B'
+ const k = 1024
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
+ const i = Math.floor(Math.log(size) / Math.log(k))
+
+ return parseFloat((size / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
+ }
+
+ const formatDate = timestamp => {
+ return new Date(timestamp).toLocaleString()
+ }
+
+ const renderBreadcrumbs = () => {
+ const pathParts = currentPath.split('/').filter(part => part !== '')
+ const breadcrumbItems = ['/']
+
+ pathParts.forEach((part, index) => {
+ const path = '/' + pathParts.slice(0, index + 1).join('/')
+ breadcrumbItems.push(path)
+ })
+
+ return (
+ <Breadcrumbs aria-label='file path'>
+ {breadcrumbItems.map((path, index) => (
+ <Link
+ key={path}
+ color='inherit'
+ href='#'
+ onClick={e => {
+ e.preventDefault()
+ handlePathClick(path)
+ }}
+ sx={{ cursor: 'pointer' }}
+ >
+ {index === 0 ? 'Root' : path.split('/').pop()}
+ </Link>
+ ))}
+ </Breadcrumbs>
+ )
+ }
+
+ return (
+ <Box sx={{ p: 3, height: '100%', overflow: 'auto' }}>
+ <Box sx={{ mb: 2, display: 'flex', gap: 1, alignItems: 'center' }}>
+ <Tooltip title='Go Back'>
+ <IconButton onClick={handleBackClick} disabled={pathHistory.length
<= 1}>
+ <Icon icon='mdi:arrow-left' />
+ </IconButton>
+ </Tooltip>
+ {renderBreadcrumbs()}
+ </Box>
+
+ <TableContainer component={Paper}>
+ <Table>
+ <TableHead>
+ <TableRow>
+ <TableCell>Name</TableCell>
+ <TableCell>Type</TableCell>
+ <TableCell>Size</TableCell>
+ <TableCell>Last Modified</TableCell>
+ </TableRow>
+ </TableHead>
+ <TableBody>
+ {store.filesetFiles && store.filesetFiles.length > 0 ? (
+ store.filesetFiles.map((file, index) => (
+ <TableRow
+ key={index}
+ hover
+ sx={{ cursor: file.isDir ? 'pointer' : 'default' }}
+ onClick={() => handleFolderClick(file)}
+ >
+ <TableCell>
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 1
}}>
+ <Icon icon={file.isDir ? 'mdi:folder' : 'mdi:file'}
color='#666' />
+ <Typography>{file.name}</Typography>
+ </Box>
+ </TableCell>
+ <TableCell>
+ <Chip
+ label={file.isDir ? 'Directory' : 'File'}
+ size='small'
+ color={file.isDir ? 'primary' : 'default'}
+ />
+ </TableCell>
+ <TableCell>{file.isDir ? '-' : formatFileSize(file.size ||
0)}</TableCell>
+ <TableCell>{file.lastModified ?
formatDate(file.lastModified) : '-'}</TableCell>
+ </TableRow>
+ ))
+ ) : (
+ <TableRow>
+ <TableCell colSpan={4} align='center'>
+ <Typography variant='body2' color='text.secondary'>
+ No files found in this directory
+ </Typography>
+ </TableCell>
+ </TableRow>
+ )}
+ </TableBody>
+ </Table>
+ </TableContainer>
+ </Box>
+ )
+}
+
+export default FilesetView
diff --git a/web/web/src/lib/api/filesets/index.js
b/web/web/src/lib/api/filesets/index.js
index bae492a11d..43039736b9 100644
--- a/web/web/src/lib/api/filesets/index.js
+++ b/web/web/src/lib/api/filesets/index.js
@@ -28,6 +28,16 @@ const Apis = {
`/api/metalakes/${encodeURIComponent(metalake)}/catalogs/${encodeURIComponent(
catalog
)}/schemas/${encodeURIComponent(schema)}/filesets/${encodeURIComponent(fileset)}`,
+ LIST_FILES: ({ metalake, catalog, schema, fileset, subPath, locationName })
=> {
+ const params = new URLSearchParams()
+ if (subPath) params.append('sub_path', subPath)
+ if (locationName) params.append('location_name', locationName)
+ const queryString = params.toString()
+
+ return
`/api/metalakes/${encodeURIComponent(metalake)}/catalogs/${encodeURIComponent(
+ catalog
+
)}/schemas/${encodeURIComponent(schema)}/filesets/${encodeURIComponent(fileset)}/files${queryString
? `?${queryString}` : ''}`
+ },
CREATE: ({ metalake, catalog, schema }) =>
`/api/metalakes/${encodeURIComponent(metalake)}/catalogs/${encodeURIComponent(catalog)}/schemas/${encodeURIComponent(schema)}/filesets`,
UPDATE: ({ metalake, catalog, schema, fileset }) =>
@@ -48,6 +58,12 @@ export const getFilesetDetailsApi = ({ metalake, catalog,
schema, fileset }) =>
})
}
+export const listFilesetFilesApi = ({ metalake, catalog, schema, fileset,
subPath = '/', locationName }) => {
+ return defHttp.get({
+ url: `${Apis.LIST_FILES({ metalake, catalog, schema, fileset, subPath,
locationName })}`
+ })
+}
+
export const createFilesetApi = ({ metalake, catalog, schema, data }) => {
return defHttp.post({ url: `${Apis.CREATE({ metalake, catalog, schema })}`,
data })
}
diff --git a/web/web/src/lib/store/metalakes/index.js
b/web/web/src/lib/store/metalakes/index.js
index 2862ca96d5..a0c5cf3ddb 100644
--- a/web/web/src/lib/store/metalakes/index.js
+++ b/web/web/src/lib/store/metalakes/index.js
@@ -50,6 +50,7 @@ import { getTablesApi, getTableDetailsApi, createTableApi,
updateTableApi, delet
import {
getFilesetsApi,
getFilesetDetailsApi,
+ listFilesetFilesApi,
createFilesetApi,
updateFilesetApi,
deleteFilesetApi
@@ -1047,6 +1048,32 @@ export const deleteFileset = createAsyncThunk(
}
)
+export const getFilesetFiles = createAsyncThunk(
+ 'appMetalakes/getFilesetFiles',
+ async ({ metalake, catalog, schema, fileset, subPath = '/', locationName },
{ getState, dispatch }) => {
+ dispatch(setTableLoading(true))
+ const [err, res] = await to(listFilesetFilesApi({ metalake, catalog,
schema, fileset, subPath, locationName }))
+ dispatch(setTableLoading(false))
+
+ if (err || !res) {
+ dispatch(resetTableData())
+ throw new Error(err)
+ }
+
+ const { files = [] } = res
+
+ dispatch(
+ setExpandedNodes([
+ `{{${metalake}}}`,
+ `{{${metalake}}}{{${catalog}}}{{${'fileset'}}}`,
+ `{{${metalake}}}{{${catalog}}}{{${'fileset'}}}{{${schema}}}`
+ ])
+ )
+
+ return { files, subPath, locationName }
+ }
+)
+
export const fetchTopics = createAsyncThunk(
'appMetalakes/fetchTopics',
async ({ init, page, metalake, catalog, schema }, { getState, dispatch }) =>
{
@@ -1465,6 +1492,7 @@ export const appMetalakesSlice = createSlice({
tables: [],
columns: [],
filesets: [],
+ filesetFiles: [],
topics: [],
models: [],
versions: [],
@@ -1749,6 +1777,15 @@ export const appMetalakesSlice = createSlice({
toast.error(action.error.message)
}
})
+ builder.addCase(getFilesetFiles.fulfilled, (state, action) => {
+ state.filesetFiles = action.payload.files
+ state.tableData = action.payload.files
+ })
+ builder.addCase(getFilesetFiles.rejected, (state, action) => {
+ if (!action.error.message.includes('CanceledError')) {
+ toast.error(action.error.message)
+ }
+ })
builder.addCase(fetchTopics.fulfilled, (state, action) => {
state.topics = action.payload.topics
if (action.payload.init) {