diff --git a/react/craco.config.js b/react/craco.config.js index 3dc8863899..617179c2b5 100644 --- a/react/craco.config.js +++ b/react/craco.config.js @@ -24,15 +24,15 @@ module.exports = { '../resources/**/*', ], }; - + // Override deprecated middleware options with setupMiddlewares const originalOnBefore = devServerConfig.onBeforeSetupMiddleware; const originalOnAfter = devServerConfig.onAfterSetupMiddleware; - + if (originalOnBefore || originalOnAfter) { delete devServerConfig.onBeforeSetupMiddleware; delete devServerConfig.onAfterSetupMiddleware; - + devServerConfig.setupMiddlewares = (middlewares, devServer) => { if (originalOnBefore) { originalOnBefore(devServer); @@ -43,7 +43,7 @@ module.exports = { return middlewares; }; } - + return devServerConfig; }, babel: { diff --git a/react/src/App.tsx b/react/src/App.tsx index 61652ffd78..f386915091 100644 --- a/react/src/App.tsx +++ b/react/src/App.tsx @@ -84,6 +84,7 @@ const SessionDetailAndContainerLogOpenerLegacy = React.lazy( const ChatPage = React.lazy(() => import('./pages/ChatPage')); const AIAgentPage = React.lazy(() => import('./pages/AIAgentPage')); +const ReservoirPage = React.lazy(() => import('./pages/ReservoirPage')); const SchedulerPage = React.lazy(() => import('./pages/SchedulerPage')); @@ -459,6 +460,41 @@ const router = createBrowserRouter([ handle: { labelKey: 'webui.menu.ResourcePolicy' }, Component: ResourcePolicyPage, }, + { + path: '/reservoir', + handle: { labelKey: 'Reservoir' }, + children: [ + { + path: '', + Component: () => { + return ( + + + + + } + > + + + + ); + }, + }, + { + path: '/reservoir/:artifactId', + element: ( + + }> + + + + ), + handle: { labelKey: 'Artifact Details' }, + }, + ], + }, { path: '/settings', element: ( diff --git a/react/src/components/MainLayout/WebUISider.tsx b/react/src/components/MainLayout/WebUISider.tsx index ac00e9aa7e..001fb00909 100644 --- a/react/src/components/MainLayout/WebUISider.tsx +++ b/react/src/components/MainLayout/WebUISider.tsx @@ -103,6 +103,7 @@ export type MenuKeys = | 'environment' | 'scheduler' | 'resource-policy' + | 'reservoir' // superAdminMenu keys | 'agent' | 'settings' @@ -285,6 +286,11 @@ const WebUISider: React.FC = (props) => { icon: , key: 'resource-policy', }, + { + label: Reservoir, + icon: , + key: 'reservoir', + }, ]); const superAdminMenu: MenuProps['items'] = [ diff --git a/react/src/components/ReservoirArtifactDetail.tsx b/react/src/components/ReservoirArtifactDetail.tsx new file mode 100644 index 0000000000..be4e9ed311 --- /dev/null +++ b/react/src/components/ReservoirArtifactDetail.tsx @@ -0,0 +1,382 @@ +import type { ReservoirArtifact } from '../types/reservoir'; +import { + getStatusColor, + getStatusIcon, + getTypeColor, + getTypeIcon, +} from '../utils/reservoir'; +import BAIText from './BAIText'; +import { + Card, + Button, + Typography, + Descriptions, + Tag, + Space, + Table, + TableColumnsType, + Modal, + Select, + Progress, + Alert, + Divider, + theme, +} from 'antd'; +import { BAIFlex } from 'backend.ai-ui'; +import dayjs from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; +import { ArrowLeft, Download, Info, CheckCircle } from 'lucide-react'; +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +dayjs.extend(relativeTime); + +const { Title, Text, Paragraph } = Typography; + +interface ReservoirArtifactDetailProps { + artifact: ReservoirArtifact; + onPull: (artifactId: string, version?: string) => void; +} + +const ReservoirArtifactDetail: React.FC = ({ + artifact, + onPull, +}) => { + const { token } = theme.useToken(); + const navigate = useNavigate(); + const [isPullModalVisible, setIsPullModalVisible] = useState(false); + const [selectedVersion, setSelectedVersion] = useState( + artifact.versions[0], + ); + const [isPulling, setIsPulling] = useState(false); + + const handlePull = () => { + setIsPulling(true); + onPull(artifact.id, selectedVersion); + setIsPullModalVisible(false); + + // Simulate pulling progress + setTimeout(() => { + setIsPulling(false); + }, 3000); + }; + + const renderPullingProgress = () => { + if (artifact.status === 'pulling' || isPulling) { + return ( + + + Downloading {artifact.name} version {selectedVersion}... + + + + } + type="info" + showIcon + style={{ marginBottom: token.marginMD }} + /> + ); + } + return null; + }; + + return ( +
+ + + + + + {artifact.name} + + + {getTypeIcon(artifact.type, 18)} {artifact.type.toUpperCase()} + + + {artifact.status.toUpperCase()} + + + + + {renderPullingProgress()} + + } + onClick={() => setIsPullModalVisible(true)} + disabled={isPulling} + loading={isPulling} + > + {`Pull latest(v${artifact.versions[0]}) version`} + + ) : null + } + style={{ marginBottom: token.marginMD }} + > + + {artifact.name} + + + {' '} + {artifact.type.toUpperCase()} + + + + + {artifact.status.toUpperCase()} + + + + {artifact.size} + + + {artifact.sourceUrl ? ( + + {artifact.source || 'N/A'} + + ) : ( + artifact.source || 'N/A' + )} + + + {dayjs(artifact.updated_at).format('lll')} + + + + {artifact.description || 'No description available'} + + + + + + + {artifact.versions.length} version + {artifact.versions.length > 1 ? 's' : ''} available + + } + > + ({ + version, + size: artifact.size, + updated_at: artifact.updated_at, + checksum: artifact.checksums?.[version], + isInstalled: false, // default to false for legacy data + isPulling: false, // default to false for legacy data + })) + ).map((versionData, index) => ({ + ...versionData, + key: versionData.version, + isLatest: index === 0, + }))} + columns={ + [ + { + title: 'Version', + dataIndex: 'version', + key: 'version', + render: (version: string, record: any) => ( +
+ + {version} + {record.isLatest && LATEST} + {record.isInstalled && ( + }> + INSTALLED + + )} + + {record.checksum && ( + + {/* SHA256: {record.checksum} */} + + )} +
+ ), + width: '40%', + }, + { + title: 'Action', + key: 'action', + render: (_, record: any) => { + const getButtonText = () => { + if (record.isPulling) return 'Pulling'; + if (record.isInstalled) return 'Reinstall'; + return 'Pull'; + }; + + const getButtonType = () => { + if (record.isPulling) return 'default'; + if (record.isInstalled) return 'default'; + return 'primary'; + }; + + return ( + + ); + }, + width: '15%', + }, + { + title: 'Size', + dataIndex: 'size', + key: 'size', + render: (size: string) => {size}, + width: '20%', + }, + { + title: 'Updated', + dataIndex: 'updated_at', + key: 'updated_at', + render: (updated_at: string) => ( + + {dayjs(updated_at).format('lll')} + + ), + width: '25%', + }, + ] as TableColumnsType + } + pagination={false} + size="small" + /> + + + {artifact.dependencies && artifact.dependencies.length > 0 && ( + + + {artifact.dependencies.map((dep) => ( + + {dep} + + ))} + + + )} + + {artifact.tags && artifact.tags.length > 0 && ( + + + {artifact.tags.map((tag) => ( + + {tag} + + ))} + + + )} + + {artifact.status === 'available' && ( + setIsPullModalVisible(false)} + okText="Pull" + cancelText="Cancel" + okButtonProps={{ + loading: isPulling, + disabled: !selectedVersion, + }} + > + + + You are about to pull {artifact.name} to + your local repository. + + + Type: {artifact.type} +
+ Size: {artifact.size} +
+ Source: {artifact.source} +
+ + } + type="info" + showIcon + icon={} + style={{ marginBottom: token.marginMD }} + /> + +
+ Select Version: + +
+
+ )} + + ); +}; + +export default ReservoirArtifactDetail; diff --git a/react/src/components/ReservoirArtifactList.tsx b/react/src/components/ReservoirArtifactList.tsx new file mode 100644 index 0000000000..433c7e9774 --- /dev/null +++ b/react/src/components/ReservoirArtifactList.tsx @@ -0,0 +1,290 @@ +import type { ReservoirArtifact } from '../types/reservoir'; +import { + getStatusColor, + getStatusIcon, + getTypeColor, + getTypeIcon, +} from '../utils/reservoir'; +import BAIText from './BAIText'; +import { + Button, + Tag, + Typography, + Tooltip, + TableColumnsType, + theme, +} from 'antd'; +import { BAIFlex, BAITable } from 'backend.ai-ui'; +import dayjs from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; +import { Download } from 'lucide-react'; +import React from 'react'; +import { Link, useNavigate } from 'react-router-dom'; + +dayjs.extend(relativeTime); + +interface ReservoirArtifactListProps { + artifacts: ReservoirArtifact[]; + onPull: (artifactId: string, version?: string) => void; + type: 'all' | 'installed' | 'available'; + order?: string; + loading?: boolean; + rowSelection?: { + type: 'checkbox'; + preserveSelectedRowKeys?: boolean; + getCheckboxProps?: (record: ReservoirArtifact) => { disabled: boolean }; + onChange?: (selectedRowKeys: React.Key[]) => void; + selectedRowKeys?: React.Key[]; + }; + pagination?: { + pageSize: number; + current: number; + total: number; + showTotal?: (total: number) => React.ReactNode; + onChange?: (current: number, pageSize: number) => void; + }; + onChangeOrder?: (order: string) => void; +} + +const ReservoirArtifactList: React.FC = ({ + artifacts, + onPull, + type, + order, + loading = false, + rowSelection, + pagination, + onChangeOrder, +}) => { + const { token } = theme.useToken(); + const navigate = useNavigate(); + + const columns: TableColumnsType = [ + { + title: 'Name', + dataIndex: 'name', + key: 'name', + render: (name: string, record: ReservoirArtifact) => ( + +
+ + + {name} + + + + {getTypeIcon(record.type, 14)} {record.type.toUpperCase()} + + + {record.description && ( + + {record.description} + + )} +
+
+ ), + sorter: onChangeOrder ? true : false, + // sortOrder: + // order === 'name' ? 'ascend' : order === '-name' ? 'descend' : false, + // width: '35%', + }, + // { + // title: 'Controls', + // key: 'controls', + // render: (_, record: ReservoirArtifact) => ( + // + // <> + //
+ + } + /> + + */} + + handleStatisticCardClick('installed')} + style={{ + cursor: 'pointer', + border: + queryParams.statusCategory === 'installed' + ? `1px solid ${token.colorPrimary}` + : `1px solid ${token.colorBorder}`, + backgroundColor: + queryParams.statusCategory === 'installed' + ? token.colorPrimaryBg + : undefined, + transition: 'all 0.2s ease', + }} + > + } + valueStyle={{ + color: + queryParams.statusCategory === 'installed' + ? token.colorPrimary + : undefined, + }} + /> + + + + handleStatisticCardClick('available')} + style={{ + cursor: 'pointer', + border: + queryParams.statusCategory === 'available' + ? `1px solid ${token.colorPrimary}` + : `1px solid ${token.colorBorder}`, + backgroundColor: + queryParams.statusCategory === 'available' + ? token.colorPrimaryBg + : undefined, + transition: 'all 0.2s ease', + }} + > + } + valueStyle={{ + color: + queryParams.statusCategory === 'available' + ? token.colorPrimary + : undefined, + }} + /> + + + + handleStatisticCardClick('pulling')} + style={{ + cursor: 'pointer', + border: + queryParams.statusCategory === 'all' && + queryParams.filter === 'status == pulling' + ? `1px solid ${token.colorPrimary}` + : `1px solid ${token.colorBorder}`, + backgroundColor: + queryParams.statusCategory === 'all' && + queryParams.filter === 'status == pulling' + ? token.colorPrimaryBg + : undefined, + transition: 'all 0.2s ease', + }} + > + } + valueStyle={{ + color: + queryParams.statusCategory === 'all' && + queryParams.filter === 'status == pulling' + ? token.colorPrimary + : undefined, + }} + /> + + + + + } + precision={1} + valueStyle={{ color: token.colorTextSecondary }} + /> + + + + + } + suffix="(24h)" + valueStyle={{ + color: token.colorTextSecondary, + fontWeight: 'normal', + }} + /> + + + + + { + const storedQuery = queryMapRef.current[key] || { + queryParams: { + statusCategory: 'all', + }, + }; + setQuery({ ...storedQuery.queryParams }, 'replace'); + setTablePaginationOption( + storedQuery.tablePaginationOption || { current: 1 }, + ); + setSelectedArtifactList([]); + setCurTabKey(key as TabKey); + }} + tabList={[ + { + key: 'artifacts', + tab: ( + + Reservoir Artifacts + {(artifactCounts.all || 0) > 0 && ( + + )} + + ), + }, + { + key: 'audit', + tab: 'Audit Logs', + }, + ]} + styles={{ + body: { + padding: `${token.paddingSM}px ${token.paddingLG}px ${token.paddingLG}px ${token.paddingLG}px`, + }, + }} + > + {curTabKey === 'artifacts' ? ( + + + + { + setQuery({ statusCategory: e.target.value }, 'replaceIn'); + setTablePaginationOption({ current: 1 }); + setSelectedArtifactList([]); + }} + options={[ + { + label: 'All', + value: 'all', + }, + { + label: 'Installed', + value: 'installed', + }, + { + label: 'Available', + value: 'available', + }, + ]} + /> + { + setQuery({ filter: value }, 'replaceIn'); + setTablePaginationOption({ current: 1 }); + setSelectedArtifactList([]); + }} + /> + + + {selectedArtifactList.length > 0 && ( + <> + {t('general.NSelected', { + count: selectedArtifactList.length, + })} + +