+
+ }
+ onClick={() => navigate('/reservoir')}
+ >
+
+
+
+ {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 (
+ }
+ onClick={() => {
+ setSelectedVersion(record.version);
+ setIsPullModalVisible(true);
+ }}
+ size="small"
+ disabled={record.isPulling || record.isInstalled}
+ type={getButtonType()}
+ loading={record.isPulling}
+ >
+ {getButtonText()}
+
+ );
+ },
+ 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) => (
+ //
+ // <>
+ // } size="small" />
+ //
+ // }
+ // onClick={() => onPull(record.id)}
+ // size="small"
+ // // @ts-ignore
+ // loading={record.status === 'pulling'}
+ // />
+ //
+ // >
+ //
+ // ),
+ // // width: '10%',
+ // },
+ // {
+ // title: 'Type',
+ // dataIndex: 'type',
+ // key: 'type',
+ // render: (type: ReservoirArtifact['type']) => (
+ // {type.toUpperCase()}
+ // ),
+ // width: '10%',
+ // },
+ {
+ title: 'Status',
+ dataIndex: 'status',
+ key: 'status',
+ render: (status: ReservoirArtifact['status'], record) => (
+ //
+
+
+ {status.toUpperCase()}
+
+ {status === 'available' && (
+
+ }
+ onClick={() => onPull(record.id)}
+ size="small"
+ // @ts-ignore
+ loading={record.status === 'pulling'}
+ />
+
+ )}
+
+ ),
+ // width: '12%',
+ },
+ {
+ title: 'Latest Version',
+ dataIndex: 'versions',
+ key: 'latest_version',
+ render: (versions: string[]) => {
+ const latestVersion =
+ versions && versions.length > 0 ? versions[0] : 'N/A';
+ return {latestVersion};
+ },
+ // width: '12%',
+ },
+ {
+ title: 'Size',
+ dataIndex: 'size',
+ key: 'size',
+ render: (size: string) => {size},
+ sorter: onChangeOrder ? true : false,
+ // sortOrder:
+ // order === 'size' ? 'ascend' : order === '-size' ? 'descend' : false,
+ // width: '10%',
+ },
+
+ {
+ title: 'Updated',
+ dataIndex: 'updated_at',
+ key: 'updated_at',
+ render: (updated_at: string) => (
+
+ {dayjs(updated_at).fromNow()}
+
+ ),
+ sorter: onChangeOrder ? true : false,
+ // sortOrder:
+ // order === 'updated_at'
+ // ? 'ascend'
+ // : order === '-updated_at'
+ // ? 'descend'
+ // : false,
+ // width: '13%',
+ },
+ ];
+
+ // const handleTableChange = (
+ // paginationInfo: any,
+ // filters: any,
+ // sorter: any,
+ // ) => {
+ // if (onChangeOrder && sorter.field) {
+ // const order =
+ // sorter.order === 'ascend' ? sorter.field : `-${sorter.field}`;
+ // onChangeOrder(order);
+ // }
+ // };
+
+ return (
+ ({
+ 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) => (
+ //
+ //
+ // {record.source && (
+ //
+ // Source:
+ // {record.source}
+ //
+ // )}
+ // {record.versions.length > 0 && (
+ //
+ // Available Versions:
+ //
+ // {record.versions.map((version) => (
+ // {version}
+ // ))}
+ //
+ //
+ // )}
+ // {record.status === 'pulling' && (
+ //
+ // )}
+ //
+ //
+ // ),
+ // expandRowByClick: true,
+ // }}
+ />
+ );
+};
+
+export default ReservoirArtifactList;
diff --git a/react/src/components/ReservoirAuditLogList.tsx b/react/src/components/ReservoirAuditLogList.tsx
new file mode 100644
index 0000000000..bdda1991f8
--- /dev/null
+++ b/react/src/components/ReservoirAuditLogList.tsx
@@ -0,0 +1,188 @@
+import type { ReservoirAuditLog } from '../types/reservoir';
+import BAIText from './BAIText';
+import { Tag, Typography } from 'antd';
+import { BAIFlex, BAIPropertyFilter, BAITable } from 'backend.ai-ui';
+import dayjs from 'dayjs';
+import { Activity, CheckCircle, XCircle } from 'lucide-react';
+import React from 'react';
+
+// import { useTranslation } from 'react-i18next';
+
+interface ReservoirAuditLogListProps {
+ auditLogs: ReservoirAuditLog[];
+ loading?: boolean;
+ filterValue?: string;
+ onFilterChange?: (value: string) => void;
+ pagination?: {
+ pageSize: number;
+ current: number;
+ total: number;
+ showTotal?: (total: number) => React.ReactNode;
+ onChange?: (current: number, pageSize: number) => void;
+ };
+ order?: string;
+ onChangeOrder?: (order: string) => void;
+}
+
+const ReservoirAuditLogList: React.FC = ({
+ auditLogs,
+ loading = false,
+ filterValue,
+ onFilterChange,
+ pagination,
+ order,
+ onChangeOrder,
+}) => {
+ // const { t } = useTranslation();
+
+ return (
+
+
+
+
+ (
+ {artifactName}
+ ),
+ sorter: true,
+ },
+ {
+ title: 'Version',
+ dataIndex: 'artifactVersion',
+ key: 'artifactVersion',
+ render: (version: string) =>
+ version ? {version} : '-',
+ },
+ {
+ title: 'Operation',
+ dataIndex: 'operation',
+ key: 'operation',
+ render: (operation: string) => {operation.toUpperCase()},
+ sorter: true,
+ },
+ {
+ title: 'Modifier',
+ dataIndex: 'modifier',
+ key: 'modifier',
+ render: (modifier: string) => (
+ {modifier}
+ ),
+ sorter: true,
+ },
+ {
+ title: 'Timestamp',
+ dataIndex: 'timestamp',
+ key: 'timestamp',
+ render: (timestamp: string) => (
+
+ {dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss')}
+
+ ),
+ sorter: true,
+ },
+ ]}
+ pagination={pagination}
+ scroll={{ x: 'max-content' }}
+ // order={order}
+ expandable={{
+ expandedRowRender: (record) => (
+
+
+ Status:
+
+ ) : record.status === 'failed' ? (
+
+ ) : (
+
+ )
+ }
+ >
+ {record.status.toUpperCase()}
+
+
+ {record.details && (
+
+ Details:
+
+ {record.details}
+
+
+ )}
+
+ ),
+ expandRowByClick: true,
+ }}
+ />
+
+ );
+};
+
+export default ReservoirAuditLogList;
diff --git a/react/src/pages/ReservoirPage.tsx b/react/src/pages/ReservoirPage.tsx
new file mode 100644
index 0000000000..ed4af1898e
--- /dev/null
+++ b/react/src/pages/ReservoirPage.tsx
@@ -0,0 +1,1005 @@
+import BAIRadioGroup from '../components/BAIRadioGroup';
+import ReservoirArtifactDetail from '../components/ReservoirArtifactDetail';
+import ReservoirArtifactList from '../components/ReservoirArtifactList';
+import ReservoirAuditLogList from '../components/ReservoirAuditLogList';
+import { handleRowSelectionChange } from '../helper';
+import { useUpdatableState } from '../hooks';
+import { useBAIPaginationOptionStateOnSearchParam } from '../hooks/reactPaginationQueryOptions';
+import { useDeferredQueryParams } from '../hooks/useDeferredQueryParams';
+import type { ReservoirArtifact, ReservoirAuditLog } from '../types/reservoir';
+import {
+ Button,
+ Badge,
+ Typography,
+ theme,
+ Col,
+ Row,
+ Tooltip,
+ Statistic,
+ Card,
+ Skeleton,
+} from 'antd';
+import { BAICard, BAIFlex, BAIPropertyFilter } from 'backend.ai-ui';
+import _ from 'lodash';
+import {
+ Trash2,
+ CheckCircle,
+ HardDrive,
+ Activity,
+ Calendar,
+ DatabaseIcon,
+} from 'lucide-react';
+import React, { useState, useMemo, useRef, Suspense } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useParams } from 'react-router-dom';
+import { StringParam, withDefault, useQueryParam } from 'use-query-params';
+
+type TabKey = 'artifacts' | 'audit';
+
+const ReservoirPage: React.FC = () => {
+ const { t } = useTranslation();
+ const { token } = theme.useToken();
+ const { artifactId } = useParams<{ artifactId: string }>();
+ const [selectedArtifactList, setSelectedArtifactList] = useState<
+ Array
+ >([]);
+
+ const {
+ // baiPaginationOption,
+ tablePaginationOption,
+ setTablePaginationOption,
+ } = useBAIPaginationOptionStateOnSearchParam({
+ current: 1,
+ pageSize: 10,
+ });
+
+ const [queryParams, setQuery] = useDeferredQueryParams({
+ order: withDefault(StringParam, '-updated_at'),
+ filter: withDefault(StringParam, undefined),
+ statusCategory: withDefault(StringParam, 'all'),
+ auditFilter: withDefault(StringParam, undefined),
+ auditOrder: withDefault(StringParam, '-timestamp'),
+ });
+
+ const [curTabKey, setCurTabKey] = useQueryParam(
+ 'tab',
+ withDefault(StringParam, 'artifacts'),
+ {
+ updateType: 'replace',
+ },
+ );
+
+ const queryMapRef = useRef({
+ [curTabKey]: {
+ queryParams,
+ tablePaginationOption,
+ },
+ });
+
+ queryMapRef.current[curTabKey] = {
+ queryParams,
+ tablePaginationOption,
+ };
+
+ const [, updateFetchKey] = useUpdatableState('initial-fetch');
+
+ // Mock audit log data
+ const mockAuditLogs: ReservoirAuditLog[] = useMemo(
+ () => [
+ {
+ id: '1',
+ artifactName: 'transformers',
+ artifactVersion: '4.30.0',
+ operation: 'pull',
+ modifier: 'john.doe@company.com',
+ timestamp: '2025-07-08T14:30:00Z',
+ status: 'success',
+ details: 'Successfully pulled transformers version 4.30.0',
+ },
+ {
+ id: '2',
+ artifactName: 'llama-2-7b-chat',
+ artifactVersion: '1.1.0',
+ operation: 'install',
+ modifier: 'jane.smith@company.com',
+ timestamp: '2025-07-08T10:15:00Z',
+ status: 'success',
+ details: 'Successfully installed llama-2-7b-chat version 1.1.0',
+ },
+ {
+ id: '3',
+ artifactName: 'pytorch-training',
+ artifactVersion: '2.0.0',
+ operation: 'pull',
+ modifier: 'system',
+ timestamp: '2025-07-08T09:00:00Z',
+ status: 'in_progress',
+ details: 'Pulling pytorch-training version 2.0.0 in progress',
+ },
+ {
+ id: '4',
+ artifactName: 'numpy',
+ artifactVersion: '1.25.0',
+ operation: 'uninstall',
+ modifier: 'admin@company.com',
+ timestamp: '2025-07-07T16:45:00Z',
+ status: 'success',
+ details: 'Successfully uninstalled numpy version 1.25.0',
+ },
+ {
+ id: '5',
+ artifactName: 'scikit-learn',
+ artifactVersion: '1.4.0',
+ operation: 'update',
+ modifier: 'bob.wilson@company.com',
+ timestamp: '2025-07-07T14:20:00Z',
+ status: 'failed',
+ details: 'Failed to update scikit-learn: dependency conflict',
+ },
+ {
+ id: '6',
+ artifactName: 'tensorflow-serving',
+ operation: 'verify',
+ modifier: 'system',
+ timestamp: '2025-07-07T08:30:00Z',
+ status: 'success',
+ details: 'All versions verified successfully',
+ },
+ {
+ id: '7',
+ artifactName: 'bert-base-uncased',
+ artifactVersion: '1.0.0',
+ operation: 'pull',
+ modifier: 'alice.johnson@company.com',
+ timestamp: '2025-07-06T18:00:00Z',
+ status: 'success',
+ details: 'Successfully pulled bert-base-uncased version 1.0.0',
+ },
+ {
+ id: '8',
+ artifactName: 'ubuntu-ml',
+ artifactVersion: '20.04',
+ operation: 'delete',
+ modifier: 'admin@company.com',
+ timestamp: '2025-07-06T12:15:00Z',
+ status: 'success',
+ details: 'Successfully deleted ubuntu-ml version 20.04',
+ },
+ ],
+ [],
+ );
+
+ // Mock data - in real implementation, this would come from API
+ const mockArtifacts: ReservoirArtifact[] = useMemo(
+ () => [
+ {
+ id: '1',
+ name: 'transformers',
+ type: 'package',
+ size: '145MB',
+ updated_at: '2025-07-08T13:20:00Z',
+ status: 'verified',
+ 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',
+ name: 'llama-2-7b-chat',
+ type: 'model',
+ size: '13.5GB',
+ updated_at: '2025-07-07T09:15:00Z',
+ status: 'verified',
+ 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',
+ name: 'pytorch-training',
+ type: 'image',
+ size: '2.3GB',
+ updated_at: '2025-07-06T16:45:00Z',
+ status: 'pulling',
+ 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',
+ name: 'numpy',
+ type: 'package',
+ size: '28MB',
+ updated_at: '2025-07-05T11:30:00Z',
+ status: 'verified',
+ 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',
+ name: 'tensorflow-serving',
+ type: 'image',
+ size: '1.8GB',
+ updated_at: '2025-07-04T14:20:00Z',
+ status: 'verifying',
+ 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',
+ name: 'scikit-learn',
+ type: 'package',
+ size: '52MB',
+ updated_at: '2025-07-08T08:00:00Z',
+ status: 'available',
+ 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',
+ name: 'bert-base-uncased',
+ type: 'model',
+ size: '440MB',
+ 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',
+ name: 'ubuntu-ml',
+ type: 'image',
+ size: '1.2GB',
+ updated_at: '2025-07-06T10:00:00Z',
+ status: 'available',
+ 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',
+ },
+ ],
+ [],
+ );
+
+ // Helper function to parse property filter
+ const parsePropertyFilter = (filterString?: string) => {
+ if (!filterString) return null;
+
+ // Simple parser for "property == value" format
+ const match = filterString.match(/(\w+)\s*(==|!=|contains)\s*(.+)/);
+ if (match) {
+ const [, property, operator, value] = match;
+ return { property, operator, value: value.trim() };
+ }
+ return null;
+ };
+
+ // Filter artifacts based on status category and property filters
+ const filteredArtifacts = useMemo(() => {
+ if (curTabKey !== 'artifacts') {
+ return []; // Return empty array for non-artifacts tabs
+ }
+
+ let filtered = mockArtifacts.filter((artifact) => {
+ switch (queryParams.statusCategory) {
+ case 'all':
+ return true; // Show all artifacts
+ case 'installed':
+ return ['verified', 'pulling', 'verifying'].includes(artifact.status);
+ case 'available':
+ return ['available'].includes(artifact.status);
+ default:
+ return true;
+ }
+ });
+
+ // Apply property filter if exists
+ const propertyFilter = parsePropertyFilter(queryParams.filter);
+ if (propertyFilter) {
+ filtered = filtered.filter((artifact) => {
+ const { property, operator, value } = propertyFilter;
+
+ switch (property) {
+ case 'name':
+ if (operator === 'contains') {
+ return artifact.name.toLowerCase().includes(value.toLowerCase());
+ } else if (operator === '==') {
+ return artifact.name.toLowerCase() === value.toLowerCase();
+ }
+ break;
+ case 'type':
+ if (operator === '==') {
+ return artifact.type === value;
+ }
+ break;
+ case 'source':
+ if (operator === 'contains') {
+ return (
+ artifact.source?.toLowerCase().includes(value.toLowerCase()) ||
+ false
+ );
+ } else if (operator === '==') {
+ return artifact.source?.toLowerCase() === value.toLowerCase();
+ }
+ break;
+ case 'status':
+ if (operator === '==') {
+ return artifact.status === value;
+ }
+ break;
+ default:
+ return true;
+ }
+ return true;
+ });
+ }
+
+ return filtered;
+ }, [
+ mockArtifacts,
+ curTabKey,
+ queryParams.statusCategory,
+ queryParams.filter,
+ ]);
+
+ // Filter audit logs based on filter
+ const filteredAuditLogs = useMemo(() => {
+ if (curTabKey !== 'audit') {
+ return [];
+ }
+ // Add filter logic here if needed
+ return mockAuditLogs;
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [mockAuditLogs, curTabKey, queryParams.auditFilter]);
+
+ // Count for badges and statistics
+ const artifactCounts = useMemo(() => {
+ const installedCount = mockArtifacts.filter((a) =>
+ ['verified', 'pulling', 'verifying'].includes(a.status),
+ ).length;
+ const availableCount = mockArtifacts.filter((a) =>
+ ['available'].includes(a.status),
+ ).length;
+ const pullingCount = mockArtifacts.filter(
+ (a) => a.status === 'pulling',
+ ).length;
+
+ // Calculate total size of installed artifacts
+ const totalSizeBytes = mockArtifacts
+ .filter((a) => ['verified', 'pulling', 'verifying'].includes(a.status))
+ .reduce((total, artifact) => {
+ const sizeStr = artifact.size;
+ const value = parseFloat(sizeStr);
+ const unit = sizeStr.replace(/[0-9.]/g, '').trim();
+
+ if (unit === 'GB') return total + value * 1024;
+ if (unit === 'MB') return total + value;
+ return total;
+ }, 0);
+
+ // Recent activity (artifacts updated in last 24 hours)
+ const now = new Date();
+ const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
+ const recentlyUpdated = mockArtifacts.filter(
+ (a) => new Date(a.updated_at) > oneDayAgo,
+ ).length;
+
+ return {
+ all: mockArtifacts.length,
+ installed: installedCount,
+ available: availableCount,
+ pulling: pullingCount,
+ model: mockArtifacts.filter((a) => a.type === 'model').length,
+ package: mockArtifacts.filter((a) => a.type === 'package').length,
+ image: mockArtifacts.filter((a) => a.type === 'image').length,
+ totalSizeGB: (totalSizeBytes / 1024).toFixed(1),
+ recentlyUpdated,
+ };
+ }, [mockArtifacts]);
+
+ // 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
+ console.log(
+ `Pulling artifact ${artifactId}${version ? ` version ${version}` : ''}`,
+ );
+ updateFetchKey(); // Trigger refresh
+ };
+
+ const isInstalledStatus = (status: ReservoirArtifact['status']) => {
+ return ['verified', 'pulling', 'verifying'].includes(status);
+ };
+
+ const handleStatisticCardClick = (statusCategory: string) => {
+ // Switch to artifacts tab if not already there
+ if (curTabKey !== 'artifacts') {
+ setCurTabKey('artifacts');
+ }
+
+ // Check if the card is already active (toggle off functionality)
+ const isCurrentlyActive =
+ statusCategory === 'pulling'
+ ? queryParams.statusCategory === 'all' &&
+ queryParams.filter === 'status == pulling'
+ : queryParams.statusCategory === statusCategory;
+
+ if (isCurrentlyActive) {
+ // Toggle off: return to 'all' with no filters
+ setQuery(
+ {
+ statusCategory: 'all',
+ filter: undefined,
+ },
+ 'replaceIn',
+ );
+ } else {
+ // Toggle on: apply the filter
+ if (statusCategory === 'pulling') {
+ // For pulling, set RadioGroup to 'all' and add status filter
+ setQuery(
+ {
+ statusCategory: 'all',
+ filter: 'status == pulling',
+ },
+ 'replaceIn',
+ );
+ } else {
+ // For other categories, use normal statusCategory filter and clear property filter
+ setQuery(
+ {
+ statusCategory,
+ filter: undefined,
+ },
+ 'replaceIn',
+ );
+ }
+ }
+
+ setTablePaginationOption({ current: 1 });
+ setSelectedArtifactList([]);
+ };
+
+ if (selectedArtifact) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+ {/*
+
+ }
+ />
+
+ */}
+
+ 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,
+ })}
+
+ }
+ onClick={() => {
+ console.log('Removing selected artifacts');
+ setSelectedArtifactList([]);
+ }}
+ />
+
+ >
+ )}
+
+
+ ({
+ disabled: !isInstalledStatus(record.status),
+ }),
+ onChange: (selectedRowKeys) => {
+ handleRowSelectionChange(
+ selectedRowKeys,
+ filteredArtifacts,
+ setSelectedArtifactList,
+ );
+ },
+ selectedRowKeys: _.map(selectedArtifactList, (i) => i.id),
+ }}
+ pagination={{
+ pageSize: tablePaginationOption.pageSize,
+ current: tablePaginationOption.current,
+ total: filteredArtifacts.length,
+ showTotal: (total) => (
+
+ {t('general.TotalItems', { total: total })}
+
+ ),
+ onChange: (current, pageSize) => {
+ if (_.isNumber(current) && _.isNumber(pageSize)) {
+ setTablePaginationOption({ current, pageSize });
+ }
+ },
+ }}
+ onChangeOrder={(order) => {
+ setQuery({ order }, 'replaceIn');
+ }}
+ />
+
+ ) : null}
+ {curTabKey === 'audit' ? (
+ }>
+ {
+ setQuery({ auditFilter: value }, 'replaceIn');
+ }}
+ pagination={{
+ pageSize: tablePaginationOption.pageSize,
+ current: tablePaginationOption.current,
+ total: filteredAuditLogs.length,
+ showTotal: (total) => (
+
+ {t('general.TotalItems', { total: total })}
+
+ ),
+ onChange: (current, pageSize) => {
+ if (_.isNumber(current) && _.isNumber(pageSize)) {
+ setTablePaginationOption({ current, pageSize });
+ }
+ },
+ }}
+ order={queryParams.auditOrder}
+ onChangeOrder={(order) => {
+ setQuery({ auditOrder: order }, 'replaceIn');
+ }}
+ />
+
+ ) : null}
+
+
+ );
+};
+
+export default ReservoirPage;
diff --git a/react/src/types/reservoir.ts b/react/src/types/reservoir.ts
new file mode 100644
index 0000000000..9993bf0388
--- /dev/null
+++ b/react/src/types/reservoir.ts
@@ -0,0 +1,38 @@
+export interface ReservoirArtifactVersion {
+ version: string;
+ size: string;
+ updated_at: string;
+ checksum?: string;
+ isInstalled?: boolean;
+ isPulling?: boolean;
+}
+
+export interface ReservoirArtifact {
+ id: string;
+ name: string;
+ type: 'model' | 'package' | 'image';
+ size: string;
+ updated_at: string;
+ status: 'verified' | 'pulling' | 'verifying' | 'available' | 'error';
+ versions: string[]; // Keep for backward compatibility
+ versionDetails?: ReservoirArtifactVersion[]; // New detailed version info
+ description?: string;
+ source?: string;
+ sourceUrl?: string;
+ tags?: string[];
+ dependencies?: string[];
+ checksums?: {
+ [version: string]: string;
+ };
+}
+
+export interface ReservoirAuditLog {
+ id: string;
+ artifactName: string;
+ artifactVersion?: string;
+ operation: 'pull' | 'install' | 'uninstall' | 'update' | 'verify' | 'delete';
+ modifier: string;
+ timestamp: string;
+ status: 'success' | 'failed' | 'in_progress';
+ details?: string;
+}
diff --git a/react/src/utils/reservoir.tsx b/react/src/utils/reservoir.tsx
new file mode 100644
index 0000000000..2aa2bb9a71
--- /dev/null
+++ b/react/src/utils/reservoir.tsx
@@ -0,0 +1,66 @@
+import type { ReservoirArtifact } from '../types/reservoir';
+import { SyncOutlined } from '@ant-design/icons';
+import { Package, Container, Brain } from 'lucide-react';
+import React from 'react';
+
+export 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';
+ }
+};
+
+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;
+ }
+};
diff --git a/src/components/backend-ai-webui.ts b/src/components/backend-ai-webui.ts
index ea77f45e19..e039046124 100644
--- a/src/components/backend-ai-webui.ts
+++ b/src/components/backend-ai-webui.ts
@@ -173,6 +173,7 @@ export default class BackendAIWebUI extends connect(store)(LitElement) {
'ai-agent',
'model-store',
'scheduler',
+ 'reservoir',
]; // temporally block pipeline from available pages 'pipeline', 'pipeline-job'
@property({ type: Array }) adminOnlyPages = [
'experiment',