diff --git a/aim/web/api/runs/views.py b/aim/web/api/runs/views.py index 014cd01796..39da1b9d03 100644 --- a/aim/web/api/runs/views.py +++ b/aim/web/api/runs/views.py @@ -1,4 +1,6 @@ from typing import Optional, Tuple +import os +import pathlib from aim.sdk.types import QueryReportMode from aim.web.api.runs.object_views import ( @@ -49,7 +51,7 @@ object_factory, ) from fastapi import Depends, Header, HTTPException, Query -from fastapi.responses import JSONResponse, StreamingResponse +from fastapi.responses import JSONResponse, StreamingResponse, FileResponse from starlette import status @@ -369,6 +371,38 @@ async def get_log_records_api(run_id: str, record_range: Optional[str] = ''): return StreamingResponse(run_log_records_streamer(run, record_range)) +@runs_router.get('/{run_id}/artifacts/{artifact_name}/') +async def get_artifact_file_api(run_id: str, artifact_name: str): + """Serve artifact files for a specific run.""" + repo = get_project_repo() + run = get_run_or_404(run_id, repo=repo) + + # Find the artifact by name + artifacts = run.artifacts + if artifact_name not in artifacts: + raise HTTPException(status_code=404, detail=f"Artifact '{artifact_name}' not found") + + artifact = artifacts[artifact_name] + file_path = artifact.path + + # Verify the file exists and is accessible + if not os.path.exists(file_path): + raise HTTPException(status_code=404, detail=f"Artifact file not found at path: {file_path}") + + # Security check: ensure the file is within reasonable bounds + try: + file_path = os.path.abspath(file_path) + except Exception: + raise HTTPException(status_code=400, detail="Invalid file path") + + # Return the file + return FileResponse( + path=file_path, + filename=artifact_name, + media_type='application/octet-stream' + ) + + def add_api_routes(): ImageApiConfig.register_endpoints(runs_router) TextApiConfig.register_endpoints(runs_router) diff --git a/aim/web/ui/src/pages/RunDetail/RunOverviewTab/RunOverviewTab.tsx b/aim/web/ui/src/pages/RunDetail/RunOverviewTab/RunOverviewTab.tsx index 1cd4bb89d1..b4d0fa57fc 100644 --- a/aim/web/ui/src/pages/RunDetail/RunOverviewTab/RunOverviewTab.tsx +++ b/aim/web/ui/src/pages/RunDetail/RunOverviewTab/RunOverviewTab.tsx @@ -13,6 +13,7 @@ import useRunMetricsBatch from '../hooks/useRunMetricsBatch'; import GitInfoCard from './components/GitInfoCard'; import RunOverviewTabMetricsCard from './components/MetricsCard/RunOverviewTabMetricsCard'; import RunOverviewTabArtifactsCard from './components/ArtifactsCard/RunOverviewTabArtifactsCard'; +import RunOverviewTabCSVTablesCard from './components/CSVTablesCard/RunOverviewTabCSVTablesCard'; import RunOverviewTabPackagesCard from './components/Packages/RunOverviewTabPackagesCard'; import RunOverviewTabParamsCard from './components/ParamsCard/RunOverviewTabParamsCard'; import RunOverviewSidebar from './components/RunOverviewSidebar/RunOverviewSidebar'; @@ -118,6 +119,14 @@ function RunOverviewTab({ runData, runHash }: IRunOverviewTabProps) { /> )} + {_.isEmpty(cardsData.artifacts) ? null : ( + + + + )} {_.isEmpty(cardsData.artifacts) ? null : ( ; + isRunInfoLoading: boolean; +} + +export interface CSVData { + name: string; + uri: string; + data: Array>; + columns: string[]; + error?: string; +} \ No newline at end of file diff --git a/aim/web/ui/src/pages/RunDetail/RunOverviewTab/components/CSVTablesCard/RunOverviewTabCSVTablesCard.scss b/aim/web/ui/src/pages/RunDetail/RunOverviewTab/components/CSVTablesCard/RunOverviewTabCSVTablesCard.scss new file mode 100644 index 0000000000..ff15bf972a --- /dev/null +++ b/aim/web/ui/src/pages/RunDetail/RunOverviewTab/components/CSVTablesCard/RunOverviewTabCSVTablesCard.scss @@ -0,0 +1,66 @@ +@use 'src/styles/abstracts' as *; + +.RunOverviewTabCSVTablesCard { + .IllustrationBlock { + margin-bottom: 1.75rem; + } + + &__csvTable { + margin-bottom: 2rem; + + &:last-child { + margin-bottom: 0; + } + + &__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid $cuddle-50; + + &__title { + display: flex; + align-items: center; + gap: 0.5rem; + } + + &__actions { + display: flex; + gap: 0.5rem; + } + } + + &__tableContainer { + max-height: 400px; + overflow: auto; + border: 1px solid $cuddle-50; + border-radius: $border-radius-sm; + + .DataList { + .IllustrationBlock { + height: 200px; + } + } + } + + &__error { + padding: 1rem; + background-color: #fef2f2; + border: 1px solid #fecaca; + border-radius: $border-radius-sm; + color: #dc2626; + font-size: 0.875rem; + } + + &__loading { + display: flex; + justify-content: center; + align-items: center; + height: 200px; + border: 1px solid $cuddle-50; + border-radius: $border-radius-sm; + } + } +} \ No newline at end of file diff --git a/aim/web/ui/src/pages/RunDetail/RunOverviewTab/components/CSVTablesCard/RunOverviewTabCSVTablesCard.tsx b/aim/web/ui/src/pages/RunDetail/RunOverviewTab/components/CSVTablesCard/RunOverviewTabCSVTablesCard.tsx new file mode 100644 index 0000000000..5a9e312428 --- /dev/null +++ b/aim/web/ui/src/pages/RunDetail/RunOverviewTab/components/CSVTablesCard/RunOverviewTabCSVTablesCard.tsx @@ -0,0 +1,346 @@ +import React from 'react'; + +import { Card, Text, Button, Icon, Spinner } from 'components/kit'; +import ErrorBoundary from 'components/ErrorBoundary/ErrorBoundary'; +import BusyLoaderWrapper from 'components/BusyLoaderWrapper/BusyLoaderWrapper'; +import { ICardProps } from 'components/kit/Card/Card.d'; +import CopyToClipBoard from 'components/CopyToClipBoard/CopyToClipBoard'; + +import { + IRunOverviewTabCSVTablesCardProps, + CSVData, +} from './RunOverviewTabCSVTablesCard.d'; + +import './RunOverviewTabCSVTablesCard.scss'; + +function RunOverviewTabCSVTablesCard({ + artifacts, + isRunInfoLoading, +}: IRunOverviewTabCSVTablesCardProps) { + const [csvData, setCsvData] = React.useState([]); + const [loadingStates, setLoadingStates] = React.useState< + Record + >({}); + + // Filter CSV artifacts + const csvArtifacts = React.useMemo(() => { + return artifacts.filter( + (artifact) => + artifact.name.toLowerCase().endsWith('.csv') || + artifact.path.toLowerCase().endsWith('.csv'), + ); + }, [artifacts]); + + // Function to parse CSV content + const parseCSV = ( + csvText: string, + ): { data: Array>; columns: string[] } => { + const lines = csvText.trim().split('\n'); + if (lines.length === 0) { + return { data: [], columns: [] }; + } + + // Parse header + const columns = lines[0] + .split(',') + .map((col) => col.trim().replace(/"/g, '')); + + // Parse data rows + const data = lines.slice(1).map((line, index) => { + const values = line.split(',').map((val) => val.trim().replace(/"/g, '')); + const row: Record = {}; + columns.forEach((col, colIndex) => { + row[col] = values[colIndex] || ''; + }); + row._rowIndex = index; // Add row index for table key + return row; + }); + + return { data, columns }; + }; + + // Check if URI is a local file path + const isLocalFile = (uri: string): boolean => { + return ( + uri.startsWith('/') || + uri.startsWith('./') || + uri.startsWith('../') || + uri.includes(':\\') || // Windows paths like C:\ + uri.startsWith('file://') + ); + }; + + // Function to load CSV data + const loadCSVData = async (artifact: { + name: string; + uri: string; + path: string; + }) => { + const key = artifact.name; + setLoadingStates((prev) => ({ ...prev, [key]: true })); + + try { + let csvText: string; + + if (isLocalFile(artifact.uri) || isLocalFile(artifact.path)) { + // For local files, use Aim's artifact serving API endpoint + // Get the run ID from the current URL + const pathParts = window.location.pathname.split('/'); + const runIdIndex = pathParts.findIndex((part) => part === 'runs'); + const runId = runIdIndex !== -1 ? pathParts[runIdIndex + 1] : null; + + if (!runId) { + throw new Error('Could not determine run ID from current URL'); + } + + const artifactEndpoint = `/api/runs/${runId}/artifacts/${encodeURIComponent( + artifact.name, + )}/`; + + try { + const response = await fetch(artifactEndpoint); + if (!response.ok) { + throw new Error(`Failed to fetch artifact: ${response.statusText}`); + } + csvText = await response.text(); + } catch (error) { + throw new Error( + `Cannot access local file: ${artifact.path}. ${ + error instanceof Error ? error.message : 'Unknown error' + }`, + ); + } + } else { + // For HTTP URLs, use direct fetch + const response = await fetch(artifact.uri); + if (!response.ok) { + throw new Error(`Failed to fetch CSV: ${response.statusText}`); + } + csvText = await response.text(); + } + + const { data, columns } = parseCSV(csvText); + + setCsvData((prev) => [ + ...prev.filter((csv) => csv.name !== artifact.name), + { + name: artifact.name, + uri: artifact.uri, + data, + columns, + }, + ]); + } catch (error) { + setCsvData((prev) => [ + ...prev.filter((csv) => csv.name !== artifact.name), + { + name: artifact.name, + uri: artifact.uri, + data: [], + columns: [], + error: error instanceof Error ? error.message : 'Failed to load CSV', + }, + ]); + } finally { + setLoadingStates((prev) => ({ ...prev, [key]: false })); + } + }; + + // Function to handle manual file upload for local files + const handleFileUpload = async (artifact: { + name: string; + uri: string; + path: string; + }) => { + const key = artifact.name; + setLoadingStates((prev) => ({ ...prev, [key]: true })); + + try { + // Create file input element + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.accept = '.csv'; + fileInput.style.display = 'none'; + + // Handle file selection + const filePromise = new Promise((resolve, reject) => { + fileInput.onchange = (event) => { + const file = (event.target as HTMLInputElement).files?.[0]; + if (file) { + resolve(file); + } else { + reject(new Error('No file selected')); + } + }; + fileInput.oncancel = () => + reject(new Error('File selection cancelled')); + }); + + // Trigger file selection + document.body.appendChild(fileInput); + fileInput.click(); + + const file = await filePromise; + document.body.removeChild(fileInput); + + // Read file content + const csvText = await file.text(); + const { data, columns } = parseCSV(csvText); + + setCsvData((prev) => [ + ...prev.filter((csv) => csv.name !== artifact.name), + { + name: artifact.name, + uri: artifact.uri, + data, + columns, + }, + ]); + } catch (error) { + if ( + error instanceof Error && + error.message !== 'File selection cancelled' + ) { + setCsvData((prev) => [ + ...prev.filter((csv) => csv.name !== artifact.name), + { + name: artifact.name, + uri: artifact.uri, + data: [], + columns: [], + error: error.message, + }, + ]); + } + } finally { + setLoadingStates((prev) => ({ ...prev, [key]: false })); + } + }; + + // Load CSV data on mount + React.useEffect(() => { + csvArtifacts.forEach((artifact) => { + loadCSVData(artifact); + }); + }, [csvArtifacts]); + + // Render individual CSV table + const renderCSVTable = (csv: CSVData) => { + const tableColumns = csv.columns.map((column) => ({ + dataKey: column, + key: column, + title: column, + width: `${100 / csv.columns.length}%`, + cellRenderer: ({ cellData }: any) =>

{cellData}

, + })); + + const dataListProps: ICardProps['dataListProps'] = { + tableColumns, + tableData: csv.data, + calcTableHeight: false, + searchableKeys: csv.columns, + illustrationConfig: { + size: 'medium', + title: 'No Data', + }, + }; + + const artifact = csvArtifacts.find((a) => a.name === csv.name); + const isLocalFileError = csv.error?.includes('Cannot access local file'); + + return ( +
+
+
+ + + {csv.name} + + {csv.data.length > 0 && ( + + ({csv.data.length} rows, {csv.columns.length} columns) + + )} +
+
+ + {isLocalFileError && artifact && ( + + )} + +
+
+ + {loadingStates[csv.name] ? ( +
+ +
+ ) : csv.error ? ( +
+ + {csv.error} + {isLocalFileError && ( + + Click "Load File" to manually select and upload this CSV file. + + )} +
+ ) : ( +
+ +
+ )} +
+ ); + }; + + // Don't render if no CSV artifacts + if (csvArtifacts.length === 0) { + return null; + } + + return ( + + + + {csvData.length > 0 ? ( + csvData.map(renderCSVTable) + ) : ( +
+ + + Loading CSV data... + +
+ )} +
+
+
+ ); +} + +RunOverviewTabCSVTablesCard.displayName = 'RunOverviewTabCSVTablesCard'; + +export default React.memo( + RunOverviewTabCSVTablesCard, +); diff --git a/aim/web/ui/src/pages/RunDetail/RunOverviewTab/components/CSVTablesCard/index.ts b/aim/web/ui/src/pages/RunDetail/RunOverviewTab/components/CSVTablesCard/index.ts new file mode 100644 index 0000000000..976e4e3eba --- /dev/null +++ b/aim/web/ui/src/pages/RunDetail/RunOverviewTab/components/CSVTablesCard/index.ts @@ -0,0 +1,3 @@ +import RunOverviewTabCSVTablesCard from './RunOverviewTabCSVTablesCard'; + +export default RunOverviewTabCSVTablesCard; \ No newline at end of file