diff --git a/sdk/python/feast/api/registry/rest/feature_services.py b/sdk/python/feast/api/registry/rest/feature_services.py index a3e634f3ce3..2486d28ea8d 100644 --- a/sdk/python/feast/api/registry/rest/feature_services.py +++ b/sdk/python/feast/api/registry/rest/feature_services.py @@ -1,6 +1,8 @@ -from typing import Dict +from typing import Dict, List, Optional from fastapi import APIRouter, Depends, Query +from fastapi.responses import JSONResponse +from pydantic import BaseModel from feast.api.registry.rest.codegen_utils import render_feature_service_code from feast.api.registry.rest.rest_utils import ( @@ -14,9 +16,39 @@ grpc_call, parse_tags, ) +from feast.feature_service import FeatureService from feast.protos.feast.registry import RegistryServer_pb2 +class FeatureViewRefModel(BaseModel): + feature_view_name: str + feature_names: Optional[List[str]] = [] + + +class ApplyFeatureServiceRequestBody(BaseModel): + name: str + project: str + features: List[FeatureViewRefModel] + description: Optional[str] = "" + tags: Optional[Dict[str, str]] = {} + owner: Optional[str] = "" + + +def _projection_view_name(projection: dict) -> Optional[str]: + return ( + projection.get("featureViewName") + or projection.get("feature_view_name") + or projection.get("name") + ) + + +def _projection_feature_columns(projection: dict) -> list: + columns = projection.get("featureColumns") + if columns is None: + columns = projection.get("feature_columns") + return columns if isinstance(columns, list) else [] + + def get_feature_service_router(grpc_handler) -> APIRouter: router = APIRouter() @@ -97,9 +129,7 @@ def get_feature_service( project=project, allow_cache=allow_cache, ) - feature_service = grpc_call(grpc_handler.GetFeatureService, req) - - result = feature_service + result = grpc_call(grpc_handler.GetFeatureService, req) if include_relationships: relationships = get_object_relationships( @@ -109,25 +139,33 @@ def get_feature_service( if result: spec = result.get("spec", result) - name = spec.get("name") or result.get("name") or "default_feature_service" + service_name = ( + spec.get("name") or result.get("name") or "default_feature_service" + ) projections = spec.get("features", []) if not isinstance(projections, list): projections = [] features_exprs = [] for proj in projections: - if isinstance(proj, dict): - view_name = proj.get("name") - feature_names = proj.get("features", []) - else: - view_name = str(proj) - feature_names = [] + if not isinstance(proj, dict): + continue + view_name = _projection_view_name(proj) if not view_name: continue + feature_columns = _projection_feature_columns(proj) + feature_names = [ + column.get("name") + for column in feature_columns + if isinstance(column, dict) and column.get("name") + ] + if feature_names: - feature_list = ", ".join([repr(f) for f in feature_names]) + feature_list = ", ".join( + [repr(feature_name) for feature_name in feature_names] + ) features_exprs.append(f"{view_name}[[{feature_list}]]") else: features_exprs.append(view_name) @@ -135,7 +173,7 @@ def get_feature_service( features_str = ", ".join(features_exprs) context = { - "name": name, + "name": service_name, "features": features_str, "tags": spec.get("tags", {}), "description": spec.get("description", ""), @@ -145,4 +183,39 @@ def get_feature_service( result["featureDefinition"] = render_feature_service_code(context) return result + @router.post("/feature_services", status_code=201) + def apply_feature_service(body: ApplyFeatureServiceRequestBody): + req = FeatureService.build_apply_request( + name=body.name, + project=body.project, + feature_view_refs=[ + (feature.feature_view_name, feature.feature_names or None) + for feature in body.features + ], + description=body.description or "", + tags=body.tags or {}, + owner=body.owner or "", + commit=True, + ) + grpc_call(grpc_handler.ApplyFeatureService, req) + + return JSONResponse( + status_code=201, + content={"name": body.name, "project": body.project, "status": "applied"}, + ) + + @router.delete("/feature_services/{name}") + def delete_feature_service( + name: str, + project: str = Query(...), + ): + req = RegistryServer_pb2.DeleteFeatureServiceRequest( + name=name, + project=project, + commit=True, + ) + grpc_call(grpc_handler.DeleteFeatureService, req) + + return {"name": name, "project": project, "status": "deleted"} + return router diff --git a/sdk/python/feast/feature_service.py b/sdk/python/feast/feature_service.py index 983ead2c416..ed4692f6a76 100644 --- a/sdk/python/feast/feature_service.py +++ b/sdk/python/feast/feature_service.py @@ -1,14 +1,18 @@ from datetime import datetime -from typing import Dict, List, Optional, Union +from typing import TYPE_CHECKING, Dict, List, Optional, Union from google.protobuf.json_format import MessageToJson from typeguard import typechecked from feast.base_feature_view import BaseFeatureView -from feast.errors import FeatureViewMissingDuringFeatureServiceInference +from feast.errors import ( + FeastObjectNotFoundException, + FeatureViewMissingDuringFeatureServiceInference, +) from feast.feature_logging import LoggingConfig from feast.feature_view import FeatureView from feast.feature_view_projection import FeatureViewProjection +from feast.field import Field from feast.labeling.label_view import LabelView from feast.on_demand_feature_view import OnDemandFeatureView from feast.protos.feast.core.FeatureService_pb2 import ( @@ -21,6 +25,9 @@ FeatureServiceSpec as FeatureServiceSpecProto, ) +if TYPE_CHECKING: + from feast.infra.registry.base_registry import BaseRegistry + @typechecked class FeatureService: @@ -161,6 +168,119 @@ def infer_features( f'{type(feature_grouping)} as part of the "features" argument.)' ) + def prepare_for_apply( + self, + registry: "BaseRegistry", + project: str, + allow_cache: bool = False, + ) -> "FeatureService": + """ + Materialize feature view projections before registry apply. + + Uses the same FeatureService construction and ``infer_features`` path as + ``FeatureStore.apply`` for SDK-defined services. + + When the service is already fully resolved (SDK path where _features is + set and projections already have features populated via infer_features, + OR the proto deserialization path where projections carry full dtype + info), this is a no-op. + """ + from feast.types import Invalid + + if self._features and all(p.features for p in self.feature_view_projections): + return self + + if ( + not self._features + and self.feature_view_projections + and all( + p.features and all(f.dtype != Invalid for f in p.features) + for p in self.feature_view_projections + ) + ): + return self + + fvs_to_update: Dict[str, Union[FeatureView, BaseFeatureView]] = {} + + if self._features: + for feature_grouping in self._features: + if isinstance(feature_grouping, BaseFeatureView): + fvs_to_update[feature_grouping.name] = ( + registry.get_any_feature_view( + feature_grouping.name, project, allow_cache=allow_cache + ) + ) + self.infer_features(fvs_to_update=fvs_to_update) + return self + + resolved_features: List[Union[FeatureView, OnDemandFeatureView, LabelView]] = [] + for projection in self.feature_view_projections: + try: + feature_view = registry.get_any_feature_view( + projection.name, project, allow_cache=allow_cache + ) + except FeastObjectNotFoundException as exc: + raise FeastObjectNotFoundException( + f"Feature view '{projection.name}' not found in project '{project}'" + ) from exc + + if not isinstance( + feature_view, (FeatureView, OnDemandFeatureView, LabelView) + ): + raise ValueError( + f"Cannot resolve projection for feature view '{projection.name}'" + ) + + fvs_to_update[feature_view.name] = feature_view + features_by_name = { + feature.name: feature for feature in feature_view.features + } + + if self._projection_matches_registry_features(projection, features_by_name): + resolved_features.append(feature_view.with_projection(projection)) + elif projection.desired_features: + resolved_features.append( + feature_view[list(projection.desired_features)] + ) + elif not projection.features: + resolved_features.append(feature_view) + else: + resolved_features.append( + feature_view[[feature.name for feature in projection.features]] + ) + + prepared = FeatureService( + name=self.name, + features=resolved_features, + tags=self.tags, + description=self.description, + owner=self.owner, + logging_config=self.logging_config, + precompute_online=self.precompute_online, + ) + prepared.created_timestamp = self.created_timestamp + prepared.last_updated_timestamp = self.last_updated_timestamp + prepared.infer_features(fvs_to_update=fvs_to_update) + + self._features = prepared._features + self.feature_view_projections = prepared.feature_view_projections + return self + + @staticmethod + def _projection_matches_registry_features( + projection: FeatureViewProjection, + features_by_name: Dict[str, Field], + ) -> bool: + if not projection.features: + return False + + for feature in projection.features: + if feature.name not in features_by_name: + return False + if feature != features_by_name[feature.name]: + return False + return True + def __repr__(self): items = (f"{k} = {v}" for k, v in self.__dict__.items()) return f"<{self.__class__.__name__}({', '.join(items)})>" @@ -259,6 +379,48 @@ def to_proto(self) -> FeatureServiceProto: return FeatureServiceProto(spec=spec, meta=meta) + @classmethod + def build_apply_request( + cls, + *, + name: str, + project: str, + feature_view_refs: List[tuple[str, Optional[List[str]]]], + description: str = "", + tags: Optional[Dict[str, str]] = None, + owner: str = "", + commit: bool = True, + ): + """Build an unresolved ApplyFeatureServiceRequest from feature view refs.""" + from feast.protos.feast.core.Feature_pb2 import FeatureSpecV2 + from feast.protos.feast.core.FeatureViewProjection_pb2 import ( + FeatureViewProjection as FeatureViewProjectionProto, + ) + from feast.protos.feast.registry import RegistryServer_pb2 + + projections = [] + for feature_view_name, feature_names in feature_view_refs: + projection = FeatureViewProjectionProto( + feature_view_name=feature_view_name, + ) + if feature_names: + for feature_name in feature_names: + projection.feature_columns.append(FeatureSpecV2(name=feature_name)) + projections.append(projection) + + spec = FeatureServiceSpecProto( + name=name, + features=projections, + tags=tags or {}, + description=description, + owner=owner, + ) + return RegistryServer_pb2.ApplyFeatureServiceRequest( + feature_service=FeatureServiceProto(spec=spec), + project=project, + commit=commit, + ) + def validate(self): if not self.precompute_online: return diff --git a/sdk/python/feast/infra/registry/registry.py b/sdk/python/feast/infra/registry/registry.py index f09f05e971f..e9b16cc030c 100644 --- a/sdk/python/feast/infra/registry/registry.py +++ b/sdk/python/feast/infra/registry/registry.py @@ -453,6 +453,7 @@ def delete_data_source(self, name: str, project: str, commit: bool = True): def apply_feature_service( self, feature_service: FeatureService, project: str, commit: bool = True ): + feature_service.prepare_for_apply(self, project, allow_cache=True) now = _utc_now() if not feature_service.created_timestamp: feature_service.created_timestamp = now diff --git a/sdk/python/feast/infra/registry/snowflake.py b/sdk/python/feast/infra/registry/snowflake.py index 5590e1b7574..f6b14757570 100644 --- a/sdk/python/feast/infra/registry/snowflake.py +++ b/sdk/python/feast/infra/registry/snowflake.py @@ -264,6 +264,7 @@ def apply_entity(self, entity: Entity, project: str, commit: bool = True): def apply_feature_service( self, feature_service: FeatureService, project: str, commit: bool = True ): + feature_service.prepare_for_apply(self, project, allow_cache=True) return self._apply_object( "FEATURE_SERVICES", project, diff --git a/sdk/python/feast/infra/registry/sql.py b/sdk/python/feast/infra/registry/sql.py index edd89347be2..1dccfee1ffc 100644 --- a/sdk/python/feast/infra/registry/sql.py +++ b/sdk/python/feast/infra/registry/sql.py @@ -1031,6 +1031,7 @@ def apply_feature_view( def apply_feature_service( self, feature_service: FeatureService, project: str, commit: bool = True ): + feature_service.prepare_for_apply(self, project, allow_cache=True) return self._apply_object( feature_services, project, 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 bc1aedd1c7c..60aff9aed52 100644 --- a/sdk/python/tests/unit/api/test_api_rest_registry.py +++ b/sdk/python/tests/unit/api/test_api_rest_registry.py @@ -2162,6 +2162,116 @@ def test_apply_and_delete_feature_view_via_rest(fastapi_test_app): assert response.status_code == 404 +def test_apply_and_delete_feature_service_via_rest(fastapi_test_app): + """Test POST /feature_services and DELETE /feature_services/{name} endpoints.""" + response = fastapi_test_app.post( + "/feature_views", + json={ + "name": "driver_stats_for_service", + "project": "demo_project", + "entities": ["user_id"], + "features": [ + { + "name": "trip_count", + "value_type": 2, + "description": "Number of completed trips", + }, + ], + "ttl_seconds": 86400, + "online": True, + "description": "Driver statistics feature view", + }, + ) + assert response.status_code == 201 + + response = fastapi_test_app.post( + "/feature_services", + json={ + "name": "driver_activity_v1", + "project": "demo_project", + "features": [ + { + "feature_view_name": "driver_stats_for_service", + "feature_names": ["trip_count"], + } + ], + "description": "Driver activity feature service", + "owner": "ml-team", + }, + ) + assert response.status_code == 201 + data = response.json() + assert data["name"] == "driver_activity_v1" + assert data["status"] == "applied" + + response = fastapi_test_app.get( + "/feature_services/driver_activity_v1?project=demo_project" + ) + assert response.status_code == 200 + data = response.json() + assert data["spec"]["name"] == "driver_activity_v1" + projections = data["spec"]["features"] + assert len(projections) == 1 + assert projections[0]["featureViewName"] == "driver_stats_for_service" + assert len(projections[0]["featureColumns"]) == 1 + assert projections[0]["featureColumns"][0]["name"] == "trip_count" + + response = fastapi_test_app.get("/feature_services?project=demo_project") + assert response.status_code == 200 + listed = next( + fs + for fs in response.json()["featureServices"] + if fs["spec"]["name"] == "driver_activity_v1" + ) + listed_projection = listed["spec"]["features"][0] + assert listed_projection["featureColumns"][0]["name"] == "trip_count" + + response = fastapi_test_app.post( + "/feature_services", + json={ + "name": "driver_activity_all", + "project": "demo_project", + "features": [ + {"feature_view_name": "driver_stats_for_service"}, + ], + "description": "All features from the feature view", + }, + ) + assert response.status_code == 201 + + response = fastapi_test_app.get( + "/feature_services/driver_activity_all?project=demo_project" + ) + assert response.status_code == 200 + all_features_projection = response.json()["spec"]["features"][0] + assert all_features_projection["featureViewName"] == "driver_stats_for_service" + assert len(all_features_projection["featureColumns"]) == 1 + assert all_features_projection["featureColumns"][0]["name"] == "trip_count" + + response = fastapi_test_app.delete( + "/feature_services/driver_activity_all?project=demo_project" + ) + assert response.status_code == 200 + + response = fastapi_test_app.delete( + "/feature_services/driver_activity_v1?project=demo_project" + ) + assert response.status_code == 200 + data = response.json() + assert data["name"] == "driver_activity_v1" + assert data["status"] == "deleted" + + response = fastapi_test_app.get( + "/feature_services/driver_activity_v1?project=demo_project" + ) + assert response.status_code == 404 + + response = fastapi_test_app.delete( + "/feature_views/driver_stats_for_service?project=demo_project" + ) + assert response.status_code == 200 + + def test_metrics_resource_counts_nonexistent_project(fastapi_test_app): """Test /metrics/resource_counts with a non-existent project returns empty data.""" response = fastapi_test_app.get( diff --git a/sdk/python/tests/unit/test_feature_service.py b/sdk/python/tests/unit/test_feature_service.py index 4448d2e8ea2..33f7290c47a 100644 --- a/sdk/python/tests/unit/test_feature_service.py +++ b/sdk/python/tests/unit/test_feature_service.py @@ -1,11 +1,152 @@ +from feast.errors import FeastObjectNotFoundException from feast.feature_service import FeatureService from feast.feature_view import FeatureView +from feast.feature_view_projection import FeatureViewProjection from feast.field import Field from feast.infra.offline_stores.file_source import FileSource -from feast.types import Float32 +from feast.protos.feast.core.Feature_pb2 import FeatureSpecV2 +from feast.protos.feast.core.FeatureViewProjection_pb2 import ( + FeatureViewProjection as FeatureViewProjectionProto, +) +from feast.types import Float32, String from tests.utils.test_wrappers import no_warnings +class _MockRegistry: + def __init__(self, feature_view: FeatureView): + self._feature_view = feature_view + + def get_any_feature_view(self, name, project, allow_cache=False): + if name != self._feature_view.name: + raise FeastObjectNotFoundException(name) + return self._feature_view + + +def _build_feature_view(): + file_source = FileSource(name="my-file-source", path="test.parquet") + return FeatureView( + name="my-feature-view", + entities=[], + schema=[ + Field(name="feature1", dtype=Float32), + Field(name="feature2", dtype=String), + ], + source=file_source, + ) + + +def test_prepare_for_apply_whole_view(): + feature_view = _build_feature_view() + feature_service = FeatureService.from_proto( + FeatureService( + name="my-feature-service", + features=[], + ).to_proto() + ) + feature_service.feature_view_projections = [ + FeatureViewProjection( + name=feature_view.name, + name_alias=None, + desired_features=[], + features=[], + ) + ] + + feature_service.prepare_for_apply(_MockRegistry(feature_view), "test_project") + + assert len(feature_service.feature_view_projections) == 1 + resolved = feature_service.feature_view_projections[0] + assert [feature.name for feature in resolved.features] == [ + "feature1", + "feature2", + ] + assert resolved.features[0].dtype == Float32 + assert resolved.features[1].dtype == String + + +def test_prepare_for_apply_feature_subset(): + feature_view = _build_feature_view() + feature_service = FeatureService( + name="my-feature-service", + features=[], + ) + feature_service.feature_view_projections = [ + FeatureViewProjection( + name=feature_view.name, + name_alias=None, + desired_features=["feature2"], + features=[], + ) + ] + + feature_service.prepare_for_apply(_MockRegistry(feature_view), "test_project") + + resolved = feature_service.feature_view_projections[0] + assert len(resolved.features) == 1 + assert resolved.features[0].name == "feature2" + assert resolved.features[0].dtype == String + + +def test_prepare_for_apply_name_only_columns(): + feature_view = _build_feature_view() + projection_proto = FeatureViewProjectionProto( + feature_view_name=feature_view.name, + ) + projection_proto.feature_columns.append(FeatureSpecV2(name="feature2")) + feature_service = FeatureService.from_proto( + FeatureService(name="my-feature-service", features=[]).to_proto() + ) + feature_service.feature_view_projections = [ + FeatureViewProjection.from_proto(projection_proto) + ] + + feature_service.prepare_for_apply(_MockRegistry(feature_view), "test_project") + + resolved = feature_service.feature_view_projections[0] + assert len(resolved.features) == 1 + assert resolved.features[0].name == "feature2" + assert resolved.features[0].dtype == String + + +def test_prepare_for_apply_keeps_complete_projection(): + feature_view = _build_feature_view() + feature_service = FeatureService( + name="my-feature-service", + features=[feature_view[["feature1"]]], + ) + original_projection = feature_service.feature_view_projections[0] + + feature_service.prepare_for_apply(_MockRegistry(feature_view), "test_project") + + assert feature_service.feature_view_projections[0] == original_projection + + +def test_build_apply_request(): + request = FeatureService.build_apply_request( + name="my-feature-service", + project="test_project", + feature_view_refs=[ + ("feature_view_a", None), + ("feature_view_b", ["feature1"]), + ], + description="test service", + tags={"team": "ml"}, + owner="owner@example.com", + ) + + spec = request.feature_service.spec + assert spec.name == "my-feature-service" + assert spec.description == "test service" + assert spec.owner == "owner@example.com" + assert dict(spec.tags) == {"team": "ml"} + assert request.project == "test_project" + assert len(spec.features) == 2 + assert spec.features[0].feature_view_name == "feature_view_a" + assert len(spec.features[0].feature_columns) == 0 + assert spec.features[1].feature_view_name == "feature_view_b" + assert [column.name for column in spec.features[1].feature_columns] == ["feature1"] + + def test_feature_service_with_description(): feature_service = FeatureService( name="my-feature-service", features=[], description="a clear description" diff --git a/ui/src/components/FeatureServiceFormModal.tsx b/ui/src/components/FeatureServiceFormModal.tsx new file mode 100644 index 00000000000..1f210bdb402 --- /dev/null +++ b/ui/src/components/FeatureServiceFormModal.tsx @@ -0,0 +1,380 @@ +import React, { useState, useEffect, useMemo } from "react"; +import { + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiHorizontalRule, + EuiCallOut, + EuiButtonEmpty, + EuiButtonIcon, + EuiFormRow, + EuiComboBox, + EuiComboBoxOptionOption, +} from "@elastic/eui"; +import { useParams } from "react-router-dom"; +import FormModal from "./forms/FormModal"; +import TagsEditor, { TagEntry } from "./forms/TagsEditor"; +import NameDescriptionOwnerFields from "./forms/NameDescriptionOwnerFields"; +import useResourceQuery, { + featureViewListPath, + restFeatureViewsToMergedList, +} from "../queries/useResourceQuery"; + +interface FeatureViewProjectionEntry { + featureViewName: string; + featureNames: string[]; +} + +interface FeatureServiceFormData { + name: string; + description: string; + owner: string; + projections: FeatureViewProjectionEntry[]; + tags: TagEntry[]; +} + +interface FeatureServiceFormModalProps { + onClose: () => void; + onSubmit: (data: FeatureServiceFormData) => void; + initialData?: FeatureServiceFormData; + isEdit?: boolean; + isSubmitting?: boolean; + submitError?: string | null; +} + +const EMPTY_PROJECTION: FeatureViewProjectionEntry = { + featureViewName: "", + featureNames: [], +}; + +const EMPTY_FORM: FeatureServiceFormData = { + name: "", + description: "", + owner: "", + projections: [{ ...EMPTY_PROJECTION }], + tags: [], +}; + +const FeatureServiceFormModal: React.FC = ({ + onClose, + onSubmit, + initialData, + isEdit = false, + isSubmitting = false, + submitError, +}) => { + const [formData, setFormData] = useState( + initialData || EMPTY_FORM, + ); + const [errors, setErrors] = useState>({}); + const [submitted, setSubmitted] = useState(false); + + const { projectName } = useParams(); + + const featureViewsQuery = useResourceQuery({ + resourceType: "feature-views-list-fs-form", + project: projectName, + restPath: featureViewListPath(projectName), + restSelect: restFeatureViewsToMergedList, + }); + + const featureViews = useMemo( + () => featureViewsQuery.data || [], + [featureViewsQuery.data], + ); + + const featureViewOptions: EuiComboBoxOptionOption[] = useMemo( + () => + featureViews.map((fv) => ({ + label: fv.name, + value: fv.name, + })), + [featureViews], + ); + + const featureNamesByView = useMemo(() => { + const map: Record = {}; + featureViews.forEach((fv) => { + const features = fv.object?.spec?.features || []; + map[fv.name] = features + .map((feature: any) => feature?.name || "") + .filter(Boolean); + }); + return map; + }, [featureViews]); + + useEffect(() => { + if (initialData) { + setFormData(initialData); + } + }, [initialData]); + + const validate = (): boolean => { + const newErrors: Record = {}; + + if (!formData.name.trim()) { + newErrors.name = "Feature service name is required."; + } else if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(formData.name)) { + newErrors.name = + "Must start with a letter or underscore, and contain only letters, numbers, and underscores."; + } + + const validProjections = formData.projections.filter((projection) => + projection.featureViewName.trim(), + ); + if (validProjections.length === 0) { + newErrors.projections = "Select at least one feature view."; + } + + const viewNames = validProjections.map( + (projection) => projection.featureViewName, + ); + if (new Set(viewNames).size !== viewNames.length) { + newErrors.projections = "Each feature view can only be selected once."; + } + + const tagKeys = formData.tags + .map((tag) => tag.key) + .filter((key) => key.trim()); + if (new Set(tagKeys).size !== tagKeys.length) { + newErrors.tags = "Tag keys must be unique."; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = () => { + setSubmitted(true); + if (validate()) { + onSubmit({ + ...formData, + projections: formData.projections.filter((projection) => + projection.featureViewName.trim(), + ), + tags: formData.tags.filter((tag) => tag.key.trim()), + }); + } + }; + + const updateField = ( + field: K, + value: FeatureServiceFormData[K], + ) => { + setFormData((prev) => ({ ...prev, [field]: value })); + if (submitted) { + setErrors((prev) => { + const next = { ...prev }; + delete next[field]; + return next; + }); + } + }; + + const addProjection = () => { + updateField("projections", [ + ...formData.projections, + { ...EMPTY_PROJECTION }, + ]); + }; + + const removeProjection = (index: number) => { + if (formData.projections.length <= 1) { + return; + } + updateField( + "projections", + formData.projections.filter( + (_, projectionIndex) => projectionIndex !== index, + ), + ); + }; + + const updateProjection = ( + index: number, + field: keyof FeatureViewProjectionEntry, + value: string | string[], + ) => { + const updated = [...formData.projections]; + updated[index] = { + ...updated[index], + [field]: value, + ...(field === "featureViewName" ? { featureNames: [] } : {}), + }; + updateField("projections", updated); + }; + + return ( + + {submitError && ( + <> + +

{submitError}

+
+ + + )} + + updateField("name", value)} + onChangeDescription={(value) => updateField("description", value)} + onChangeOwner={(value) => updateField("owner", value)} + nameDisabled={isEdit} + nameError={errors.name} + nameHelpText="A unique identifier for this feature service." + namePlaceholder="e.g. customer_activity_v1" + descriptionPlaceholder="Describe what this feature service is used for..." + /> + + + + + + + +

Feature views

+
+
+ + + Add feature view + + +
+ + + Select one or more feature views to include in this service. Leave + feature selection empty to include all features from a view. + + + {errors.projections && ( + <> + + + + )} + + {formData.projections.map((projection, index) => { + const availableFeatures = + featureNamesByView[projection.featureViewName] || []; + const selectedFeatureOptions = projection.featureNames.map( + (featureName) => ({ + label: featureName, + value: featureName, + }), + ); + const featureOptions = availableFeatures.map((featureName) => ({ + label: featureName, + value: featureName, + })); + + return ( + + + + + + + updateProjection( + index, + "featureViewName", + selected.length > 0 ? selected[0].value || "" : "", + ) + } + placeholder="Select a feature view" + isLoading={featureViewsQuery.isLoading} + compressed + /> + + + + + + updateProjection( + index, + "featureNames", + selected.map((option) => option.value || option.label), + ) + } + placeholder={ + projection.featureViewName + ? "All features" + : "Select a feature view first" + } + isDisabled={!projection.featureViewName} + compressed + /> + + + + + removeProjection(index)} + disabled={formData.projections.length <= 1} + /> + + + + + ); + })} + + + + updateField("tags", tags)} + error={errors.tags} + /> +
+ ); +}; + +export default FeatureServiceFormModal; +export type { FeatureServiceFormData, FeatureViewProjectionEntry }; diff --git a/ui/src/pages/data-sources/DataSourceInstance.tsx b/ui/src/pages/data-sources/DataSourceInstance.tsx index 1ed2cfa5eab..5f2ff17994a 100644 --- a/ui/src/pages/data-sources/DataSourceInstance.tsx +++ b/ui/src/pages/data-sources/DataSourceInstance.tsx @@ -1,21 +1,183 @@ -import React from "react"; +import React, { useState } from "react"; import { Route, Routes, useNavigate, useParams } from "react-router-dom"; -import { EuiPageTemplate } from "@elastic/eui"; +import { + EuiPageTemplate, + EuiButton, + EuiButtonEmpty, + EuiConfirmModal, +} from "@elastic/eui"; import { DataSourceIcon } from "../../graphics/DataSourceIcon"; import { useMatchExact } from "../../hooks/useMatchSubpath"; import { useDocumentTitle } from "../../hooks/useDocumentTitle"; import DataSourceRawData from "./DataSourceRawData"; import DataSourceOverviewTab from "./DataSourceOverviewTab"; +import DataSourceFormModal, { + DataSourceFormData, +} from "../../components/DataSourceFormModal"; +import { + useApplyDataSource, + useDeleteDataSource, +} from "../../queries/mutations/useDataSourceMutations"; +import useLoadDataSource from "./useLoadDataSource"; +import { feast } from "../../protos"; import { useDataSourceCustomTabs, useDataSourceCustomTabRoutes, } from "../../custom-tabs/TabsRegistryContext"; +const buildEditFormData = (ds: any): DataSourceFormData => { + const spec = ds.spec || ds; + const tags = spec.tags + ? Object.entries(spec.tags).map(([key, value]) => ({ + key, + value: value as string, + })) + : []; + + return { + name: spec.name || ds.name || "", + description: spec.description || ds.description || "", + owner: spec.owner || ds.owner || "", + sourceType: String(spec.type ?? ds.type ?? 0), + timestampField: spec.timestampField || ds.timestampField || "", + createdTimestampColumn: + spec.createdTimestampColumn || ds.createdTimestampColumn || "", + tags, + fileUri: spec.fileOptions?.uri || ds.fileOptions?.uri || "", + bigqueryTable: + spec.bigqueryOptions?.table || ds.bigqueryOptions?.table || "", + bigqueryQuery: + spec.bigqueryOptions?.query || ds.bigqueryOptions?.query || "", + snowflakeTable: + spec.snowflakeOptions?.table || ds.snowflakeOptions?.table || "", + snowflakeDatabase: + spec.snowflakeOptions?.database || ds.snowflakeOptions?.database || "", + snowflakeSchema: + spec.snowflakeOptions?.schema || ds.snowflakeOptions?.schema || "", + redshiftTable: + spec.redshiftOptions?.table || ds.redshiftOptions?.table || "", + redshiftDatabase: + spec.redshiftOptions?.database || ds.redshiftOptions?.database || "", + redshiftSchema: + spec.redshiftOptions?.schema || ds.redshiftOptions?.schema || "", + kafkaBootstrapServers: + spec.kafkaOptions?.kafkaBootstrapServers || + ds.kafkaOptions?.kafkaBootstrapServers || + "", + kafkaTopic: spec.kafkaOptions?.topic || ds.kafkaOptions?.topic || "", + sparkTable: spec.sparkOptions?.table || ds.sparkOptions?.table || "", + sparkPath: spec.sparkOptions?.path || ds.sparkOptions?.path || "", + kinesisRegion: + spec.kinesisOptions?.region || ds.kinesisOptions?.region || "", + kinesisStreamName: + spec.kinesisOptions?.streamName || ds.kinesisOptions?.streamName || "", + trinoTable: spec.trinoOptions?.table || ds.trinoOptions?.table || "", + trinoQuery: spec.trinoOptions?.query || ds.trinoOptions?.query || "", + athenaTable: spec.athenaOptions?.table || ds.athenaOptions?.table || "", + athenaQuery: spec.athenaOptions?.query || ds.athenaOptions?.query || "", + athenaDatabase: + spec.athenaOptions?.database || ds.athenaOptions?.database || "", + athenaDataSource: + spec.athenaOptions?.dataSource || ds.athenaOptions?.dataSource || "", + customSourceClassName: + spec.customOptions?.className || ds.customOptions?.className || "", + customSourceConfig: + spec.customOptions?.config || ds.customOptions?.config || "", + rayReaderType: "", + rayPath: "", + rayReaderOptions: "", + postgresTable: "", + postgresQuery: "", + mongodbCollection: "", + clickhouseTable: "", + clickhouseQuery: "", + mssqlTable: "", + mssqlConnectionStr: "", + oracleTable: "", + oracleConnectionStr: "", + couchbaseDatabase: "", + couchbaseScope: "", + couchbaseCollection: "", + couchbaseQuery: "", + }; +}; + +const formDataToPayload = (formData: DataSourceFormData, project: string) => { + const payload: Record = { + name: formData.name, + project, + type: parseInt(formData.sourceType, 10), + timestamp_field: formData.timestampField, + created_timestamp_column: formData.createdTimestampColumn, + description: formData.description, + owner: formData.owner, + tags: Object.fromEntries( + formData.tags.filter((t) => t.key.trim()).map((t) => [t.key, t.value]), + ), + }; + + const st = formData.sourceType; + if (st === String(feast.core.DataSource.SourceType.BATCH_FILE)) { + payload.file_options = { uri: formData.fileUri }; + } else if (st === String(feast.core.DataSource.SourceType.BATCH_BIGQUERY)) { + payload.bigquery_options = { + table: formData.bigqueryTable, + query: formData.bigqueryQuery, + }; + } else if (st === String(feast.core.DataSource.SourceType.BATCH_SNOWFLAKE)) { + payload.snowflake_options = { + table: formData.snowflakeTable, + database: formData.snowflakeDatabase, + schema_: formData.snowflakeSchema, + }; + } else if (st === String(feast.core.DataSource.SourceType.BATCH_REDSHIFT)) { + payload.redshift_options = { + table: formData.redshiftTable, + database: formData.redshiftDatabase, + schema_: formData.redshiftSchema, + }; + } else if (st === String(feast.core.DataSource.SourceType.STREAM_KAFKA)) { + payload.kafka_options = { + kafka_bootstrap_servers: formData.kafkaBootstrapServers, + topic: formData.kafkaTopic, + }; + } else if (st === String(feast.core.DataSource.SourceType.BATCH_SPARK)) { + payload.spark_options = { + table: formData.sparkTable, + path: formData.sparkPath, + }; + } else if (st === String(feast.core.DataSource.SourceType.BATCH_TRINO)) { + payload.trino_options = { + table: formData.trinoTable, + query: formData.trinoQuery, + }; + } else if (st === String(feast.core.DataSource.SourceType.BATCH_ATHENA)) { + payload.athena_options = { + table: formData.athenaTable, + query: formData.athenaQuery, + database: formData.athenaDatabase, + data_source: formData.athenaDataSource, + }; + } else if (st === String(feast.core.DataSource.SourceType.STREAM_KINESIS)) { + payload.kinesis_options = { + region: formData.kinesisRegion, + stream_name: formData.kinesisStreamName, + }; + } else if (st === String(feast.core.DataSource.SourceType.CUSTOM_SOURCE)) { + payload.custom_options = { + class_name: formData.customSourceClassName, + config: formData.customSourceConfig, + }; + } + + return payload; +}; + const DataSourceInstance = () => { const navigate = useNavigate(); - let { dataSourceName } = useParams(); + let { dataSourceName, projectName } = useParams(); useDocumentTitle(`${dataSourceName} | Data Source | Feast`); @@ -34,12 +196,66 @@ const DataSourceInstance = () => { const CustomTabRoutes = useDataSourceCustomTabRoutes(); + const { data } = useLoadDataSource(dataSourceName || ""); + const applyDataSource = useApplyDataSource(); + const deleteDataSource = useDeleteDataSource(); + + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [editError, setEditError] = useState(null); + + const handleDelete = () => { + deleteDataSource.mutate( + { name: dataSourceName || "", project: projectName || "" }, + { + onSuccess: () => { + navigate(`/p/${projectName}/data-source`); + }, + }, + ); + }; + + const handleEditSubmit = (formData: DataSourceFormData) => { + const payload = formDataToPayload(formData, projectName || ""); + applyDataSource.mutate(payload as any, { + onSuccess: () => { + setIsEditModalOpen(false); + setEditError(null); + }, + onError: (err: unknown) => { + const message = + err instanceof Error ? err.message : "An unexpected error occurred."; + setEditError(message); + }, + }); + }; + return ( { + setEditError(null); + setIsEditModalOpen(true); + }} + > + Edit + , + setShowDeleteConfirm(true)} + > + Delete + , + ]} tabs={tabs} /> @@ -49,6 +265,37 @@ const DataSourceInstance = () => { {CustomTabRoutes} + + {showDeleteConfirm && ( + setShowDeleteConfirm(false)} + onConfirm={handleDelete} + cancelButtonText="Cancel" + confirmButtonText="Delete" + buttonColor="danger" + isLoading={deleteDataSource.isLoading} + > +

+ This will permanently remove the data source. This action cannot be + undone. +

+
+ )} + + {isEditModalOpen && data && ( + { + setIsEditModalOpen(false); + setEditError(null); + }} + onSubmit={handleEditSubmit} + initialData={buildEditFormData(data)} + isEdit + isSubmitting={applyDataSource.isLoading} + submitError={editError} + /> + )}
); }; diff --git a/ui/src/pages/data-sources/DataSourceOverviewTab.tsx b/ui/src/pages/data-sources/DataSourceOverviewTab.tsx index 42a8d2536f0..c5e3e2ac635 100644 --- a/ui/src/pages/data-sources/DataSourceOverviewTab.tsx +++ b/ui/src/pages/data-sources/DataSourceOverviewTab.tsx @@ -4,8 +4,6 @@ import { EuiLoadingSpinner, EuiText, EuiTitle, - EuiButtonEmpty, - EuiCallOut, } from "@elastic/eui"; import { EuiPanel, @@ -15,168 +13,15 @@ import { EuiDescriptionListDescription, EuiSpacer, } from "@elastic/eui"; -import React, { useState } from "react"; +import React from "react"; import { useParams } from "react-router-dom"; -import DataSourceFormModal, { - DataSourceFormData, -} from "../../components/DataSourceFormModal"; -import { feast } from "../../protos"; -import { useApplyDataSource } from "../../queries/mutations/useDataSourceMutations"; import BatchSourcePropertiesView from "./BatchSourcePropertiesView"; import FeatureViewEdgesList from "../entities/FeatureViewEdgesList"; import RequestDataSourceSchemaTable from "./RequestDataSourceSchemaTable"; import useLoadDataSource from "./useLoadDataSource"; -const buildEditFormData = (ds: any): DataSourceFormData => { - const spec = ds.spec || ds; - const tags = spec.tags - ? Object.entries(spec.tags).map(([key, value]) => ({ - key, - value: value as string, - })) - : []; - - return { - name: spec.name || ds.name || "", - description: spec.description || ds.description || "", - owner: spec.owner || ds.owner || "", - sourceType: String(spec.type ?? ds.type ?? 0), - timestampField: spec.timestampField || ds.timestampField || "", - createdTimestampColumn: - spec.createdTimestampColumn || ds.createdTimestampColumn || "", - tags, - fileUri: spec.fileOptions?.uri || ds.fileOptions?.uri || "", - bigqueryTable: - spec.bigqueryOptions?.table || ds.bigqueryOptions?.table || "", - bigqueryQuery: - spec.bigqueryOptions?.query || ds.bigqueryOptions?.query || "", - snowflakeTable: - spec.snowflakeOptions?.table || ds.snowflakeOptions?.table || "", - snowflakeDatabase: - spec.snowflakeOptions?.database || ds.snowflakeOptions?.database || "", - snowflakeSchema: - spec.snowflakeOptions?.schema || ds.snowflakeOptions?.schema || "", - redshiftTable: - spec.redshiftOptions?.table || ds.redshiftOptions?.table || "", - redshiftDatabase: - spec.redshiftOptions?.database || ds.redshiftOptions?.database || "", - redshiftSchema: - spec.redshiftOptions?.schema || ds.redshiftOptions?.schema || "", - kafkaBootstrapServers: - spec.kafkaOptions?.kafkaBootstrapServers || - ds.kafkaOptions?.kafkaBootstrapServers || - "", - kafkaTopic: spec.kafkaOptions?.topic || ds.kafkaOptions?.topic || "", - sparkTable: spec.sparkOptions?.table || ds.sparkOptions?.table || "", - sparkPath: spec.sparkOptions?.path || ds.sparkOptions?.path || "", - kinesisRegion: - spec.kinesisOptions?.region || ds.kinesisOptions?.region || "", - kinesisStreamName: - spec.kinesisOptions?.streamName || ds.kinesisOptions?.streamName || "", - trinoTable: spec.trinoOptions?.table || ds.trinoOptions?.table || "", - trinoQuery: spec.trinoOptions?.query || ds.trinoOptions?.query || "", - athenaTable: spec.athenaOptions?.table || ds.athenaOptions?.table || "", - athenaQuery: spec.athenaOptions?.query || ds.athenaOptions?.query || "", - athenaDatabase: - spec.athenaOptions?.database || ds.athenaOptions?.database || "", - athenaDataSource: - spec.athenaOptions?.dataSource || ds.athenaOptions?.dataSource || "", - customSourceClassName: - spec.customOptions?.className || ds.customOptions?.className || "", - customSourceConfig: - spec.customOptions?.config || ds.customOptions?.config || "", - rayReaderType: "", - rayPath: "", - rayReaderOptions: "", - postgresTable: "", - postgresQuery: "", - mongodbCollection: "", - clickhouseTable: "", - clickhouseQuery: "", - mssqlTable: "", - mssqlConnectionStr: "", - oracleTable: "", - oracleConnectionStr: "", - couchbaseDatabase: "", - couchbaseScope: "", - couchbaseCollection: "", - couchbaseQuery: "", - }; -}; - -const formDataToPayload = (formData: DataSourceFormData, project: string) => { - const payload: Record = { - name: formData.name, - project, - type: parseInt(formData.sourceType, 10), - timestamp_field: formData.timestampField, - created_timestamp_column: formData.createdTimestampColumn, - description: formData.description, - owner: formData.owner, - tags: Object.fromEntries( - formData.tags.filter((t) => t.key.trim()).map((t) => [t.key, t.value]), - ), - }; - - const st = formData.sourceType; - if (st === String(feast.core.DataSource.SourceType.BATCH_FILE)) { - payload.file_options = { uri: formData.fileUri }; - } else if (st === String(feast.core.DataSource.SourceType.BATCH_BIGQUERY)) { - payload.bigquery_options = { - table: formData.bigqueryTable, - query: formData.bigqueryQuery, - }; - } else if (st === String(feast.core.DataSource.SourceType.BATCH_SNOWFLAKE)) { - payload.snowflake_options = { - table: formData.snowflakeTable, - database: formData.snowflakeDatabase, - schema_: formData.snowflakeSchema, - }; - } else if (st === String(feast.core.DataSource.SourceType.BATCH_REDSHIFT)) { - payload.redshift_options = { - table: formData.redshiftTable, - database: formData.redshiftDatabase, - schema_: formData.redshiftSchema, - }; - } else if (st === String(feast.core.DataSource.SourceType.STREAM_KAFKA)) { - payload.kafka_options = { - kafka_bootstrap_servers: formData.kafkaBootstrapServers, - topic: formData.kafkaTopic, - }; - } else if (st === String(feast.core.DataSource.SourceType.BATCH_SPARK)) { - payload.spark_options = { - table: formData.sparkTable, - path: formData.sparkPath, - }; - } else if (st === String(feast.core.DataSource.SourceType.BATCH_TRINO)) { - payload.trino_options = { - table: formData.trinoTable, - query: formData.trinoQuery, - }; - } else if (st === String(feast.core.DataSource.SourceType.BATCH_ATHENA)) { - payload.athena_options = { - table: formData.athenaTable, - query: formData.athenaQuery, - database: formData.athenaDatabase, - data_source: formData.athenaDataSource, - }; - } else if (st === String(feast.core.DataSource.SourceType.STREAM_KINESIS)) { - payload.kinesis_options = { - region: formData.kinesisRegion, - stream_name: formData.kinesisStreamName, - }; - } else if (st === String(feast.core.DataSource.SourceType.CUSTOM_SOURCE)) { - payload.custom_options = { - class_name: formData.customSourceClassName, - config: formData.customSourceConfig, - }; - } - - return payload; -}; - const DataSourceOverviewTab = () => { - const { dataSourceName, projectName } = useParams(); + const { dataSourceName } = useParams(); const dsName = dataSourceName === undefined ? "" : dataSourceName; const { isLoading, isSuccess, isError, data, consumingFeatureViews } = @@ -192,31 +37,6 @@ const DataSourceOverviewTab = () => { }, {}) : undefined; - const [isEditModalOpen, setIsEditModalOpen] = useState(false); - const [successMessage, setSuccessMessage] = useState(null); - const [errorMessage, setErrorMessage] = useState(null); - const applyDataSource = useApplyDataSource(); - - const handleEditSubmit = (formData: DataSourceFormData) => { - const payload = formDataToPayload(formData, projectName || ""); - applyDataSource.mutate(payload as any, { - onSuccess: () => { - setIsEditModalOpen(false); - setErrorMessage(null); - setSuccessMessage( - `Data source "${formData.name}" updated successfully.`, - ); - setTimeout(() => setSuccessMessage(null), 5000); - }, - onError: (err: unknown) => { - // Error shown inside the modal via submitError prop - const message = - err instanceof Error ? err.message : "An unexpected error occurred."; - setErrorMessage(message); - }, - }); - }; - const spec = data?.spec || data; const sourceType = spec?.type; @@ -231,39 +51,6 @@ const DataSourceOverviewTab = () => { {isError &&

Error loading data source: {dataSourceName}

} {isSuccess && data && ( - {successMessage && ( - <> - - - - )} - {errorMessage && ( - <> - - - - )} - - - setIsEditModalOpen(true)} - > - Edit Data Source - - - - @@ -339,20 +126,6 @@ const DataSourceOverviewTab = () => { )} - - {isEditModalOpen && data && ( - { - setIsEditModalOpen(false); - setErrorMessage(null); - }} - onSubmit={handleEditSubmit} - initialData={buildEditFormData(data)} - isEdit - isSubmitting={applyDataSource.isLoading} - submitError={errorMessage} - /> - )} ); }; diff --git a/ui/src/pages/entities/EntityInstance.tsx b/ui/src/pages/entities/EntityInstance.tsx index e3be0ef167f..4807568ce2c 100644 --- a/ui/src/pages/entities/EntityInstance.tsx +++ b/ui/src/pages/entities/EntityInstance.tsx @@ -1,31 +1,128 @@ -import React from "react"; +import React, { useState } from "react"; import { Route, Routes, useNavigate, useParams } from "react-router-dom"; -import { EuiPageTemplate } from "@elastic/eui"; +import { + EuiPageTemplate, + EuiButton, + EuiButtonEmpty, + EuiConfirmModal, +} from "@elastic/eui"; import { EntityIcon } from "../../graphics/EntityIcon"; import { useMatchExact } from "../../hooks/useMatchSubpath"; import EntityOverviewTab from "./EntityOverviewTab"; import { useDocumentTitle } from "../../hooks/useDocumentTitle"; +import EntityFormModal, { + EntityFormData, +} from "../../components/EntityFormModal"; +import { + useApplyEntity, + useDeleteEntity, +} from "../../queries/mutations/useEntityMutations"; +import useLoadEntity from "./useLoadEntity"; import { useEntityCustomTabs, useEntityCustomTabRoutes, } from "../../custom-tabs/TabsRegistryContext"; +import { feast } from "../../protos"; + +const buildEditFormData = (entity: feast.core.IEntity): EntityFormData => { + const tags = entity.spec?.tags + ? Object.entries(entity.spec.tags).map(([key, value]) => ({ + key, + value: String(value), + })) + : []; + + const joinKeys = entity.spec?.joinKey ? [entity.spec.joinKey] : [""]; + + return { + name: entity.spec?.name || "", + description: entity.spec?.description || "", + joinKeys, + valueType: String(entity.spec?.valueType ?? 0), + tags, + }; +}; const EntityInstance = () => { const navigate = useNavigate(); - let { entityName } = useParams(); + let { entityName, projectName } = useParams(); const { customNavigationTabs } = useEntityCustomTabs(navigate); const CustomTabRoutes = useEntityCustomTabRoutes(); useDocumentTitle(`${entityName} | Entity | Feast`); + const { data } = useLoadEntity(entityName || ""); + const applyEntity = useApplyEntity(); + const deleteEntity = useDeleteEntity(); + + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [editError, setEditError] = useState(null); + + const handleDelete = () => { + deleteEntity.mutate( + { name: entityName || "", project: projectName || "" }, + { + onSuccess: () => { + navigate(`/p/${projectName}/entity`); + }, + }, + ); + }; + + const handleEditSubmit = (formData: EntityFormData) => { + const payload = { + name: formData.name, + project: projectName || "", + join_key: formData.joinKeys[0] || formData.name, + value_type: parseInt(formData.valueType, 10), + description: formData.description, + tags: Object.fromEntries( + formData.tags.filter((t) => t.key.trim()).map((t) => [t.key, t.value]), + ), + owner: "", + }; + applyEntity.mutate(payload, { + onSuccess: () => { + setIsEditModalOpen(false); + setEditError(null); + }, + onError: (err: unknown) => { + const message = + err instanceof Error ? err.message : "An unexpected error occurred."; + setEditError(message); + }, + }); + }; + return ( { + setEditError(null); + setIsEditModalOpen(true); + }} + > + Edit + , + setShowDeleteConfirm(true)} + > + Delete + , + ]} tabs={[ { label: "Overview", @@ -43,6 +140,37 @@ const EntityInstance = () => { {CustomTabRoutes} + + {showDeleteConfirm && ( + setShowDeleteConfirm(false)} + onConfirm={handleDelete} + cancelButtonText="Cancel" + confirmButtonText="Delete" + buttonColor="danger" + isLoading={deleteEntity.isLoading} + > +

+ This will permanently remove the entity. This action cannot be + undone. +

+
+ )} + + {isEditModalOpen && data && ( + { + setIsEditModalOpen(false); + setEditError(null); + }} + onSubmit={handleEditSubmit} + initialData={buildEditFormData(data)} + isEdit + isSubmitting={applyEntity.isLoading} + submitError={editError} + /> + )}
); }; diff --git a/ui/src/pages/entities/EntityOverviewTab.tsx b/ui/src/pages/entities/EntityOverviewTab.tsx index 0304d9e6511..c590eeb3b8e 100644 --- a/ui/src/pages/entities/EntityOverviewTab.tsx +++ b/ui/src/pages/entities/EntityOverviewTab.tsx @@ -3,8 +3,6 @@ import { EuiHorizontalRule, EuiLoadingSpinner, EuiTitle, - EuiButtonEmpty, - EuiCallOut, } from "@elastic/eui"; import { EuiPanel, @@ -15,12 +13,9 @@ import { EuiDescriptionListTitle, EuiDescriptionListDescription, } from "@elastic/eui"; -import React, { useContext, useState } from "react"; +import React, { useContext } from "react"; import { useParams } from "react-router-dom"; import PermissionsDisplay from "../../components/PermissionsDisplay"; -import EntityFormModal, { - EntityFormData, -} from "../../components/EntityFormModal"; import TagsDisplay from "../../components/TagsDisplay"; import RegistryPathContext from "../../contexts/RegistryPathContext"; import { FEAST_FCO_TYPES } from "../../parsers/types"; @@ -28,30 +23,9 @@ import { FEAST_FCO_TYPES } from "../../parsers/types"; import useLoadRegistry from "../../queries/useLoadRegistry"; import { getEntityPermissions } from "../../utils/permissionUtils"; import { toDate } from "../../utils/timestamp"; -import { feast } from "../../protos"; import FeatureViewEdgesList from "./FeatureViewEdgesList"; import useFeatureViewEdgesByEntity from "./useFeatureViewEdgesByEntity"; import useLoadEntity from "./useLoadEntity"; -import { useApplyEntity } from "../../queries/mutations/useEntityMutations"; - -const buildEditFormData = (entity: feast.core.IEntity): EntityFormData => { - const tags = entity.spec?.tags - ? Object.entries(entity.spec.tags).map(([key, value]) => ({ - key, - value: String(value), - })) - : []; - - const joinKeys = entity.spec?.joinKey ? [entity.spec.joinKey] : [""]; - - return { - name: entity.spec?.name || "", - description: entity.spec?.description || "", - joinKeys, - valueType: String(entity.spec?.valueType ?? 0), - tags, - }; -}; const EntityOverviewTab = () => { let { entityName, projectName } = useParams(); @@ -75,39 +49,6 @@ const EntityOverviewTab = () => { }, {}) : undefined; - const [isEditModalOpen, setIsEditModalOpen] = useState(false); - const [successMessage, setSuccessMessage] = useState(null); - const [errorMessage, setErrorMessage] = useState(null); - const applyEntity = useApplyEntity(); - - const handleEditSubmit = (formData: EntityFormData) => { - const payload = { - name: formData.name, - project: projectName || "", - join_key: formData.joinKeys[0] || formData.name, - value_type: parseInt(formData.valueType, 10), - description: formData.description, - tags: Object.fromEntries( - formData.tags.filter((t) => t.key.trim()).map((t) => [t.key, t.value]), - ), - owner: "", - }; - applyEntity.mutate(payload, { - onSuccess: () => { - setIsEditModalOpen(false); - setErrorMessage(null); - setSuccessMessage(`Entity "${formData.name}" updated successfully.`); - setTimeout(() => setSuccessMessage(null), 5000); - }, - onError: (err: unknown) => { - // Error shown inside the modal via submitError prop - const message = - err instanceof Error ? err.message : "An unexpected error occurred."; - setErrorMessage(message); - }, - }); - }; - return ( {isLoading && ( @@ -119,39 +60,6 @@ const EntityOverviewTab = () => { {isError &&

Error loading entity: {entityName}

} {isSuccess && data && ( - {successMessage && ( - <> - - - - )} - {errorMessage && ( - <> - - - - )} - - - setIsEditModalOpen(true)} - > - Edit Entity - - - - @@ -272,20 +180,6 @@ const EntityOverviewTab = () => { )} - - {isEditModalOpen && data && ( - { - setIsEditModalOpen(false); - setErrorMessage(null); - }} - onSubmit={handleEditSubmit} - initialData={buildEditFormData(data)} - isEdit - isSubmitting={applyEntity.isLoading} - submitError={errorMessage} - /> - )}
); }; diff --git a/ui/src/pages/feature-services/FeatureServiceIndexEmptyState.tsx b/ui/src/pages/feature-services/FeatureServiceIndexEmptyState.tsx index a8080d0a68b..e2905f1fd02 100644 --- a/ui/src/pages/feature-services/FeatureServiceIndexEmptyState.tsx +++ b/ui/src/pages/feature-services/FeatureServiceIndexEmptyState.tsx @@ -2,28 +2,41 @@ import React from "react"; import { EuiEmptyPrompt, EuiTitle, EuiLink, EuiButton } from "@elastic/eui"; import FeastIconBlue from "../../graphics/FeastIconBlue"; -const FeatureServiceIndexEmptyState = () => { +interface FeatureServiceIndexEmptyStateProps { + onCreate?: () => void; +} + +const FeatureServiceIndexEmptyState: React.FC< + FeatureServiceIndexEmptyStateProps +> = ({ onCreate }) => { return ( There are no feature services} body={

- This project does not have any Feature Services. Learn more about - creating Feature Services in Feast Docs. + Feature services group related features from one or more feature views + for training or online serving. Create your first feature service to + get started.

} actions={ - { - window.open( - "https://docs.feast.dev/getting-started/concepts/feature-retrieval#feature-services", - "_blank", - ); - }} - > - Open Feature Services Docs - + onCreate ? ( + + Create Feature Service + + ) : ( + { + window.open( + "https://docs.feast.dev/getting-started/concepts/feature-retrieval#feature-services", + "_blank", + ); + }} + > + Open Feature Services Docs + + ) } footer={ <> diff --git a/ui/src/pages/feature-services/FeatureServiceInstance.tsx b/ui/src/pages/feature-services/FeatureServiceInstance.tsx index b88d2f4bdbf..4c16b383e94 100644 --- a/ui/src/pages/feature-services/FeatureServiceInstance.tsx +++ b/ui/src/pages/feature-services/FeatureServiceInstance.tsx @@ -1,11 +1,24 @@ -import React from "react"; +import React, { useState } from "react"; import { Route, Routes, useNavigate, useParams } from "react-router-dom"; -import { EuiPageTemplate } from "@elastic/eui"; +import { + EuiPageTemplate, + EuiButton, + EuiButtonEmpty, + EuiConfirmModal, +} from "@elastic/eui"; import { FeatureServiceIcon } from "../../graphics/FeatureServiceIcon"; import { useMatchExact } from "../../hooks/useMatchSubpath"; import FeatureServiceOverviewTab from "./FeatureServiceOverviewTab"; import { useDocumentTitle } from "../../hooks/useDocumentTitle"; +import FeatureServiceFormModal, { + FeatureServiceFormData, +} from "../../components/FeatureServiceFormModal"; +import { + useApplyFeatureService, + useDeleteFeatureService, +} from "../../queries/mutations/useFeatureServiceMutations"; +import useLoadFeatureService from "./useLoadFeatureService"; import { useFeatureServiceCustomTabs, @@ -14,19 +27,107 @@ import { const FeatureServiceInstance = () => { const navigate = useNavigate(); - let { featureServiceName } = useParams(); + let { featureServiceName, projectName } = useParams(); useDocumentTitle(`${featureServiceName} | Feature Service | Feast`); const { customNavigationTabs } = useFeatureServiceCustomTabs(navigate); const CustomTabRoutes = useFeatureServiceCustomTabRoutes(); + const { data } = useLoadFeatureService(featureServiceName || ""); + const deleteFeatureService = useDeleteFeatureService(); + const applyFeatureService = useApplyFeatureService(); + + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [editError, setEditError] = useState(null); + + const handleDelete = () => { + deleteFeatureService.mutate( + { name: featureServiceName || "", project: projectName || "" }, + { + onSuccess: () => { + navigate(`/p/${projectName}/feature-service`); + }, + }, + ); + }; + + const buildInitialEditData = (): FeatureServiceFormData | undefined => { + if (!data?.spec) return undefined; + const spec = data.spec; + return { + name: spec.name || featureServiceName || "", + description: spec.description || "", + owner: spec.owner || "", + projections: (spec.features || []).map((proj: any) => ({ + featureViewName: proj.featureViewName || "", + featureNames: (proj.featureColumns || []) + .map((col: any) => col.name) + .filter(Boolean), + })), + tags: Object.entries(spec.tags || {}).map(([key, value]) => ({ + key, + value: value as string, + })), + }; + }; + + const handleEditSubmit = (formData: FeatureServiceFormData) => { + const payload = { + name: formData.name, + project: projectName || "", + features: formData.projections.map((projection) => ({ + feature_view_name: projection.featureViewName, + feature_names: projection.featureNames, + })), + description: formData.description, + owner: formData.owner, + tags: Object.fromEntries( + formData.tags + .filter((tag) => tag.key.trim()) + .map((tag) => [tag.key, tag.value]), + ), + }; + applyFeatureService.mutate(payload, { + onSuccess: () => { + setIsEditModalOpen(false); + setEditError(null); + }, + onError: (err: unknown) => { + const message = + err instanceof Error ? err.message : "An unexpected error occurred."; + setEditError(message); + }, + }); + }; + return ( { + setEditError(null); + setIsEditModalOpen(true); + }} + > + Edit + , + setShowDeleteConfirm(true)} + > + Delete + , + ]} tabs={[ { label: "Overview", @@ -44,6 +145,37 @@ const FeatureServiceInstance = () => { {CustomTabRoutes} + + {showDeleteConfirm && ( + setShowDeleteConfirm(false)} + onConfirm={handleDelete} + cancelButtonText="Cancel" + confirmButtonText="Delete" + buttonColor="danger" + isLoading={deleteFeatureService.isLoading} + > +

+ This will permanently remove the feature service. This action cannot + be undone. +

+
+ )} + + {isEditModalOpen && ( + { + setIsEditModalOpen(false); + setEditError(null); + }} + onSubmit={handleEditSubmit} + initialData={buildInitialEditData()} + isEdit={true} + isSubmitting={applyFeatureService.isLoading} + submitError={editError} + /> + )}
); }; diff --git a/ui/src/pages/feature-services/FeatureServiceListingTable.tsx b/ui/src/pages/feature-services/FeatureServiceListingTable.tsx index 8dd8b299d74..c26b976e197 100644 --- a/ui/src/pages/feature-services/FeatureServiceListingTable.tsx +++ b/ui/src/pages/feature-services/FeatureServiceListingTable.tsx @@ -29,7 +29,6 @@ const FeatureServiceListingTable = ({ name: "Name", field: "spec.name", render: (name: string, item: feast.core.IFeatureService) => { - // For "All Projects" view, link to the specific project const itemProject = item?.spec?.project || (item as any)?.project || projectName; return ( @@ -42,10 +41,12 @@ const FeatureServiceListingTable = ({ { name: "# of Features", field: "spec.features", - render: (featureViews: feast.core.IFeatureViewProjection[]) => { - var numFeatures = 0; - featureViews.forEach((featureView) => { - numFeatures += featureView.featureColumns!.length; + render: ( + featureViews: feast.core.IFeatureViewProjection[] | undefined, + ) => { + let numFeatures = 0; + (featureViews || []).forEach((featureView) => { + numFeatures += (featureView.featureColumns || []).length; }); return numFeatures; }, @@ -59,7 +60,6 @@ const FeatureServiceListingTable = ({ }, ]; - // Add Project column when viewing all projects if (projectName === "all") { columns.splice(1, 0, { name: "Project", diff --git a/ui/src/pages/feature-services/FeatureServiceOverviewTab.tsx b/ui/src/pages/feature-services/FeatureServiceOverviewTab.tsx index bbea04a07bc..f1ac3c5e349 100644 --- a/ui/src/pages/feature-services/FeatureServiceOverviewTab.tsx +++ b/ui/src/pages/feature-services/FeatureServiceOverviewTab.tsx @@ -39,11 +39,12 @@ const FeatureServiceOverviewTab = () => { const labelProjections: any[] = []; if (data) { data?.spec?.features?.forEach((featureView: any) => { + const columnCount = (featureView?.featureColumns || []).length; if (featureView.viewType === "labelView") { - numLabels += featureView?.featureColumns!.length; + numLabels += columnCount; labelProjections.push(featureView); } else { - numFeatures += featureView?.featureColumns!.length; + numFeatures += columnCount; featureProjections.push(featureView); } }); diff --git a/ui/src/pages/feature-services/Index.tsx b/ui/src/pages/feature-services/Index.tsx index 7a2744c7876..b50b6066a24 100644 --- a/ui/src/pages/feature-services/Index.tsx +++ b/ui/src/pages/feature-services/Index.tsx @@ -1,14 +1,15 @@ -import React from "react"; +import React, { useState } from "react"; import { useParams } from "react-router-dom"; import { EuiPageTemplate, EuiLoadingSpinner, - EuiTitle, EuiSpacer, + EuiTitle, + EuiFieldSearch, EuiFlexGroup, EuiFlexItem, - EuiFieldSearch, + EuiButton, EuiCallOut, } from "@elastic/eui"; @@ -25,10 +26,16 @@ import { useDocumentTitle } from "../../hooks/useDocumentTitle"; import FeatureServiceIndexEmptyState from "./FeatureServiceIndexEmptyState"; import TagSearch from "../../components/TagSearch"; import ExportButton from "../../components/ExportButton"; +import FeatureServiceFormModal, { + FeatureServiceFormData, +} from "../../components/FeatureServiceFormModal"; +import { useApplyFeatureService } from "../../queries/mutations/useFeatureServiceMutations"; import { useFeatureServiceTagsAggregation } from "../../hooks/useTagsAggregation"; import { feast } from "../../protos"; import useResourceQuery, { featureServiceListPath, + featureViewListPath, + restFeatureViewsToMergedList, } from "../../queries/useResourceQuery"; const useLoadFeatureServices = () => { @@ -50,7 +57,7 @@ const shouldIncludeFSsGivenTokenGroups = ( if (entryTagValue) { return values.every((value) => { - return value.length > 0 ? entryTagValue.indexOf(value) >= 0 : true; // Don't filter if the string is empty + return value.length > 0 ? entryTagValue.indexOf(value) >= 0 : true; }); } else { return false; @@ -84,11 +91,46 @@ const filterFn = ( return filteredByTags; }; +const formDataToPayload = ( + formData: FeatureServiceFormData, + project: string, +) => ({ + name: formData.name, + project, + features: formData.projections.map((projection) => ({ + feature_view_name: projection.featureViewName, + feature_names: projection.featureNames, + })), + description: formData.description, + owner: formData.owner, + tags: Object.fromEntries( + formData.tags + .filter((tag) => tag.key.trim()) + .map((tag) => [tag.key, tag.value]), + ), +}); + const Index = () => { + const { projectName } = useParams(); const { isLoading, isSuccess, isError, isPermissionDenied, data } = useLoadFeatureServices(); + const isAllProjects = projectName === "all"; const tagAggregationQuery = useFeatureServiceTagsAggregation(); + const featureViewsQuery = useResourceQuery({ + resourceType: "feature-views-list-fs-prereq", + project: projectName, + restPath: featureViewListPath(projectName), + restSelect: restFeatureViewsToMergedList, + enabled: !isAllProjects, + }); + + const [isModalOpen, setIsModalOpen] = useState(false); + const [successMessage, setSuccessMessage] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + const [prereqWarning, setPrereqWarning] = useState(null); + const applyFeatureService = useApplyFeatureService(); + useDocumentTitle(`Feature Services | Feast`); const { searchString, searchTokens, setSearchString } = useSearchQuery(); @@ -109,6 +151,40 @@ const Index = () => { ? filterFn(data, { tagTokenGroups, searchTokens }) : data; + const handleCreateClick = () => { + const featureViews = featureViewsQuery.data || []; + if (featureViews.length === 0) { + setPrereqWarning( + "Feature services require at least one feature view. Create a feature view first, or proceed and add views later.", + ); + } else { + setPrereqWarning(null); + } + setIsModalOpen(true); + }; + + const handleCreateSubmit = (formData: FeatureServiceFormData) => { + const payload = formDataToPayload(formData, projectName || ""); + applyFeatureService.mutate(payload, { + onSuccess: () => { + setIsModalOpen(false); + setErrorMessage(null); + setPrereqWarning(null); + setSuccessMessage( + `Feature service "${formData.name}" created successfully.`, + ); + setTimeout(() => setSuccessMessage(null), 5000); + }, + onError: (err: unknown) => { + const message = + err instanceof Error ? err.message : "An unexpected error occurred."; + setErrorMessage(message); + }, + }); + }; + + const showEmptyState = isSuccess && (!data || data.length === 0); + return ( { iconType={FeatureServiceIcon} pageTitle="Feature Services" rightSideItems={[ + ...(isAllProjects + ? [] + : [ + + Create Feature Service + , + ]), , ]} /> + {successMessage && ( + <> + + + + )} + {prereqWarning && !isModalOpen && ( + <> + + + + )} {isLoading && (

Loading @@ -137,8 +248,12 @@ const Index = () => { {isError && !isPermissionDenied && (

We encountered an error while loading.

)} - {isSuccess && !data && } - {isSuccess && filterResult && ( + {showEmptyState && ( + + )} + {isSuccess && filterResult && filterResult.length > 0 && ( @@ -173,6 +288,18 @@ const Index = () => { )}
+ + {isModalOpen && ( + { + setIsModalOpen(false); + setErrorMessage(null); + }} + onSubmit={handleCreateSubmit} + isSubmitting={applyFeatureService.isLoading} + submitError={errorMessage} + /> + )}
); }; diff --git a/ui/src/pages/feature-views/RegularFeatureViewInstance.tsx b/ui/src/pages/feature-views/RegularFeatureViewInstance.tsx index a3c831b315f..b800a861481 100644 --- a/ui/src/pages/feature-views/RegularFeatureViewInstance.tsx +++ b/ui/src/pages/feature-views/RegularFeatureViewInstance.tsx @@ -1,6 +1,12 @@ -import React, { useContext } from "react"; -import { Route, Routes, useNavigate } from "react-router-dom"; -import { EuiBadge, EuiPageTemplate } from "@elastic/eui"; +import React, { useContext, useState } from "react"; +import { Route, Routes, useNavigate, useParams } from "react-router-dom"; +import { + EuiBadge, + EuiButton, + EuiButtonEmpty, + EuiConfirmModal, + EuiPageTemplate, +} from "@elastic/eui"; import { FeatureViewIcon } from "../../graphics/FeatureViewIcon"; @@ -8,6 +14,13 @@ import { useMatchExact, useMatchSubpath } from "../../hooks/useMatchSubpath"; import RegularFeatureViewOverviewTab from "./RegularFeatureViewOverviewTab"; import FeatureViewLineageTab from "./FeatureViewLineageTab"; import FeatureViewVersionsTab from "./FeatureViewVersionsTab"; +import FeatureViewFormModal, { + FeatureViewFormData, +} from "../../components/FeatureViewFormModal"; +import { + useApplyFeatureView, + useDeleteFeatureView, +} from "../../queries/mutations/useFeatureViewMutations"; import { useRegularFeatureViewCustomTabs, @@ -21,12 +34,69 @@ interface RegularFeatureInstanceProps { permissions?: any[]; } +const buildEditFormData = ( + fv: feast.core.IFeatureView, +): FeatureViewFormData => { + const tags = fv.spec?.tags + ? Object.entries(fv.spec.tags).map(([key, value]) => ({ key, value })) + : []; + + const features = (fv.spec?.features || []).map((f) => ({ + name: f.name || "", + valueType: String(f.valueType ?? 0), + description: f.description || "", + })); + + let ttlValue = 0; + let ttlUnit = "seconds"; + if (fv.spec?.ttl?.seconds) { + const secs = + typeof fv.spec.ttl.seconds === "number" + ? fv.spec.ttl.seconds + : ((fv.spec.ttl.seconds as any).toNumber?.() ?? 0); + if (secs > 0 && secs % 86400 === 0) { + ttlValue = secs / 86400; + ttlUnit = "days"; + } else if (secs > 0 && secs % 3600 === 0) { + ttlValue = secs / 3600; + ttlUnit = "hours"; + } else if (secs > 0 && secs % 60 === 0) { + ttlValue = secs / 60; + ttlUnit = "minutes"; + } else { + ttlValue = secs; + ttlUnit = "seconds"; + } + } + + return { + name: fv.spec?.name || "", + description: fv.spec?.description || "", + owner: fv.spec?.owner || "", + entities: fv.spec?.entities || [], + features, + batchSource: fv.spec?.batchSource?.name || "", + ttlValue, + ttlUnit, + online: fv.spec?.online ?? true, + tags, + }; +}; + +const TTL_UNITS: Record = { + days: 86400, + hours: 3600, + minutes: 60, + seconds: 1, +}; + const RegularFeatureInstance = ({ data, permissions, }: RegularFeatureInstanceProps) => { const { enabledFeatureStatistics } = useContext(FeatureFlagsContext); const navigate = useNavigate(); + const { projectName } = useParams(); const { customNavigationTabs } = useRegularFeatureViewCustomTabs(navigate); let tabs = [ @@ -70,6 +140,56 @@ const RegularFeatureInstance = ({ const TabRoutes = useRegularFeatureViewCustomTabRoutes(); + const applyFeatureView = useApplyFeatureView(); + const deleteFeatureView = useDeleteFeatureView(); + + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [editError, setEditError] = useState(null); + + const handleDelete = () => { + deleteFeatureView.mutate( + { name: data?.spec?.name || "", project: projectName || "" }, + { + onSuccess: () => { + navigate(`/p/${projectName}/feature-view`); + }, + }, + ); + }; + + const handleEditSubmit = (formData: FeatureViewFormData) => { + const payload = { + name: formData.name, + project: projectName || "", + entities: formData.entities, + features: formData.features.map((f) => ({ + name: f.name, + value_type: parseInt(f.valueType, 10), + description: f.description, + })), + batch_source: formData.batchSource, + ttl_seconds: formData.ttlValue * (TTL_UNITS[formData.ttlUnit] || 1), + online: formData.online, + description: formData.description, + owner: formData.owner, + tags: Object.fromEntries( + formData.tags.filter((t) => t.key.trim()).map((t) => [t.key, t.value]), + ), + }; + applyFeatureView.mutate(payload, { + onSuccess: () => { + setIsEditModalOpen(false); + setEditError(null); + }, + onError: (err: unknown) => { + const message = + err instanceof Error ? err.message : "An unexpected error occurred."; + setEditError(message); + }, + }); + }; + return ( } + rightSideItems={[ + { + setEditError(null); + setIsEditModalOpen(true); + }} + > + Edit + , + setShowDeleteConfirm(true)} + > + Delete + , + ]} tabs={tabs} /> @@ -112,6 +252,37 @@ const RegularFeatureInstance = ({ {TabRoutes} + + {showDeleteConfirm && ( + setShowDeleteConfirm(false)} + onConfirm={handleDelete} + cancelButtonText="Cancel" + confirmButtonText="Delete" + buttonColor="danger" + isLoading={deleteFeatureView.isLoading} + > +

+ This will permanently remove the feature view. This action cannot be + undone. +

+
+ )} + + {isEditModalOpen && data && ( + { + setIsEditModalOpen(false); + setEditError(null); + }} + onSubmit={handleEditSubmit} + initialData={buildEditFormData(data)} + isEdit + isSubmitting={applyFeatureView.isLoading} + submitError={editError} + /> + )}
); }; diff --git a/ui/src/pages/feature-views/RegularFeatureViewOverviewTab.tsx b/ui/src/pages/feature-views/RegularFeatureViewOverviewTab.tsx index 59614dd5334..e58e690c04e 100644 --- a/ui/src/pages/feature-views/RegularFeatureViewOverviewTab.tsx +++ b/ui/src/pages/feature-views/RegularFeatureViewOverviewTab.tsx @@ -1,7 +1,5 @@ import { EuiBadge, - EuiButtonEmpty, - EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, @@ -12,13 +10,10 @@ import { EuiTitle, EuiToolTip, } from "@elastic/eui"; -import React, { useState } from "react"; +import React from "react"; import { useNavigate, useParams } from "react-router-dom"; import FeaturesListDisplay from "../../components/FeaturesListDisplay"; -import FeatureViewFormModal, { - FeatureViewFormData, -} from "../../components/FeatureViewFormModal"; import PermissionsDisplay from "../../components/PermissionsDisplay"; import TagsDisplay from "../../components/TagsDisplay"; import { encodeSearchQueryString } from "../../hooks/encodeSearchQueryString"; @@ -26,7 +21,6 @@ import { EntityRelation } from "../../parsers/parseEntityRelationships"; import { FEAST_FCO_TYPES } from "../../parsers/types"; import useLoadRelationshipData from "../../queries/useLoadRelationshipsData"; import useLoadFeatureUsage from "../../queries/useLoadFeatureUsage"; -import { useApplyFeatureView } from "../../queries/mutations/useFeatureViewMutations"; import { getEntityPermissions } from "../../utils/permissionUtils"; import BatchSourcePropertiesView from "../data-sources/BatchSourcePropertiesView"; import ConsumingFeatureServicesList from "./ConsumingFeatureServicesList"; @@ -48,55 +42,6 @@ interface RegularFeatureViewOverviewTabProps { permissions?: any[]; } -const buildEditFormData = ( - fv: feast.core.IFeatureView, -): FeatureViewFormData => { - const tags = fv.spec?.tags - ? Object.entries(fv.spec.tags).map(([key, value]) => ({ key, value })) - : []; - - const features = (fv.spec?.features || []).map((f) => ({ - name: f.name || "", - valueType: String(f.valueType ?? 0), - description: f.description || "", - })); - - let ttlValue = 0; - let ttlUnit = "seconds"; - if (fv.spec?.ttl?.seconds) { - const secs = - typeof fv.spec.ttl.seconds === "number" - ? fv.spec.ttl.seconds - : ((fv.spec.ttl.seconds as any).toNumber?.() ?? 0); - if (secs > 0 && secs % 86400 === 0) { - ttlValue = secs / 86400; - ttlUnit = "days"; - } else if (secs > 0 && secs % 3600 === 0) { - ttlValue = secs / 3600; - ttlUnit = "hours"; - } else if (secs > 0 && secs % 60 === 0) { - ttlValue = secs / 60; - ttlUnit = "minutes"; - } else { - ttlValue = secs; - ttlUnit = "seconds"; - } - } - - return { - name: fv.spec?.name || "", - description: fv.spec?.description || "", - owner: fv.spec?.owner || "", - entities: fv.spec?.entities || [], - features, - batchSource: fv.spec?.batchSource?.name || "", - ttlValue, - ttlUnit, - online: fv.spec?.online ?? true, - tags, - }; -}; - const RegularFeatureViewOverviewTab = ({ data, permissions, @@ -118,55 +63,6 @@ const RegularFeatureViewOverviewTab = ({ : []; const numOfFs = fsNames.length; - const [isEditModalOpen, setIsEditModalOpen] = useState(false); - const [successMessage, setSuccessMessage] = useState(null); - const [errorMessage, setErrorMessage] = useState(null); - const applyFeatureView = useApplyFeatureView(); - - const TTL_UNITS: Record = { - days: 86400, - hours: 3600, - minutes: 60, - seconds: 1, - }; - - const handleEditSubmit = (formData: FeatureViewFormData) => { - const payload = { - name: formData.name, - project: projectName || "", - entities: formData.entities, - features: formData.features.map((f) => ({ - name: f.name, - value_type: parseInt(f.valueType, 10), - description: f.description, - })), - batch_source: formData.batchSource, - ttl_seconds: formData.ttlValue * (TTL_UNITS[formData.ttlUnit] || 1), - online: formData.online, - description: formData.description, - owner: formData.owner, - tags: Object.fromEntries( - formData.tags.filter((t) => t.key.trim()).map((t) => [t.key, t.value]), - ), - }; - applyFeatureView.mutate(payload, { - onSuccess: () => { - setIsEditModalOpen(false); - setErrorMessage(null); - setSuccessMessage( - `Feature view "${formData.name}" updated successfully.`, - ); - setTimeout(() => setSuccessMessage(null), 5000); - }, - onError: (err: unknown) => { - // Error shown inside the modal via submitError prop - const message = - err instanceof Error ? err.message : "An unexpected error occurred."; - setErrorMessage(message); - }, - }); - }; - const fvUsage = usageData?.feature_usage?.[fvName]; const runCount = fvUsage?.run_count ?? 0; const lastUsed = fvUsage?.last_used ?? null; @@ -175,39 +71,6 @@ const RegularFeatureViewOverviewTab = ({ return ( - {successMessage && ( - <> - - - - )} - {errorMessage && ( - <> - - - - )} - - - setIsEditModalOpen(true)} - > - Edit Feature View - - - - @@ -373,20 +236,6 @@ const RegularFeatureViewOverviewTab = ({ })} - - {isEditModalOpen && data && ( - { - setIsEditModalOpen(false); - setErrorMessage(null); - }} - onSubmit={handleEditSubmit} - initialData={buildEditFormData(data)} - isEdit - isSubmitting={applyFeatureView.isLoading} - submitError={errorMessage} - /> - )} ); }; diff --git a/ui/src/pages/feature-views/components/FeatureViewProjectionDisplayPanel.tsx b/ui/src/pages/feature-views/components/FeatureViewProjectionDisplayPanel.tsx index a80f8039e19..4abb5d02f75 100644 --- a/ui/src/pages/feature-views/components/FeatureViewProjectionDisplayPanel.tsx +++ b/ui/src/pages/feature-views/components/FeatureViewProjectionDisplayPanel.tsx @@ -51,7 +51,7 @@ const FeatureViewProjectionDisplayPanel = ( ); diff --git a/ui/src/pages/saved-data-sets/DatasetInstance.tsx b/ui/src/pages/saved-data-sets/DatasetInstance.tsx index ce13cb26e3c..2856af5f342 100644 --- a/ui/src/pages/saved-data-sets/DatasetInstance.tsx +++ b/ui/src/pages/saved-data-sets/DatasetInstance.tsx @@ -95,7 +95,6 @@ const DatasetInstance = () => { { setEditError(null); setShowEditModal(true); @@ -106,7 +105,6 @@ const DatasetInstance = () => { setShowDeleteConfirm(true)} > diff --git a/ui/src/queries/mutations/useFeatureServiceMutations.ts b/ui/src/queries/mutations/useFeatureServiceMutations.ts new file mode 100644 index 00000000000..85bd3f4fd44 --- /dev/null +++ b/ui/src/queries/mutations/useFeatureServiceMutations.ts @@ -0,0 +1,100 @@ +import { useMutation, useQueryClient } from "react-query"; + +interface FeatureViewProjectionPayload { + feature_view_name: string; + feature_names?: string[]; +} + +interface ApplyFeatureServicePayload { + name: string; + project: string; + features: FeatureViewProjectionPayload[]; + description?: string; + tags?: Record; + owner?: string; +} + +interface DeleteFeatureServicePayload { + name: string; + project: string; +} + +interface MutationResult { + name: string; + project: string; + status: string; +} + +const API_BASE = "/api/v1"; + +const applyFeatureService = async ( + payload: ApplyFeatureServicePayload, +): Promise => { + const response = await fetch(`${API_BASE}/feature_services`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const error = await response + .json() + .catch(() => ({ detail: response.statusText })); + throw new Error( + error.detail || `Failed to apply feature service: ${response.status}`, + ); + } + + return response.json(); +}; + +const deleteFeatureService = async ( + payload: DeleteFeatureServicePayload, +): Promise => { + const response = await fetch( + `${API_BASE}/feature_services/${encodeURIComponent(payload.name)}?project=${encodeURIComponent(payload.project)}`, + { method: "DELETE" }, + ); + + if (!response.ok) { + const error = await response + .json() + .catch(() => ({ detail: response.statusText })); + throw new Error( + error.detail || `Failed to delete feature service: ${response.status}`, + ); + } + + return response.json(); +}; + +const useApplyFeatureService = () => { + const queryClient = useQueryClient(); + + return useMutation(applyFeatureService, { + onSuccess: () => { + queryClient.invalidateQueries(["rest"]); + queryClient.invalidateQueries(["feature-services-rest"]); + queryClient.invalidateQueries(["feature-service-rest"]); + }, + }); +}; + +const useDeleteFeatureService = () => { + const queryClient = useQueryClient(); + + return useMutation(deleteFeatureService, { + onSuccess: () => { + queryClient.invalidateQueries(["rest"]); + queryClient.invalidateQueries(["feature-services-rest"]); + queryClient.invalidateQueries(["feature-service-rest"]); + }, + }); +}; + +export { useApplyFeatureService, useDeleteFeatureService }; +export type { + ApplyFeatureServicePayload, + DeleteFeatureServicePayload, + FeatureViewProjectionPayload, +}; diff --git a/ui/src/utils/permissionUtils.ts b/ui/src/utils/permissionUtils.ts index c4c4cef032e..2f1f8e06628 100644 --- a/ui/src/utils/permissionUtils.ts +++ b/ui/src/utils/permissionUtils.ts @@ -1,5 +1,16 @@ import { FEAST_FCO_TYPES } from "../parsers/types"; +/** + * Test if a regex pattern is potentially vulnerable to catastrophic backtracking. + * Rejects patterns with nested quantifiers like (a+)+ or (a*)* + */ +const isSafePattern = (pattern: string): boolean => { + if (pattern.length > 1000) return false; + // Reject nested quantifiers: a quantifier applied to a group containing a quantifier + if (/(\([^)]*[+*][^)]*\))[+*{]/.test(pattern)) return false; + return true; +}; + /** * Get permissions for a specific entity * @param permissions List of all permissions @@ -42,6 +53,9 @@ export const getEntityPermissions = ( matchesName = true; // If no name patterns, matches all names } else { matchesName = permission.spec?.name_patterns?.some((pattern: string) => { + if (!pattern || !isSafePattern(pattern)) { + return pattern === entityName; + } try { const regex = new RegExp(pattern); return regex.test(entityName);