From af48bb44b654f9260fdd3101c3610c1603643556 Mon Sep 17 00:00:00 2001 From: Varkha Sharma Date: Tue, 2 Dec 2025 17:13:10 -0800 Subject: [PATCH 1/6] add pane and global search filter --- ui/src/components/ArtifactPTable/index.css | 5 + ui/src/components/ArtifactPTable/index.jsx | 170 +++++++++------- .../components/ResizableSplitPane/index.jsx | 179 +++++++++++++++++ .../components/LabelContentPanel.jsx | 161 +++++++++++++++ .../components/PaginationControls.jsx | 108 +++++++++++ ui/src/pages/artifacts_postgres/index.jsx | 183 +++++++++++++++++- 6 files changed, 730 insertions(+), 76 deletions(-) create mode 100644 ui/src/components/ResizableSplitPane/index.jsx create mode 100644 ui/src/pages/artifacts_postgres/components/LabelContentPanel.jsx create mode 100644 ui/src/pages/artifacts_postgres/components/PaginationControls.jsx diff --git a/ui/src/components/ArtifactPTable/index.css b/ui/src/components/ArtifactPTable/index.css index 51052f63b..a1f53efa9 100644 --- a/ui/src/components/ArtifactPTable/index.css +++ b/ui/src/components/ArtifactPTable/index.css @@ -39,6 +39,11 @@ font-size: 1.2em; /* Adjust the size as needed */ } + .sort-header-content { + display: inline-flex; + align-items: center; + } + td { text-align: left; } diff --git a/ui/src/components/ArtifactPTable/index.jsx b/ui/src/components/ArtifactPTable/index.jsx index adc1ccdf5..526f6608f 100644 --- a/ui/src/components/ArtifactPTable/index.jsx +++ b/ui/src/components/ArtifactPTable/index.jsx @@ -15,7 +15,7 @@ ***/ // ArtifactTable.jsx -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useRef } from "react"; import ModelCardPopup from "../ModelCardPopup"; import Highlight from "../Highlight"; import FastAPIClient from "../../client"; @@ -25,43 +25,61 @@ import LabelCardPopup from "../LabelCardPopup"; const client = new FastAPIClient(config); -const ArtifactPTable = ({artifacts, artifactType, onsortOrder, onsortTimeOrder, filterValue}) => { - const [data, setData] = useState([]); + +const ArtifactPTable = ({ + artifacts, + artifactType, + onSortOrder, + onSortTimeOrder, + filterValue, + onLabelClick, + expandedRow: externalExpandedRow, + setExpandedRow: externalSetExpandedRow, +}) => { + const [sortOrder, setSortOrder] = useState("asc"); const [sortTimeOrder, setSortTimeOrder] = useState("asc"); - const [expandedRow, setExpandedRow] = useState(null); - const [showPopup, setShowPopup] = useState(false); + // Use internal state as fallback if external state is not provided + const [internalExpandedRow, setInternalExpandedRow] = useState(null); + // Use external state if provided, otherwise use internal state + const expandedRow = externalExpandedRow !== undefined ? externalExpandedRow : internalExpandedRow; + const setExpandedRow = externalSetExpandedRow || setInternalExpandedRow; + + + const [showModelPopup, setShowModelPopup] = useState(false); + const [showLabelPopup, setShowLabelPopup] = useState(false); const [popupData, setPopupData] = useState(""); const [labelData, setLabelData] = useState(""); + // Handle expanded row based on filter value - only when filter actually changes + const prevFilterRef = useRef(String(filterValue || "")); + + console.log("artifacts in ArtifactPTable:", artifacts); useEffect(() => { - // if data then set artifacts with that data else set it null. - setData(artifacts); - // handle expanded row based on filter value - if (filterValue.trim() !== ""){ - // expand all rows when filter value is set - setExpandedRow("all"); - }else{ - // collapse all rows when filter value is empty - setExpandedRow(null); - } - }, [artifacts]); + const prev = prevFilterRef.current; + const current = String(filterValue || ""); + if (prev !== current) { + if (current.trim() !== "") { + setExpandedRow("all"); + } else if (prev.trim() !== "") { + // only collapse if previously non-empty + setExpandedRow(null); + } + prevFilterRef.current = current; + } + }, [filterValue, setExpandedRow, artifactType]); // minimal deps + // removed expandedRow - not sure for what reason it was here - const renderArrow = () => ( - - {sortOrder === "asc" ? "↑" : sortOrder === "desc" ? "↓" : "↑"} - - ); - const renderArrowDate = () => ( + const renderArrow = (order) => ( - {sortTimeOrder === "asc" ? "↑" : sortTimeOrder === "desc" ? "↓" : "↑"} + {order === "asc" ? "↑" : "↓"} ); const getPropertyValue = (properties, propertyName) => { - // // Check if properties is a string and parse it + // Check if properties is a string and parse it if (typeof properties === "string") { try { properties = JSON.parse(properties); // Parse the string to an array @@ -73,7 +91,6 @@ const ArtifactPTable = ({artifacts, artifactType, onsortOrder, onsortTimeOrder, // Ensure properties is now an array if (!Array.isArray(properties)) { - console.warn("Expected an array for properties, got:", properties); return "N/A"; } @@ -82,7 +99,7 @@ const ArtifactPTable = ({artifacts, artifactType, onsortOrder, onsortTimeOrder, .filter(prop => prop.name === propertyName) // Filter properties by name .map(prop => prop.value); // Extract string_value - // // Return the values as a comma-separated string or "N/A" if no values are found + // Return the values as a comma-separated string or "N/A" if no values are found return values.length > 0 ? values.join(", ") : "N/A"; }; @@ -92,31 +109,29 @@ const ArtifactPTable = ({artifacts, artifactType, onsortOrder, onsortTimeOrder, setExpandedRow(expandedRow === rowId ? null : rowId); }; + const toggleSort = (currentOrder, setOrder, callback) => { + const newSortOrder = currentOrder === "asc" ? "desc" : "asc"; + setOrder(newSortOrder); + callback(newSortOrder); + }; + const toggleSortOrder = () => { - const newSortOrder = sortOrder === "asc" ? "desc" : "asc"; - setSortOrder(newSortOrder); - onsortOrder(newSortOrder); + toggleSort(sortOrder, setSortOrder, onSortOrder); }; const toggleSortTimeOrder = () => { - const newSortOrder = sortTimeOrder === "asc" ? "desc" : "asc"; - setSortTimeOrder(newSortOrder); - onsortTimeOrder(newSortOrder); + toggleSort(sortTimeOrder, setSortTimeOrder, onSortTimeOrder); }; - const handleClosePopup = () => { - setShowPopup(false); + const handleCloseModelPopup = () => { + setShowModelPopup(false); }; - const getLabelData = (label_name) => { - console.log(label_name) - client.getLabelData(label_name).then((data) => { - console.log(data); - setLabelData(data); - }); - } + const handleCloseLabelPopup = () => { + setShowLabelPopup(false); + }; - const renderLabels = ({ artifact, filterValue, client, setLabelData, setShowPopup, showPopup, labelData, handleClosePopup }) => { + const renderLabels = (artifact) => { const labelsUri = getPropertyValue(artifact.artifact_properties, "labels_uri"); if (!labelsUri || labelsUri === "N/A" || labelsUri.trim() === "") { @@ -135,19 +150,12 @@ const ArtifactPTable = ({artifacts, artifactType, onsortOrder, onsortTimeOrder, e.preventDefault(); client.getLabelData(label_name.split(":")[1] || label_name).then((data) => { setLabelData(data); - setShowPopup(true); + setShowLabelPopup(true); }); }} > - {label_name} + - {showPopup && ( - - )} )); }; @@ -162,8 +170,8 @@ const ArtifactPTable = ({artifacts, artifactType, onsortOrder, onsortTimeOrder, ID - - Name {renderArrow()} + + Name {renderArrow(sortOrder)} Execution TYPE @@ -173,8 +181,8 @@ const ArtifactPTable = ({artifacts, artifactType, onsortOrder, onsortTimeOrder, )} - - DATE {renderArrowDate()} + + DATE {renderArrow(sortTimeOrder)} {artifactType === "Dataset" && ( @@ -198,7 +206,23 @@ const ArtifactPTable = ({artifacts, artifactType, onsortOrder, onsortTimeOrder, { /* Convert artifact ID to string and render it with highlighted search term if it matches the filter value */} - + + {artifactType === "Label" && onLabelClick ? ( + { + e.preventDefault(); + e.stopPropagation(); + onLabelClick(artifact.name, artifact); + }} + className="text-blue-600 hover:text-blue-800 hover:underline cursor-pointer" + > + + + ) : ( + + )} + {artifactType === "Model" && ( @@ -207,34 +231,18 @@ const ArtifactPTable = ({artifacts, artifactType, onsortOrder, onsortTimeOrder, onClick={() => { client.getModelCard(artifact.artifact_id).then((res) => { setPopupData(res); - setShowPopup(true); + setShowModelPopup(true); }); }} > View Model Card - {showPopup && ( - - )} )} {artifactType === "Dataset" && ( - {renderLabels({ - artifact, - filterValue, - client, - setLabelData, - setShowPopup, - showPopup, - labelData, - handleClosePopup - })} + {renderLabels(artifact)} )} @@ -264,6 +272,20 @@ const ArtifactPTable = ({artifacts, artifactType, onsortOrder, onsortTimeOrder, + {showModelPopup && ( + + )} + {showLabelPopup && ( + + )} ); }; diff --git a/ui/src/components/ResizableSplitPane/index.jsx b/ui/src/components/ResizableSplitPane/index.jsx new file mode 100644 index 000000000..4c324a946 --- /dev/null +++ b/ui/src/components/ResizableSplitPane/index.jsx @@ -0,0 +1,179 @@ +/*** + * Copyright (2025) Hewlett Packard Enterprise Development LP + * + * Licensed 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. + ***/ + +import React, { useState, useEffect, useRef, useCallback } from "react"; + +const ResizableSplitPane = ({ + leftContent, + rightContent, + initialSplitPercentage = 50, + minPercentage = 20, + maxPercentage = 80, + onResize = null +}) => { + const [splitPercentage, setSplitPercentage] = useState(initialSplitPercentage); + const [isDragging, setIsDragging] = useState(false); + const containerRef = useRef(null); + const rafRef = useRef(null); + + // Helper to calculate percentage from client position + const calculatePercentage = useCallback((clientX) => { + if (!containerRef.current) return splitPercentage; + + const containerRect = containerRef.current.getBoundingClientRect(); + const newPercentage = ((clientX - containerRect.left) / containerRect.width) * 100; + return Math.max(minPercentage, Math.min(maxPercentage, newPercentage)); + }, [minPercentage, maxPercentage, splitPercentage]); + + // Update split percentage with requestAnimationFrame for smooth performance + const updateSplitPercentage = useCallback((newPercentage) => { + if (rafRef.current) { + cancelAnimationFrame(rafRef.current); + } + + rafRef.current = requestAnimationFrame(() => { + setSplitPercentage(newPercentage); + if (onResize) { + onResize(newPercentage); + } + }); + }, [onResize]); + + const handleMouseMove = useCallback((e) => { + if (!isDragging) return; + const newPercentage = calculatePercentage(e.clientX); + updateSplitPercentage(newPercentage); + }, [isDragging, calculatePercentage, updateSplitPercentage]); + + const handleTouchMove = useCallback((e) => { + if (!isDragging || !e.touches.length) return; + const touch = e.touches[0]; + const newPercentage = calculatePercentage(touch.clientX); + updateSplitPercentage(newPercentage); + }, [isDragging, calculatePercentage, updateSplitPercentage]); + + const handleMouseUp = useCallback(() => { + setIsDragging(false); + }, []); + + const handleMouseDown = (e) => { + setIsDragging(true); + e.preventDefault(); + }; + + const handleTouchStart = (e) => { + setIsDragging(true); + e.preventDefault(); + }; + + const handleKeyDown = (e) => { + const step = e.shiftKey ? 5 : 1; // Hold shift for larger steps + + if (e.key === 'ArrowLeft') { + e.preventDefault(); + const newPercentage = Math.max(minPercentage, splitPercentage - step); + setSplitPercentage(newPercentage); + if (onResize) { + onResize(newPercentage); + } + } else if (e.key === 'ArrowRight') { + e.preventDefault(); + const newPercentage = Math.min(maxPercentage, splitPercentage + step); + setSplitPercentage(newPercentage); + if (onResize) { + onResize(newPercentage); + } + } else if (e.key === 'Home') { + e.preventDefault(); + setSplitPercentage(minPercentage); + if (onResize) { + onResize(minPercentage); + } + } else if (e.key === 'End') { + e.preventDefault(); + setSplitPercentage(maxPercentage); + if (onResize) { + onResize(maxPercentage); + } + } + }; + + useEffect(() => { + if (!isDragging) return; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + document.addEventListener('touchmove', handleTouchMove, { passive: false }); + document.addEventListener('touchend', handleMouseUp); + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + document.removeEventListener('touchmove', handleTouchMove); + document.removeEventListener('touchend', handleMouseUp); + + // Cancel any pending animation frames + if (rafRef.current) { + cancelAnimationFrame(rafRef.current); + } + }; + }, [isDragging, handleMouseMove, handleTouchMove, handleMouseUp]); + + return ( +
+ {/* Left Pane */} +
+ {leftContent} +
+ + {/* Resizer */} +
+
+
+
+
+ + {/* Right Pane */} +
+ {rightContent} +
+
+ ); +}; + +export default ResizableSplitPane; diff --git a/ui/src/pages/artifacts_postgres/components/LabelContentPanel.jsx b/ui/src/pages/artifacts_postgres/components/LabelContentPanel.jsx new file mode 100644 index 000000000..973cb874e --- /dev/null +++ b/ui/src/pages/artifacts_postgres/components/LabelContentPanel.jsx @@ -0,0 +1,161 @@ +/*** + * Copyright (2023) Hewlett Packard Enterprise Development LP + * + * Licensed 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. + ***/ + +import React from "react"; +import Loader from "../../../components/Loader"; + +const LabelContentPanel = ({ + selectedTableLabel, + labelContentLoading, + labelData, + parsedLabelData, + labelColumns, + currentPage, + rowsPerPage, + setCurrentPage, + setRowsPerPage +}) => { + if (!selectedTableLabel) { + return ( +
+
+
+

+ Select a Label +

+

+ Click on a label name in the table to view its content +

+
+
+
+ ); + } + + if (labelContentLoading) { + return ( +
+
+ +
+
+ ); + } + + if (!labelData) { + return ( +
+
+

No content available

+
+
+ ); + } + + return ( +
+
+ {/* Header aligned with left table */} +
+
+

+ {selectedTableLabel.name.split(":")[1] || selectedTableLabel.name} +

+ {selectedTableLabel?.isSearchResult && selectedTableLabel?.searchFilter && ( + + Filtered: {selectedTableLabel.searchFilter} + + )} +
+
+ + {/* Fixed size table container */} +
+
+ + + + {labelColumns.map((column, index) => ( + + ))} + + + + {parsedLabelData.slice(currentPage * rowsPerPage, (currentPage + 1) * rowsPerPage).map((row, rowIndex) => ( + + {labelColumns.map((column, colIndex) => ( + + ))} + + ))} + +
+ {column.name} +
+ {String(row[column.name] || '')} +
+
+ + {/* Pagination controls */} +
+
+ Rows per page: + +
+ +
+ + {currentPage * rowsPerPage + 1}-{Math.min((currentPage + 1) * rowsPerPage, parsedLabelData.length)} of {parsedLabelData.length} + + + +
+
+
+
+
+ ); +}; + +export default LabelContentPanel; diff --git a/ui/src/pages/artifacts_postgres/components/PaginationControls.jsx b/ui/src/pages/artifacts_postgres/components/PaginationControls.jsx new file mode 100644 index 000000000..c072076f9 --- /dev/null +++ b/ui/src/pages/artifacts_postgres/components/PaginationControls.jsx @@ -0,0 +1,108 @@ +/*** + * Copyright (2023) Hewlett Packard Enterprise Development LP + * + * Licensed 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. + ***/ + +import React from "react"; + +const PaginationControls = ({ + totalItems, + activePage, + clickedButton, + onPageClick, + onPrevClick, + onNextClick +}) => { + const totalPages = Math.ceil(totalItems / 5); + + if (totalItems === 0) { + return null; + } + + return ( +
+ + + {Array.from({ length: totalPages }).map((_, index) => { + const pageNumber = index + 1; + + if ( + pageNumber === 1 || + pageNumber === totalPages + ) { + return ( + + ); + } else if ( + (activePage <= 3 && pageNumber <= 6) || + (activePage >= totalPages - 2 && pageNumber >= totalPages - 5) || + Math.abs(pageNumber - activePage) <= 2 + ) { + return ( + + ); + } else if ( + (pageNumber === 2 && activePage > 3) || + (pageNumber === totalPages - 1 && activePage < totalPages - 3) + ) { + return ( + + ... + + ); + } + return null; + })} + + +
+ ); +}; + +export default PaginationControls; diff --git a/ui/src/pages/artifacts_postgres/index.jsx b/ui/src/pages/artifacts_postgres/index.jsx index 270c3c32d..f808c981e 100644 --- a/ui/src/pages/artifacts_postgres/index.jsx +++ b/ui/src/pages/artifacts_postgres/index.jsx @@ -23,6 +23,9 @@ import Footer from "../../components/Footer"; import "./index.css"; import Sidebar from "../../components/Sidebar"; import ArtifactTypeSidebar from "../../components/ArtifactTypeSidebar"; +import LabelContentPanel from "./components/LabelContentPanel"; +import ResizableSplitPane from "../../components/ResizableSplitPane"; +import Papa from "papaparse"; const client = new FastAPIClient(config); @@ -41,6 +44,15 @@ const ArtifactsPostgres = () => { const [clickedButton, setClickedButton] = useState("page"); const [selectedCol, setSelectedCol] = useState("name"); + // Label content panel states + const [selectedTableLabel, setSelectedTableLabel] = useState(null); + const [labelContentLoading, setLabelContentLoading] = useState(false); + const [labelData, setLabelData] = useState(null); + const [parsedLabelData, setParsedLabelData] = useState([]); + const [labelColumns, setLabelColumns] = useState([]); + const [labelCurrentPage, setLabelCurrentPage] = useState(0); + const [labelRowsPerPage, setLabelRowsPerPage] = useState(10); + useEffect(() => { fetchPipelines(); // Fetch pipelines and artifact types when the component mounts },[]); @@ -87,6 +99,11 @@ const ArtifactsPostgres = () => { if (selectedArtifactType !== artifactType) { // if same artifact type is not clicked, sets page as null until it retrieves data for that type. setArtifacts(null); + // Reset label panel state when switching artifact types + setSelectedTableLabel(null); + setLabelData(null); + setParsedLabelData([]); + setLabelColumns([]); } setSelectedArtifactType(artifactType); setActivePage(1); @@ -135,6 +152,51 @@ const ArtifactsPostgres = () => { setClickedButton("next"); handlePageClick(activePage + 1); } + }; + + const handleLabelClick = (labelName, artifact) => { + setSelectedTableLabel({ name: labelName, ...artifact }); + setLabelContentLoading(true); + setLabelCurrentPage(0); // Reset to first page + + // Extract UUID from labelName (format: "path/to/labels.csv:uuid" or just "uuid") + const labelId = labelName.includes(":") ? labelName.split(":")[1] : labelName; + + client.getLabelData(labelId).then((csvData) => { + setLabelData(csvData); + console.log("label CSV data = ", csvData); + + // Parse CSV data using Papa.parse + if (csvData && typeof csvData === 'string' && csvData.trim().length > 0) { + const parsed = Papa.parse(csvData, { header: true }); + console.log("parsed data = ", parsed.data); + + if (parsed.data && parsed.data.length > 0) { + setParsedLabelData(parsed.data); + + // Extract columns from Papa.parse meta fields + if (parsed.meta.fields) { + const columns = parsed.meta.fields.map(field => ({ name: field })); + console.log("columns = ", columns); + setLabelColumns(columns); + } + } else { + console.warn("Parsed CSV data is empty"); + setParsedLabelData([]); + setLabelColumns([]); + } + } else { + console.warn("No CSV data received from getLabelData"); + setParsedLabelData([]); + setLabelColumns([]); + } + setLabelContentLoading(false); + }).catch((error) => { + console.error("Error fetching label data:", error); + setLabelContentLoading(false); + setParsedLabelData([]); + setLabelColumns([]); + }); }; return ( @@ -162,7 +224,123 @@ const ArtifactsPostgres = () => { /> )} -
+ {selectedArtifactType === "Label" ? ( + // Resizable split view for Label artifact type + + {artifacts !== null && artifacts.length > 0 ? ( + <> + + {totalItems > 0 && ( +
+ + {Array.from({ length: Math.ceil(totalItems / 5) }).map( + (_, index) => { + const pageNumber = index + 1; + if ( + pageNumber === 1 || + pageNumber === Math.ceil(totalItems / 5) + ) { + return ( + + ); + } else if ( + (activePage <= 3 && pageNumber <= 6) || + (activePage >= Math.ceil(totalItems / 5) - 2 && + pageNumber >= Math.ceil(totalItems / 5) - 5) || + Math.abs(pageNumber - activePage) <= 2 + ) { + return ( + + ); + } else if ( + (pageNumber === 2 && activePage > 3) || + (pageNumber === Math.ceil(totalItems / 5) - 1 && + activePage < Math.ceil(totalItems / 5) - 3) + ) { + return ( + + ... + + ); + } + return null; + }, + )} + +
+ )} + + ) : ( +
No data available
+ )} +
+ } + rightContent={ + + } + /> + ) : ( + // Standard view for other artifact types +
{artifacts !== null && artifacts.length > 0 ? ( { )} -
+ + )}