diff --git a/sdk/python/feast/cli/ui.py b/sdk/python/feast/cli/ui.py
index 9fd7b24b7cd..ac4a8b3e220 100644
--- a/sdk/python/feast/cli/ui.py
+++ b/sdk/python/feast/cli/ui.py
@@ -70,7 +70,6 @@ def ui(
"Please configure --key and --cert args to start the feature server in SSL mode."
)
store = create_feature_store(ctx)
- # Pass in the registry_dump method to get around a circular dependency
store.serve_ui(
host=host,
port=port,
diff --git a/sdk/python/feast/ui_server.py b/sdk/python/feast/ui_server.py
index 99a4abc9c81..22044ed3196 100644
--- a/sdk/python/feast/ui_server.py
+++ b/sdk/python/feast/ui_server.py
@@ -1,4 +1,5 @@
import json
+import logging
import threading
from importlib import resources as importlib_resources
from typing import Callable, Optional
@@ -10,24 +11,59 @@
import feast
+logger = logging.getLogger(__name__)
-def get_app(
+
+def _build_projects_list(
store: "feast.FeatureStore",
project_id: str,
- registry_ttl_secs: int,
- root_path: str = "",
+ root_path: str,
):
- app = FastAPI()
+ """Build the projects list for the UI."""
+ discovered_projects = []
+ registry = store.registry.proto()
- app.add_middleware(
- CORSMiddleware,
- allow_origins=["*"],
- allow_credentials=True,
- allow_methods=["*"],
- allow_headers=["*"],
- )
+ registry_path_template = f"{root_path}/api/v1"
+
+ 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": registry_path_template,
+ }
+ )
+ else:
+ discovered_projects.append(
+ {
+ "name": "Project",
+ "description": "Test project",
+ "id": project_id,
+ "registryPath": registry_path_template,
+ }
+ )
+
+ if len(discovered_projects) > 1:
+ all_projects_entry = {
+ "name": "All Projects",
+ "description": "View data across all projects",
+ "id": "all",
+ "registryPath": registry_path_template,
+ }
+ discovered_projects.insert(0, all_projects_entry)
+
+ return {"projects": discovered_projects}
+
+
+def _setup_rest_mode(app: FastAPI, store: "feast.FeatureStore", registry_ttl_secs: int):
+ """Mount the REST registry API routes on the UI server under /api/v1."""
+ from feast.api.registry.rest import register_all_routes
+ from feast.registry_server import RegistryServer
- # Asynchronously refresh registry, notifying shutdown and canceling the active timer if the app is shutting down
registry_proto = None
shutting_down = False
active_timer: Optional[threading.Timer] = None
@@ -51,61 +87,11 @@ def shutdown_event():
async_refresh()
- ui_dir_ref = importlib_resources.files(__spec__.parent) / "ui/build/" # type: ignore[name-defined, arg-type]
- 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:
- # 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",
- }
- )
+ grpc_handler = RegistryServer(store.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")
- def read_registry():
- if registry_proto is None:
- return Response(
- status_code=status.HTTP_503_SERVICE_UNAVAILABLE
- ) # Service Unavailable
- return Response(
- content=registry_proto.SerializeToString(),
- media_type="application/octet-stream",
- )
+ rest_app = FastAPI(root_path="/api/v1")
+ register_all_routes(rest_app, grpc_handler)
+ app.mount("/api/v1", rest_app)
@app.get("/health")
def health():
@@ -115,14 +101,38 @@ def health():
else Response(status_code=status.HTTP_503_SERVICE_UNAVAILABLE)
)
- # For all other paths (such as paths that would otherwise be handled by react router), pass to React
+ logger.info("REST registry API mounted at /api/v1")
+
+
+def get_app(
+ store: "feast.FeatureStore",
+ project_id: str,
+ registry_ttl_secs: int,
+ root_path: str = "",
+):
+ app = FastAPI()
+
+ app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+ )
+
+ _setup_rest_mode(app, store, registry_ttl_secs)
+
+ ui_dir_ref = importlib_resources.files(__spec__.parent) / "ui/build/" # type: ignore[name-defined, arg-type]
+ with importlib_resources.as_file(ui_dir_ref) as ui_dir:
+ projects_dict = _build_projects_list(store, project_id, root_path)
+ with ui_dir.joinpath("projects-list.json").open(mode="w") as f:
+ f.write(json.dumps(projects_dict))
+
@app.api_route("/p/{path_name:path}", methods=["GET"])
def catch_all():
filename = ui_dir.joinpath("index.html")
-
with open(filename) as f:
content = f.read()
-
return Response(content, media_type="text/html")
app.mount(
@@ -151,6 +161,9 @@ def start_server(
registry_ttl_sec,
root_path,
)
+
+ logger.info(f"Starting Feast UI server on {host}:{port}")
+
if tls_key_path and tls_cert_path:
uvicorn.run(
app,
diff --git a/sdk/python/tests/unit/test_ui_server.py b/sdk/python/tests/unit/test_ui_server.py
index 36389f7b860..92689fffa3c 100644
--- a/sdk/python/tests/unit/test_ui_server.py
+++ b/sdk/python/tests/unit/test_ui_server.py
@@ -23,12 +23,10 @@ def _create_mock_ui_files(temp_dir):
ui_dir = os.path.join(temp_dir, "ui", "build")
os.makedirs(ui_dir, exist_ok=True)
- # Create projects-list.json file
projects_file = os.path.join(ui_dir, "projects-list.json")
with open(projects_file, "w") as f:
json.dump({"projects": []}, f)
- # Create index.html file
index_file = os.path.join(ui_dir, "index.html")
with open(index_file, "w") as f:
f.write("
Test UI")
@@ -36,20 +34,13 @@ def _create_mock_ui_files(temp_dir):
@contextlib.contextmanager
def _setup_importlib_mocks(temp_dir):
- """Helper function to setup importlib resource mocks.
-
- This function mocks the importlib_resources functionality used by the UI server
- to serve static files. It creates a proper context manager that returns the
- temporary directory path when used with importlib_resources.as_file().
- """
+ """Helper function to setup importlib resource mocks."""
mock_path = Path(temp_dir)
- # Create a proper context manager mock
mock_context_manager = MagicMock()
mock_context_manager.__enter__.return_value = mock_path
mock_context_manager.__exit__.return_value = None
- # Mock the files() method to return a mock that supports division
mock_file_ref = MagicMock()
mock_file_ref.__truediv__.return_value = MagicMock()
@@ -73,15 +64,11 @@ def mock_feature_store():
@pytest.fixture
def ui_app_with_registry(mock_feature_store):
- """Fixture for UI app with valid registry data.
-
- Creates a UI app instance with a properly configured feature store
- that has valid registry data available for testing endpoints that
- require registry access.
- """
+ """Fixture for UI app with valid registry data."""
mock_registry = MagicMock()
mock_proto = MagicMock()
mock_proto.SerializeToString.return_value = b"mock_proto_data"
+ mock_proto.projects = []
mock_registry.proto.return_value = mock_proto
mock_feature_store.registry = mock_registry
@@ -95,12 +82,7 @@ def ui_app_with_registry(mock_feature_store):
@pytest.fixture
def ui_app_without_registry(mock_feature_store):
- """Fixture for UI app with None registry data.
-
- Creates a UI app instance with a feature store that has no registry
- data available, used for testing error conditions and service
- unavailable responses.
- """
+ """Fixture for UI app with None registry data."""
mock_registry = MagicMock()
mock_registry.proto.return_value = None
mock_feature_store.registry = mock_registry
@@ -114,53 +96,19 @@ def ui_app_without_registry(mock_feature_store):
def test_ui_server_health_endpoint(ui_app_with_registry):
- """Test the UI server health endpoint returns 200 when registry is available.
-
- This test verifies that the /health endpoint correctly returns HTTP 200
- when the feature store registry is properly initialized and contains data.
- """
+ """Health endpoint returns 200 when registry is available."""
client = TestClient(ui_app_with_registry)
response = client.get("/health")
assertpy.assert_that(response.status_code).is_equal_to(EXPECTED_SUCCESS_STATUS)
def test_ui_server_health_endpoint_with_none_registry(ui_app_without_registry):
- """Test the UI server health endpoint returns 503 when registry is None.
-
- This test verifies that the /health endpoint correctly returns HTTP 503
- (Service Unavailable) when the feature store registry is not available
- or contains no data.
- """
+ """Health endpoint returns 503 when registry is None."""
client = TestClient(ui_app_without_registry)
response = client.get("/health")
assertpy.assert_that(response.status_code).is_equal_to(EXPECTED_ERROR_STATUS)
-def test_registry_endpoint_with_valid_data(ui_app_with_registry):
- """Test the registry endpoint returns valid data with correct content type.
-
- This test verifies that the /registry endpoint correctly returns HTTP 200
- with the proper content-type header when registry data is available.
- """
- client = TestClient(ui_app_with_registry)
- response = client.get("/registry")
- assertpy.assert_that(response.status_code).is_equal_to(EXPECTED_SUCCESS_STATUS)
- assertpy.assert_that(response.headers["content-type"]).is_equal_to(
- "application/octet-stream"
- )
-
-
-def test_registry_endpoint_with_none_data(ui_app_without_registry):
- """Test the registry endpoint returns 503 when registry data is None.
-
- This test verifies that the /registry endpoint correctly returns HTTP 503
- (Service Unavailable) when no registry data is available.
- """
- client = TestClient(ui_app_without_registry)
- response = client.get("/registry")
- assertpy.assert_that(response.status_code).is_equal_to(EXPECTED_ERROR_STATUS)
-
-
@pytest.mark.parametrize(
"registry_available,expected_status",
[(True, EXPECTED_SUCCESS_STATUS), (False, EXPECTED_ERROR_STATUS)],
@@ -168,15 +116,12 @@ def test_registry_endpoint_with_none_data(ui_app_without_registry):
def test_health_endpoint_status(
registry_available, expected_status, mock_feature_store
):
- """Test the health endpoint returns correct status based on registry availability.
-
- This parametrized test verifies that the /health endpoint returns the
- appropriate HTTP status code based on whether registry data is available.
- """
+ """Health endpoint returns correct status based on registry availability."""
if registry_available:
mock_registry = MagicMock()
mock_proto = MagicMock()
mock_proto.SerializeToString.return_value = b"mock_proto_data"
+ mock_proto.projects = []
mock_registry.proto.return_value = mock_proto
mock_feature_store.registry = mock_registry
else:
@@ -195,15 +140,93 @@ def test_health_endpoint_status(
def test_catch_all_route(ui_app_with_registry):
- """Test the catch-all route for React router paths.
-
- This test reveals a bug in the original UI server code where ui_dir
- is not in scope for the catch_all function. The ui_dir variable is defined
- inside the importlib_resources context manager but used outside of it.
- This causes a NameError when the route is accessed.
- """
+ """Test the catch-all route for React router paths."""
client = TestClient(ui_app_with_registry)
- # The route will fail due to the scope issue with ui_dir
- with pytest.raises(Exception): # Expecting NameError or FileNotFoundError
+ with pytest.raises(Exception):
client.get("/p/some/react/path")
+
+
+# ---------- projects-list.json tests ----------
+
+
+def _read_projects_list(temp_dir):
+ """Read the projects-list.json written by get_app via the mock (ui_dir = temp_dir)."""
+ projects_file = os.path.join(temp_dir, "projects-list.json")
+ with open(projects_file) as f:
+ return json.load(f)
+
+
+def test_projects_list_registry_path(mock_feature_store):
+ """projects-list.json uses /api/v1 as registryPath."""
+ mock_registry = MagicMock()
+ mock_proto = MagicMock()
+ mock_proto.SerializeToString.return_value = b"data"
+ mock_proto.projects = []
+ mock_registry.proto.return_value = mock_proto
+ mock_feature_store.registry = mock_registry
+
+ with tempfile.TemporaryDirectory() as temp_dir:
+ _create_mock_ui_files(temp_dir)
+
+ with _setup_importlib_mocks(temp_dir):
+ get_app(mock_feature_store, TEST_PROJECT_NAME, REGISTRY_TTL_SECS)
+
+ data = _read_projects_list(temp_dir)
+ assertpy.assert_that(data["projects"][0]["registryPath"]).is_equal_to("/api/v1")
+
+
+def test_projects_list_with_root_path(mock_feature_store):
+ """root_path prefix is included in registryPath."""
+ mock_registry = MagicMock()
+ mock_proto = MagicMock()
+ mock_proto.SerializeToString.return_value = b"data"
+ mock_proto.projects = []
+ mock_registry.proto.return_value = mock_proto
+ mock_feature_store.registry = mock_registry
+
+ with tempfile.TemporaryDirectory() as temp_dir:
+ _create_mock_ui_files(temp_dir)
+
+ with _setup_importlib_mocks(temp_dir):
+ get_app(
+ mock_feature_store,
+ TEST_PROJECT_NAME,
+ REGISTRY_TTL_SECS,
+ root_path="/feast",
+ )
+
+ data = _read_projects_list(temp_dir)
+ assertpy.assert_that(data["projects"][0]["registryPath"]).is_equal_to(
+ "/feast/api/v1"
+ )
+
+
+def test_projects_list_multiple_projects(mock_feature_store):
+ """Multiple projects get an 'All Projects' entry prepended."""
+ mock_registry = MagicMock()
+ mock_proto = MagicMock()
+ mock_proto.SerializeToString.return_value = b"data"
+
+ proj1 = MagicMock()
+ proj1.spec.name = "project_alpha"
+ proj1.spec.description = "Alpha project"
+ proj2 = MagicMock()
+ proj2.spec.name = "project_beta"
+ proj2.spec.description = "Beta project"
+ mock_proto.projects = [proj1, proj2]
+
+ mock_registry.proto.return_value = mock_proto
+ mock_feature_store.registry = mock_registry
+
+ with tempfile.TemporaryDirectory() as temp_dir:
+ _create_mock_ui_files(temp_dir)
+
+ with _setup_importlib_mocks(temp_dir):
+ get_app(mock_feature_store, TEST_PROJECT_NAME, REGISTRY_TTL_SECS)
+
+ data = _read_projects_list(temp_dir)
+ assertpy.assert_that(len(data["projects"])).is_equal_to(3)
+ assertpy.assert_that(data["projects"][0]["id"]).is_equal_to("all")
+ assertpy.assert_that(data["projects"][1]["id"]).is_equal_to("project_alpha")
+ assertpy.assert_that(data["projects"][2]["id"]).is_equal_to("project_beta")
diff --git a/ui/.prettierignore b/ui/.prettierignore
index d2fa6a8b18e..14a40bc5836 100644
--- a/ui/.prettierignore
+++ b/ui/.prettierignore
@@ -1,3 +1,4 @@
*.css
*.md
dist/
+build/
diff --git a/ui/src/FeastUISansProviders.test.tsx b/ui/src/FeastUISansProviders.test.tsx
index cf4a01621de..f0a940e44b3 100644
--- a/ui/src/FeastUISansProviders.test.tsx
+++ b/ui/src/FeastUISansProviders.test.tsx
@@ -10,33 +10,17 @@ import {
import userEvent from "@testing-library/user-event";
import FeastUISansProviders from "./FeastUISansProviders";
-import {
- projectsListWithDefaultProject,
- creditHistoryRegistry,
- creditHistoryRegistryDB,
-} from "./mocks/handlers";
+import { allRestHandlers } from "./mocks/handlers";
import { readFileSync } from "fs";
import { feast } from "./protos";
import path from "path";
// declare which API requests to mock
-const server = setupServer(
- projectsListWithDefaultProject,
- creditHistoryRegistry,
- creditHistoryRegistryDB,
-);
+const server = setupServer(...allRestHandlers);
const registry = readFileSync(path.resolve(__dirname, "../public/registry.db"));
const parsedRegistry = feast.core.Registry.decode(registry);
-console.log("Registry Feature Views:", parsedRegistry.featureViews?.length);
-if (parsedRegistry.featureViews && parsedRegistry.featureViews.length > 0) {
- console.log(
- "First Feature View Name:",
- parsedRegistry.featureViews[0].spec?.name,
- );
-}
-
// establish API mocking before all tests
beforeAll(() => server.listen());
// reset any request handlers that are declared as a part of our tests
@@ -64,14 +48,12 @@ test("full app rendering", async () => {
// Explore Panel Should Appear
expect(screen.getByText(/Explore this Project/i)).toBeInTheDocument();
- const projectNameRegExp = new RegExp(
- parsedRegistry.projects[0].spec?.name!,
- "i",
- );
-
// It should load the default project, which is credit_scoring_aws
+ // The heading shows the display name from projects-list.json
await waitFor(() => {
- expect(screen.getByText(projectNameRegExp)).toBeInTheDocument();
+ expect(
+ screen.getByRole("heading", { name: /Credit Score Project/i }),
+ ).toBeInTheDocument();
});
});
diff --git a/ui/src/FeastUISansProviders.tsx b/ui/src/FeastUISansProviders.tsx
index 9a2207e22dd..50de27b5944 100644
--- a/ui/src/FeastUISansProviders.tsx
+++ b/ui/src/FeastUISansProviders.tsx
@@ -38,11 +38,14 @@ import {
ProjectListContext,
ProjectsListContextInterface,
} from "./contexts/ProjectListContext";
+import DataModeContext from "./contexts/DataModeContext";
+import type { DataModeConfig, FetchOptions } from "./contexts/DataModeContext";
interface FeastUIConfigs {
tabsRegistry?: FeastTabsRegistryInterface;
featureFlags?: FeatureFlags;
projectListPromise?: Promise;
+ fetchOptions?: FetchOptions;
}
const defaultProjectListPromise = (basename: string) => {
@@ -95,95 +98,111 @@ const FeastUISansProvidersInner = ({
}) => {
const { colorMode } = useTheme();
+ const dataModeConfig: DataModeConfig = {
+ fetchOptions: feastUIConfigs?.fetchOptions,
+ };
+
return (
-
-
+
-
-
- }>
- } />
- }>
- } />
- } />
- }
- />
- } />
- }
- />
- }
- >
- }
- />
+
+
+
+ }>
+ } />
}
- />
- }
- />
- } />
- }
- />
+ path="/p/:projectName/*"
+ element={}
+ >
+ } />
+ }
+ />
+ }
+ />
+ } />
+ }
+ />
+ }
+ >
+ }
+ />
+ }
+ />
+ }
+ />
+ } />
+ }
+ />
- } />
- }
- />
- }
- />
- } />
- } />
+ } />
+ }
+ />
+ }
+ />
+ }
+ />
+ } />
+
-
- } />
-
-
-
-
+ } />
+
+
+
+
+
);
diff --git a/ui/src/components/ObjectsCountStats.tsx b/ui/src/components/ObjectsCountStats.tsx
index bf1dd2dc9dd..6fc6d2d22dc 100644
--- a/ui/src/components/ObjectsCountStats.tsx
+++ b/ui/src/components/ObjectsCountStats.tsx
@@ -1,4 +1,4 @@
-import React, { useContext } from "react";
+import React from "react";
import {
EuiFlexGroup,
EuiFlexItem,
@@ -7,45 +7,60 @@ import {
EuiTitle,
EuiSpacer,
} from "@elastic/eui";
-import useLoadRegistry from "../queries/useLoadRegistry";
import { useNavigate, useParams } from "react-router-dom";
-import RegistryPathContext from "../contexts/RegistryPathContext";
-
-const useLoadObjectStats = () => {
- const registryUrl = useContext(RegistryPathContext);
- const query = useLoadRegistry(registryUrl);
-
- const data =
- query.isSuccess && query.data
- ? {
- featureServices: query.data.objects.featureServices?.length || 0,
- featureViews: query.data.mergedFVList.length,
- entities: query.data.objects.entities?.length || 0,
- dataSources: query.data.objects.dataSources?.length || 0,
- }
- : undefined;
-
- return {
- ...query,
- data,
- };
-};
+import useResourceQuery, {
+ entityListPath,
+ featureViewListPath,
+ featureServiceListPath,
+ dataSourceListPath,
+ restFeatureViewsToMergedList,
+} from "../queries/useResourceQuery";
+import type { genericFVType } from "../parsers/mergedFVTypes";
const statStyle = { cursor: "pointer" };
const ObjectsCountStats = () => {
- const { isLoading, isSuccess, isError, data } = useLoadObjectStats();
const { projectName } = useParams();
-
const navigate = useNavigate();
+ const { data: featureServices, isSuccess: fsOk } = useResourceQuery({
+ resourceType: "stats-fs",
+ project: projectName,
+ restPath: featureServiceListPath(projectName),
+ restSelect: (d) => d.featureServices,
+ });
+
+ const { data: featureViews, isSuccess: fvOk } = useResourceQuery<
+ genericFVType[]
+ >({
+ resourceType: "stats-fvs",
+ project: projectName,
+ restPath: featureViewListPath(projectName),
+ restSelect: restFeatureViewsToMergedList,
+ });
+
+ const { data: entities, isSuccess: entOk } = useResourceQuery({
+ resourceType: "stats-ent",
+ project: projectName,
+ restPath: entityListPath(projectName),
+ restSelect: (d) => d.entities,
+ });
+
+ const { data: dataSources, isSuccess: dsOk } = useResourceQuery({
+ resourceType: "stats-ds",
+ project: projectName,
+ restPath: dataSourceListPath(projectName),
+ restSelect: (d) => d.dataSources,
+ });
+
+ const allOk = fsOk && fvOk && entOk && dsOk;
+
return (
- {isLoading && Loading
}
- {isError && There was an error in loading registry information.
}
- {isSuccess && data && (
+ {!allOk && Loading
}
+ {allOk && (
Registered in this Feast project are …
@@ -57,7 +72,7 @@ const ObjectsCountStats = () => {
style={statStyle}
onClick={() => navigate(`/p/${projectName}/feature-service`)}
description="Feature Services→"
- title={data.featureServices}
+ title={featureServices?.length || 0}
reverse
/>
@@ -66,7 +81,7 @@ const ObjectsCountStats = () => {
style={statStyle}
description="Feature Views→"
onClick={() => navigate(`/p/${projectName}/feature-view`)}
- title={data.featureViews}
+ title={featureViews?.length || 0}
reverse
/>
@@ -75,7 +90,7 @@ const ObjectsCountStats = () => {
style={statStyle}
description="Entities→"
onClick={() => navigate(`/p/${projectName}/entity`)}
- title={data.entities}
+ title={entities?.length || 0}
reverse
/>
@@ -84,7 +99,7 @@ const ObjectsCountStats = () => {
style={statStyle}
description="Data Sources→"
onClick={() => navigate(`/p/${projectName}/data-source`)}
- title={data.dataSources}
+ title={dataSources?.length || 0}
reverse
/>
diff --git a/ui/src/components/ProjectSelector.test.tsx b/ui/src/components/ProjectSelector.test.tsx
index 40d89cde93c..d311e7ef980 100644
--- a/ui/src/components/ProjectSelector.test.tsx
+++ b/ui/src/components/ProjectSelector.test.tsx
@@ -5,18 +5,10 @@ import userEvent from "@testing-library/user-event";
import FeastUISansProviders from "../FeastUISansProviders";
-import {
- projectsListWithDefaultProject,
- creditHistoryRegistry,
- creditHistoryRegistryDB,
-} from "../mocks/handlers";
+import { allRestHandlers } from "../mocks/handlers";
// declare which API requests to mock
-const server = setupServer(
- projectsListWithDefaultProject,
- creditHistoryRegistry,
- creditHistoryRegistryDB,
-);
+const server = setupServer(...allRestHandlers);
// establish API mocking before all tests
beforeAll(() => server.listen());
@@ -48,12 +40,12 @@ test("in a full App render, it shows the right initial project", async () => {
// Wait for Project Data from Registry to Load
await screen.findAllByRole("heading", {
- name: /Project: credit_scoring_aws/i,
+ name: /Project: Credit Score Project/i,
});
// Before User Event: Heading is the credit scoring project
screen.getByRole("heading", {
- name: /credit_scoring_aws/i,
+ name: /Credit Score Project/i,
});
// Do the select option user event
@@ -78,6 +70,6 @@ test("in a full App render, it shows the right initial project", async () => {
// ... and the new heading should appear
// meaning we successfully navigated
await screen.findByRole("heading", {
- name: /Project: credit_scoring_aws/i,
+ name: /Project: Credit Score Project/i,
});
});
diff --git a/ui/src/contexts/DataModeContext.tsx b/ui/src/contexts/DataModeContext.tsx
new file mode 100644
index 00000000000..c8ef4ea0bab
--- /dev/null
+++ b/ui/src/contexts/DataModeContext.tsx
@@ -0,0 +1,20 @@
+import React, { useContext } from "react";
+
+interface FetchOptions {
+ headers?: Record;
+ credentials?: RequestCredentials;
+}
+
+interface DataModeConfig {
+ fetchOptions?: FetchOptions;
+}
+
+const defaultConfig: DataModeConfig = {};
+
+const DataModeContext = React.createContext(defaultConfig);
+
+const useDataMode = () => useContext(DataModeContext);
+
+export default DataModeContext;
+export { useDataMode };
+export type { DataModeConfig, FetchOptions };
diff --git a/ui/src/contexts/ProjectListContext.ts b/ui/src/contexts/ProjectListContext.ts
index c42b22f6611..a230300be3b 100644
--- a/ui/src/contexts/ProjectListContext.ts
+++ b/ui/src/contexts/ProjectListContext.ts
@@ -13,6 +13,7 @@ const ProjectEntrySchema = z.object({
const ProjectsListSchema = z.object({
default: z.string().optional(),
projects: z.array(ProjectEntrySchema),
+ mode: z.string().optional(),
});
type ProjectsListType = z.infer;
diff --git a/ui/src/hooks/useTagsAggregation.ts b/ui/src/hooks/useTagsAggregation.ts
index 5d36fd54285..9ad0d78d6f5 100644
--- a/ui/src/hooks/useTagsAggregation.ts
+++ b/ui/src/hooks/useTagsAggregation.ts
@@ -1,13 +1,13 @@
-import { useContext, useMemo } from "react";
-import RegistryPathContext from "../contexts/RegistryPathContext";
-import useLoadRegistry from "../queries/useLoadRegistry";
-import { feast } from "../protos";
+import { useMemo } from "react";
+import { useParams } from "react-router-dom";
+import useResourceQuery, {
+ featureViewListPath,
+ featureServiceListPath,
+} from "../queries/useResourceQuery";
-// Usage of generic type parameter T
-// https://stackoverflow.com/questions/53203409/how-to-tell-typescript-that-im-returning-an-array-of-arrays-of-the-input-type
const buildTagCollection = (
array: T[],
- recordExtractor: (unknownFCO: T) => Record | undefined, // Assumes that tags are always a Record
+ recordExtractor: (unknownFCO: T) => Record | undefined,
): Record> => {
const tagCollection = array.reduce(
(memo: Record>, fco: T) => {
@@ -38,17 +38,17 @@ const buildTagCollection = (
};
const useFeatureViewTagsAggregation = () => {
- const registryUrl = useContext(RegistryPathContext);
- const query = useLoadRegistry(registryUrl);
+ const { projectName } = useParams();
+ const query = useResourceQuery({
+ resourceType: "tags-fvs",
+ project: projectName,
+ restPath: featureViewListPath(projectName),
+ restSelect: (d) => d.featureViews,
+ });
const data = useMemo(() => {
- return query.data && query.data.objects && query.data.objects.featureViews
- ? buildTagCollection(
- query.data.objects.featureViews!,
- (fv) => {
- return fv.spec?.tags!;
- },
- )
+ return query.data
+ ? buildTagCollection(query.data, (fv) => fv.spec?.tags)
: undefined;
}, [query.data]);
@@ -59,19 +59,17 @@ const useFeatureViewTagsAggregation = () => {
};
const useFeatureServiceTagsAggregation = () => {
- const registryUrl = useContext(RegistryPathContext);
- const query = useLoadRegistry(registryUrl);
+ const { projectName } = useParams();
+ const query = useResourceQuery({
+ resourceType: "tags-fss",
+ project: projectName,
+ restPath: featureServiceListPath(projectName),
+ restSelect: (d) => d.featureServices,
+ });
const data = useMemo(() => {
- return query.data &&
- query.data.objects &&
- query.data.objects.featureServices
- ? buildTagCollection(
- query.data.objects.featureServices,
- (fs) => {
- return fs.spec?.tags!;
- },
- )
+ return query.data
+ ? buildTagCollection(query.data, (fs) => fs.spec?.tags)
: undefined;
}, [query.data]);
diff --git a/ui/src/mocks/handlers.ts b/ui/src/mocks/handlers.ts
index 1c32bb2cf87..d36c81db846 100644
--- a/ui/src/mocks/handlers.ts
+++ b/ui/src/mocks/handlers.ts
@@ -1,10 +1,48 @@
import { http, HttpResponse } from "msw";
import { readFileSync } from "fs";
import path from "path";
+import { feast } from "../protos";
-const registry = readFileSync(
+const registryBuf = readFileSync(
path.resolve(__dirname, "../../public/registry.db"),
);
+const parsedRegistry = feast.core.Registry.decode(registryBuf);
+
+const toJSON = (obj: any) => (obj && obj.toJSON ? obj.toJSON() : obj);
+
+const entitiesJSON = (parsedRegistry.entities || []).map(toJSON);
+const featureViewsJSON = (parsedRegistry.featureViews || []).map((fv) => ({
+ ...toJSON(fv),
+ type: "featureView",
+}));
+const onDemandFVsJSON = (parsedRegistry.onDemandFeatureViews || []).map(
+ (fv) => ({
+ ...toJSON(fv),
+ type: "onDemandFeatureView",
+ }),
+);
+const streamFVsJSON = (parsedRegistry.streamFeatureViews || []).map((fv) => ({
+ ...toJSON(fv),
+ type: "streamFeatureView",
+}));
+const allFeatureViewsJSON = [
+ ...featureViewsJSON,
+ ...onDemandFVsJSON,
+ ...streamFVsJSON,
+];
+const featureServicesJSON = (parsedRegistry.featureServices || []).map(toJSON);
+const dataSourcesJSON = (parsedRegistry.dataSources || []).map(toJSON);
+const savedDatasetsJSON = (parsedRegistry.savedDatasets || []).map(toJSON);
+const projectsJSON = (parsedRegistry.projects || []).map(toJSON);
+
+const allFeatures = featureViewsJSON.flatMap((fv: any) =>
+ (fv?.spec?.features || []).map((f: any) => ({
+ name: f.name,
+ featureViewName: fv.spec?.name,
+ valueType: f.valueType,
+ project: fv.spec?.project,
+ })),
+);
const projectsListWithDefaultProject = http.get("/projects-list.json", () =>
HttpResponse.json({
@@ -14,22 +52,232 @@ const projectsListWithDefaultProject = http.get("/projects-list.json", () =>
name: "Credit Score Project",
description: "Project for credit scoring team and associated models.",
id: "credit_scoring_aws",
- registryPath: "/registry.db", // Changed to match what the test expects
+ registryPath: "/api/v1",
},
],
}),
);
-const creditHistoryRegistryPB = http.get("/registry.pb", () => {
- return HttpResponse.arrayBuffer(registry.buffer);
-});
+// REST API list endpoints
+const restEntities = http.get("/api/v1/entities", () =>
+ HttpResponse.json({
+ entities: entitiesJSON,
+ pagination: {},
+ relationships: {},
+ }),
+);
+
+const restFeatureViews = http.get("/api/v1/feature_views", () =>
+ HttpResponse.json({
+ featureViews: allFeatureViewsJSON,
+ pagination: {},
+ relationships: {},
+ }),
+);
+
+const restFeatureServices = http.get("/api/v1/feature_services", () =>
+ HttpResponse.json({
+ featureServices: featureServicesJSON,
+ pagination: {},
+ relationships: {},
+ }),
+);
+
+const restDataSources = http.get("/api/v1/data_sources", () =>
+ HttpResponse.json({
+ dataSources: dataSourcesJSON,
+ pagination: {},
+ relationships: {},
+ }),
+);
+
+const restSavedDatasets = http.get("/api/v1/saved_datasets", () =>
+ HttpResponse.json({
+ savedDatasets: savedDatasetsJSON,
+ pagination: {},
+ }),
+);
+
+const restProjects = http.get("/api/v1/projects", () =>
+ HttpResponse.json({
+ projects: projectsJSON,
+ pagination: {},
+ }),
+);
+
+const restFeatures = http.get("/api/v1/features", () =>
+ HttpResponse.json({
+ features: allFeatures,
+ pagination: {},
+ }),
+);
+
+const restPermissions = http.get("/api/v1/permissions", () =>
+ HttpResponse.json({
+ permissions: [],
+ pagination: {},
+ }),
+);
-const creditHistoryRegistryDB = http.get("/registry.db", () => {
- return HttpResponse.arrayBuffer(registry.buffer);
+// Detail endpoints
+const restFeatureViewDetail = http.get(
+ "/api/v1/feature_views/:name",
+ ({ params }) => {
+ const name = params.name as string;
+ const fv = allFeatureViewsJSON.find((f: any) => f.spec?.name === name);
+ if (!fv) return HttpResponse.json({ detail: "Not found" }, { status: 404 });
+ return HttpResponse.json(fv);
+ },
+);
+
+const restEntityDetail = http.get("/api/v1/entities/:name", ({ params }) => {
+ const name = params.name as string;
+ const entity = entitiesJSON.find((e: any) => e.spec?.name === name);
+ if (!entity)
+ return HttpResponse.json({ detail: "Not found" }, { status: 404 });
+ return HttpResponse.json(entity);
});
-export {
+const restFeatureServiceDetail = http.get(
+ "/api/v1/feature_services/:name",
+ ({ params }) => {
+ const name = params.name as string;
+ const fs = featureServicesJSON.find((f: any) => f.spec?.name === name);
+ if (!fs) return HttpResponse.json({ detail: "Not found" }, { status: 404 });
+ return HttpResponse.json(fs);
+ },
+);
+
+const restDataSourceDetail = http.get(
+ "/api/v1/data_sources/:name",
+ ({ params }) => {
+ const name = params.name as string;
+ const ds = dataSourcesJSON.find((d: any) => d.name === name);
+ if (!ds) return HttpResponse.json({ detail: "Not found" }, { status: 404 });
+ return HttpResponse.json(ds);
+ },
+);
+
+const restFeatureDetail = http.get(
+ "/api/v1/features/:fvName/:featureName",
+ ({ params }) => {
+ const fvName = params.fvName as string;
+ const featureName = params.featureName as string;
+ const fv = allFeatureViewsJSON.find((f: any) => f.spec?.name === fvName);
+ if (!fv) return HttpResponse.json({ detail: "Not found" }, { status: 404 });
+ const feature = (fv as any).spec?.features?.find(
+ (f: any) => f.name === featureName,
+ );
+ if (!feature)
+ return HttpResponse.json({ detail: "Not found" }, { status: 404 });
+ return HttpResponse.json({
+ featureViewName: fvName,
+ featureName,
+ feature,
+ featureView: fv,
+ });
+ },
+);
+
+// "all" endpoints (for global search / all-projects view)
+const restEntitiesAll = http.get("/api/v1/entities/all", () =>
+ HttpResponse.json({
+ entities: entitiesJSON.map((e: any) => ({
+ ...e,
+ project: e.spec?.project,
+ })),
+ pagination: {},
+ relationships: {},
+ }),
+);
+
+const restFeatureViewsAll = http.get("/api/v1/feature_views/all", () =>
+ HttpResponse.json({
+ featureViews: allFeatureViewsJSON.map((fv: any) => ({
+ ...fv,
+ project: fv.spec?.project,
+ })),
+ pagination: {},
+ relationships: {},
+ }),
+);
+
+const restFeatureServicesAll = http.get("/api/v1/feature_services/all", () =>
+ HttpResponse.json({
+ featureServices: featureServicesJSON.map((fs: any) => ({
+ ...fs,
+ project: fs.spec?.project,
+ })),
+ pagination: {},
+ relationships: {},
+ }),
+);
+
+const restDataSourcesAll = http.get("/api/v1/data_sources/all", () =>
+ HttpResponse.json({
+ dataSources: dataSourcesJSON.map((ds: any) => ({
+ ...ds,
+ project: ds.project,
+ })),
+ pagination: {},
+ relationships: {},
+ }),
+);
+
+const restSavedDatasetsAll = http.get("/api/v1/saved_datasets/all", () =>
+ HttpResponse.json({
+ savedDatasets: savedDatasetsJSON,
+ pagination: {},
+ }),
+);
+
+const restFeaturesAll = http.get("/api/v1/features/all", () =>
+ HttpResponse.json({
+ features: allFeatures,
+ pagination: {},
+ }),
+);
+
+const restSavedDatasetDetail = http.get(
+ "/api/v1/saved_datasets/:name",
+ ({ params }) => {
+ const name = params.name as string;
+ const sd = savedDatasetsJSON.find((d: any) => d.spec?.name === name);
+ if (!sd) return HttpResponse.json({ detail: "Not found" }, { status: 404 });
+ return HttpResponse.json(sd);
+ },
+);
+
+const restMetrics = http.get("/api/v1/metrics/:type", () =>
+ HttpResponse.json({}),
+);
+
+const allRestHandlers = [
projectsListWithDefaultProject,
- creditHistoryRegistryPB as creditHistoryRegistry,
- creditHistoryRegistryDB,
-};
+ // "all" endpoints must come before parameterized detail routes
+ restEntitiesAll,
+ restFeatureViewsAll,
+ restFeatureServicesAll,
+ restDataSourcesAll,
+ restSavedDatasetsAll,
+ restFeaturesAll,
+ // List endpoints
+ restEntities,
+ restFeatureViews,
+ restFeatureServices,
+ restDataSources,
+ restSavedDatasets,
+ restProjects,
+ restFeatures,
+ restPermissions,
+ // Detail endpoints
+ restFeatureViewDetail,
+ restEntityDetail,
+ restFeatureServiceDetail,
+ restDataSourceDetail,
+ restSavedDatasetDetail,
+ restFeatureDetail,
+ restMetrics,
+];
+
+export { projectsListWithDefaultProject, allRestHandlers };
diff --git a/ui/src/pages/ProjectOverviewPage.tsx b/ui/src/pages/ProjectOverviewPage.tsx
index 839fbcc5d89..9d6c9fb65c1 100644
--- a/ui/src/pages/ProjectOverviewPage.tsx
+++ b/ui/src/pages/ProjectOverviewPage.tsx
@@ -1,4 +1,4 @@
-import React, { useContext } from "react";
+import React from "react";
import {
EuiPageTemplate,
EuiText,
@@ -7,8 +7,6 @@ import {
EuiTitle,
EuiSpacer,
EuiSkeletonText,
- EuiEmptyPrompt,
- EuiFieldSearch,
EuiPanel,
EuiStat,
EuiCard,
@@ -17,54 +15,76 @@ import {
import { useDocumentTitle } from "../hooks/useDocumentTitle";
import ObjectsCountStats from "../components/ObjectsCountStats";
import ExplorePanel from "../components/ExplorePanel";
-import useLoadRegistry from "../queries/useLoadRegistry";
-import RegistryPathContext from "../contexts/RegistryPathContext";
-import RegistryVisualizationTab from "../components/RegistryVisualizationTab";
-import RegistrySearch from "../components/RegistrySearch";
+import useResourceQuery, {
+ restFeatureViewsToMergedList,
+} from "../queries/useResourceQuery";
import { useParams, useNavigate } from "react-router-dom";
import { useLoadProjectsList } from "../contexts/ProjectListContext";
+import type { genericFVType } from "../parsers/mergedFVTypes";
+
+const getItemProject = (item: any): string =>
+ item?.project || item?.spec?.project || "";
// 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) {
+ const { data: allFVs } = useResourceQuery({
+ resourceType: "all-proj-fvs",
+ restPath: "/feature_views/all?limit=100&include_relationships=true",
+ restSelect: restFeatureViewsToMergedList,
+ });
+
+ const { data: allEntities } = useResourceQuery({
+ resourceType: "all-proj-entities",
+ restPath: "/entities/all?limit=100",
+ restSelect: (d) => d.entities,
+ });
+
+ const { data: allDS } = useResourceQuery({
+ resourceType: "all-proj-ds",
+ restPath: "/data_sources/all?limit=100",
+ restSelect: (d) => d.dataSources,
+ });
+
+ const { data: allFS } = useResourceQuery({
+ resourceType: "all-proj-fs",
+ restPath: "/feature_services/all?limit=100",
+ restSelect: (d) => d.featureServices,
+ });
+
+ const { data: allFeatures } = useResourceQuery({
+ resourceType: "all-proj-features",
+ restPath: "/features/all?limit=100",
+ restSelect: (d) => d.features,
+ });
+
+ const loaded = allFVs && allEntities && allDS && allFS && allFeatures;
+
+ if (!loaded) {
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,
+ featureViews: allFVs.length,
+ entities: allEntities.length,
+ dataSources: allDS.length,
+ featureServices: allFS.length,
+ features: allFeatures.length,
};
- // 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) ||
- [];
+ const matchesProject = (item: any) => getItemProject(item) === project.id;
return {
...project,
counts: {
- featureViews: projectFVs.length,
- entities: projectEntities.length,
- features: projectFeatures.length,
+ featureViews: allFVs.filter((fv) => matchesProject(fv.object || fv))
+ .length,
+ entities: allEntities.filter(matchesProject).length,
+ features: allFeatures.filter(matchesProject).length,
},
};
});
@@ -195,112 +215,59 @@ const AllProjectsDashboard = () => {
const ProjectOverviewPage = () => {
useDocumentTitle("Feast Home");
- const registryUrl = useContext(RegistryPathContext);
const { projectName } = useParams<{ projectName: string }>();
- const { isLoading, isSuccess, isError, data } = useLoadRegistry(
- registryUrl,
- projectName,
- );
+ const { data: projectsData } = useLoadProjectsList();
// Show aggregated dashboard for "All Projects" view
if (projectName === "all") {
return ;
}
- const categories = [
- {
- name: "Data Sources",
- data: data?.objects.dataSources || [],
- getLink: (item: any) => `/p/${projectName}/data-source/${item.name}`,
- },
- {
- name: "Entities",
- data: data?.objects.entities || [],
- getLink: (item: any) => `/p/${projectName}/entity/${item.name}`,
- },
- {
- name: "Features",
- data: data?.allFeatures || [],
- getLink: (item: any) => {
- const featureView = item?.featureView;
- return featureView
- ? `/p/${projectName}/feature-view/${featureView}/feature/${item.name}`
- : "#";
- },
- },
- {
- name: "Feature Views",
- data: data?.mergedFVList || [],
- getLink: (item: any) => `/p/${projectName}/feature-view/${item.name}`,
- },
- {
- name: "Feature Services",
- data: data?.objects.featureServices || [],
- getLink: (item: any) => {
- const serviceName = item?.name || item?.spec?.name;
- return serviceName
- ? `/p/${projectName}/feature-service/${serviceName}`
- : "#";
- },
- },
- ];
+ const currentProject = projectsData?.projects.find(
+ (p) => p.id === projectName,
+ );
return (
- {isLoading && }
- {isSuccess && data?.project && `Project: ${data.project}`}
+ {currentProject
+ ? `Project: ${currentProject.name}`
+ : projectName
+ ? `Project: ${projectName}`
+ : ""}
- {isLoading && }
- {isError && (
- Error Loading Project Configs}
- body={
-
- There was an error loading the Project Configurations.
- Please check that feature_store.yaml file is
- available and well-formed.
-
- }
- />
+ {currentProject?.description ? (
+
+ {currentProject.description}
+
+ ) : (
+
+
+ Welcome to your new Feast project. In this UI, you can see
+ Data Sources, Entities, Features, Feature Views, and Feature
+ Services registered in Feast.
+
+
+ It looks like this project already has some objects
+ registered. If you are new to this project, we suggest
+ starting by exploring the Feature Services, as they represent
+ the collection of Feature Views serving a particular model.
+
+
+ Note: We encourage you to replace this
+ welcome message with more suitable content for your team. You
+ can do so by specifying a project_description in
+ your feature_store.yaml file.
+
+
)}
- {isSuccess &&
- (data?.description ? (
-
- {data.description}
-
- ) : (
-
-
- Welcome to your new Feast project. In this UI, you can see
- Data Sources, Entities, Features, Feature Views, and Feature
- Services registered in Feast.
-
-
- It looks like this project already has some objects
- registered. If you are new to this project, we suggest
- starting by exploring the Feature Services, as they
- represent the collection of Feature Views serving a
- particular model.
-
-
- Note: We encourage you to replace this
- welcome message with more suitable content for your team.
- You can do so by specifying a{" "}
- project_description in your{" "}
- feature_store.yaml file.
-
-
- ))}
diff --git a/ui/src/pages/Sidebar.tsx b/ui/src/pages/Sidebar.tsx
index 55c8ec805c9..cf3d64a6816 100644
--- a/ui/src/pages/Sidebar.tsx
+++ b/ui/src/pages/Sidebar.tsx
@@ -1,10 +1,17 @@
-import React, { useContext, useState } from "react";
+import React, { useState } from "react";
import { EuiIcon, EuiSideNav, htmlIdGenerator } from "@elastic/eui";
import { Link, useParams } from "react-router-dom";
import { useMatchSubpath } from "../hooks/useMatchSubpath";
-import useLoadRegistry from "../queries/useLoadRegistry";
-import RegistryPathContext from "../contexts/RegistryPathContext";
+import useResourceQuery, {
+ entityListPath,
+ featureViewListPath,
+ featureServiceListPath,
+ dataSourceListPath,
+ savedDatasetListPath,
+ featuresListPath,
+ restFeatureViewsToMergedList,
+} from "../queries/useResourceQuery";
import { DataSourceIcon } from "../graphics/DataSourceIcon";
import { EntityIcon } from "../graphics/EntityIcon";
@@ -14,11 +21,58 @@ import { DatasetIcon } from "../graphics/DatasetIcon";
import { FeatureIcon } from "../graphics/FeatureIcon";
import { HomeIcon } from "../graphics/HomeIcon";
import { PermissionsIcon } from "../graphics/PermissionsIcon";
+import type { genericFVType } from "../parsers/mergedFVTypes";
const SideNav = () => {
- const registryUrl = useContext(RegistryPathContext);
const { projectName } = useParams();
- const { isSuccess, data } = useLoadRegistry(registryUrl, projectName);
+
+ const { isSuccess: dsSuccess, data: dataSources } = useResourceQuery({
+ resourceType: "sidebar-ds",
+ project: projectName,
+ restPath: dataSourceListPath(projectName),
+ restSelect: (d) => d.dataSources,
+ });
+
+ const { isSuccess: entSuccess, data: entities } = useResourceQuery({
+ resourceType: "sidebar-entities",
+ project: projectName,
+ restPath: entityListPath(projectName),
+ restSelect: (d) => d.entities,
+ });
+
+ const { isSuccess: fvSuccess, data: featureViews } = useResourceQuery<
+ genericFVType[]
+ >({
+ resourceType: "sidebar-fvs",
+ project: projectName,
+ restPath: featureViewListPath(projectName),
+ restSelect: restFeatureViewsToMergedList,
+ });
+
+ const { isSuccess: featSuccess, data: features } = useResourceQuery({
+ resourceType: "sidebar-features",
+ project: projectName,
+ restPath: featuresListPath(projectName),
+ restSelect: (d) => d.features,
+ });
+
+ const { isSuccess: fsSuccess, data: featureServices } = useResourceQuery<
+ any[]
+ >({
+ resourceType: "sidebar-fs",
+ project: projectName,
+ restPath: featureServiceListPath(projectName),
+ restSelect: (d) => d.featureServices,
+ });
+
+ const { isSuccess: sdSuccess, data: savedDatasets } = useResourceQuery(
+ {
+ resourceType: "sidebar-sd",
+ project: projectName,
+ restPath: savedDatasetListPath(projectName),
+ restSelect: (d) => d.savedDatasets,
+ },
+ );
const [isSideNavOpenOnMobile, setisSideNavOpenOnMobile] = useState(false);
@@ -26,41 +80,12 @@ const SideNav = () => {
setisSideNavOpenOnMobile(!isSideNavOpenOnMobile);
};
- const dataSourcesLabel = `Data Sources ${
- isSuccess && data?.objects.dataSources
- ? `(${data?.objects.dataSources?.length})`
- : ""
- }`;
-
- const entitiesLabel = `Entities ${
- isSuccess && data?.objects.entities
- ? `(${data?.objects.entities?.length})`
- : ""
- }`;
-
- const featureViewsLabel = `Feature Views ${
- isSuccess && data?.mergedFVList && data?.mergedFVList.length > 0
- ? `(${data?.mergedFVList.length})`
- : ""
- }`;
-
- const featureListLabel = `Features ${
- isSuccess && data?.allFeatures && data?.allFeatures.length > 0
- ? `(${data?.allFeatures.length})`
- : ""
- }`;
-
- const featureServicesLabel = `Feature Services ${
- isSuccess && data?.objects.featureServices
- ? `(${data?.objects.featureServices?.length})`
- : ""
- }`;
-
- const savedDatasetsLabel = `Datasets ${
- isSuccess && data?.objects.savedDatasets
- ? `(${data?.objects.savedDatasets?.length})`
- : ""
- }`;
+ const dataSourcesLabel = `Data Sources ${dsSuccess && dataSources ? `(${dataSources.length})` : ""}`;
+ const entitiesLabel = `Entities ${entSuccess && entities ? `(${entities.length})` : ""}`;
+ const featureViewsLabel = `Feature Views ${fvSuccess && featureViews && featureViews.length > 0 ? `(${featureViews.length})` : ""}`;
+ const featureListLabel = `Features ${featSuccess && features && features.length > 0 ? `(${features.length})` : ""}`;
+ const featureServicesLabel = `Feature Services ${fsSuccess && featureServices ? `(${featureServices.length})` : ""}`;
+ const savedDatasetsLabel = `Datasets ${sdSuccess && savedDatasets ? `(${savedDatasets.length})` : ""}`;
const baseUrl = `/p/${projectName}`;
diff --git a/ui/src/pages/data-sources/DataSourceOverviewTab.tsx b/ui/src/pages/data-sources/DataSourceOverviewTab.tsx
index d702034a558..8d570f3f26d 100644
--- a/ui/src/pages/data-sources/DataSourceOverviewTab.tsx
+++ b/ui/src/pages/data-sources/DataSourceOverviewTab.tsx
@@ -86,7 +86,7 @@ const DataSourceOverviewTab = () => {
{
+ data?.requestDataOptions?.schema!.map((obj: any) => {
return {
fieldName: obj.name!,
valueType: obj.valueType!,
@@ -109,7 +109,7 @@ const DataSourceOverviewTab = () => {
{consumingFeatureViews && consumingFeatureViews.length > 0 ? (
{
+ fvNames={consumingFeatureViews.map((f: any) => {
return f.target.name;
})}
/>
diff --git a/ui/src/pages/data-sources/Index.tsx b/ui/src/pages/data-sources/Index.tsx
index 96aef712aec..84309775e0b 100644
--- a/ui/src/pages/data-sources/Index.tsx
+++ b/ui/src/pages/data-sources/Index.tsx
@@ -1,4 +1,4 @@
-import React, { useContext } from "react";
+import React from "react";
import { useParams } from "react-router-dom";
import {
@@ -11,30 +11,25 @@ import {
EuiSpacer,
} from "@elastic/eui";
-import useLoadRegistry from "../../queries/useLoadRegistry";
import DatasourcesListingTable from "./DataSourcesListingTable";
import { useDocumentTitle } from "../../hooks/useDocumentTitle";
-import RegistryPathContext from "../../contexts/RegistryPathContext";
import DataSourceIndexEmptyState from "./DataSourceIndexEmptyState";
import { DataSourceIcon } from "../../graphics/DataSourceIcon";
import { useSearchQuery } from "../../hooks/useSearchInputWithTags";
import { feast } from "../../protos";
import ExportButton from "../../components/ExportButton";
+import useResourceQuery, {
+ dataSourceListPath,
+} from "../../queries/useResourceQuery";
const useLoadDatasources = () => {
- const registryUrl = useContext(RegistryPathContext);
const { projectName } = useParams();
- const registryQuery = useLoadRegistry(registryUrl, projectName);
-
- const data =
- registryQuery.data === undefined
- ? undefined
- : registryQuery.data.objects.dataSources;
-
- return {
- ...registryQuery,
- data,
- };
+ return useResourceQuery({
+ resourceType: "data-sources-list",
+ project: projectName,
+ restPath: dataSourceListPath(projectName),
+ restSelect: (d) => d.dataSources,
+ });
};
const filterFn = (data: feast.core.IDataSource[], searchTokens: string[]) => {
diff --git a/ui/src/pages/data-sources/useLoadDataSource.ts b/ui/src/pages/data-sources/useLoadDataSource.ts
index 43f697fca03..bc0a409b9c8 100644
--- a/ui/src/pages/data-sources/useLoadDataSource.ts
+++ b/ui/src/pages/data-sources/useLoadDataSource.ts
@@ -1,35 +1,33 @@
-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";
+import useResourceQuery, {
+ dataSourceDetailPath,
+} from "../../queries/useResourceQuery";
const useLoadDataSource = (dataSourceName: string) => {
- const registryUrl = useContext(RegistryPathContext);
const { projectName } = useParams();
- const registryQuery = useLoadRegistry(registryUrl, projectName);
- const data =
- registryQuery.data === undefined
- ? undefined
- : registryQuery.data.objects.dataSources?.find(
- (ds) => ds.name === dataSourceName,
- );
+ const dsQuery = useResourceQuery({
+ resourceType: `data-source:${dataSourceName}`,
+ project: projectName,
+ restPath: dataSourceDetailPath(dataSourceName, projectName || ""),
+ restSelect: (d) => ({
+ dataSource: d,
+ relationships: d?.relationships || [],
+ }),
+ enabled: !!dataSourceName,
+ });
- const consumingFeatureViews =
- registryQuery.data === undefined
- ? undefined
- : registryQuery.data.relationships.filter((relationship) => {
- return (
- relationship.source.type === FEAST_FCO_TYPES.dataSource &&
- relationship.source.name === data?.name &&
- relationship.target.type === FEAST_FCO_TYPES.featureView
- );
- });
+ const dataSource = dsQuery.data?.dataSource;
+ const relationships = dsQuery.data?.relationships || [];
+
+ const consumingFeatureViews = relationships.filter(
+ (rel: any) =>
+ rel?.source?.type === "dataSource" && rel?.target?.type === "featureView",
+ );
return {
- ...registryQuery,
- data,
+ ...dsQuery,
+ data: dataSource,
consumingFeatureViews,
};
};
diff --git a/ui/src/pages/entities/EntitiesListingTable.tsx b/ui/src/pages/entities/EntitiesListingTable.tsx
index 51ffb7c8609..d5c28b0ea33 100644
--- a/ui/src/pages/entities/EntitiesListingTable.tsx
+++ b/ui/src/pages/entities/EntitiesListingTable.tsx
@@ -20,7 +20,8 @@ const EntitiesListingTable = ({ entities }: EntitiesListingTableProps) => {
sortable: true,
render: (name: string, item: feast.core.IEntity) => {
// For "All Projects" view, link to the specific project
- const itemProject = item?.spec?.project || projectName;
+ const itemProject =
+ item?.spec?.project || (item as any)?.project || projectName;
return (
{name}
@@ -52,7 +53,7 @@ const EntitiesListingTable = ({ entities }: EntitiesListingTableProps) => {
if (projectName === "all") {
columns.splice(1, 0, {
name: "Project",
- field: "spec.project",
+ field: "project",
sortable: true,
render: (project: string) => {
return {project || "Unknown"};
diff --git a/ui/src/pages/entities/Index.tsx b/ui/src/pages/entities/Index.tsx
index 070c53d38fa..216e713f382 100644
--- a/ui/src/pages/entities/Index.tsx
+++ b/ui/src/pages/entities/Index.tsx
@@ -1,31 +1,26 @@
-import React, { useContext } from "react";
+import React from "react";
import { useParams } from "react-router-dom";
import { EuiPageTemplate, EuiLoadingSpinner } from "@elastic/eui";
import { EntityIcon } from "../../graphics/EntityIcon";
-import useLoadRegistry from "../../queries/useLoadRegistry";
import EntitiesListingTable from "./EntitiesListingTable";
import { useDocumentTitle } from "../../hooks/useDocumentTitle";
-import RegistryPathContext from "../../contexts/RegistryPathContext";
import EntityIndexEmptyState from "./EntityIndexEmptyState";
import ExportButton from "../../components/ExportButton";
+import useResourceQuery, {
+ entityListPath,
+} from "../../queries/useResourceQuery";
const useLoadEntities = () => {
- const registryUrl = useContext(RegistryPathContext);
const { projectName } = useParams();
- const registryQuery = useLoadRegistry(registryUrl, projectName);
-
- const data =
- registryQuery.data === undefined
- ? undefined
- : registryQuery.data.objects.entities;
-
- return {
- ...registryQuery,
- data,
- };
+ return useResourceQuery({
+ resourceType: "entities-list",
+ project: projectName,
+ restPath: entityListPath(projectName),
+ restSelect: (d) => d.entities,
+ });
};
const Index = () => {
diff --git a/ui/src/pages/entities/useLoadEntity.ts b/ui/src/pages/entities/useLoadEntity.ts
index fdb4a7968f1..cf20c33bd8f 100644
--- a/ui/src/pages/entities/useLoadEntity.ts
+++ b/ui/src/pages/entities/useLoadEntity.ts
@@ -1,24 +1,18 @@
-import { useContext } from "react";
import { useParams } from "react-router-dom";
-import RegistryPathContext from "../../contexts/RegistryPathContext";
-import useLoadRegistry from "../../queries/useLoadRegistry";
+import useResourceQuery, {
+ entityDetailPath,
+} from "../../queries/useResourceQuery";
const useLoadEntity = (entityName: string) => {
- const registryUrl = useContext(RegistryPathContext);
const { projectName } = useParams();
- const registryQuery = useLoadRegistry(registryUrl, projectName);
- const data =
- registryQuery.data === undefined
- ? undefined
- : registryQuery.data.objects.entities?.find(
- (fv) => fv?.spec?.name === entityName,
- );
-
- return {
- ...registryQuery,
- data,
- };
+ return useResourceQuery({
+ resourceType: `entity:${entityName}`,
+ project: projectName,
+ restPath: entityDetailPath(entityName, projectName || ""),
+ restSelect: (d) => d,
+ enabled: !!entityName,
+ });
};
export default useLoadEntity;
diff --git a/ui/src/pages/feature-services/FeatureServiceListingTable.tsx b/ui/src/pages/feature-services/FeatureServiceListingTable.tsx
index acc68b6e619..8dd8b299d74 100644
--- a/ui/src/pages/feature-services/FeatureServiceListingTable.tsx
+++ b/ui/src/pages/feature-services/FeatureServiceListingTable.tsx
@@ -30,7 +30,8 @@ const FeatureServiceListingTable = ({
field: "spec.name",
render: (name: string, item: feast.core.IFeatureService) => {
// For "All Projects" view, link to the specific project
- const itemProject = item?.spec?.project || projectName;
+ const itemProject =
+ item?.spec?.project || (item as any)?.project || projectName;
return (
{name}
@@ -62,7 +63,7 @@ const FeatureServiceListingTable = ({
if (projectName === "all") {
columns.splice(1, 0, {
name: "Project",
- field: "spec.project",
+ field: "project",
sortable: true,
render: (project: string) => {
return project || "Unknown";
diff --git a/ui/src/pages/feature-services/FeatureServiceOverviewTab.tsx b/ui/src/pages/feature-services/FeatureServiceOverviewTab.tsx
index be922e41261..c439d48fc96 100644
--- a/ui/src/pages/feature-services/FeatureServiceOverviewTab.tsx
+++ b/ui/src/pages/feature-services/FeatureServiceOverviewTab.tsx
@@ -36,7 +36,7 @@ const FeatureServiceOverviewTab = () => {
let numFeatures = 0;
let numFeatureViews = 0;
if (data) {
- data?.spec?.features?.forEach((featureView) => {
+ data?.spec?.features?.forEach((featureView: any) => {
numFeatureViews += 1;
numFeatures += featureView?.featureColumns!.length;
});
@@ -159,7 +159,7 @@ const FeatureServiceOverviewTab = () => {
{data?.spec?.features?.length! > 0 ? (
{
+ data?.spec?.features?.map((f: any) => {
return f.featureViewName!;
})!
}
diff --git a/ui/src/pages/feature-services/Index.tsx b/ui/src/pages/feature-services/Index.tsx
index 260a9b821dc..b68bb9697e3 100644
--- a/ui/src/pages/feature-services/Index.tsx
+++ b/ui/src/pages/feature-services/Index.tsx
@@ -1,4 +1,4 @@
-import React, { useContext } from "react";
+import React from "react";
import { useParams } from "react-router-dom";
import {
@@ -13,7 +13,6 @@ import {
import { FeatureServiceIcon } from "../../graphics/FeatureServiceIcon";
-import useLoadRegistry from "../../queries/useLoadRegistry";
import FeatureServiceListingTable from "./FeatureServiceListingTable";
import {
useSearchQuery,
@@ -22,27 +21,23 @@ import {
tagTokenGroupsType,
} from "../../hooks/useSearchInputWithTags";
import { useDocumentTitle } from "../../hooks/useDocumentTitle";
-import RegistryPathContext from "../../contexts/RegistryPathContext";
import FeatureServiceIndexEmptyState from "./FeatureServiceIndexEmptyState";
import TagSearch from "../../components/TagSearch";
import ExportButton from "../../components/ExportButton";
import { useFeatureServiceTagsAggregation } from "../../hooks/useTagsAggregation";
import { feast } from "../../protos";
+import useResourceQuery, {
+ featureServiceListPath,
+} from "../../queries/useResourceQuery";
const useLoadFeatureServices = () => {
- const registryUrl = useContext(RegistryPathContext);
const { projectName } = useParams();
- const registryQuery = useLoadRegistry(registryUrl, projectName);
-
- const data =
- registryQuery.data === undefined
- ? undefined
- : registryQuery.data.objects.featureServices;
-
- return {
- ...registryQuery,
- data,
- };
+ return useResourceQuery({
+ resourceType: "feature-services-list",
+ project: projectName,
+ restPath: featureServiceListPath(projectName),
+ restSelect: (d) => d.featureServices,
+ });
};
const shouldIncludeFSsGivenTokenGroups = (
diff --git a/ui/src/pages/feature-services/useLoadFeatureService.ts b/ui/src/pages/feature-services/useLoadFeatureService.ts
index 004ab35b927..81fff2e931d 100644
--- a/ui/src/pages/feature-services/useLoadFeatureService.ts
+++ b/ui/src/pages/feature-services/useLoadFeatureService.ts
@@ -1,53 +1,50 @@
-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";
import { EntityReference } from "../../parsers/parseEntityRelationships";
+import useResourceQuery, {
+ featureServiceDetailPath,
+} from "../../queries/useResourceQuery";
const useLoadFeatureService = (featureServiceName: string) => {
- const registryUrl = useContext(RegistryPathContext);
const { projectName } = useParams();
- const registryQuery = useLoadRegistry(registryUrl, projectName);
- const data =
- registryQuery.data === undefined
- ? undefined
- : registryQuery.data.objects.featureServices?.find(
- (fs) => fs?.spec?.name === featureServiceName,
- );
+ const fsQuery = useResourceQuery({
+ resourceType: `feature-service:${featureServiceName}`,
+ project: projectName,
+ restPath: featureServiceDetailPath(featureServiceName, projectName || ""),
+ restSelect: (d) => ({
+ featureService: d,
+ indirectRelationships: d?.relationships || [],
+ permissions: d?.permissions || [],
+ }),
+ enabled: !!featureServiceName,
+ });
+
+ const featureService = fsQuery.data?.featureService;
+ const indirectRelationships = fsQuery.data?.indirectRelationships || [];
+ const permissions = fsQuery.data?.permissions || [];
- let entities =
- data === undefined
+ let entities: EntityReference[] | undefined =
+ featureService === undefined
? undefined
- : registryQuery.data?.indirectRelationships
- .filter((relationship) => {
- return (
- relationship.target.type === FEAST_FCO_TYPES.featureService &&
- relationship.target.name === data?.spec?.name &&
- relationship.source.type === FEAST_FCO_TYPES.entity
- );
- })
- .map((relationship) => {
- return relationship.source;
- });
- // Deduplicate on name of entity
+ : indirectRelationships
+ .filter(
+ (rel: any) =>
+ rel?.target?.type === "featureService" &&
+ rel?.source?.type === "entity",
+ )
+ .map((rel: any) => rel.source);
+
if (entities) {
- let entityToName: { [key: string]: EntityReference } = {};
- for (let entity of entities) {
+ const entityToName: { [key: string]: EntityReference } = {};
+ for (const entity of entities) {
entityToName[entity.name] = entity;
}
entities = Object.values(entityToName);
}
+
return {
- ...registryQuery,
- data: data
- ? {
- ...data,
- permissions: registryQuery.data?.permissions,
- }
- : undefined,
+ ...fsQuery,
+ data: featureService ? { ...featureService, permissions } : undefined,
entities,
};
};
diff --git a/ui/src/pages/feature-views/FeatureViewListingTable.tsx b/ui/src/pages/feature-views/FeatureViewListingTable.tsx
index 7537f8122c9..9fd6f8f8fd7 100644
--- a/ui/src/pages/feature-views/FeatureViewListingTable.tsx
+++ b/ui/src/pages/feature-views/FeatureViewListingTable.tsx
@@ -31,7 +31,10 @@ const FeatureViewListingTable = ({
sortable: true,
render: (name: string, item: genericFVType) => {
// For "All Projects" view, link to the specific project
- const itemProject = item.object?.spec?.project || projectName;
+ const itemProject =
+ item.object?.spec?.project ||
+ (item.object as any)?.project ||
+ projectName;
return (
{name}{" "}
@@ -63,7 +66,13 @@ const FeatureViewListingTable = ({
columns.splice(1, 0, {
name: "Project",
render: (item: genericFVType) => {
- return {item.object?.spec?.project || "Unknown"};
+ return (
+
+ {item.object?.spec?.project ||
+ (item.object as any)?.project ||
+ "Unknown"}
+
+ );
},
});
}
diff --git a/ui/src/pages/feature-views/FeatureViewVersionsTab.tsx b/ui/src/pages/feature-views/FeatureViewVersionsTab.tsx
index 1e5e44d6804..08ee8880f92 100644
--- a/ui/src/pages/feature-views/FeatureViewVersionsTab.tsx
+++ b/ui/src/pages/feature-views/FeatureViewVersionsTab.tsx
@@ -160,7 +160,7 @@ const FeatureViewVersionsTab = ({
r.featureViewName === featureViewName,
) || [];
- const decodedVersions = useMemo(
+ const decodedVersions: DecodedVersion[] = useMemo(
() => records.map(decodeVersionProto),
[records],
);
diff --git a/ui/src/pages/feature-views/Index.tsx b/ui/src/pages/feature-views/Index.tsx
index b1c28895370..849d1899a3e 100644
--- a/ui/src/pages/feature-views/Index.tsx
+++ b/ui/src/pages/feature-views/Index.tsx
@@ -1,4 +1,4 @@
-import React, { useContext } from "react";
+import React from "react";
import { useParams } from "react-router-dom";
import {
@@ -13,7 +13,6 @@ import {
import { FeatureViewIcon } from "../../graphics/FeatureViewIcon";
-import useLoadRegistry from "../../queries/useLoadRegistry";
import FeatureViewListingTable from "./FeatureViewListingTable";
import {
filterInputInterface,
@@ -22,26 +21,23 @@ import {
} from "../../hooks/useSearchInputWithTags";
import { genericFVType, regularFVInterface } from "../../parsers/mergedFVTypes";
import { useDocumentTitle } from "../../hooks/useDocumentTitle";
-import RegistryPathContext from "../../contexts/RegistryPathContext";
import FeatureViewIndexEmptyState from "./FeatureViewIndexEmptyState";
import { useFeatureViewTagsAggregation } from "../../hooks/useTagsAggregation";
import TagSearch from "../../components/TagSearch";
import ExportButton from "../../components/ExportButton";
+import useResourceQuery, {
+ featureViewListPath,
+ restFeatureViewsToMergedList,
+} from "../../queries/useResourceQuery";
const useLoadFeatureViews = () => {
- const registryUrl = useContext(RegistryPathContext);
const { projectName } = useParams();
- const registryQuery = useLoadRegistry(registryUrl, projectName);
-
- const data =
- registryQuery.data === undefined
- ? undefined
- : registryQuery.data.mergedFVList;
-
- return {
- ...registryQuery,
- data,
- };
+ return useResourceQuery({
+ resourceType: "feature-views-list",
+ project: projectName,
+ restPath: featureViewListPath(projectName),
+ restSelect: restFeatureViewsToMergedList,
+ });
};
const shouldIncludeFVsGivenTokenGroups = (
diff --git a/ui/src/pages/feature-views/useLoadFeatureView.ts b/ui/src/pages/feature-views/useLoadFeatureView.ts
index 08e8646f60f..5f88aab0a7d 100644
--- a/ui/src/pages/feature-views/useLoadFeatureView.ts
+++ b/ui/src/pages/feature-views/useLoadFeatureView.ts
@@ -1,71 +1,56 @@
-import { useContext } from "react";
-import RegistryPathContext from "../../contexts/RegistryPathContext";
-import useLoadRegistry from "../../queries/useLoadRegistry";
+import { useParams } from "react-router-dom";
+import useResourceQuery, {
+ featureViewDetailPath,
+ restFeatureViewDetailToGeneric,
+} from "../../queries/useResourceQuery";
+import type { genericFVType } from "../../parsers/mergedFVTypes";
const useLoadFeatureView = (featureViewName: string) => {
- const registryUrl = useContext(RegistryPathContext);
- const registryQuery = useLoadRegistry(registryUrl);
-
- const data =
- registryQuery.data === undefined
- ? undefined
- : registryQuery.data.mergedFVMap[featureViewName];
-
- return {
- ...registryQuery,
- data,
- };
+ const { projectName } = useParams();
+
+ return useResourceQuery({
+ resourceType: `feature-view:${featureViewName}`,
+ project: projectName,
+ restPath: featureViewDetailPath(featureViewName, projectName || ""),
+ restSelect: restFeatureViewDetailToGeneric,
+ enabled: !!featureViewName,
+ });
};
const useLoadRegularFeatureView = (featureViewName: string) => {
- const registryUrl = useContext(RegistryPathContext);
- const registryQuery = useLoadRegistry(registryUrl);
-
- const data =
- registryQuery.data === undefined
- ? undefined
- : registryQuery.data.objects.featureViews?.find((fv) => {
- return fv?.spec?.name === featureViewName;
- });
-
- return {
- ...registryQuery,
- data,
- };
+ const { projectName } = useParams();
+
+ return useResourceQuery({
+ resourceType: `regular-fv:${featureViewName}`,
+ project: projectName,
+ restPath: featureViewDetailPath(featureViewName, projectName || ""),
+ restSelect: (d) => (d?.type === "featureView" ? d : undefined),
+ enabled: !!featureViewName,
+ });
};
const useLoadOnDemandFeatureView = (featureViewName: string) => {
- const registryUrl = useContext(RegistryPathContext);
- const registryQuery = useLoadRegistry(registryUrl);
-
- const data =
- registryQuery.data === undefined
- ? undefined
- : registryQuery.data.objects.onDemandFeatureViews?.find((fv) => {
- return fv?.spec?.name === featureViewName;
- });
-
- return {
- ...registryQuery,
- data,
- };
+ const { projectName } = useParams();
+
+ return useResourceQuery({
+ resourceType: `odfv:${featureViewName}`,
+ project: projectName,
+ restPath: featureViewDetailPath(featureViewName, projectName || ""),
+ restSelect: (d) => (d?.type === "onDemandFeatureView" ? d : undefined),
+ enabled: !!featureViewName,
+ });
};
const useLoadStreamFeatureView = (featureViewName: string) => {
- const registryUrl = useContext(RegistryPathContext);
- const registryQuery = useLoadRegistry(registryUrl);
-
- const data =
- registryQuery.data === undefined
- ? undefined
- : registryQuery.data.objects.streamFeatureViews?.find((fv) => {
- return fv.spec?.name === featureViewName;
- });
-
- return {
- ...registryQuery,
- data,
- };
+ const { projectName } = useParams();
+
+ return useResourceQuery({
+ resourceType: `sfv:${featureViewName}`,
+ project: projectName,
+ restPath: featureViewDetailPath(featureViewName, projectName || ""),
+ restSelect: (d) => (d?.type === "streamFeatureView" ? d : undefined),
+ enabled: !!featureViewName,
+ });
};
export default useLoadFeatureView;
diff --git a/ui/src/pages/features/FeatureListPage.tsx b/ui/src/pages/features/FeatureListPage.tsx
index 36087f98bc0..3edc6df8a40 100644
--- a/ui/src/pages/features/FeatureListPage.tsx
+++ b/ui/src/pages/features/FeatureListPage.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useContext } from "react";
+import React, { useState } from "react";
import {
EuiBasicTable,
EuiTableFieldDataColumnType,
@@ -19,9 +19,10 @@ import {
import EuiCustomLink from "../../components/EuiCustomLink";
import ExportButton from "../../components/ExportButton";
import { useParams } from "react-router-dom";
-import useLoadRegistry from "../../queries/useLoadRegistry";
-import RegistryPathContext from "../../contexts/RegistryPathContext";
import { FeatureIcon } from "../../graphics/FeatureIcon";
+import useResourceQuery, {
+ featuresListPath,
+} from "../../queries/useResourceQuery";
import { FEAST_FCO_TYPES } from "../../parsers/types";
import {
getEntityPermissions,
@@ -43,11 +44,22 @@ type FeatureColumn =
const FeatureListPage = () => {
const { projectName } = useParams();
- const registryUrl = useContext(RegistryPathContext);
- const { data, isLoading, isError } = useLoadRegistry(
- registryUrl,
- projectName,
- );
+ const {
+ data: features,
+ isLoading,
+ isError,
+ } = useResourceQuery({
+ resourceType: "features-list",
+ project: projectName,
+ restPath: featuresListPath(projectName),
+ restSelect: (d) => d.features,
+ });
+ const { data: permissions } = useResourceQuery({
+ resourceType: "permissions",
+ project: projectName,
+ restPath: `/permissions?project=${encodeURIComponent(projectName || "")}`,
+ restSelect: (d) => d.permissions,
+ });
const [searchText, setSearchText] = useState("");
const [selectedPermissionAction, setSelectedPermissionAction] = useState("");
@@ -57,27 +69,22 @@ const FeatureListPage = () => {
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(100);
- const featuresWithPermissions: Feature[] = (data?.allFeatures || []).map(
- (feature) => {
- return {
- ...feature,
- permissions: getEntityPermissions(
- selectedPermissionAction
- ? filterPermissionsByAction(
- data?.permissions,
- selectedPermissionAction,
- )
- : data?.permissions,
- FEAST_FCO_TYPES.featureView,
- feature.featureView,
- ),
- };
- },
- );
+ const featuresWithPermissions: Feature[] = (features || []).map((feature) => {
+ return {
+ ...feature,
+ permissions: getEntityPermissions(
+ selectedPermissionAction
+ ? filterPermissionsByAction(permissions, selectedPermissionAction)
+ : permissions,
+ FEAST_FCO_TYPES.featureView,
+ feature.featureView,
+ ),
+ };
+ });
- const features: Feature[] = featuresWithPermissions;
+ const enrichedFeatures: Feature[] = featuresWithPermissions;
- const filteredFeatures = features.filter((feature) =>
+ const filteredFeatures = enrichedFeatures.filter((feature) =>
feature.name.toLowerCase().includes(searchText.toLowerCase()),
);
diff --git a/ui/src/pages/features/useLoadFeature.ts b/ui/src/pages/features/useLoadFeature.ts
index 54bf31e996f..be322a9c8aa 100644
--- a/ui/src/pages/features/useLoadFeature.ts
+++ b/ui/src/pages/features/useLoadFeature.ts
@@ -1,27 +1,32 @@
-import { useContext } from "react";
-import RegistryPathContext from "../../contexts/RegistryPathContext";
-import useLoadRegistry from "../../queries/useLoadRegistry";
+import { useParams } from "react-router-dom";
+import useResourceQuery, {
+ featureDetailPath,
+} from "../../queries/useResourceQuery";
const useLoadFeature = (featureViewName: string, featureName: string) => {
- const registryUrl = useContext(RegistryPathContext);
- const registryQuery = useLoadRegistry(registryUrl);
+ const { projectName } = useParams();
- const data =
- registryQuery.data === undefined
- ? undefined
- : registryQuery.data.objects.featureViews?.find((fv) => {
- return fv?.spec?.name === featureViewName;
- });
+ const fvQuery = useResourceQuery({
+ resourceType: `feature:${featureViewName}:${featureName}`,
+ project: projectName,
+ restPath: featureDetailPath(
+ featureViewName,
+ featureName,
+ projectName || "",
+ ),
+ restSelect: (d) => d,
+ enabled: !!featureViewName && !!featureName,
+ });
const featureData =
- data === undefined
+ fvQuery.data === undefined
? undefined
- : data?.spec?.features?.find((f) => {
- return f.name === featureName;
- });
+ : fvQuery.data?.spec?.features?.find(
+ (f: any) => f.name === featureName,
+ ) || fvQuery.data;
return {
- ...registryQuery,
+ ...fvQuery,
featureData,
};
};
diff --git a/ui/src/pages/saved-data-sets/DatasetOverviewTab.tsx b/ui/src/pages/saved-data-sets/DatasetOverviewTab.tsx
index 9ee7dd1aa42..d9a0ab43af9 100644
--- a/ui/src/pages/saved-data-sets/DatasetOverviewTab.tsx
+++ b/ui/src/pages/saved-data-sets/DatasetOverviewTab.tsx
@@ -69,7 +69,7 @@ const EntityOverviewTab = () => {
{
+ data?.spec?.joinKeys!.map((joinKey: any) => {
return { name: joinKey };
})!
}
diff --git a/ui/src/pages/saved-data-sets/Index.tsx b/ui/src/pages/saved-data-sets/Index.tsx
index c6cc81f4146..f78ee572ae6 100644
--- a/ui/src/pages/saved-data-sets/Index.tsx
+++ b/ui/src/pages/saved-data-sets/Index.tsx
@@ -1,28 +1,25 @@
-import React, { useContext } from "react";
+import React from "react";
+import { useParams } from "react-router-dom";
import { EuiPageTemplate, EuiLoadingSpinner } from "@elastic/eui";
import { DatasetIcon } from "../../graphics/DatasetIcon";
-import useLoadRegistry from "../../queries/useLoadRegistry";
import { useDocumentTitle } from "../../hooks/useDocumentTitle";
-import RegistryPathContext from "../../contexts/RegistryPathContext";
import DatasetsListingTable from "./DatasetsListingTable";
import DatasetsIndexEmptyState from "./DatasetsIndexEmptyState";
+import useResourceQuery, {
+ savedDatasetListPath,
+} from "../../queries/useResourceQuery";
const useLoadSavedDataSets = () => {
- const registryUrl = useContext(RegistryPathContext);
- const registryQuery = useLoadRegistry(registryUrl);
-
- const data =
- registryQuery.data === undefined
- ? undefined
- : registryQuery.data.objects.savedDatasets;
-
- return {
- ...registryQuery,
- data,
- };
+ const { projectName } = useParams();
+ return useResourceQuery({
+ resourceType: "saved-datasets-list",
+ project: projectName,
+ restPath: savedDatasetListPath(projectName),
+ restSelect: (d) => d.savedDatasets,
+ });
};
const Index = () => {
diff --git a/ui/src/pages/saved-data-sets/useLoadDataset.ts b/ui/src/pages/saved-data-sets/useLoadDataset.ts
index 40f8a8ebd48..c560f7f9eb6 100644
--- a/ui/src/pages/saved-data-sets/useLoadDataset.ts
+++ b/ui/src/pages/saved-data-sets/useLoadDataset.ts
@@ -1,22 +1,18 @@
-import { useContext } from "react";
-import RegistryPathContext from "../../contexts/RegistryPathContext";
-import useLoadRegistry from "../../queries/useLoadRegistry";
+import { useParams } from "react-router-dom";
+import useResourceQuery, {
+ savedDatasetDetailPath,
+} from "../../queries/useResourceQuery";
-const useLoadEntity = (entityName: string) => {
- const registryUrl = useContext(RegistryPathContext);
- const registryQuery = useLoadRegistry(registryUrl);
+const useLoadDataset = (datasetName: string) => {
+ const { projectName } = useParams();
- const data =
- registryQuery.data === undefined
- ? undefined
- : registryQuery.data.objects.savedDatasets?.find(
- (fv) => fv.spec?.name === entityName,
- );
-
- return {
- ...registryQuery,
- data,
- };
+ return useResourceQuery({
+ resourceType: `saved-dataset:${datasetName}`,
+ project: projectName,
+ restPath: savedDatasetDetailPath(datasetName, projectName || ""),
+ restSelect: (d) => d,
+ enabled: !!datasetName,
+ });
};
-export default useLoadEntity;
+export default useLoadDataset;
diff --git a/ui/src/queries/restApiClient.ts b/ui/src/queries/restApiClient.ts
new file mode 100644
index 00000000000..4cf2cb64bdd
--- /dev/null
+++ b/ui/src/queries/restApiClient.ts
@@ -0,0 +1,40 @@
+import type { FetchOptions } from "../contexts/DataModeContext";
+
+class RestApiError extends Error {
+ status: number;
+ constructor(message: string, status: number) {
+ super(message);
+ this.name = "RestApiError";
+ this.status = status;
+ }
+}
+
+const restFetch = async (
+ baseUrl: string,
+ path: string,
+ fetchOptions?: FetchOptions,
+): Promise => {
+ const url = `${baseUrl}${path}`;
+ const headers: Record = {
+ Accept: "application/json",
+ ...fetchOptions?.headers,
+ };
+
+ const res = await fetch(url, {
+ method: "GET",
+ headers,
+ credentials: fetchOptions?.credentials,
+ });
+
+ if (!res.ok) {
+ throw new RestApiError(
+ `REST API error: ${res.status} ${res.statusText}`,
+ res.status,
+ );
+ }
+
+ return res.json();
+};
+
+export default restFetch;
+export { RestApiError };
diff --git a/ui/src/queries/useLoadRegistry.ts b/ui/src/queries/useLoadRegistry.ts
index e3f5ac87a1d..abd490dab38 100644
--- a/ui/src/queries/useLoadRegistry.ts
+++ b/ui/src/queries/useLoadRegistry.ts
@@ -4,18 +4,20 @@ import parseEntityRelationships, {
EntityRelation,
} from "../parsers/parseEntityRelationships";
import parseIndirectRelationships from "../parsers/parseIndirectRelationships";
-import { feast } from "../protos";
+import { useDataMode } from "../contexts/DataModeContext";
+import restFetch from "./restApiClient";
+import type { FetchOptions } from "../contexts/DataModeContext";
interface FeatureStoreAllData {
project: string;
description?: string;
- objects: feast.core.Registry;
+ objects: any;
relationships: EntityRelation[];
mergedFVMap: Record;
mergedFVList: genericFVType[];
indirectRelationships: EntityRelation[];
allFeatures: Feature[];
- permissions?: any[]; // Add permissions field
+ permissions?: any[];
}
interface Feature {
@@ -25,251 +27,185 @@ interface Feature {
project?: string;
}
-const useLoadRegistry = (url: string, projectName?: string) => {
- return useQuery(
- `registry:${url}:${projectName || "all"}`,
- () => {
- return fetch(url, {
- headers: {
- "Content-Type": "application/json",
- },
- })
- .then((res) => {
- const contentType = res.headers.get("content-type");
- if (contentType && contentType.includes("application/json")) {
- return res.json();
- } else {
- return res.arrayBuffer();
- }
- })
- .then((data) => {
- let objects;
-
- if (data instanceof ArrayBuffer) {
- objects = feast.core.Registry.decode(new Uint8Array(data));
- } else {
- objects = data;
- }
- // const objects = FeastRegistrySchema.parse(json);
-
- if (!objects.featureViews) {
- 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
- ) {
- try {
- const fs = require("fs");
- const path = require("path");
- const { feast } = require("../protos");
-
- const registry = fs.readFileSync(
- path.resolve(__dirname, "../../public/registry.db"),
- );
- const parsedRegistry = feast.core.Registry.decode(registry);
-
- if (
- parsedRegistry.featureViews &&
- parsedRegistry.featureViews.length > 0
- ) {
- objects.featureViews = parsedRegistry.featureViews;
- }
- } catch (e) {
- console.error("Error loading test registry:", e);
- }
- }
-
- const { mergedFVMap, mergedFVList } = mergedFVTypes(objects);
-
- const relationships = parseEntityRelationships(objects);
-
- // Only contains Entity -> FS or DS -> FS relationships
- const indirectRelationships = parseIndirectRelationships(
- relationships,
- objects,
- );
+// ---------------------------------------------------------------------------
+// Shared post-processing (used by the bulk REST fetch)
+// ---------------------------------------------------------------------------
+
+const assembleFeatureStoreData = (
+ objects: any,
+ projectName?: string,
+): FeatureStoreAllData => {
+ const { mergedFVMap, mergedFVList } = mergedFVTypes(objects);
+ const relationships = parseEntityRelationships(objects);
+ const indirectRelationships = parseIndirectRelationships(
+ relationships,
+ objects,
+ );
- // console.log({
- // objects,
- // mergedFVMap,
- // mergedFVList,
- // relationships,
- // indirectRelationships,
- // });
- const allFeatures: Feature[] =
- objects.featureViews?.flatMap(
- (fv: any) =>
- fv?.spec?.features?.map((feature: any) => ({
- name: feature.name ?? "Unknown",
- featureView: fv?.spec?.name || "Unknown FeatureView",
- type:
- feature.valueType != null
- ? feast.types.ValueType.Enum[feature.valueType]
- : "Unknown Type",
- project: fv?.spec?.project, // Include project from parent feature view
- })) || [],
- ) || [];
+ const allFeatures: Feature[] =
+ objects.featureViews?.flatMap(
+ (fv: any) =>
+ fv?.spec?.features?.map((feature: any) => ({
+ name: feature.name ?? "Unknown",
+ featureView: fv?.spec?.name || "Unknown FeatureView",
+ type:
+ feature.valueType != null
+ ? typeof feature.valueType === "number"
+ ? String(feature.valueType)
+ : feature.valueType
+ : "Unknown Type",
+ project: fv?.spec?.project || fv?.project,
+ })) || [],
+ ) || [];
+
+ let resolvedProjectName: string =
+ projectName === "all"
+ ? "All Projects"
+ : projectName ||
+ (objects.projects &&
+ objects.projects.length > 0 &&
+ objects.projects[0].spec &&
+ objects.projects[0].spec.name
+ ? objects.projects[0].spec.name
+ : objects.project
+ ? objects.project
+ : "default");
+
+ let projectDescription: string | undefined;
+ 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: resolvedProjectName,
+ description: projectDescription,
+ objects,
+ mergedFVMap,
+ mergedFVList,
+ relationships,
+ indirectRelationships,
+ allFeatures,
+ permissions: objects.permissions || [],
+ };
+};
- // 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");
+// ---------------------------------------------------------------------------
+// REST fetch strategy
+// ---------------------------------------------------------------------------
+
+const fetchREST = async (
+ apiBaseUrl: string,
+ projectName?: string,
+ fetchOptions?: FetchOptions,
+): Promise => {
+ const projectParam =
+ projectName && projectName !== "all"
+ ? `?project=${encodeURIComponent(projectName)}`
+ : "";
+ const useAllEndpoint = !projectParam;
+
+ const [
+ entitiesResp,
+ featureViewsResp,
+ featureServicesResp,
+ dataSourcesResp,
+ savedDatasetsResp,
+ projectsResp,
+ ] = await Promise.all([
+ restFetch(
+ apiBaseUrl,
+ useAllEndpoint
+ ? "/entities/all?include_relationships=true"
+ : `/entities${projectParam}&include_relationships=true`,
+ fetchOptions,
+ ),
+ restFetch(
+ apiBaseUrl,
+ useAllEndpoint
+ ? "/feature_views/all?include_relationships=true"
+ : `/feature_views${projectParam}&include_relationships=true`,
+ fetchOptions,
+ ),
+ restFetch(
+ apiBaseUrl,
+ useAllEndpoint
+ ? "/feature_services/all?include_relationships=true"
+ : `/feature_services${projectParam}&include_relationships=true`,
+ fetchOptions,
+ ),
+ restFetch(
+ apiBaseUrl,
+ useAllEndpoint
+ ? "/data_sources/all?include_relationships=true"
+ : `/data_sources${projectParam}&include_relationships=true`,
+ fetchOptions,
+ ),
+ restFetch(
+ apiBaseUrl,
+ useAllEndpoint
+ ? "/saved_datasets/all?include_relationships=true"
+ : `/saved_datasets${projectParam}&include_relationships=true`,
+ fetchOptions,
+ ),
+ restFetch(apiBaseUrl, "/projects", fetchOptions),
+ ]);
+
+ const entities = entitiesResp.entities || [];
+ const allFeatureViews = featureViewsResp.featureViews || [];
+ const featureServices = featureServicesResp.featureServices || [];
+ const dataSources = dataSourcesResp.dataSources || [];
+ const savedDatasets = savedDatasetsResp.savedDatasets || [];
+ const projects = projectsResp.projects || [];
+
+ const featureViews: any[] = [];
+ const onDemandFeatureViews: any[] = [];
+ const streamFeatureViews: any[] = [];
+
+ for (const fv of allFeatureViews) {
+ const fvType = fv.type;
+ if (fvType === "onDemandFeatureView") {
+ onDemandFeatureViews.push(fv);
+ } else if (fvType === "streamFeatureView") {
+ streamFeatureViews.push(fv);
+ } else {
+ featureViews.push(fv);
+ }
+ }
+
+ const objects: any = {
+ entities,
+ featureViews,
+ onDemandFeatureViews,
+ streamFeatureViews,
+ featureServices,
+ dataSources,
+ savedDatasets,
+ projects,
+ };
+
+ return assembleFeatureStoreData(objects, projectName);
+};
- let projectDescription = undefined;
+// ---------------------------------------------------------------------------
+// Public hook
+// ---------------------------------------------------------------------------
- // 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;
- }
- }
+const useLoadRegistry = (url: string, projectName?: string) => {
+ const { fetchOptions } = useDataMode();
- return {
- project: resolvedProjectName,
- description: projectDescription,
- objects,
- mergedFVMap,
- mergedFVList,
- relationships,
- indirectRelationships,
- allFeatures,
- permissions:
- objects.permissions && objects.permissions.length > 0
- ? objects.permissions
- : [
- {
- spec: {
- name: "zipcode-features-reader",
- types: [2], // FeatureView
- name_patterns: ["zipcode_features"],
- policy: { roles: ["analyst", "data_scientist"] },
- actions: [1, 4, 5], // DESCRIBE, READ_ONLINE, READ_OFFLINE
- },
- },
- {
- spec: {
- name: "zipcode-source-writer",
- types: [7], // FileSource
- name_patterns: ["zipcode"],
- policy: { roles: ["admin", "data_engineer"] },
- actions: [0, 2, 7], // CREATE, UPDATE, WRITE_OFFLINE
- },
- },
- {
- spec: {
- name: "credit-score-v1-reader",
- types: [6], // FeatureService
- name_patterns: ["credit_score_v1"],
- policy: { roles: ["model_user", "data_scientist"] },
- actions: [1, 4], // DESCRIBE, READ_ONLINE
- },
- },
- {
- spec: {
- name: "risky-features-reader",
- types: [2, 6], // FeatureView, FeatureService
- name_patterns: [],
- required_tags: { stage: "prod" },
- policy: { roles: ["trusted_analyst"] },
- actions: [5], // READ_OFFLINE
- },
- },
- ],
- };
- });
- },
+ return useQuery(
+ ["registry-rest-bulk", url, projectName || "all"],
+ () => fetchREST(url, projectName, fetchOptions),
{
- staleTime: Infinity, // Given that we are reading from a registry dump, this seems reasonable for now.
+ staleTime: 30_000,
+ enabled: !!url,
},
);
};
diff --git a/ui/src/queries/useResourceQuery.ts b/ui/src/queries/useResourceQuery.ts
new file mode 100644
index 00000000000..3047f50af46
--- /dev/null
+++ b/ui/src/queries/useResourceQuery.ts
@@ -0,0 +1,193 @@
+import { useContext } from "react";
+import { useQuery, UseQueryResult } from "react-query";
+import RegistryPathContext from "../contexts/RegistryPathContext";
+import { useDataMode } from "../contexts/DataModeContext";
+import restFetch from "./restApiClient";
+import { FEAST_FV_TYPES, genericFVType } from "../parsers/mergedFVTypes";
+
+interface ResourceQueryOptions {
+ resourceType: string;
+ project?: string;
+ restPath: string;
+ restSelect?: (data: any) => T | undefined;
+ enabled?: boolean;
+}
+
+/**
+ * Generic hook for fetching a specific resource slice via REST API.
+ *
+ * Each caller fires its own lightweight endpoint request, and react-query
+ * deduplicates identical keys automatically.
+ */
+function useResourceQuery({
+ resourceType,
+ project,
+ restPath,
+ restSelect,
+ enabled = true,
+}: ResourceQueryOptions): UseQueryResult {
+ const registryUrl = useContext(RegistryPathContext);
+ const { fetchOptions } = useDataMode();
+
+ return useQuery(
+ ["rest", resourceType, registryUrl, project || "all"],
+ () => restFetch(registryUrl, restPath, fetchOptions),
+ {
+ enabled: !!registryUrl && enabled,
+ staleTime: 30_000,
+ select: restSelect,
+ },
+ );
+}
+
+// ---------------------------------------------------------------------------
+// REST endpoint path builders
+// ---------------------------------------------------------------------------
+
+function entityListPath(project?: string): string {
+ if (project && project !== "all") {
+ return `/entities?project=${encodeURIComponent(project)}&include_relationships=true`;
+ }
+ return "/entities/all?limit=100&include_relationships=true";
+}
+
+function entityDetailPath(name: string, project: string): string {
+ return `/entities/${encodeURIComponent(name)}?project=${encodeURIComponent(project)}&include_relationships=true`;
+}
+
+function featureViewListPath(project?: string): string {
+ if (project && project !== "all") {
+ return `/feature_views?project=${encodeURIComponent(project)}&include_relationships=true`;
+ }
+ return "/feature_views/all?limit=100&include_relationships=true";
+}
+
+function featureViewDetailPath(name: string, project: string): string {
+ return `/feature_views/${encodeURIComponent(name)}?project=${encodeURIComponent(project)}&include_relationships=true`;
+}
+
+function featureServiceListPath(project?: string): string {
+ if (project && project !== "all") {
+ return `/feature_services?project=${encodeURIComponent(project)}&include_relationships=true`;
+ }
+ return "/feature_services/all?limit=100&include_relationships=true";
+}
+
+function featureServiceDetailPath(name: string, project: string): string {
+ return `/feature_services/${encodeURIComponent(name)}?project=${encodeURIComponent(project)}&include_relationships=true`;
+}
+
+function dataSourceListPath(project?: string): string {
+ if (project && project !== "all") {
+ return `/data_sources?project=${encodeURIComponent(project)}&include_relationships=true`;
+ }
+ return "/data_sources/all?limit=100&include_relationships=true";
+}
+
+function dataSourceDetailPath(name: string, project: string): string {
+ return `/data_sources/${encodeURIComponent(name)}?project=${encodeURIComponent(project)}&include_relationships=true`;
+}
+
+function savedDatasetListPath(project?: string): string {
+ if (project && project !== "all") {
+ return `/saved_datasets?project=${encodeURIComponent(project)}`;
+ }
+ return "/saved_datasets/all?limit=100";
+}
+
+function savedDatasetDetailPath(name: string, project: string): string {
+ return `/saved_datasets/${encodeURIComponent(name)}?project=${encodeURIComponent(project)}`;
+}
+
+function featuresListPath(project?: string): string {
+ if (project && project !== "all") {
+ return `/features?project=${encodeURIComponent(project)}`;
+ }
+ return "/features/all?limit=100";
+}
+
+function featureDetailPath(
+ featureViewName: string,
+ featureName: string,
+ project: string,
+): string {
+ return `/features/${encodeURIComponent(featureViewName)}/${encodeURIComponent(featureName)}?project=${encodeURIComponent(project)}`;
+}
+
+// ---------------------------------------------------------------------------
+// REST response → mergedFVList converter
+// ---------------------------------------------------------------------------
+
+function restFeatureViewsToMergedList(resp: any): genericFVType[] {
+ const featureViews = resp?.featureViews || [];
+ return featureViews.map((fv: any) => {
+ const fvType = fv.type;
+ if (fvType === "onDemandFeatureView") {
+ return {
+ name: fv.spec?.name,
+ type: FEAST_FV_TYPES.ondemand,
+ features: fv.spec?.features || [],
+ object: fv,
+ };
+ }
+ if (fvType === "streamFeatureView") {
+ return {
+ name: fv.spec?.name,
+ type: FEAST_FV_TYPES.stream,
+ features: fv.spec?.features || [],
+ object: fv,
+ };
+ }
+ return {
+ name: fv.spec?.name,
+ type: FEAST_FV_TYPES.regular,
+ features: fv.spec?.features || [],
+ object: fv,
+ };
+ });
+}
+
+function restFeatureViewDetailToGeneric(resp: any): genericFVType | undefined {
+ if (!resp || !resp.spec) return undefined;
+ const fvType = resp.type;
+ if (fvType === "onDemandFeatureView") {
+ return {
+ name: resp.spec.name,
+ type: FEAST_FV_TYPES.ondemand,
+ features: resp.spec.features || [],
+ object: resp,
+ };
+ }
+ if (fvType === "streamFeatureView") {
+ return {
+ name: resp.spec.name,
+ type: FEAST_FV_TYPES.stream,
+ features: resp.spec.features || [],
+ object: resp,
+ };
+ }
+ return {
+ name: resp.spec.name,
+ type: FEAST_FV_TYPES.regular,
+ features: resp.spec.features || [],
+ object: resp,
+ };
+}
+
+export default useResourceQuery;
+export {
+ entityListPath,
+ entityDetailPath,
+ featureViewListPath,
+ featureViewDetailPath,
+ featureServiceListPath,
+ featureServiceDetailPath,
+ dataSourceListPath,
+ dataSourceDetailPath,
+ savedDatasetListPath,
+ savedDatasetDetailPath,
+ featuresListPath,
+ featureDetailPath,
+ restFeatureViewsToMergedList,
+ restFeatureViewDetailToGeneric,
+};