diff --git a/react/src/App.tsx b/react/src/App.tsx index ebf791116c..ef7ab4ec67 100644 --- a/react/src/App.tsx +++ b/react/src/App.tsx @@ -463,21 +463,37 @@ const router = createBrowserRouter([ { path: '/reservoir', handle: { labelKey: 'Reservoir' }, - Component: () => { - return ( - - - - - } - > - - - - ); - }, + children: [ + { + path: '', + Component: () => { + return ( + + + + + } + > + + + + ); + }, + }, + { + path: '/reservoir/:artifactId', + element: ( + + }> + + + + ), + handle: { labelKey: 'Artifact Details' }, + }, + ], }, { path: '/settings', diff --git a/react/src/components/ReservoirArtifactDetail.tsx b/react/src/components/ReservoirArtifactDetail.tsx index c5f04c8a2d..4dd1badd88 100644 --- a/react/src/components/ReservoirArtifactDetail.tsx +++ b/react/src/components/ReservoirArtifactDetail.tsx @@ -1,4 +1,11 @@ import type { ReservoirArtifact } from '../types/reservoir'; +import { + getStatusColor, + getStatusIcon, + getTypeColor, + getTypeIcon, +} from '../utils/reservoir'; +import BAIText from './BAIText'; import { Card, Button, @@ -6,8 +13,8 @@ import { Descriptions, Tag, Space, - Flex, - List, + Table, + TableColumnsType, Modal, Select, Progress, @@ -15,21 +22,12 @@ import { Divider, theme, } from 'antd'; +import { Flex } from 'backend.ai-ui'; import dayjs from 'dayjs'; import relativeTime from 'dayjs/plugin/relativeTime'; -import { - ArrowLeft, - Download, - CheckCircle, - Loader, - AlertCircle, - CloudDownload, - Package, - Container, - Brain, - Info, -} from 'lucide-react'; +import { ArrowLeft, Download, Info, CheckCircle } from 'lucide-react'; import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; dayjs.extend(relativeTime); @@ -37,82 +35,21 @@ const { Title, Text, Paragraph } = Typography; interface ReservoirArtifactDetailProps { artifact: ReservoirArtifact; - onBack: () => void; onPull: (artifactId: string, version?: string) => void; } const ReservoirArtifactDetail: React.FC = ({ artifact, - onBack, 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 getStatusIcon = (status: ReservoirArtifact['status']) => { - switch (status) { - case 'verified': - return ; - case 'pulling': - return ; - case 'verifying': - return ; - case 'available': - return ; - case 'error': - return ; - default: - return null; - } - }; - - const getStatusColor = (status: ReservoirArtifact['status']) => { - switch (status) { - case 'verified': - return 'success'; - case 'pulling': - return 'processing'; - case 'verifying': - return 'warning'; - case 'available': - return 'default'; - case 'error': - return 'error'; - default: - return 'default'; - } - }; - - const getTypeIcon = (type: ReservoirArtifact['type']) => { - switch (type) { - case 'model': - return ; - case 'package': - return ; - case 'image': - return ; - default: - return null; - } - }; - - const getTypeColor = (type: ReservoirArtifact['type']) => { - switch (type) { - case 'model': - return 'blue'; - case 'package': - return 'green'; - case 'image': - return 'orange'; - default: - return 'default'; - } - }; - const handlePull = () => { setIsPulling(true); onPull(artifact.id, selectedVersion); @@ -151,28 +88,27 @@ const ReservoirArtifactDetail: React.FC = ({ return (
- + - - - {getTypeIcon(artifact.type)} + + {artifact.name} - - {artifact.type.toUpperCase()} + + {getTypeIcon(artifact.type, 18)} {artifact.type.toUpperCase()} {artifact.status.toUpperCase()} @@ -192,7 +128,7 @@ const ReservoirArtifactDetail: React.FC = ({ disabled={isPulling} loading={isPulling} > - Pull Artifact + {`Pull latest(v${artifact.versions[0]}) version`} ) : null } @@ -205,6 +141,7 @@ const ReservoirArtifactDetail: React.FC = ({ color={getTypeColor(artifact.type)} icon={getTypeIcon(artifact.type)} > + {' '} {artifact.type.toUpperCase()} @@ -217,13 +154,23 @@ const ReservoirArtifactDetail: React.FC = ({ - {artifact.size} + {artifact.size} - {artifact.source || 'N/A'} + {artifact.sourceUrl ? ( + + {artifact.source || 'N/A'} + + ) : ( + artifact.source || 'N/A' + )} - {dayjs(artifact.updated_at).fromNow()} + {dayjs(artifact.updated_at).format('lll')} @@ -243,44 +190,111 @@ const ReservoirArtifactDetail: React.FC = ({ } > - ( - } - onClick={() => { - setSelectedVersion(version); - setIsPullModalVisible(true); - }} - disabled={isPulling} - > - Pull - - ) : null, - ]} - > - - {index === 0 ? 'LATEST' : `v${version}`} - - } - title={{version}} - description={ - artifact.checksums?.[version] && ( - - SHA256: {artifact.checksums[version]} - - ) - } - /> - - )} + ({ + 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" /> diff --git a/react/src/components/ReservoirArtifactList.tsx b/react/src/components/ReservoirArtifactList.tsx index 35c3a5dc22..c2b3d92eaf 100644 --- a/react/src/components/ReservoirArtifactList.tsx +++ b/react/src/components/ReservoirArtifactList.tsx @@ -1,11 +1,15 @@ import type { ReservoirArtifact } from '../types/reservoir'; +import { + getStatusColor, + getStatusIcon, + getTypeColor, + getTypeIcon, +} from '../utils/reservoir'; import BAIText from './BAIText'; -import { CloseOutlined, SyncOutlined } from '@ant-design/icons'; import { Table, Button, Tag, - Space, Typography, Tooltip, TableColumnsType, @@ -14,14 +18,14 @@ import { import { Flex } from 'backend.ai-ui'; import dayjs from 'dayjs'; import relativeTime from 'dayjs/plugin/relativeTime'; -import { Download, Package, Container, Brain, TrashIcon } from 'lucide-react'; +import { Download } from 'lucide-react'; import React from 'react'; +import { Link, useNavigate } from 'react-router-dom'; dayjs.extend(relativeTime); interface ReservoirArtifactListProps { artifacts: ReservoirArtifact[]; - onArtifactSelect: (artifact: ReservoirArtifact) => void; onPull: (artifactId: string, version?: string) => void; type: 'all' | 'installed' | 'available'; order?: string; @@ -45,7 +49,6 @@ interface ReservoirArtifactListProps { const ReservoirArtifactList: React.FC = ({ artifacts, - onArtifactSelect, onPull, type, order, @@ -55,66 +58,7 @@ const ReservoirArtifactList: React.FC = ({ onChangeOrder, }) => { const { token } = theme.useToken(); - - // const getStatusIcon = (status: ReservoirArtifact['status']) => { - // switch (status) { - // case 'verified': - // return ; - // case 'pulling': - // return ; - // case 'verifying': - // return ; - // case 'available': - // return ; - // case 'error': - // return ; - // default: - // return null; - // } - // }; - - const getStatusColor = (status: ReservoirArtifact['status']) => { - switch (status) { - case 'verified': - return 'success'; - case 'pulling': - return 'processing'; - case 'verifying': - return 'warning'; - case 'available': - return 'default'; - case 'error': - return 'error'; - default: - return 'default'; - } - }; - - const getTypeIcon = (type: ReservoirArtifact['type']) => { - switch (type) { - case 'model': - return ; - case 'package': - return ; - case 'image': - return ; - default: - return null; - } - }; - - const getTypeColor = (type: ReservoirArtifact['type']) => { - switch (type) { - case 'model': - return 'blue'; - case 'package': - return 'green'; - case 'image': - return 'orange'; - default: - return 'default'; - } - }; + const navigate = useNavigate(); const columns: TableColumnsType = [ { @@ -125,18 +69,15 @@ const ReservoirArtifactList: React.FC = ({
- + = ({ } } > - {getTypeIcon(record.type)} {record.type.toUpperCase()} + {getTypeIcon(record.type, 14)} {record.type.toUpperCase()} {record.description && ( @@ -204,12 +145,16 @@ const ReservoirArtifactList: React.FC = ({ // - ) : null - } + icon={getStatusIcon(status)} color={getStatusColor(status)} + style={ + status === 'available' + ? { + borderStyle: 'dashed', + backgroundColor: token.colorBgContainer, + } + : undefined + } > {status.toUpperCase()} @@ -293,6 +238,17 @@ const ReservoirArtifactList: React.FC = ({ onChange={handleTableChange} size="middle" scroll={{ x: 'max-content' }} + onRow={(record) => ({ + onClick: (event) => { + // Don't trigger row click if clicking on a button or link + const target = event.target as HTMLElement; + const isClickableElement = target.closest('button, a, .ant-btn'); + if (!isClickableElement) { + navigate('/reservoir/' + record.id); + } + }, + style: { cursor: 'pointer' }, + })} // expandable={{ // expandedRowRender: (record) => ( //
diff --git a/react/src/pages/ReservoirPage.tsx b/react/src/pages/ReservoirPage.tsx index e2c4ef42fb..d113017730 100644 --- a/react/src/pages/ReservoirPage.tsx +++ b/react/src/pages/ReservoirPage.tsx @@ -32,6 +32,7 @@ import { } from 'lucide-react'; import React, { useState, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; +import { useParams } from 'react-router-dom'; import { StringParam, withDefault } from 'use-query-params'; type TabKey = 'artifacts' | 'audit'; @@ -39,9 +40,7 @@ type TabKey = 'artifacts' | 'audit'; const ReservoirPage: React.FC = () => { const { t } = useTranslation(); const { token } = theme.useToken(); - - const [selectedArtifact, setSelectedArtifact] = - useState(null); + const { artifactId } = useParams<{ artifactId: string }>(); const [selectedArtifactList, setSelectedArtifactList] = useState< Array >([]); @@ -86,10 +85,35 @@ const ReservoirPage: React.FC = () => { size: '145MB', updated_at: '2025-07-08T13:20:00Z', status: 'verified', - versions: ['4.28.0', '4.29.1', '4.30.0'], + versions: ['4.30.0', '4.29.1', '4.28.0'], + versionDetails: [ + { + version: '4.30.0', + size: '145MB', + updated_at: '2025-07-08T13:20:00Z', + checksum: 'sha256:a1b2c3d4e5f6', + isInstalled: true, + }, + { + version: '4.29.1', + size: '142MB', + updated_at: '2025-07-05T10:15:00Z', + checksum: 'sha256:b2c3d4e5f6a1', + isInstalled: false, + isPulling: false, + }, + { + version: '4.28.0', + size: '140MB', + updated_at: '2025-07-02T09:30:00Z', + checksum: 'sha256:c3d4e5f6a1b2', + isInstalled: true, + }, + ], description: 'State-of-the-art Machine Learning for PyTorch, TensorFlow, and JAX.', source: 'PyPI', + sourceUrl: 'https://pypi.org/project/transformers/', }, { id: '2', @@ -98,9 +122,25 @@ const ReservoirPage: React.FC = () => { size: '13.5GB', updated_at: '2025-07-07T09:15:00Z', status: 'verified', - versions: ['1.0.0', '1.1.0'], + versions: ['1.1.0', '1.0.0'], + versionDetails: [ + { + version: '1.1.0', + size: '13.5GB', + updated_at: '2025-07-07T09:15:00Z', + checksum: 'sha256:d4e5f6a1b2c3', + isInstalled: true, + }, + { + version: '1.0.0', + size: '13.2GB', + updated_at: '2025-07-01T14:30:00Z', + checksum: 'sha256:e5f6a1b2c3d4', + }, + ], description: "Meta's Llama 2 Chat model with 7 billion parameters.", source: 'HuggingFace', + sourceUrl: 'https://huggingface.co/meta-llama/Llama-2-7b-chat-hf', }, { id: '3', @@ -109,9 +149,25 @@ const ReservoirPage: React.FC = () => { size: '2.3GB', updated_at: '2025-07-06T16:45:00Z', status: 'pulling', - versions: ['1.13.1', '2.0.0'], + versions: ['2.0.0', '1.13.1'], + versionDetails: [ + { + version: '2.0.0', + size: '2.3GB', + updated_at: '2025-07-06T16:45:00Z', + checksum: 'sha256:f6a1b2c3d4e5', + isPulling: true, + }, + { + version: '1.13.1', + size: '2.1GB', + updated_at: '2025-06-28T12:00:00Z', + checksum: 'sha256:a1b2c3d4e5f6', + }, + ], description: 'PyTorch training environment with CUDA support.', source: 'Docker Hub', + sourceUrl: 'https://hub.docker.com/r/pytorch/pytorch', }, { id: '4', @@ -120,10 +176,31 @@ const ReservoirPage: React.FC = () => { size: '28MB', updated_at: '2025-07-05T11:30:00Z', status: 'verified', - versions: ['1.24.0', '1.25.0', '1.26.0'], + versions: ['1.26.0', '1.25.0', '1.24.0'], + versionDetails: [ + { + version: '1.26.0', + size: '28MB', + updated_at: '2025-07-05T11:30:00Z', + checksum: 'sha256:b2c3d4e5f6a1', + }, + { + version: '1.25.0', + size: '27MB', + updated_at: '2025-06-20T08:45:00Z', + checksum: 'sha256:c3d4e5f6a1b2', + }, + { + version: '1.24.0', + size: '26MB', + updated_at: '2025-06-10T15:20:00Z', + checksum: 'sha256:d4e5f6a1b2c3', + }, + ], description: 'Fundamental package for scientific computing with Python.', source: 'PyPI', + sourceUrl: 'https://pypi.org/project/numpy/', }, { id: '5', @@ -132,9 +209,24 @@ const ReservoirPage: React.FC = () => { size: '1.8GB', updated_at: '2025-07-04T14:20:00Z', status: 'verifying', - versions: ['2.13.0', '2.14.0'], + versions: ['2.14.0', '2.13.0'], + versionDetails: [ + { + version: '2.14.0', + size: '1.8GB', + updated_at: '2025-07-04T14:20:00Z', + checksum: 'sha256:e5f6a1b2c3d4', + }, + { + version: '2.13.0', + size: '1.7GB', + updated_at: '2025-06-25T11:10:00Z', + checksum: 'sha256:f6a1b2c3d4e5', + }, + ], description: 'TensorFlow Serving for model deployment.', source: 'Docker Hub', + sourceUrl: 'https://hub.docker.com/r/tensorflow/serving', }, { id: '6', @@ -143,9 +235,30 @@ const ReservoirPage: React.FC = () => { size: '52MB', updated_at: '2025-07-08T08:00:00Z', status: 'available', - versions: ['1.3.0', '1.4.0', '1.5.0'], + versions: ['1.5.0', '1.4.0', '1.3.0'], + versionDetails: [ + { + version: '1.5.0', + size: '52MB', + updated_at: '2025-07-08T08:00:00Z', + checksum: 'sha256:a1b2c3d4e5f6', + }, + { + version: '1.4.0', + size: '50MB', + updated_at: '2025-06-30T16:30:00Z', + checksum: 'sha256:b2c3d4e5f6a1', + }, + { + version: '1.3.0', + size: '48MB', + updated_at: '2025-06-15T13:45:00Z', + checksum: 'sha256:c3d4e5f6a1b2', + }, + ], description: 'Machine learning library for Python.', source: 'PyPI', + sourceUrl: 'https://pypi.org/project/scikit-learn/', }, { id: '7', @@ -155,9 +268,18 @@ const ReservoirPage: React.FC = () => { updated_at: '2025-07-07T12:00:00Z', status: 'available', versions: ['1.0.0'], + versionDetails: [ + { + version: '1.0.0', + size: '440MB', + updated_at: '2025-07-07T12:00:00Z', + checksum: 'sha256:d4e5f6a1b2c3', + }, + ], description: 'BERT base model (uncased) for natural language processing.', source: 'HuggingFace', + sourceUrl: 'https://huggingface.co/bert-base-uncased', }, { id: '8', @@ -166,9 +288,24 @@ const ReservoirPage: React.FC = () => { size: '1.2GB', updated_at: '2025-07-06T10:00:00Z', status: 'available', - versions: ['20.04', '22.04'], + versions: ['22.04', '20.04'], + versionDetails: [ + { + version: '22.04', + size: '1.2GB', + updated_at: '2025-07-06T10:00:00Z', + checksum: 'sha256:e5f6a1b2c3d4', + }, + { + version: '20.04', + size: '1.1GB', + updated_at: '2025-06-20T14:15:00Z', + checksum: 'sha256:f6a1b2c3d4e5', + }, + ], description: 'Ubuntu with machine learning tools pre-installed.', source: 'Docker Hub', + sourceUrl: 'https://hub.docker.com/_/ubuntu', }, ], [], @@ -239,13 +376,11 @@ const ReservoirPage: React.FC = () => { }; }, [mockArtifacts]); - const handleArtifactSelect = (artifact: ReservoirArtifact) => { - setSelectedArtifact(artifact); - }; - - const handleBackToList = () => { - setSelectedArtifact(null); - }; + // Find selected artifact based on URL parameter + const selectedArtifact = useMemo(() => { + if (!artifactId) return null; + return mockArtifacts.find((artifact) => artifact.id === artifactId) || null; + }, [artifactId, mockArtifacts]); const handlePullArtifact = (artifactId: string, version?: string) => { // Mock implementation - in real app, this would trigger an API call @@ -264,7 +399,6 @@ const ReservoirPage: React.FC = () => { @@ -512,7 +646,6 @@ const ReservoirPage: React.FC = () => { { + switch (status) { + case 'verified': + return 'success'; + case 'pulling': + return 'processing'; + case 'verifying': + return 'warning'; + case 'available': + return 'default'; + case 'error': + return 'error'; + default: + return 'default'; + } +}; + +export const getStatusIcon = (status: ReservoirArtifact['status']) => { + switch (status) { + case 'pulling': + case 'verifying': + return ; + default: + return null; + } +}; + +export const getTypeColor = (type: ReservoirArtifact['type']) => { + switch (type) { + case 'model': + return 'blue'; + case 'package': + return 'green'; + case 'image': + return 'orange'; + default: + return 'default'; + } +}; + +export const getTypeIcon = ( + type: ReservoirArtifact['type'], + size: number = 16, +) => { + const colorMap = { + model: '#1677ff', + package: '#52c41a', + image: '#fa8c16', + }; + + switch (type) { + case 'model': + return ; + case 'package': + return ; + case 'image': + return ; + default: + return null; + } +};