diff --git a/docs/reference/alpha-web-ui.md b/docs/reference/alpha-web-ui.md index 0556482fcf8..3fe8ce052a8 100644 --- a/docs/reference/alpha-web-ui.md +++ b/docs/reference/alpha-web-ui.md @@ -153,3 +153,12 @@ const tabsRegistry = { ``` Examples of custom tabs can be found in the `ui/custom-tabs` folder. + +## Refreshing the registry + +The Feast UI caches registry data (projects, feature views, entities, etc.) using the registry cache. After running `feast apply` to make changes, it may take up to `cache_ttl_seconds` before the updates appear in the UI. + +To see changes faster: + +- **Lower the TTL**: Set `cache_ttl_seconds: 10` (or similar) in your `feature_store.yaml` registry config. This makes all registry consumers — including the UI — pick up changes within 10 seconds. +- **Refresh on demand**: The UI has a **Refresh** button that explicitly invalidates the server-side registry cache (`POST /api/v1/registry/refresh`) and reloads the UI without a full page refresh. diff --git a/sdk/python/feast/api/registry/rest/__init__.py b/sdk/python/feast/api/registry/rest/__init__.py index 899829bf927..4f5712e1493 100644 --- a/sdk/python/feast/api/registry/rest/__init__.py +++ b/sdk/python/feast/api/registry/rest/__init__.py @@ -14,7 +14,7 @@ from feast.api.registry.rest.metrics import get_metrics_router from feast.api.registry.rest.monitoring import get_monitoring_router from feast.api.registry.rest.permissions import get_permission_router -from feast.api.registry.rest.projects import get_project_router +from feast.api.registry.rest.projects import get_project_router, get_registry_router from feast.api.registry.rest.saved_datasets import get_saved_dataset_router from feast.api.registry.rest.search import get_search_router @@ -44,6 +44,9 @@ def register_all_routes(app: FastAPI, grpc_handler, server=None, store=None): app.include_router(get_monitoring_router(grpc_handler, store=resolved_store)) app.include_router(get_compute_engine_router(grpc_handler, store=resolved_store)) + if resolved_store: + app.include_router(get_registry_router(resolved_store)) + _register_openlineage_consumer(app, resolved_store) diff --git a/sdk/python/feast/api/registry/rest/projects.py b/sdk/python/feast/api/registry/rest/projects.py index 659fb22dc8f..2047c8717c9 100644 --- a/sdk/python/feast/api/registry/rest/projects.py +++ b/sdk/python/feast/api/registry/rest/projects.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, Query, Response, status from feast.api.registry.rest.rest_utils import ( get_pagination_params, @@ -50,3 +50,19 @@ def list_projects( } return router + + +def get_registry_router(store) -> APIRouter: + router = APIRouter() + + @router.post("/registry/refresh") + def refresh_registry(): + from feast.permissions.action import AuthzedAction + from feast.permissions.security_manager import assert_permissions + + project = store.registry.get_project(name=store.project, allow_cache=True) + assert_permissions(resource=project, actions=[AuthzedAction.UPDATE]) + store.refresh_registry() + return Response(status_code=status.HTTP_200_OK) + + return router diff --git a/sdk/python/feast/ui_server.py b/sdk/python/feast/ui_server.py index 948d1ea3742..574aeacc257 100644 --- a/sdk/python/feast/ui_server.py +++ b/sdk/python/feast/ui_server.py @@ -889,304 +889,310 @@ def get_app( 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.get("/api/mlflow-runs") - def get_mlflow_runs(max_results: int = 50): - """Return MLflow runs linked to this Feast project via auto-logging.""" - mlflow_cfg = getattr(store.config, "mlflow", None) - if not mlflow_cfg or not mlflow_cfg.enabled: - return {"runs": [], "mlflow_uri": None} + @app.get("/projects-list.json") + def get_projects_list(): + return _build_projects_list(store, project_id, root_path) - try: - import mlflow - - tracking_uri = mlflow_cfg.get_tracking_uri() - mlflow_ui_base = tracking_uri or mlflow.get_tracking_uri() or "" - client = mlflow.MlflowClient(tracking_uri=tracking_uri) - - project_name = store.config.project - experiment = client.get_experiment_by_name(project_name) - if experiment is None: - return {"runs": [], "mlflow_uri": mlflow_ui_base or None} - experiment_ids = [experiment.experiment_id] - - safe_project = project_name.replace("\\", "\\\\").replace("'", "\\'") - filter_str = ( - f"tags.`feast.project` = '{safe_project}' " - f"AND tags.`feast.retrieval_type` != ''" - ) + @app.get("/api/mlflow-runs") + def get_mlflow_runs(max_results: int = 50): + """Return MLflow runs linked to this Feast project via auto-logging.""" + mlflow_cfg = getattr(store.config, "mlflow", None) + if not mlflow_cfg or not mlflow_cfg.enabled: + return {"runs": [], "mlflow_uri": None} - max_results = min(max(max_results, 1), 200) - runs = client.search_runs( - experiment_ids=experiment_ids, - filter_string=filter_str, - max_results=max_results, - order_by=["start_time DESC"], - ) - - run_id_to_models: Dict[str, List[dict]] = {} try: - for rm in client.search_registered_models(): - for mv in rm.latest_versions or []: - if mv.run_id: - run_id_to_models.setdefault(mv.run_id, []).append( - { - "model_name": rm.name, - "version": mv.version, - "stage": mv.current_stage, - "mlflow_url": ( - f"{mlflow_ui_base}/#/models/" - f"{rm.name}/versions/{mv.version}" - ), - } - ) - except Exception: - pass - - result = [] - for run in runs: - run_tags = run.data.tags - run_params = run.data.params - fv_raw = run_tags.get("feast.feature_views", "") - refs_raw = run_tags.get( - "feast.feature_refs", - run_params.get("feast.feature_refs", ""), + import mlflow + + tracking_uri = mlflow_cfg.get_tracking_uri() + mlflow_ui_base = tracking_uri or mlflow.get_tracking_uri() or "" + client = mlflow.MlflowClient(tracking_uri=tracking_uri) + + project_name = store.config.project + experiment = client.get_experiment_by_name(project_name) + if experiment is None: + return {"runs": [], "mlflow_uri": mlflow_ui_base or None} + experiment_ids = [experiment.experiment_id] + + safe_project = project_name.replace("\\", "\\\\").replace("'", "\\'") + filter_str = ( + f"tags.`feast.project` = '{safe_project}' " + f"AND tags.`feast.retrieval_type` != ''" ) - result.append( - { - "run_id": run.info.run_id, - "run_name": run.info.run_name, - "status": run.info.status, - "start_time": run.info.start_time, - "feature_service": run_tags.get("feast.feature_service"), - "feature_views": [v for v in fv_raw.split(",") if v], - "feature_refs": [v for v in refs_raw.split(",") if v], - "retrieval_type": run_tags.get("feast.retrieval_type"), - "entity_count": run_tags.get( - "feast.entity_count", - run_params.get("feast.entity_count"), - ), - "mlflow_url": ( - f"{mlflow_ui_base}/#/experiments/" - f"{run.info.experiment_id}/runs/{run.info.run_id}" - ), - "registered_models": run_id_to_models.get(run.info.run_id, []), - } + + max_results = min(max(max_results, 1), 200) + runs = client.search_runs( + experiment_ids=experiment_ids, + filter_string=filter_str, + max_results=max_results, + order_by=["start_time DESC"], ) - return {"runs": result, "mlflow_uri": mlflow_ui_base or None} - except ImportError: - return { - "runs": [], - "mlflow_uri": None, - "error": "mlflow is not installed", - } - except Exception: - return { - "runs": [], - "mlflow_uri": None, - "error": "Failed to fetch MLflow runs", - } + run_id_to_models: Dict[str, List[dict]] = {} + try: + for rm in client.search_registered_models(): + for mv in rm.latest_versions or []: + if mv.run_id: + run_id_to_models.setdefault(mv.run_id, []).append( + { + "model_name": rm.name, + "version": mv.version, + "stage": mv.current_stage, + "mlflow_url": ( + f"{mlflow_ui_base}/#/models/" + f"{rm.name}/versions/{mv.version}" + ), + } + ) + except Exception: + pass - _feature_usage_cache: Dict = {"data": None, "timestamp": 0.0} - _FEATURE_USAGE_TTL_SECONDS = 300 + result = [] + for run in runs: + run_tags = run.data.tags + run_params = run.data.params + fv_raw = run_tags.get("feast.feature_views", "") + refs_raw = run_tags.get( + "feast.feature_refs", + run_params.get("feast.feature_refs", ""), + ) + result.append( + { + "run_id": run.info.run_id, + "run_name": run.info.run_name, + "status": run.info.status, + "start_time": run.info.start_time, + "feature_service": run_tags.get("feast.feature_service"), + "feature_views": [v for v in fv_raw.split(",") if v], + "feature_refs": [v for v in refs_raw.split(",") if v], + "retrieval_type": run_tags.get("feast.retrieval_type"), + "entity_count": run_tags.get( + "feast.entity_count", + run_params.get("feast.entity_count"), + ), + "mlflow_url": ( + f"{mlflow_ui_base}/#/experiments/" + f"{run.info.experiment_id}/runs/{run.info.run_id}" + ), + "registered_models": run_id_to_models.get( + run.info.run_id, [] + ), + } + ) + + return {"runs": result, "mlflow_uri": mlflow_ui_base or None} + except ImportError: + return { + "runs": [], + "mlflow_uri": None, + "error": "mlflow is not installed", + } + except Exception: + return { + "runs": [], + "mlflow_uri": None, + "error": "Failed to fetch MLflow runs", + } - @app.get("/api/mlflow-feature-usage") - def get_mlflow_feature_usage(): - """Return per-feature-view usage stats aggregated from MLflow runs. + _feature_usage_cache: Dict = {"data": None, "timestamp": 0.0} + _FEATURE_USAGE_TTL_SECONDS = 300 - Caches results for 5 minutes to avoid hammering the MLflow server. - """ - import time as _time + @app.get("/api/mlflow-feature-usage") + def get_mlflow_feature_usage(): + """Return per-feature-view usage stats aggregated from MLflow runs. - mlflow_cfg = getattr(store.config, "mlflow", None) - if not mlflow_cfg or not mlflow_cfg.enabled: - return {"feature_usage": {}, "mlflow_enabled": False} + Caches results for 5 minutes to avoid hammering the MLflow server. + """ + import time as _time - now = _time.monotonic() - if ( - _feature_usage_cache["data"] is not None - and (now - _feature_usage_cache["timestamp"]) < _FEATURE_USAGE_TTL_SECONDS - ): - return _feature_usage_cache["data"] + mlflow_cfg = getattr(store.config, "mlflow", None) + if not mlflow_cfg or not mlflow_cfg.enabled: + return {"feature_usage": {}, "mlflow_enabled": False} - try: - import mlflow + now = _time.monotonic() + if ( + _feature_usage_cache["data"] is not None + and (now - _feature_usage_cache["timestamp"]) + < _FEATURE_USAGE_TTL_SECONDS + ): + return _feature_usage_cache["data"] + + try: + import mlflow + + tracking_uri = mlflow_cfg.get_tracking_uri() + client = mlflow.MlflowClient(tracking_uri=tracking_uri) + project_name = store.config.project + + experiment = client.get_experiment_by_name(project_name) + if experiment is None: + result = {"feature_usage": {}, "mlflow_enabled": True} + _feature_usage_cache["data"] = result + _feature_usage_cache["timestamp"] = now + return result + + safe_project = project_name.replace("\\", "\\\\").replace("'", "\\'") + filter_str = ( + f"tags.`feast.project` = '{safe_project}' " + f"AND tags.`feast.retrieval_type` != ''" + ) + runs = client.search_runs( + experiment_ids=[experiment.experiment_id], + filter_string=filter_str, + max_results=200, + order_by=["start_time DESC"], + ) - tracking_uri = mlflow_cfg.get_tracking_uri() - client = mlflow.MlflowClient(tracking_uri=tracking_uri) - project_name = store.config.project + run_id_to_models: Dict[str, List[str]] = {} + try: + for rm in client.search_registered_models(): + for mv in rm.latest_versions or []: + if mv.run_id: + run_id_to_models.setdefault(mv.run_id, []).append( + rm.name + ) + except Exception: + pass - experiment = client.get_experiment_by_name(project_name) - if experiment is None: - result = {"feature_usage": {}, "mlflow_enabled": True} + usage: Dict[str, dict] = {} + for run in runs: + refs_raw = run.data.tags.get("feast.feature_refs", "") + fv_names = set() + for ref in refs_raw.split(","): + ref = ref.strip() + if ":" in ref: + fv_names.add(ref.split(":")[0]) + + run_models = run_id_to_models.get(run.info.run_id, []) + + for fv_name in fv_names: + if fv_name not in usage: + usage[fv_name] = { + "run_count": 0, + "last_used": None, + "models": [], + } + usage[fv_name]["run_count"] += 1 + run_ts = run.info.start_time + if usage[fv_name]["last_used"] is None or ( + run_ts and run_ts > usage[fv_name]["last_used"] + ): + usage[fv_name]["last_used"] = run_ts + for m in run_models: + if m not in usage[fv_name]["models"]: + usage[fv_name]["models"].append(m) + + result = {"feature_usage": usage, "mlflow_enabled": True} _feature_usage_cache["data"] = result _feature_usage_cache["timestamp"] = now return result + except ImportError: + return { + "feature_usage": {}, + "mlflow_enabled": False, + "error": "mlflow is not installed", + } + except Exception as e: + logger.debug("Failed to fetch feature usage: %s", e) + return { + "feature_usage": {}, + "mlflow_enabled": True, + "error": "Failed to fetch usage data", + } - safe_project = project_name.replace("\\", "\\\\").replace("'", "\\'") - filter_str = ( - f"tags.`feast.project` = '{safe_project}' " - f"AND tags.`feast.retrieval_type` != ''" - ) - runs = client.search_runs( - experiment_ids=[experiment.experiment_id], - filter_string=filter_str, - max_results=200, - order_by=["start_time DESC"], - ) + @app.get("/api/mlflow-feature-models") + def get_mlflow_feature_models(): + """Return a mapping of feature_ref -> registered models that use it. - run_id_to_models: Dict[str, List[str]] = {} - try: - for rm in client.search_registered_models(): - for mv in rm.latest_versions or []: - if mv.run_id: - run_id_to_models.setdefault(mv.run_id, []).append(rm.name) - except Exception: - pass + Walks the MLflow Model Registry, inspects the training run for each + model's latest version(s), reads the ``feast.feature_refs`` tag, and + inverts it into a reverse index so the UI can show which registered + models depend on a given feature. + """ + mlflow_cfg = getattr(store.config, "mlflow", None) + if not mlflow_cfg or not mlflow_cfg.enabled: + return {"feature_models": {}} - usage: Dict[str, dict] = {} - for run in runs: - refs_raw = run.data.tags.get("feast.feature_refs", "") - fv_names = set() - for ref in refs_raw.split(","): - ref = ref.strip() - if ":" in ref: - fv_names.add(ref.split(":")[0]) - - run_models = run_id_to_models.get(run.info.run_id, []) - - for fv_name in fv_names: - if fv_name not in usage: - usage[fv_name] = { - "run_count": 0, - "last_used": None, - "models": [], - } - usage[fv_name]["run_count"] += 1 - run_ts = run.info.start_time - if usage[fv_name]["last_used"] is None or ( - run_ts and run_ts > usage[fv_name]["last_used"] - ): - usage[fv_name]["last_used"] = run_ts - for m in run_models: - if m not in usage[fv_name]["models"]: - usage[fv_name]["models"].append(m) - - result = {"feature_usage": usage, "mlflow_enabled": True} - _feature_usage_cache["data"] = result - _feature_usage_cache["timestamp"] = now - return result - except ImportError: - return { - "feature_usage": {}, - "mlflow_enabled": False, - "error": "mlflow is not installed", - } - except Exception as e: - logger.debug("Failed to fetch feature usage: %s", e) - return { - "feature_usage": {}, - "mlflow_enabled": True, - "error": "Failed to fetch usage data", - } + try: + import mlflow - @app.get("/api/mlflow-feature-models") - def get_mlflow_feature_models(): - """Return a mapping of feature_ref -> registered models that use it. + tracking_uri = mlflow_cfg.get_tracking_uri() + mlflow_ui_base = tracking_uri or mlflow.get_tracking_uri() or "" + client = mlflow.MlflowClient(tracking_uri=tracking_uri) + project_name = store.config.project - Walks the MLflow Model Registry, inspects the training run for each - model's latest version(s), reads the ``feast.feature_refs`` tag, and - inverts it into a reverse index so the UI can show which registered - models depend on a given feature. - """ - mlflow_cfg = getattr(store.config, "mlflow", None) - if not mlflow_cfg or not mlflow_cfg.enabled: - return {"feature_models": {}} + feature_models: Dict[str, List[dict]] = {} - try: - import mlflow - - tracking_uri = mlflow_cfg.get_tracking_uri() - mlflow_ui_base = tracking_uri or mlflow.get_tracking_uri() or "" - client = mlflow.MlflowClient(tracking_uri=tracking_uri) - project_name = store.config.project - - feature_models: Dict[str, List[dict]] = {} - - for rm in client.search_registered_models(): - model_name = rm.name - latest_versions = rm.latest_versions or [] - for mv in latest_versions: - if not mv.run_id: - continue - try: - run = client.get_run(mv.run_id) - except Exception: - continue - - tags = run.data.tags - if tags.get("feast.project") != project_name: - continue - - refs_raw = tags.get("feast.feature_refs", "") - feature_refs = [r for r in refs_raw.split(",") if r] - - model_info = { - "model_name": model_name, - "version": mv.version, - "stage": mv.current_stage, - "mlflow_url": ( - f"{mlflow_ui_base}/#/models/" - f"{model_name}/versions/{mv.version}" - ), - } + for rm in client.search_registered_models(): + model_name = rm.name + latest_versions = rm.latest_versions or [] + for mv in latest_versions: + if not mv.run_id: + continue + try: + run = client.get_run(mv.run_id) + except Exception: + continue + + tags = run.data.tags + if tags.get("feast.project") != project_name: + continue + + refs_raw = tags.get("feast.feature_refs", "") + feature_refs = [r for r in refs_raw.split(",") if r] + + model_info = { + "model_name": model_name, + "version": mv.version, + "stage": mv.current_stage, + "mlflow_url": ( + f"{mlflow_ui_base}/#/models/" + f"{model_name}/versions/{mv.version}" + ), + } - for ref in feature_refs: - feature_models.setdefault(ref, []).append(model_info) + for ref in feature_refs: + feature_models.setdefault(ref, []).append(model_info) - return {"feature_models": feature_models} - except ImportError: - return { - "feature_models": {}, - "error": "mlflow is not installed", - } - except Exception as e: - logger.debug("Failed to fetch MLflow feature-model mapping: %s", e) - return { - "feature_models": {}, - "error": "Failed to fetch model data", - } + return {"feature_models": feature_models} + except ImportError: + return { + "feature_models": {}, + "error": "mlflow is not installed", + } + except Exception as e: + logger.debug("Failed to fetch MLflow feature-model mapping: %s", e) + return { + "feature_models": {}, + "error": "Failed to fetch model data", + } - auth_config_json = _build_auth_config_json(store) - - def _serve_index(): - filename = ui_dir.joinpath("index.html") - with open(filename) as f: - content = f.read() - if auth_config_json: - tag = f'' - content = content.replace("", f"{tag}\n", 1) - return Response(content, media_type="text/html") - - @app.get("/") - def serve_root(): - return _serve_index() - - @app.api_route("/p/{path_name:path}", methods=["GET"]) - def catch_all(): - return _serve_index() - - app.mount( - "/", - StaticFiles(directory=ui_dir, html=True), - name="site", - ) + auth_config_json = _build_auth_config_json(store) + + def _serve_index(): + filename = ui_dir.joinpath("index.html") + with open(filename) as f: + content = f.read() + if auth_config_json: + tag = f'' + content = content.replace("", f"{tag}\n", 1) + return Response(content, media_type="text/html") + + @app.get("/") + def serve_root(): + return _serve_index() + + @app.api_route("/p/{path_name:path}", methods=["GET"]) + def catch_all(): + return _serve_index() + + app.mount( + "/", + StaticFiles(directory=ui_dir, html=True), + name="site", + ) - return app + return app def start_server( diff --git a/sdk/python/tests/unit/api/test_api_rest_registry.py b/sdk/python/tests/unit/api/test_api_rest_registry.py index 60aff9aed52..fe67ff34ebe 100644 --- a/sdk/python/tests/unit/api/test_api_rest_registry.py +++ b/sdk/python/tests/unit/api/test_api_rest_registry.py @@ -2286,3 +2286,45 @@ def test_metrics_resource_counts_nonexistent_project(fastapi_test_app): assert data["featureServices"] == [] assert data["featureViews"] == [] assert "registryLastUpdated" in data + + +def test_registry_refresh_via_rest(fastapi_test_app): + """POST /registry/refresh invalidates the registry cache and returns 200.""" + response = fastapi_test_app.post("/registry/refresh") + assert response.status_code == 200 + + +def test_registry_refresh_via_rest_error(): + """POST /registry/refresh returns 500 when refresh fails.""" + from unittest.mock import patch + + from feast.api.registry.rest import register_all_routes + from feast.registry_server import RegistryServer + + tmp_dir = tempfile.TemporaryDirectory() + registry_path = os.path.join(tmp_dir.name, "registry.db") + + config = { + "registry": registry_path, + "project": "demo_project", + "provider": "local", + "offline_store": {"type": "file"}, + "online_store": {"type": "sqlite", "path": ":memory:"}, + } + from fastapi import FastAPI + + from feast.repo_config import RepoConfig + + store = FeatureStore(config=RepoConfig.model_validate(config)) + store.apply([]) + + app = FastAPI() + grpc_handler = RegistryServer(store.registry, store=store) + register_all_routes(app, grpc_handler, store=store) + + with patch.object(store, "refresh_registry", side_effect=Exception("db error")): + client = TestClient(app, raise_server_exceptions=False) + response = client.post("/registry/refresh") + assert response.status_code == 500 + + tmp_dir.cleanup() diff --git a/sdk/python/tests/unit/api/test_api_rest_registry_server.py b/sdk/python/tests/unit/api/test_api_rest_registry_server.py index 464e033a27e..c2fe987ab07 100644 --- a/sdk/python/tests/unit/api/test_api_rest_registry_server.py +++ b/sdk/python/tests/unit/api/test_api_rest_registry_server.py @@ -75,4 +75,4 @@ def test_routes_registered_in_app(): server = MagicMock() register_all_routes(app, grpc_handler, server) - assert app.include_router.call_count == 14 + assert app.include_router.call_count == 15 diff --git a/sdk/python/tests/unit/test_ui_server.py b/sdk/python/tests/unit/test_ui_server.py index d878a3f1a20..8bf01ac5f24 100644 --- a/sdk/python/tests/unit/test_ui_server.py +++ b/sdk/python/tests/unit/test_ui_server.py @@ -148,11 +148,12 @@ def test_catch_all_route(ui_app_with_registry): # ---------- 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 _read_projects_list(app): + """Fetch the projects-list.json from the dynamic endpoint.""" + client = TestClient(app) + resp = client.get("/projects-list.json") + assertpy.assert_that(resp.status_code).is_equal_to(EXPECTED_SUCCESS_STATUS) + return resp.json() def test_projects_list_registry_path(mock_feature_store): @@ -165,9 +166,9 @@ def test_projects_list_registry_path(mock_feature_store): _create_mock_ui_files(temp_dir) with _setup_importlib_mocks(temp_dir): - get_app(mock_feature_store, TEST_PROJECT_NAME) + app = get_app(mock_feature_store, TEST_PROJECT_NAME) - data = _read_projects_list(temp_dir) + data = _read_projects_list(app) assertpy.assert_that(data["projects"][0]["registryPath"]).is_equal_to("/api/v1") @@ -181,13 +182,13 @@ def test_projects_list_with_root_path(mock_feature_store): _create_mock_ui_files(temp_dir) with _setup_importlib_mocks(temp_dir): - get_app( + app = get_app( mock_feature_store, TEST_PROJECT_NAME, root_path="/feast", ) - data = _read_projects_list(temp_dir) + data = _read_projects_list(app) assertpy.assert_that(data["projects"][0]["registryPath"]).is_equal_to( "/feast/api/v1" ) @@ -206,9 +207,9 @@ def test_projects_list_multiple_projects(mock_feature_store): _create_mock_ui_files(temp_dir) with _setup_importlib_mocks(temp_dir): - get_app(mock_feature_store, TEST_PROJECT_NAME) + app = get_app(mock_feature_store, TEST_PROJECT_NAME) - data = _read_projects_list(temp_dir) + data = _read_projects_list(app) 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") @@ -225,9 +226,9 @@ def test_projects_list_fallback_on_empty(mock_feature_store): _create_mock_ui_files(temp_dir) with _setup_importlib_mocks(temp_dir): - get_app(mock_feature_store, TEST_PROJECT_NAME) + app = get_app(mock_feature_store, TEST_PROJECT_NAME) - data = _read_projects_list(temp_dir) + data = _read_projects_list(app) assertpy.assert_that(len(data["projects"])).is_equal_to(1) assertpy.assert_that(data["projects"][0]["id"]).is_equal_to(TEST_PROJECT_NAME) @@ -242,8 +243,73 @@ def test_projects_list_fallback_on_exception(mock_feature_store): _create_mock_ui_files(temp_dir) with _setup_importlib_mocks(temp_dir): - get_app(mock_feature_store, TEST_PROJECT_NAME) + app = get_app(mock_feature_store, TEST_PROJECT_NAME) - data = _read_projects_list(temp_dir) + data = _read_projects_list(app) assertpy.assert_that(len(data["projects"])).is_equal_to(1) assertpy.assert_that(data["projects"][0]["id"]).is_equal_to(TEST_PROJECT_NAME) + + +def test_projects_list_dynamic_refresh(mock_feature_store): + """New projects appear without restarting the server.""" + mock_registry = MagicMock() + mock_registry.list_projects.return_value = [ + _make_project_mock("picked_elk"), + ] + mock_feature_store.registry = mock_registry + + with tempfile.TemporaryDirectory() as temp_dir: + _create_mock_ui_files(temp_dir) + + with _setup_importlib_mocks(temp_dir): + app = get_app(mock_feature_store, TEST_PROJECT_NAME) + + client = TestClient(app) + + data = client.get("/projects-list.json").json() + assertpy.assert_that(len(data["projects"])).is_equal_to(1) + assertpy.assert_that(data["projects"][0]["id"]).is_equal_to("picked_elk") + + mock_registry.list_projects.return_value = [ + _make_project_mock("picked_elk"), + _make_project_mock("picked_elk2"), + ] + + data = client.get("/projects-list.json").json() + 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("picked_elk") + assertpy.assert_that(data["projects"][2]["id"]).is_equal_to("picked_elk2") + + +def test_registry_refresh_endpoint(mock_feature_store): + """POST /api/v1/registry/refresh calls store.refresh_registry().""" + mock_feature_store.refresh_registry = MagicMock() + + with tempfile.TemporaryDirectory() as temp_dir: + _create_mock_ui_files(temp_dir) + + with _setup_importlib_mocks(temp_dir): + app = get_app(mock_feature_store, TEST_PROJECT_NAME) + + client = TestClient(app) + resp = client.post("/api/v1/registry/refresh") + assertpy.assert_that(resp.status_code).is_equal_to(EXPECTED_SUCCESS_STATUS) + mock_feature_store.refresh_registry.assert_called_once() + + +def test_registry_refresh_endpoint_error(mock_feature_store): + """POST /api/v1/registry/refresh returns 500 when refresh_registry raises.""" + mock_feature_store.refresh_registry = MagicMock( + side_effect=Exception("registry unreachable") + ) + + with tempfile.TemporaryDirectory() as temp_dir: + _create_mock_ui_files(temp_dir) + + with _setup_importlib_mocks(temp_dir): + app = get_app(mock_feature_store, TEST_PROJECT_NAME) + + client = TestClient(app, raise_server_exceptions=False) + resp = client.post("/api/v1/registry/refresh") + assertpy.assert_that(resp.status_code).is_equal_to(500) diff --git a/ui/src/FeastUISansProviders.tsx b/ui/src/FeastUISansProviders.tsx index 1f032e3bc05..60a02aa1d56 100644 --- a/ui/src/FeastUISansProviders.tsx +++ b/ui/src/FeastUISansProviders.tsx @@ -79,10 +79,12 @@ const FeastUISansProviders = ({ ? { projectsListPromise: feastUIConfigs?.projectListPromise, isCustom: true, + basename, } : { projectsListPromise: defaultProjectListPromise(basename), isCustom: false, + basename, }; return ( diff --git a/ui/src/contexts/ProjectListContext.ts b/ui/src/contexts/ProjectListContext.ts index a230300be3b..c0c24840efb 100644 --- a/ui/src/contexts/ProjectListContext.ts +++ b/ui/src/contexts/ProjectListContext.ts @@ -20,6 +20,7 @@ type ProjectsListType = z.infer; interface ProjectsListContextInterface { projectsListPromise: Promise; isCustom: boolean; + basename?: string; } const ProjectListContext = React.createContext< diff --git a/ui/src/contexts/RegistryRefreshContext.ts b/ui/src/contexts/RegistryRefreshContext.ts new file mode 100644 index 00000000000..12be5977d88 --- /dev/null +++ b/ui/src/contexts/RegistryRefreshContext.ts @@ -0,0 +1,22 @@ +import React, { useContext } from "react"; + +interface RegistryRefreshContextInterface { + refreshing: boolean; + handleRefresh: () => Promise; +} + +const RegistryRefreshContext = React.createContext< + RegistryRefreshContextInterface | undefined +>(undefined); + +const useRegistryRefreshContext = () => { + const ctx = useContext(RegistryRefreshContext); + if (!ctx) { + throw new Error( + "useRegistryRefreshContext must be used within RegistryRefreshContext.Provider", + ); + } + return ctx; +}; + +export { RegistryRefreshContext, useRegistryRefreshContext }; diff --git a/ui/src/hooks/useRegistryRefresh.ts b/ui/src/hooks/useRegistryRefresh.ts new file mode 100644 index 00000000000..cb684cf5636 --- /dev/null +++ b/ui/src/hooks/useRegistryRefresh.ts @@ -0,0 +1,80 @@ +import { useCallback, useContext, useState } from "react"; +import { useQueryClient } from "react-query"; +import { + ProjectListContext, + ProjectsListSchema, +} from "../contexts/ProjectListContext"; +import { useDataMode } from "../contexts/DataModeContext"; + +interface Toast { + id: string; + title: string; + color: "success" | "danger"; + iconType: string; +} + +const useRegistryRefresh = () => { + const [refreshing, setRefreshing] = useState(false); + const [toasts, setToasts] = useState([]); + const queryClient = useQueryClient(); + const projectListCtx = useContext(ProjectListContext); + const basename = projectListCtx?.basename || ""; + const { fetchOptions } = useDataMode(); + + const removeToast = useCallback((removedToast: { id: string }) => { + setToasts((prev) => prev.filter((t) => t.id !== removedToast.id)); + }, []); + + const handleRefresh = useCallback(async () => { + setRefreshing(true); + try { + const refreshRes = await fetch(`${basename}/api/v1/registry/refresh`, { + method: "POST", + headers: { ...fetchOptions?.headers }, + credentials: fetchOptions?.credentials, + }); + if (!refreshRes.ok) { + throw new Error(`Registry refresh failed (${refreshRes.status})`); + } + const res = await fetch(`${basename}/projects-list.json`, { + headers: { + "Content-Type": "application/json", + ...fetchOptions?.headers, + }, + credentials: fetchOptions?.credentials, + }); + if (!res.ok) { + throw new Error(`Failed to fetch project list (${res.status})`); + } + const json = await res.json(); + const parsed = ProjectsListSchema.parse(json); + queryClient.setQueryData("feast-projects-list", parsed); + await queryClient.invalidateQueries("registry-rest-bulk"); + setToasts((prev) => [ + ...prev, + { + id: String(Date.now()), + title: "Refresh successful", + color: "success" as const, + iconType: "check", + }, + ]); + } catch { + setToasts((prev) => [ + ...prev, + { + id: String(Date.now()), + title: "Refresh failed", + color: "danger" as const, + iconType: "alert", + }, + ]); + } finally { + setRefreshing(false); + } + }, [basename, queryClient, fetchOptions]); + + return { refreshing, toasts, handleRefresh, removeToast }; +}; + +export default useRegistryRefresh; diff --git a/ui/src/pages/Layout.tsx b/ui/src/pages/Layout.tsx index 6df1b8d4d77..a951b9a2649 100644 --- a/ui/src/pages/Layout.tsx +++ b/ui/src/pages/Layout.tsx @@ -1,6 +1,7 @@ import React, { useState, useRef, useEffect } from "react"; import { + EuiGlobalToastList, EuiPage, EuiPageSidebar, EuiPageBody, @@ -34,6 +35,8 @@ import RegistrySearch, { import GlobalSearchShortcut from "../components/GlobalSearchShortcut"; import CommandPalette from "../components/CommandPalette"; import { useAuth } from "../contexts/AuthContext"; +import { RegistryRefreshContext } from "../contexts/RegistryRefreshContext"; +import useRegistryRefresh from "../hooks/useRegistryRefresh"; const Layout = () => { let { projectName } = useParams(); @@ -41,6 +44,8 @@ const Layout = () => { const [isUserMenuOpen, setIsUserMenuOpen] = useState(false); const searchRef = useRef(null); const { user, logout, isAuthEnabled } = useAuth(); + const { refreshing, toasts, handleRefresh, removeToast } = + useRegistryRefresh(); const { data: projectsData } = useLoadProjectsList(); @@ -166,227 +171,250 @@ const Layout = () => { }, []); return ( - - - setIsCommandPaletteOpen(false)} - categories={globalCategories} - /> - - - - - - {registryPath && ( - - - - - + + + + setIsCommandPaletteOpen(false)} + categories={globalCategories} + /> + + + + + + {registryPath && ( + + + + + +
+ +
+
+ )} +
+ + +
- -
-
- )} -
+
+ + {data && ( + +
+ +
+
+ )} + {!data && } - - -
-
- - {data && ( - -
- -
-
- )} - {!data && } + {projectName && ( + + + Refresh + + + )} - {isAuthEnabled && user && ( - - setIsUserMenuOpen((v) => !v)} - style={{ - display: "flex", - alignItems: "center", - gap: 8, - background: "none", - border: "none", - cursor: "pointer", - padding: "4px 8px", - borderRadius: 6, - }} - aria-label="User menu" - > - - - {user.username} - - - - } - isOpen={isUserMenuOpen} - closePopover={() => setIsUserMenuOpen(false)} - anchorPosition="downRight" - panelPaddingSize="m" - > -
- - + {isAuthEnabled && user && ( + + setIsUserMenuOpen((v) => !v)} + style={{ + display: "flex", + alignItems: "center", + gap: 8, + background: "none", + border: "none", + cursor: "pointer", + padding: "4px 8px", + borderRadius: 6, + }} + aria-label="User menu" + > - - - + {user.username} - {user.email && ( + + + } + isOpen={isUserMenuOpen} + closePopover={() => setIsUserMenuOpen(false)} + anchorPosition="downRight" + panelPaddingSize="m" + > +
+ + + + + + + {user.username} + + {user.email && ( + + {user.email} + + )} + + + + {user.roles.length > 0 && ( + <> + - {user.email} + Roles - )} - - + +
+ {user.roles + .filter( + (r) => + ![ + "default-roles-feast", + "offline_access", + "uma_authorization", + ].includes(r), + ) + .map((role) => ( + + + {role} + + + ))} +
+ + )} - {user.roles.length > 0 && ( - <> - - - Roles - - -
- {user.roles - .filter( - (r) => - ![ - "default-roles-feast", - "offline_access", - "uma_authorization", - ].includes(r), - ) - .map((role) => ( - - {role} - + {user.groups.length > 0 && ( + <> + + + Groups + + +
+ {user.groups.map((group) => ( + + {group} + ))} -
- - )} - - {user.groups.length > 0 && ( - <> - - - Groups - - -
- {user.groups.map((group) => ( - - {group} - - ))} -
- - )} +
+ + )} - - - Sign out - -
- -
- )} -
-
-
- + + + Sign out + +
+
+
+ )} +
+
+
+ +
-
- - -
-
+ + + + + + ); }; diff --git a/ui/src/pages/RootProjectSelectionPage.tsx b/ui/src/pages/RootProjectSelectionPage.tsx index fb488e714bc..6740e266f2a 100644 --- a/ui/src/pages/RootProjectSelectionPage.tsx +++ b/ui/src/pages/RootProjectSelectionPage.tsx @@ -1,7 +1,9 @@ import React, { useEffect } from "react"; import { + EuiButtonEmpty, EuiCard, EuiFlexGrid, + EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSkeletonText, @@ -13,10 +15,12 @@ import { import { useLoadProjectsList } from "../contexts/ProjectListContext"; import { useNavigate } from "react-router-dom"; import FeastIconBlue from "../graphics/FeastIconBlue"; +import { useRegistryRefreshContext } from "../contexts/RegistryRefreshContext"; const RootProjectSelectionPage = () => { const { isLoading, isSuccess, data } = useLoadProjectsList(); const navigate = useNavigate(); + const { refreshing, handleRefresh } = useRegistryRefreshContext(); useEffect(() => { if (data && data.default) { @@ -48,12 +52,27 @@ const RootProjectSelectionPage = () => { return ( - -

Welcome to Feast

-
- -

Select one of the projects.

-
+ + + +

Welcome to Feast

+
+ +

Select one of the projects.

+
+
+ + + Refresh + + +
{isLoading && } {isSuccess && data?.projects && (