From ae102fd74c840aae1a925790510ebf2b6818504d Mon Sep 17 00:00:00 2001 From: ntkathole Date: Wed, 22 Oct 2025 22:28:18 +0530 Subject: [PATCH] feat: Added support for filtering multi-projects Signed-off-by: ntkathole --- sdk/python/feast/ui_server.py | 37 +++- ui/src/components/CommandPalette.tsx | 25 ++- ui/src/components/GlobalSearchShortcut.tsx | 10 - ui/src/components/ProjectSelector.tsx | 18 +- ui/src/components/RegistrySearch.tsx | 12 ++ .../components/RegistryVisualizationTab.tsx | 7 +- ui/src/mocks/handlers.ts | 4 +- ui/src/pages/Layout.tsx | 105 ++++++++-- ui/src/pages/ProjectOverviewPage.tsx | 186 +++++++++++++++++- ui/src/pages/Sidebar.tsx | 2 +- .../data-sources/DataSourceOverviewTab.tsx | 4 +- .../data-sources/DataSourcesListingTable.tsx | 18 +- ui/src/pages/data-sources/Index.tsx | 4 +- .../pages/data-sources/useLoadDataSource.ts | 4 +- .../pages/entities/EntitiesListingTable.tsx | 18 +- ui/src/pages/entities/EntityOverviewTab.tsx | 4 +- ui/src/pages/entities/Index.tsx | 4 +- ui/src/pages/entities/useLoadEntity.ts | 4 +- .../FeatureServiceListingTable.tsx | 18 +- ui/src/pages/feature-services/Index.tsx | 4 +- .../feature-services/useLoadFeatureService.ts | 4 +- .../feature-views/FeatureViewInstance.tsx | 4 +- .../feature-views/FeatureViewLineageTab.tsx | 4 +- .../feature-views/FeatureViewListingTable.tsx | 14 +- ui/src/pages/feature-views/Index.tsx | 4 +- ui/src/pages/features/FeatureListPage.tsx | 50 +++-- ui/src/pages/lineage/Index.tsx | 38 +++- ui/src/pages/permissions/Index.tsx | 7 +- ui/src/queries/useLoadRegistry.ts | 123 ++++++++++-- ui/src/queries/useLoadRelationshipsData.ts | 4 +- 30 files changed, 631 insertions(+), 109 deletions(-) diff --git a/sdk/python/feast/ui_server.py b/sdk/python/feast/ui_server.py index e85a11afc06..8e201bcf944 100644 --- a/sdk/python/feast/ui_server.py +++ b/sdk/python/feast/ui_server.py @@ -61,16 +61,45 @@ def shutdown_event(): with importlib_resources.as_file(ui_dir_ref) as ui_dir: # Initialize with the projects-list.json file with ui_dir.joinpath("projects-list.json").open(mode="w") as f: - projects_dict = { - "projects": [ + # Get all projects from the registry + discovered_projects = [] + registry = store.registry.proto() + + # Use the projects list from the registry + if registry and registry.projects and len(registry.projects) > 0: + for proj in registry.projects: + if proj.spec and proj.spec.name: + discovered_projects.append( + { + "name": proj.spec.name.replace("_", " ").title(), + "description": proj.spec.description + or f"Project: {proj.spec.name}", + "id": proj.spec.name, + "registryPath": f"{root_path}/registry", + } + ) + else: + # If no projects in registry, use the current project from feature_store.yaml + discovered_projects.append( { "name": "Project", "description": "Test project", "id": project_id, "registryPath": f"{root_path}/registry", } - ] - } + ) + + # Add "All Projects" option at the beginning if there are multiple projects + if len(discovered_projects) > 1: + all_projects_entry = { + "name": "All Projects", + "description": "View data across all projects", + "id": "all", + "registryPath": f"{root_path}/registry", + } + discovered_projects.insert(0, all_projects_entry) + + projects_dict = {"projects": discovered_projects} f.write(json.dumps(projects_dict)) @app.get("/registry") diff --git a/ui/src/components/CommandPalette.tsx b/ui/src/components/CommandPalette.tsx index 9750d73e2d9..8e18898400a 100644 --- a/ui/src/components/CommandPalette.tsx +++ b/ui/src/components/CommandPalette.tsx @@ -149,6 +149,7 @@ const CommandPalette: React.FC = ({ ? String(item.spec.description || "") : "", type: getItemType(item, name), + projectId: "projectId" in item ? String(item.projectId) : undefined, }; }); @@ -158,15 +159,7 @@ const CommandPalette: React.FC = ({ }; }); - console.log( - "CommandPalette isOpen:", - isOpen, - "categories:", - categories.length, - ); // Debug log - if (!isOpen) { - console.log("CommandPalette not rendering due to isOpen=false"); return null; } @@ -227,16 +220,11 @@ const CommandPalette: React.FC = ({ href={item.link} onClick={(e) => { e.preventDefault(); - console.log( - "Search result clicked:", - item.name, - ); onClose(); setSearchText(""); - console.log("Navigating to:", item.link); navigate(item.link); }} style={{ @@ -253,6 +241,17 @@ const CommandPalette: React.FC = ({ {item.description} )} + {item.projectId && ( +
+ Project: {item.projectId} +
+ )} {item.type && ( diff --git a/ui/src/components/GlobalSearchShortcut.tsx b/ui/src/components/GlobalSearchShortcut.tsx index 28e55454f30..aa96abe5b97 100644 --- a/ui/src/components/GlobalSearchShortcut.tsx +++ b/ui/src/components/GlobalSearchShortcut.tsx @@ -9,23 +9,13 @@ const GlobalSearchShortcut: React.FC = ({ }) => { useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { - console.log( - "Key pressed:", - event.key, - "metaKey:", - event.metaKey, - "ctrlKey:", - event.ctrlKey, - ); if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { - console.log("Cmd+K detected, preventing default and calling onOpen"); event.preventDefault(); event.stopPropagation(); onOpen(); } }; - console.log("Adding keydown event listener to window"); window.addEventListener("keydown", handleKeyDown, true); return () => { window.removeEventListener("keydown", handleKeyDown, true); diff --git a/ui/src/components/ProjectSelector.tsx b/ui/src/components/ProjectSelector.tsx index 1bb7ebf85a7..ac9057bfb00 100644 --- a/ui/src/components/ProjectSelector.tsx +++ b/ui/src/components/ProjectSelector.tsx @@ -1,11 +1,12 @@ import { EuiSelect, useGeneratedHtmlId } from "@elastic/eui"; import React from "react"; -import { useNavigate, useParams } from "react-router-dom"; +import { useNavigate, useParams, useLocation } from "react-router-dom"; import { useLoadProjectsList } from "../contexts/ProjectListContext"; const ProjectSelector = () => { const { projectName } = useParams(); const navigate = useNavigate(); + const location = useLocation(); const { isLoading, data } = useLoadProjectsList(); @@ -22,7 +23,20 @@ const ProjectSelector = () => { const basicSelectId = useGeneratedHtmlId({ prefix: "basicSelect" }); const onChange = (e: React.ChangeEvent) => { - navigate(`/p/${e.target.value}`); + const newProjectId = e.target.value; + + // If we're on a project page, maintain the current path context + if (projectName && location.pathname.startsWith(`/p/${projectName}`)) { + // Replace the old project name with the new one in the current path + const newPath = location.pathname.replace( + `/p/${projectName}`, + `/p/${newProjectId}`, + ); + navigate(newPath); + } else { + // Otherwise, just navigate to the project home + navigate(`/p/${newProjectId}`); + } }; return ( diff --git a/ui/src/components/RegistrySearch.tsx b/ui/src/components/RegistrySearch.tsx index d9d72a20b1a..46d72966713 100644 --- a/ui/src/components/RegistrySearch.tsx +++ b/ui/src/components/RegistrySearch.tsx @@ -112,6 +112,7 @@ const RegistrySearch = forwardRef( ? String(item.spec.description || "") : "", type: getItemType(item, name), + projectId: "projectId" in item ? String(item.projectId) : undefined, }; }); @@ -187,6 +188,17 @@ const RegistrySearch = forwardRef( {item.description} )} + {item.projectId && ( +
+ Project: {item.projectId} +
+ )}
{item.type && ( diff --git a/ui/src/components/RegistryVisualizationTab.tsx b/ui/src/components/RegistryVisualizationTab.tsx index accf02971c6..ebc77604322 100644 --- a/ui/src/components/RegistryVisualizationTab.tsx +++ b/ui/src/components/RegistryVisualizationTab.tsx @@ -1,4 +1,5 @@ import React, { useContext, useState } from "react"; +import { useParams } from "react-router-dom"; import { EuiEmptyPrompt, EuiLoadingSpinner, @@ -16,7 +17,11 @@ import { filterPermissionsByAction } from "../utils/permissionUtils"; const RegistryVisualizationTab = () => { const registryUrl = useContext(RegistryPathContext); - const { isLoading, isSuccess, isError, data } = useLoadRegistry(registryUrl); + const { projectName } = useParams(); + const { isLoading, isSuccess, isError, data } = useLoadRegistry( + registryUrl, + projectName, + ); const [selectedObjectType, setSelectedObjectType] = useState(""); const [selectedObjectName, setSelectedObjectName] = useState(""); const [selectedPermissionAction, setSelectedPermissionAction] = useState(""); diff --git a/ui/src/mocks/handlers.ts b/ui/src/mocks/handlers.ts index 23904787c16..1c32bb2cf87 100644 --- a/ui/src/mocks/handlers.ts +++ b/ui/src/mocks/handlers.ts @@ -8,12 +8,12 @@ const registry = readFileSync( const projectsListWithDefaultProject = http.get("/projects-list.json", () => HttpResponse.json({ - default: "credit_score_project", + default: "credit_scoring_aws", projects: [ { name: "Credit Score Project", description: "Project for credit scoring team and associated models.", - id: "credit_score_project", + id: "credit_scoring_aws", registryPath: "/registry.db", // Changed to match what the test expects }, ], diff --git a/ui/src/pages/Layout.tsx b/ui/src/pages/Layout.tsx index 4a00eb64a37..0e3341b8820 100644 --- a/ui/src/pages/Layout.tsx +++ b/ui/src/pages/Layout.tsx @@ -42,8 +42,19 @@ const Layout = () => { }); const registryPath = currentProject?.registryPath || ""; - const { data } = useLoadRegistry(registryPath); + // For global search, use the first available registry path (typically all projects share the same registry) + // If projects have different registries, we use the first one as the "global" registry + const globalRegistryPath = + projectsData?.projects?.[0]?.registryPath || registryPath; + + // Load filtered data for current project (for sidebar and page-level search) + const { data } = useLoadRegistry(registryPath, projectName); + + // Load unfiltered data for global search (across all projects) + const { data: globalData } = useLoadRegistry(globalRegistryPath); + + // Categories for page-level search (filtered to current project) const categories = data ? [ { @@ -84,31 +95,92 @@ const Layout = () => { ] : []; + // Helper function to extract project ID from an item + const getProjectId = (item: any): string => { + // Try different possible locations for the project field + return item?.spec?.project || item?.project || projectName || "unknown"; + }; + + // Categories for global search (includes all projects) + const globalCategories = globalData + ? [ + { + name: "Data Sources", + data: (globalData.objects.dataSources || []).map((item: any) => ({ + ...item, + projectId: getProjectId(item), + })), + getLink: (item: any) => { + const project = item?.projectId || getProjectId(item); + return `/p/${project}/data-source/${item.name}`; + }, + }, + { + name: "Entities", + data: (globalData.objects.entities || []).map((item: any) => ({ + ...item, + projectId: getProjectId(item), + })), + getLink: (item: any) => { + const project = item?.projectId || getProjectId(item); + return `/p/${project}/entity/${item.name}`; + }, + }, + { + name: "Features", + data: (globalData.allFeatures || []).map((item: any) => ({ + ...item, + projectId: getProjectId(item), + })), + getLink: (item: any) => { + const featureView = item?.featureView; + const project = item?.projectId || getProjectId(item); + return featureView + ? `/p/${project}/feature-view/${featureView}/feature/${item.name}` + : "#"; + }, + }, + { + name: "Feature Views", + data: (globalData.mergedFVList || []).map((item: any) => ({ + ...item, + projectId: getProjectId(item), + })), + getLink: (item: any) => { + const project = item?.projectId || getProjectId(item); + return `/p/${project}/feature-view/${item.name}`; + }, + }, + { + name: "Feature Services", + data: (globalData.objects.featureServices || []).map((item: any) => ({ + ...item, + projectId: getProjectId(item), + })), + getLink: (item: any) => { + const serviceName = item?.name || item?.spec?.name; + const project = item?.projectId || getProjectId(item); + return serviceName + ? `/p/${project}/feature-service/${serviceName}` + : "#"; + }, + }, + ] + : []; + const handleSearchOpen = () => { - console.log("Opening command palette - before state update"); // Debug log setIsCommandPaletteOpen(true); - console.log("Command palette state should be updated to true"); }; useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { - console.log( - "Layout key pressed:", - event.key, - "metaKey:", - event.metaKey, - "ctrlKey:", - event.ctrlKey, - ); if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { - console.log("Layout detected Cmd+K, preventing default"); event.preventDefault(); event.stopPropagation(); handleSearchOpen(); } }; - console.log("Layout adding keydown event listener"); window.addEventListener("keydown", handleKeyDown, true); return () => { window.removeEventListener("keydown", handleKeyDown, true); @@ -121,7 +193,7 @@ const Layout = () => { setIsCommandPaletteOpen(false)} - categories={categories} + categories={globalCategories} /> { grow={false} style={{ width: "600px", maxWidth: "90%" }} > - + diff --git a/ui/src/pages/ProjectOverviewPage.tsx b/ui/src/pages/ProjectOverviewPage.tsx index aa8d7cd4745..839fbcc5d89 100644 --- a/ui/src/pages/ProjectOverviewPage.tsx +++ b/ui/src/pages/ProjectOverviewPage.tsx @@ -9,6 +9,9 @@ import { EuiSkeletonText, EuiEmptyPrompt, EuiFieldSearch, + EuiPanel, + EuiStat, + EuiCard, } from "@elastic/eui"; import { useDocumentTitle } from "../hooks/useDocumentTitle"; @@ -18,14 +21,191 @@ import useLoadRegistry from "../queries/useLoadRegistry"; import RegistryPathContext from "../contexts/RegistryPathContext"; import RegistryVisualizationTab from "../components/RegistryVisualizationTab"; import RegistrySearch from "../components/RegistrySearch"; -import { useParams } from "react-router-dom"; +import { useParams, useNavigate } from "react-router-dom"; +import { useLoadProjectsList } from "../contexts/ProjectListContext"; + +// Component for "All Projects" view +const AllProjectsDashboard = () => { + const registryUrl = useContext(RegistryPathContext); + const navigate = useNavigate(); + const { data: projectsData } = useLoadProjectsList(); + const { data: registryData } = useLoadRegistry(registryUrl); + + if (!registryData) { + return ; + } + + // Calculate total counts across all projects + const totalCounts = { + featureViews: registryData.objects.featureViews?.length || 0, + entities: registryData.objects.entities?.length || 0, + dataSources: registryData.objects.dataSources?.length || 0, + featureServices: registryData.objects.featureServices?.length || 0, + features: registryData.allFeatures?.length || 0, + }; + + // Get projects from registry and count their objects + const projects = projectsData?.projects.filter((p) => p.id !== "all") || []; + const projectStats = projects.map((project) => { + const projectFVs = + registryData.objects.featureViews?.filter( + (fv: any) => fv?.spec?.project === project.id, + ) || []; + const projectEntities = + registryData.objects.entities?.filter( + (e: any) => e?.spec?.project === project.id, + ) || []; + const projectFeatures = + registryData.allFeatures?.filter((f: any) => f?.project === project.id) || + []; + + return { + ...project, + counts: { + featureViews: projectFVs.length, + entities: projectEntities.length, + features: projectFeatures.length, + }, + }; + }); + + return ( + + + +

All Projects Overview

+
+ + + +

+ View aggregated statistics and explore data across all your Feast + projects. +

+
+ + + {/* Total Stats */} + + +

Total Across All Projects

+
+ + + + + + + + + + + + + + + + + + +
+ + + + {/* Individual Projects */} + +

Projects ({projects.length})

+
+ + + {projectStats.map((project) => ( + + navigate(`/p/${project.id}`)} + style={{ cursor: "pointer" }} + > + + + + + {project.counts.featureViews} +
+ + Feature Views + +
+
+ + + {project.counts.entities} +
+ + Entities + +
+
+ + + {project.counts.features} +
+ + Features + +
+
+
+
+
+ ))} +
+
+
+ ); +}; const ProjectOverviewPage = () => { useDocumentTitle("Feast Home"); const registryUrl = useContext(RegistryPathContext); - const { isLoading, isSuccess, isError, data } = useLoadRegistry(registryUrl); - const { projectName } = useParams<{ projectName: string }>(); + const { isLoading, isSuccess, isError, data } = useLoadRegistry( + registryUrl, + projectName, + ); + + // Show aggregated dashboard for "All Projects" view + if (projectName === "all") { + return ; + } const categories = [ { diff --git a/ui/src/pages/Sidebar.tsx b/ui/src/pages/Sidebar.tsx index d7a5a54cda0..55c8ec805c9 100644 --- a/ui/src/pages/Sidebar.tsx +++ b/ui/src/pages/Sidebar.tsx @@ -17,8 +17,8 @@ import { PermissionsIcon } from "../graphics/PermissionsIcon"; const SideNav = () => { const registryUrl = useContext(RegistryPathContext); - const { isSuccess, data } = useLoadRegistry(registryUrl); const { projectName } = useParams(); + const { isSuccess, data } = useLoadRegistry(registryUrl, projectName); const [isSideNavOpenOnMobile, setisSideNavOpenOnMobile] = useState(false); diff --git a/ui/src/pages/data-sources/DataSourceOverviewTab.tsx b/ui/src/pages/data-sources/DataSourceOverviewTab.tsx index e4931aa7c50..d702034a558 100644 --- a/ui/src/pages/data-sources/DataSourceOverviewTab.tsx +++ b/ui/src/pages/data-sources/DataSourceOverviewTab.tsx @@ -27,9 +27,9 @@ import RequestDataSourceSchemaTable from "./RequestDataSourceSchemaTable"; import useLoadDataSource from "./useLoadDataSource"; const DataSourceOverviewTab = () => { - let { dataSourceName } = useParams(); + let { dataSourceName, projectName } = useParams(); const registryUrl = useContext(RegistryPathContext); - const registryQuery = useLoadRegistry(registryUrl); + const registryQuery = useLoadRegistry(registryUrl, projectName); const dsName = dataSourceName === undefined ? "" : dataSourceName; const { isLoading, isSuccess, isError, data, consumingFeatureViews } = diff --git a/ui/src/pages/data-sources/DataSourcesListingTable.tsx b/ui/src/pages/data-sources/DataSourcesListingTable.tsx index fd1ff73deb7..c314a4dfb94 100644 --- a/ui/src/pages/data-sources/DataSourcesListingTable.tsx +++ b/ui/src/pages/data-sources/DataSourcesListingTable.tsx @@ -18,9 +18,11 @@ const DatasourcesListingTable = ({ name: "Name", field: "name", sortable: true, - render: (name: string) => { + render: (name: string, item: feast.core.IDataSource) => { + // For "All Projects" view, link to the specific project + const itemProject = item?.project || projectName; return ( - + {name} ); @@ -36,6 +38,18 @@ const DatasourcesListingTable = ({ }, ]; + // Add Project column when viewing all projects + if (projectName === "all") { + columns.splice(1, 0, { + name: "Project", + field: "project", + sortable: true, + render: (project: string) => { + return {project || "Unknown"}; + }, + }); + } + const getRowProps = (item: feast.core.IDataSource) => { return { "data-test-subj": `row-${item.name}`, diff --git a/ui/src/pages/data-sources/Index.tsx b/ui/src/pages/data-sources/Index.tsx index 59bdcecd1df..96aef712aec 100644 --- a/ui/src/pages/data-sources/Index.tsx +++ b/ui/src/pages/data-sources/Index.tsx @@ -1,4 +1,5 @@ import React, { useContext } from "react"; +import { useParams } from "react-router-dom"; import { EuiPageTemplate, @@ -22,7 +23,8 @@ import ExportButton from "../../components/ExportButton"; const useLoadDatasources = () => { const registryUrl = useContext(RegistryPathContext); - const registryQuery = useLoadRegistry(registryUrl); + const { projectName } = useParams(); + const registryQuery = useLoadRegistry(registryUrl, projectName); const data = registryQuery.data === undefined diff --git a/ui/src/pages/data-sources/useLoadDataSource.ts b/ui/src/pages/data-sources/useLoadDataSource.ts index aa9f4e731bf..43f697fca03 100644 --- a/ui/src/pages/data-sources/useLoadDataSource.ts +++ b/ui/src/pages/data-sources/useLoadDataSource.ts @@ -1,11 +1,13 @@ import { useContext } from "react"; +import { useParams } from "react-router-dom"; import RegistryPathContext from "../../contexts/RegistryPathContext"; import { FEAST_FCO_TYPES } from "../../parsers/types"; import useLoadRegistry from "../../queries/useLoadRegistry"; const useLoadDataSource = (dataSourceName: string) => { const registryUrl = useContext(RegistryPathContext); - const registryQuery = useLoadRegistry(registryUrl); + const { projectName } = useParams(); + const registryQuery = useLoadRegistry(registryUrl, projectName); const data = registryQuery.data === undefined diff --git a/ui/src/pages/entities/EntitiesListingTable.tsx b/ui/src/pages/entities/EntitiesListingTable.tsx index 06190409b04..51ffb7c8609 100644 --- a/ui/src/pages/entities/EntitiesListingTable.tsx +++ b/ui/src/pages/entities/EntitiesListingTable.tsx @@ -18,9 +18,11 @@ const EntitiesListingTable = ({ entities }: EntitiesListingTableProps) => { name: "Name", field: "spec.name", sortable: true, - render: (name: string) => { + render: (name: string, item: feast.core.IEntity) => { + // For "All Projects" view, link to the specific project + const itemProject = item?.spec?.project || projectName; return ( - + {name} ); @@ -46,6 +48,18 @@ const EntitiesListingTable = ({ entities }: EntitiesListingTableProps) => { }, ]; + // Add Project column when viewing all projects + if (projectName === "all") { + columns.splice(1, 0, { + name: "Project", + field: "spec.project", + sortable: true, + render: (project: string) => { + return {project || "Unknown"}; + }, + }); + } + const getRowProps = (item: feast.core.IEntity) => { return { "data-test-subj": `row-${item?.spec?.name}`, diff --git a/ui/src/pages/entities/EntityOverviewTab.tsx b/ui/src/pages/entities/EntityOverviewTab.tsx index 09d9aaa3446..8a20688d140 100644 --- a/ui/src/pages/entities/EntityOverviewTab.tsx +++ b/ui/src/pages/entities/EntityOverviewTab.tsx @@ -28,9 +28,9 @@ import useFeatureViewEdgesByEntity from "./useFeatureViewEdgesByEntity"; import useLoadEntity from "./useLoadEntity"; const EntityOverviewTab = () => { - let { entityName } = useParams(); + let { entityName, projectName } = useParams(); const registryUrl = useContext(RegistryPathContext); - const registryQuery = useLoadRegistry(registryUrl); + const registryQuery = useLoadRegistry(registryUrl, projectName); const eName = entityName === undefined ? "" : entityName; const { isLoading, isSuccess, isError, data } = useLoadEntity(eName); diff --git a/ui/src/pages/entities/Index.tsx b/ui/src/pages/entities/Index.tsx index bed1bfb762c..070c53d38fa 100644 --- a/ui/src/pages/entities/Index.tsx +++ b/ui/src/pages/entities/Index.tsx @@ -1,4 +1,5 @@ import React, { useContext } from "react"; +import { useParams } from "react-router-dom"; import { EuiPageTemplate, EuiLoadingSpinner } from "@elastic/eui"; @@ -13,7 +14,8 @@ import ExportButton from "../../components/ExportButton"; const useLoadEntities = () => { const registryUrl = useContext(RegistryPathContext); - const registryQuery = useLoadRegistry(registryUrl); + const { projectName } = useParams(); + const registryQuery = useLoadRegistry(registryUrl, projectName); const data = registryQuery.data === undefined diff --git a/ui/src/pages/entities/useLoadEntity.ts b/ui/src/pages/entities/useLoadEntity.ts index e3e2ede8c2c..fdb4a7968f1 100644 --- a/ui/src/pages/entities/useLoadEntity.ts +++ b/ui/src/pages/entities/useLoadEntity.ts @@ -1,10 +1,12 @@ import { useContext } from "react"; +import { useParams } from "react-router-dom"; import RegistryPathContext from "../../contexts/RegistryPathContext"; import useLoadRegistry from "../../queries/useLoadRegistry"; const useLoadEntity = (entityName: string) => { const registryUrl = useContext(RegistryPathContext); - const registryQuery = useLoadRegistry(registryUrl); + const { projectName } = useParams(); + const registryQuery = useLoadRegistry(registryUrl, projectName); const data = registryQuery.data === undefined diff --git a/ui/src/pages/feature-services/FeatureServiceListingTable.tsx b/ui/src/pages/feature-services/FeatureServiceListingTable.tsx index 69d4d1f969d..acc68b6e619 100644 --- a/ui/src/pages/feature-services/FeatureServiceListingTable.tsx +++ b/ui/src/pages/feature-services/FeatureServiceListingTable.tsx @@ -28,9 +28,11 @@ const FeatureServiceListingTable = ({ { name: "Name", field: "spec.name", - render: (name: string) => { + render: (name: string, item: feast.core.IFeatureService) => { + // For "All Projects" view, link to the specific project + const itemProject = item?.spec?.project || projectName; return ( - + {name} ); @@ -56,6 +58,18 @@ const FeatureServiceListingTable = ({ }, ]; + // Add Project column when viewing all projects + if (projectName === "all") { + columns.splice(1, 0, { + name: "Project", + field: "spec.project", + sortable: true, + render: (project: string) => { + return project || "Unknown"; + }, + }); + } + tagKeysSet.forEach((key) => { columns.push({ name: key, diff --git a/ui/src/pages/feature-services/Index.tsx b/ui/src/pages/feature-services/Index.tsx index 0da8986e610..260a9b821dc 100644 --- a/ui/src/pages/feature-services/Index.tsx +++ b/ui/src/pages/feature-services/Index.tsx @@ -1,4 +1,5 @@ import React, { useContext } from "react"; +import { useParams } from "react-router-dom"; import { EuiPageTemplate, @@ -30,7 +31,8 @@ import { feast } from "../../protos"; const useLoadFeatureServices = () => { const registryUrl = useContext(RegistryPathContext); - const registryQuery = useLoadRegistry(registryUrl); + const { projectName } = useParams(); + const registryQuery = useLoadRegistry(registryUrl, projectName); const data = registryQuery.data === undefined diff --git a/ui/src/pages/feature-services/useLoadFeatureService.ts b/ui/src/pages/feature-services/useLoadFeatureService.ts index fe21fe2d36b..004ab35b927 100644 --- a/ui/src/pages/feature-services/useLoadFeatureService.ts +++ b/ui/src/pages/feature-services/useLoadFeatureService.ts @@ -1,5 +1,6 @@ import { FEAST_FCO_TYPES } from "../../parsers/types"; import { useContext } from "react"; +import { useParams } from "react-router-dom"; import RegistryPathContext from "../../contexts/RegistryPathContext"; import useLoadRegistry from "../../queries/useLoadRegistry"; @@ -7,7 +8,8 @@ import { EntityReference } from "../../parsers/parseEntityRelationships"; const useLoadFeatureService = (featureServiceName: string) => { const registryUrl = useContext(RegistryPathContext); - const registryQuery = useLoadRegistry(registryUrl); + const { projectName } = useParams(); + const registryQuery = useLoadRegistry(registryUrl, projectName); const data = registryQuery.data === undefined diff --git a/ui/src/pages/feature-views/FeatureViewInstance.tsx b/ui/src/pages/feature-views/FeatureViewInstance.tsx index 4a0cc6a9129..93d0245b9fa 100644 --- a/ui/src/pages/feature-views/FeatureViewInstance.tsx +++ b/ui/src/pages/feature-views/FeatureViewInstance.tsx @@ -14,9 +14,9 @@ import useLoadRegistry from "../../queries/useLoadRegistry"; import RegistryPathContext from "../../contexts/RegistryPathContext"; const FeatureViewInstance = () => { - const { featureViewName } = useParams(); + const { featureViewName, projectName } = useParams(); const registryUrl = React.useContext(RegistryPathContext); - const registryQuery = useLoadRegistry(registryUrl); + const registryQuery = useLoadRegistry(registryUrl, projectName); const fvName = featureViewName === undefined ? "" : featureViewName; diff --git a/ui/src/pages/feature-views/FeatureViewLineageTab.tsx b/ui/src/pages/feature-views/FeatureViewLineageTab.tsx index 39c31e17dbd..8759935e8ea 100644 --- a/ui/src/pages/feature-views/FeatureViewLineageTab.tsx +++ b/ui/src/pages/feature-views/FeatureViewLineageTab.tsx @@ -22,13 +22,13 @@ interface FeatureViewLineageTabProps { const FeatureViewLineageTab = ({ data }: FeatureViewLineageTabProps) => { const registryUrl = useContext(RegistryPathContext); + const { featureViewName, projectName } = useParams(); const { isLoading, isSuccess, isError, data: registryData, - } = useLoadRegistry(registryUrl); - const { featureViewName } = useParams(); + } = useLoadRegistry(registryUrl, projectName); const [selectedPermissionAction, setSelectedPermissionAction] = useState(""); const filterNode = { diff --git a/ui/src/pages/feature-views/FeatureViewListingTable.tsx b/ui/src/pages/feature-views/FeatureViewListingTable.tsx index cf0fc305f84..e865abe6e74 100644 --- a/ui/src/pages/feature-views/FeatureViewListingTable.tsx +++ b/ui/src/pages/feature-views/FeatureViewListingTable.tsx @@ -30,8 +30,10 @@ const FeatureViewListingTable = ({ field: "name", sortable: true, render: (name: string, item: genericFVType) => { + // For "All Projects" view, link to the specific project + const itemProject = item.object?.spec?.project || projectName; return ( - + {name}{" "} {(item.type === "ondemand" && ondemand) || (item.type === "stream" && stream)} @@ -49,6 +51,16 @@ const FeatureViewListingTable = ({ }, ]; + // Add Project column when viewing all projects + if (projectName === "all") { + columns.splice(1, 0, { + name: "Project", + render: (item: genericFVType) => { + return {item.object?.spec?.project || "Unknown"}; + }, + }); + } + // Add columns if they come up in search tagKeysSet.forEach((key) => { columns.push({ diff --git a/ui/src/pages/feature-views/Index.tsx b/ui/src/pages/feature-views/Index.tsx index 57ac597168b..b1c28895370 100644 --- a/ui/src/pages/feature-views/Index.tsx +++ b/ui/src/pages/feature-views/Index.tsx @@ -1,4 +1,5 @@ import React, { useContext } from "react"; +import { useParams } from "react-router-dom"; import { EuiPageTemplate, @@ -29,7 +30,8 @@ import ExportButton from "../../components/ExportButton"; const useLoadFeatureViews = () => { const registryUrl = useContext(RegistryPathContext); - const registryQuery = useLoadRegistry(registryUrl); + const { projectName } = useParams(); + const registryQuery = useLoadRegistry(registryUrl, projectName); const data = registryQuery.data === undefined diff --git a/ui/src/pages/features/FeatureListPage.tsx b/ui/src/pages/features/FeatureListPage.tsx index 72428dde494..36087f98bc0 100644 --- a/ui/src/pages/features/FeatureListPage.tsx +++ b/ui/src/pages/features/FeatureListPage.tsx @@ -33,6 +33,7 @@ interface Feature { name: string; featureView: string; type: string; + project?: string; permissions?: any[]; } @@ -43,7 +44,10 @@ type FeatureColumn = const FeatureListPage = () => { const { projectName } = useParams(); const registryUrl = useContext(RegistryPathContext); - const { data, isLoading, isError } = useLoadRegistry(registryUrl); + const { data, isLoading, isError } = useLoadRegistry( + registryUrl, + projectName, + ); const [searchText, setSearchText] = useState(""); const [selectedPermissionAction, setSelectedPermissionAction] = useState(""); @@ -95,23 +99,31 @@ const FeatureListPage = () => { name: "Feature Name", field: "name", sortable: true, - render: (name: string, feature: Feature) => ( - - {name} - - ), + render: (name: string, feature: Feature) => { + // For "All Projects" view, link to the specific project + const itemProject = feature.project || projectName; + return ( + + {name} + + ); + }, }, { name: "Feature View", field: "featureView", sortable: true, - render: (featureView: string) => ( - - {featureView} - - ), + render: (featureView: string, feature: Feature) => { + // For "All Projects" view, link to the specific project + const itemProject = feature.project || projectName; + return ( + + {featureView} + + ); + }, }, { name: "Type", field: "type", sortable: true }, { @@ -144,6 +156,18 @@ const FeatureListPage = () => { }, ]; + // Add Project column when viewing all projects + if (projectName === "all") { + columns.splice(1, 0, { + name: "Project", + field: "project", + sortable: true, + render: (project: string) => { + return {project || "Unknown"}; + }, + }); + } + const onTableChange = ({ page, sort }: CriteriaWithPagination) => { if (sort) { setSortField(sort.field as keyof Feature); diff --git a/ui/src/pages/lineage/Index.tsx b/ui/src/pages/lineage/Index.tsx index 24112ea8571..a3a9ca19296 100644 --- a/ui/src/pages/lineage/Index.tsx +++ b/ui/src/pages/lineage/Index.tsx @@ -16,8 +16,44 @@ import { useParams } from "react-router-dom"; const LineagePage = () => { useDocumentTitle("Feast Lineage"); const registryUrl = useContext(RegistryPathContext); - const { isLoading, isSuccess, isError, data } = useLoadRegistry(registryUrl); const { projectName } = useParams<{ projectName: string }>(); + const { isLoading, isSuccess, isError, data } = useLoadRegistry( + registryUrl, + projectName, + ); + + // Show message for "All Projects" view + if (projectName === "all") { + return ( + + + +

Lineage Visualization

+
+ + Project Selection Required} + body={ + <> +

+ Lineage visualization requires a specific project context to + show the relationships between Feature Views, Entities, and + Data Sources. +

+

+ + Please select a specific project from the dropdown above + {" "} + to view its lineage graph. +

+ + } + /> +
+
+ ); + } return ( diff --git a/ui/src/pages/permissions/Index.tsx b/ui/src/pages/permissions/Index.tsx index 683d3dcdba0..76dde026e90 100644 --- a/ui/src/pages/permissions/Index.tsx +++ b/ui/src/pages/permissions/Index.tsx @@ -13,6 +13,7 @@ import { EuiFormRow, } from "@elastic/eui"; import { useContext, useState } from "react"; +import { useParams } from "react-router-dom"; import RegistryPathContext from "../../contexts/RegistryPathContext"; import useLoadRegistry from "../../queries/useLoadRegistry"; import PermissionsDisplay from "../../components/PermissionsDisplay"; @@ -20,7 +21,11 @@ import { filterPermissionsByAction } from "../../utils/permissionUtils"; const PermissionsIndex = () => { const registryUrl = useContext(RegistryPathContext); - const { isLoading, isSuccess, isError, data } = useLoadRegistry(registryUrl); + const { projectName } = useParams(); + const { isLoading, isSuccess, isError, data } = useLoadRegistry( + registryUrl, + projectName, + ); const [selectedPermissionAction, setSelectedPermissionAction] = useState(""); return ( diff --git a/ui/src/queries/useLoadRegistry.ts b/ui/src/queries/useLoadRegistry.ts index 4354ec0e98e..e3f5ac87a1d 100644 --- a/ui/src/queries/useLoadRegistry.ts +++ b/ui/src/queries/useLoadRegistry.ts @@ -22,11 +22,12 @@ interface Feature { name: string; featureView: string; type: string; + project?: string; } -const useLoadRegistry = (url: string) => { +const useLoadRegistry = (url: string, projectName?: string) => { return useQuery( - `registry:${url}`, + `registry:${url}:${projectName || "all"}`, () => { return fetch(url, { headers: { @@ -55,6 +56,78 @@ const useLoadRegistry = (url: string) => { objects.featureViews = []; } + // Filter objects by project if projectName is provided + // Skip filtering if projectName is "all" (All Projects view) + // Only filter if we detect that the registry contains multiple projects + if (projectName && projectName !== "all") { + // Check if the registry actually has multiple projects + const projectsInRegistry = new Set(); + objects.featureViews?.forEach((fv: any) => { + if (fv?.spec?.project) projectsInRegistry.add(fv.spec.project); + }); + objects.entities?.forEach((entity: any) => { + if (entity?.spec?.project) + projectsInRegistry.add(entity.spec.project); + }); + + // Only apply filtering if there are actually multiple projects in the registry + // OR if the projectName matches one of the projects in the registry + const shouldFilter = + projectsInRegistry.size > 1 || + projectsInRegistry.has(projectName); + + if (shouldFilter && projectsInRegistry.has(projectName)) { + if (objects.featureViews) { + objects.featureViews = objects.featureViews.filter( + (fv: any) => fv?.spec?.project === projectName, + ); + } + if (objects.entities) { + objects.entities = objects.entities.filter( + (entity: any) => entity?.spec?.project === projectName, + ); + } + if (objects.dataSources) { + objects.dataSources = objects.dataSources.filter( + (ds: any) => ds?.project === projectName, + ); + } + if (objects.featureServices) { + objects.featureServices = objects.featureServices.filter( + (fs: any) => fs?.spec?.project === projectName, + ); + } + if (objects.onDemandFeatureViews) { + objects.onDemandFeatureViews = + objects.onDemandFeatureViews.filter( + (odfv: any) => odfv?.spec?.project === projectName, + ); + } + if (objects.streamFeatureViews) { + objects.streamFeatureViews = objects.streamFeatureViews.filter( + (sfv: any) => sfv?.spec?.project === projectName, + ); + } + if (objects.savedDatasets) { + objects.savedDatasets = objects.savedDatasets.filter( + (sd: any) => sd?.spec?.project === projectName, + ); + } + if (objects.validationReferences) { + objects.validationReferences = + objects.validationReferences.filter( + (vr: any) => vr?.project === projectName, + ); + } + if (objects.permissions) { + objects.permissions = objects.permissions.filter( + (perm: any) => + perm?.spec?.project === projectName || !perm?.spec?.project, + ); + } + } + } + if ( process.env.NODE_ENV === "test" && objects.featureViews.length === 0 @@ -107,32 +180,42 @@ const useLoadRegistry = (url: string) => { feature.valueType != null ? feast.types.ValueType.Enum[feature.valueType] : "Unknown Type", + project: fv?.spec?.project, // Include project from parent feature view })) || [], ) || []; - let projectName = - process.env.NODE_ENV === "test" - ? "credit_scoring_aws" - : objects.projects && - objects.projects.length > 0 && - objects.projects[0].spec && - objects.projects[0].spec.name - ? objects.projects[0].spec.name - : objects.project - ? objects.project - : "credit_scoring_aws"; + // Use the provided projectName parameter if available, otherwise try to determine from registry + let resolvedProjectName: string = + projectName === "all" + ? "All Projects" + : projectName || + (process.env.NODE_ENV === "test" + ? "credit_scoring_aws" + : objects.projects && + objects.projects.length > 0 && + objects.projects[0].spec && + objects.projects[0].spec.name + ? objects.projects[0].spec.name + : objects.project + ? objects.project + : "credit_scoring_aws"); let projectDescription = undefined; - if ( - objects.projects && - objects.projects.length > 0 && - objects.projects[0].spec - ) { - projectDescription = objects.projects[0].spec.description; + + // Find project description from the projects array + if (projectName === "all") { + projectDescription = "View data across all projects"; + } else if (objects.projects && objects.projects.length > 0) { + const currentProject = objects.projects.find( + (p: any) => p?.spec?.name === resolvedProjectName, + ); + if (currentProject?.spec) { + projectDescription = currentProject.spec.description; + } } return { - project: projectName, + project: resolvedProjectName, description: projectDescription, objects, mergedFVMap, diff --git a/ui/src/queries/useLoadRelationshipsData.ts b/ui/src/queries/useLoadRelationshipsData.ts index 6f65af7e764..c0b7f1c1a28 100644 --- a/ui/src/queries/useLoadRelationshipsData.ts +++ b/ui/src/queries/useLoadRelationshipsData.ts @@ -1,10 +1,12 @@ import { useContext } from "react"; +import { useParams } from "react-router-dom"; import RegistryPathContext from "../contexts/RegistryPathContext"; import useLoadRegistry from "./useLoadRegistry"; const useLoadRelationshipData = () => { const registryUrl = useContext(RegistryPathContext); - const registryQuery = useLoadRegistry(registryUrl); + const { projectName } = useParams(); + const registryQuery = useLoadRegistry(registryUrl, projectName); const data = registryQuery.data === undefined