diff --git a/sdk/python/feast/api/registry/rest/feature_views.py b/sdk/python/feast/api/registry/rest/feature_views.py index 04c1f42cefa..1f6e6604c80 100644 --- a/sdk/python/feast/api/registry/rest/feature_views.py +++ b/sdk/python/feast/api/registry/rest/feature_views.py @@ -32,6 +32,7 @@ class FeatureModel(BaseModel): name: str value_type: int = 2 + description: Optional[str] = "" class ApplyFeatureViewRequestBody(BaseModel): @@ -307,7 +308,13 @@ def list_all_feature_views( def apply_feature_view(body: ApplyFeatureViewRequestBody): feature_specs = [] for f in body.features or []: - feature_specs.append(FeatureSpecV2(name=f.name, value_type=f.value_type)) + feature_specs.append( + FeatureSpecV2( + name=f.name, + value_type=f.value_type, + description=f.description or "", + ) + ) batch_source_proto = ( DataSourceProto(name=body.batch_source) if body.batch_source else None 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 04eacb6cb9b..b87f0476e15 100644 --- a/sdk/python/tests/unit/api/test_api_rest_registry.py +++ b/sdk/python/tests/unit/api/test_api_rest_registry.py @@ -2091,8 +2091,16 @@ def test_apply_and_delete_feature_view_via_rest(fastapi_test_app): "project": "demo_project", "entities": ["user_id"], "features": [ - {"name": "trip_count", "value_type": 2}, - {"name": "avg_rating", "value_type": 4}, + { + "name": "trip_count", + "value_type": 2, + "description": "Number of completed trips", + }, + { + "name": "avg_rating", + "value_type": 4, + "description": "Average driver rating", + }, ], "ttl_seconds": 86400, "online": True, @@ -2107,7 +2115,10 @@ def test_apply_and_delete_feature_view_via_rest(fastapi_test_app): # Verify it exists response = fastapi_test_app.get("/feature_views/driver_stats?project=demo_project") assert response.status_code == 200 - assert response.json()["spec"]["name"] == "driver_stats" + spec = response.json()["spec"] + assert spec["name"] == "driver_stats" + assert spec["features"][0]["description"] == "Number of completed trips" + assert spec["features"][1]["description"] == "Average driver rating" # Delete it response = fastapi_test_app.delete( diff --git a/sdk/python/tests/utils/rag_test_utils.py b/sdk/python/tests/utils/rag_test_utils.py index e3aac2546bd..675fa28b4a5 100644 --- a/sdk/python/tests/utils/rag_test_utils.py +++ b/sdk/python/tests/utils/rag_test_utils.py @@ -92,7 +92,9 @@ def run_side_effect(cmd, *args, **kwargs): with runner.local_repo( get_example_repo("example_feature_repo_1.py"), offline_store="file", - online_store="milvus", + # The vector store tests below use MockVectorStore and only need the + # feature view registered; avoid Milvus Lite index setup flakiness. + online_store="sqlite", apply=False, teardown=True, ) as store: diff --git a/ui/src/components/DataSourceFormModal.tsx b/ui/src/components/DataSourceFormModal.tsx new file mode 100644 index 00000000000..6f39ac088a2 --- /dev/null +++ b/ui/src/components/DataSourceFormModal.tsx @@ -0,0 +1,457 @@ +import React, { useState, useEffect } from "react"; +import { + EuiFormRow, + EuiFieldText, + EuiSelect, + EuiSpacer, + EuiHorizontalRule, + EuiText, +} from "@elastic/eui"; +import { feast } from "../protos"; +import FormModal from "./forms/FormModal"; +import TagsEditor, { TagEntry } from "./forms/TagsEditor"; +import NameDescriptionOwnerFields from "./forms/NameDescriptionOwnerFields"; + +const SOURCE_TYPE_OPTIONS = [ + { + value: String(feast.core.DataSource.SourceType.BATCH_FILE), + text: "File (Parquet / CSV)", + }, + { + value: String(feast.core.DataSource.SourceType.BATCH_BIGQUERY), + text: "BigQuery", + }, + { + value: String(feast.core.DataSource.SourceType.BATCH_SNOWFLAKE), + text: "Snowflake", + }, + { + value: String(feast.core.DataSource.SourceType.BATCH_REDSHIFT), + text: "Redshift", + }, + { + value: String(feast.core.DataSource.SourceType.STREAM_KAFKA), + text: "Kafka", + }, + { + value: String(feast.core.DataSource.SourceType.BATCH_SPARK), + text: "Spark", + }, + { + value: String(feast.core.DataSource.SourceType.REQUEST_SOURCE), + text: "Request Source", + }, + { + value: String(feast.core.DataSource.SourceType.PUSH_SOURCE), + text: "Push Source", + }, +]; + +interface DataSourceFormData { + name: string; + description: string; + owner: string; + sourceType: string; + timestampField: string; + createdTimestampColumn: string; + tags: TagEntry[]; + fileUri: string; + bigqueryTable: string; + bigqueryQuery: string; + snowflakeTable: string; + snowflakeDatabase: string; + snowflakeSchema: string; + redshiftTable: string; + redshiftDatabase: string; + redshiftSchema: string; + kafkaBootstrapServers: string; + kafkaTopic: string; + sparkTable: string; + sparkPath: string; +} + +interface DataSourceFormModalProps { + onClose: () => void; + onSubmit: (data: DataSourceFormData) => void; + initialData?: DataSourceFormData; + isEdit?: boolean; +} + +const EMPTY_FORM: DataSourceFormData = { + name: "", + description: "", + owner: "", + sourceType: String(feast.core.DataSource.SourceType.BATCH_FILE), + timestampField: "", + createdTimestampColumn: "", + tags: [], + fileUri: "", + bigqueryTable: "", + bigqueryQuery: "", + snowflakeTable: "", + snowflakeDatabase: "", + snowflakeSchema: "", + redshiftTable: "", + redshiftDatabase: "", + redshiftSchema: "", + kafkaBootstrapServers: "", + kafkaTopic: "", + sparkTable: "", + sparkPath: "", +}; + +const DataSourceFormModal: React.FC = ({ + onClose, + onSubmit, + initialData, + isEdit = false, +}) => { + const [formData, setFormData] = useState( + initialData || EMPTY_FORM, + ); + const [errors, setErrors] = useState>({}); + const [submitted, setSubmitted] = useState(false); + + useEffect(() => { + if (initialData) { + setFormData(initialData); + } + }, [initialData]); + + const validate = (): boolean => { + const newErrors: Record = {}; + + if (!formData.name.trim()) { + newErrors.name = "Data source 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 st = formData.sourceType; + if (st === String(feast.core.DataSource.SourceType.BATCH_FILE)) { + if (!formData.fileUri.trim()) { + newErrors.fileUri = "File URI is required for file sources."; + } + } else if (st === String(feast.core.DataSource.SourceType.BATCH_BIGQUERY)) { + if (!formData.bigqueryTable.trim() && !formData.bigqueryQuery.trim()) { + newErrors.bigqueryTable = "Either table or query is required."; + } + } else if ( + st === String(feast.core.DataSource.SourceType.BATCH_SNOWFLAKE) + ) { + if (!formData.snowflakeTable.trim()) { + newErrors.snowflakeTable = "Table name is required for Snowflake."; + } + } else if (st === String(feast.core.DataSource.SourceType.STREAM_KAFKA)) { + if (!formData.kafkaBootstrapServers.trim()) { + newErrors.kafkaBootstrapServers = "Bootstrap servers are required."; + } + if (!formData.kafkaTopic.trim()) { + newErrors.kafkaTopic = "Topic is required."; + } + } + + const tagKeys = formData.tags.map((t) => t.key).filter((k) => k.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()) { + const cleanedData = { + ...formData, + tags: formData.tags.filter((t) => t.key.trim()), + }; + onSubmit(cleanedData); + } + }; + + const updateField = ( + field: K, + value: DataSourceFormData[K], + ) => { + setFormData((prev) => ({ ...prev, [field]: value })); + if (submitted) { + setErrors((prev) => { + const next = { ...prev }; + delete next[field]; + return next; + }); + } + }; + + const renderSourceTypeFields = () => { + const st = formData.sourceType; + + if (st === String(feast.core.DataSource.SourceType.BATCH_FILE)) { + return ( + + updateField("fileUri", e.target.value)} + isInvalid={!!errors.fileUri} + placeholder="s3://bucket/path/to/data.parquet" + /> + + ); + } + + if (st === String(feast.core.DataSource.SourceType.BATCH_BIGQUERY)) { + return ( + <> + + updateField("bigqueryTable", e.target.value)} + isInvalid={!!errors.bigqueryTable} + placeholder="project:dataset.table" + /> + + + updateField("bigqueryQuery", e.target.value)} + placeholder="SELECT * FROM ..." + /> + + + ); + } + + if (st === String(feast.core.DataSource.SourceType.BATCH_SNOWFLAKE)) { + return ( + <> + + updateField("snowflakeTable", e.target.value)} + isInvalid={!!errors.snowflakeTable} + placeholder="MY_TABLE" + /> + + + updateField("snowflakeDatabase", e.target.value)} + placeholder="MY_DATABASE" + /> + + + updateField("snowflakeSchema", e.target.value)} + placeholder="PUBLIC" + /> + + + ); + } + + if (st === String(feast.core.DataSource.SourceType.BATCH_REDSHIFT)) { + return ( + <> + + updateField("redshiftTable", e.target.value)} + placeholder="my_table" + /> + + + updateField("redshiftDatabase", e.target.value)} + placeholder="my_database" + /> + + + updateField("redshiftSchema", e.target.value)} + placeholder="public" + /> + + + ); + } + + if (st === String(feast.core.DataSource.SourceType.STREAM_KAFKA)) { + return ( + <> + + + updateField("kafkaBootstrapServers", e.target.value) + } + isInvalid={!!errors.kafkaBootstrapServers} + placeholder="localhost:9092" + /> + + + updateField("kafkaTopic", e.target.value)} + isInvalid={!!errors.kafkaTopic} + placeholder="my-feature-topic" + /> + + + ); + } + + if (st === String(feast.core.DataSource.SourceType.BATCH_SPARK)) { + return ( + <> + + updateField("sparkTable", e.target.value)} + placeholder="catalog.database.table" + /> + + + updateField("sparkPath", e.target.value)} + placeholder="s3://bucket/path/" + /> + + + ); + } + + if ( + st === String(feast.core.DataSource.SourceType.REQUEST_SOURCE) || + st === String(feast.core.DataSource.SourceType.PUSH_SOURCE) + ) { + return ( + + No additional configuration required for this source type. + + ); + } + + return null; + }; + + const isBatchSource = + formData.sourceType !== + String(feast.core.DataSource.SourceType.STREAM_KAFKA) && + formData.sourceType !== + String(feast.core.DataSource.SourceType.REQUEST_SOURCE) && + formData.sourceType !== + String(feast.core.DataSource.SourceType.PUSH_SOURCE); + + return ( + + updateField("name", v)} + onChangeDescription={(v) => updateField("description", v)} + onChangeOwner={(v) => updateField("owner", v)} + nameDisabled={isEdit} + nameError={errors.name} + nameHelpText="A unique name for this data source." + namePlaceholder="e.g. customer_transactions" + descriptionPlaceholder="Describe this data source..." + /> + + + updateField("sourceType", e.target.value)} + disabled={isEdit} + /> + + + + + +

Source Configuration

+
+ + + {renderSourceTypeFields()} + + {isBatchSource && ( + <> + + + updateField("timestampField", e.target.value)} + placeholder="event_timestamp" + /> + + + + updateField("createdTimestampColumn", e.target.value) + } + placeholder="created_at" + /> + + + )} + + + + updateField("tags", tags)} + error={errors.tags} + /> +
+ ); +}; + +export default DataSourceFormModal; +export type { DataSourceFormData }; diff --git a/ui/src/components/EntityFormModal.tsx b/ui/src/components/EntityFormModal.tsx new file mode 100644 index 00000000000..0b64c71d668 --- /dev/null +++ b/ui/src/components/EntityFormModal.tsx @@ -0,0 +1,260 @@ +import React, { useState, useEffect } from "react"; +import { + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, + EuiFieldText, + EuiButtonEmpty, + EuiButtonIcon, + EuiText, + EuiHorizontalRule, + EuiCallOut, +} from "@elastic/eui"; +import { feast } from "../protos"; +import FormModal from "./forms/FormModal"; +import TagsEditor, { TagEntry } from "./forms/TagsEditor"; +import NameDescriptionOwnerFields from "./forms/NameDescriptionOwnerFields"; +import ValueTypeSelect from "./forms/ValueTypeSelect"; + +interface EntityFormData { + name: string; + description: string; + joinKeys: string[]; + valueType: string; + tags: TagEntry[]; +} + +interface EntityFormModalProps { + onClose: () => void; + onSubmit: (data: EntityFormData) => void; + initialData?: EntityFormData; + isEdit?: boolean; + isSubmitting?: boolean; + submitError?: string | null; +} + +const EMPTY_FORM: EntityFormData = { + name: "", + description: "", + joinKeys: [""], + valueType: String(feast.types.ValueType.Enum.STRING), + tags: [], +}; + +const EntityFormModal: 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); + + useEffect(() => { + if (initialData) { + setFormData(initialData); + } + }, [initialData]); + + const validate = (): boolean => { + const newErrors: Record = {}; + + if (!formData.name.trim()) { + newErrors.name = "Entity 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 nonEmptyKeys = formData.joinKeys.filter((k) => k.trim()); + if (nonEmptyKeys.length === 0) { + newErrors.joinKeys = "At least one join key is required."; + } else { + if (new Set(nonEmptyKeys).size !== nonEmptyKeys.length) { + newErrors.joinKeys = "Join keys must be unique."; + } + const invalidKey = nonEmptyKeys.find( + (k) => !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(k), + ); + if (invalidKey) { + newErrors.joinKeys = `Invalid join key "${invalidKey}". Use only letters, numbers, and underscores.`; + } + } + + const tagKeys = formData.tags.map((t) => t.key).filter((k) => k.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()) { + const cleanedData = { + ...formData, + joinKeys: formData.joinKeys.filter((k) => k.trim()), + tags: formData.tags.filter((t) => t.key.trim()), + }; + onSubmit(cleanedData); + } + }; + + const updateField = ( + field: K, + value: EntityFormData[K], + ) => { + setFormData((prev) => ({ ...prev, [field]: value })); + if (submitted) { + setErrors((prev) => { + const next = { ...prev }; + delete next[field]; + return next; + }); + } + }; + + const addJoinKey = () => { + updateField("joinKeys", [...formData.joinKeys, ""]); + }; + + const removeJoinKey = (index: number) => { + if (formData.joinKeys.length <= 1) return; + updateField( + "joinKeys", + formData.joinKeys.filter((_, i) => i !== index), + ); + }; + + const updateJoinKey = (index: number, value: string) => { + const updated = [...formData.joinKeys]; + updated[index] = value; + updateField("joinKeys", updated); + }; + + return ( + + {submitError && ( + <> + +

{submitError}

+
+ + + )} + + updateField("name", v)} + onChangeDescription={(v) => updateField("description", v)} + nameDisabled={isEdit} + nameError={errors.name} + nameHelpText="A unique identifier for this entity (e.g. customer_id)." + namePlaceholder="e.g. customer_id" + descriptionPlaceholder="Describe what this entity represents..." + /> + + + + + + + +

Join Keys

+
+
+ + + Add join key + + +
+ + + Column name(s) used to join this entity in data sources. + + + {errors.joinKeys && ( + <> + + + + )} + + {formData.joinKeys.map((key, index) => ( + + + updateJoinKey(index, e.target.value)} + placeholder={ + index === 0 ? "e.g. customer_id" : "e.g. timestamp_field" + } + compressed + isInvalid={!!errors.joinKeys && !key.trim()} + /> + + + removeJoinKey(index)} + disabled={formData.joinKeys.length <= 1} + /> + + + ))} + + + + updateField("valueType", v)} + helpText="Data type of the join key." + /> + + + + updateField("tags", tags)} + error={errors.tags} + /> +
+ ); +}; + +export default EntityFormModal; +export type { EntityFormData, TagEntry }; diff --git a/ui/src/components/FeatureViewFormModal.tsx b/ui/src/components/FeatureViewFormModal.tsx new file mode 100644 index 00000000000..c77d8dd4344 --- /dev/null +++ b/ui/src/components/FeatureViewFormModal.tsx @@ -0,0 +1,437 @@ +import React, { useState, useEffect } from "react"; +import { + EuiFormRow, + EuiFieldText, + EuiFieldNumber, + EuiSelect, + EuiSwitch, + EuiComboBox, + EuiComboBoxOptionOption, + EuiSpacer, + EuiCallOut, + EuiButtonEmpty, +} 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 FeatureFieldEditor, { + FeatureFieldEntry, +} from "./forms/FeatureFieldEditor"; +import EntityFormModal, { EntityFormData } from "./EntityFormModal"; +import DataSourceFormModal, { DataSourceFormData } from "./DataSourceFormModal"; +import { useLoadEntitiesREST } from "../queries/useLoadEntitiesREST"; +import { useLoadDataSourcesREST } from "../queries/useLoadDataSourcesREST"; +import { useApplyEntity } from "../queries/mutations/useEntityMutations"; +import { useApplyDataSource } from "../queries/mutations/useDataSourceMutations"; +import { feast } from "../protos"; + +const TTL_UNIT_OPTIONS = [ + { value: "seconds", text: "Seconds" }, + { value: "minutes", text: "Minutes" }, + { value: "hours", text: "Hours" }, + { value: "days", text: "Days" }, +]; + +interface FeatureViewFormData { + name: string; + description: string; + owner: string; + entities: string[]; + features: FeatureFieldEntry[]; + batchSource: string; + ttlValue: number; + ttlUnit: string; + online: boolean; + tags: TagEntry[]; +} + +interface FeatureViewFormModalProps { + onClose: () => void; + onSubmit: (data: FeatureViewFormData) => void; + initialData?: FeatureViewFormData; + isEdit?: boolean; +} + +const EMPTY_FORM: FeatureViewFormData = { + name: "", + description: "", + owner: "", + entities: [], + features: [], + batchSource: "", + ttlValue: 0, + ttlUnit: "seconds", + online: true, + tags: [], +}; + +const FeatureViewFormModal: React.FC = ({ + onClose, + onSubmit, + initialData, + isEdit = false, +}) => { + const [formData, setFormData] = useState( + initialData || EMPTY_FORM, + ); + const [errors, setErrors] = useState>({}); + const [submitted, setSubmitted] = useState(false); + const [showEntityForm, setShowEntityForm] = useState(false); + const [showDataSourceForm, setShowDataSourceForm] = useState(false); + + const { projectName } = useParams(); + const entitiesQuery = useLoadEntitiesREST(projectName || ""); + const dataSourcesQuery = useLoadDataSourcesREST(projectName || ""); + const applyEntity = useApplyEntity(); + const applyDataSource = useApplyDataSource(); + + const entities = entitiesQuery.data?.entities || []; + const dataSources = dataSourcesQuery.data?.dataSources || []; + + const entityOptions: EuiComboBoxOptionOption[] = entities.map( + (e: any) => ({ + label: e?.spec?.name || e?.name || "", + }), + ); + + const dataSourceOptions = dataSources.map((ds: any) => ({ + value: ds?.name || ds?.spec?.name || "", + text: ds?.name || ds?.spec?.name || "", + })); + + const hasNoEntities = entitiesQuery.isSuccess && entities.length === 0; + const hasNoDataSources = + dataSourcesQuery.isSuccess && dataSources.length === 0; + + useEffect(() => { + if (initialData) { + setFormData(initialData); + } + }, [initialData]); + + const validate = (): boolean => { + const newErrors: Record = {}; + + if (!formData.name.trim()) { + newErrors.name = "Feature view 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."; + } + + if (formData.features.length === 0) { + newErrors.features = "At least one feature is required."; + } else { + const hasEmptyName = formData.features.some((f) => !f.name.trim()); + if (hasEmptyName) { + newErrors.features = "All features must have a name."; + } + const featureNames = formData.features.map((f) => f.name.trim()); + if (new Set(featureNames).size !== featureNames.length) { + newErrors.features = "Feature names must be unique."; + } + } + + if (!formData.batchSource.trim()) { + newErrors.batchSource = "A batch source is required."; + } + + const tagKeys = formData.tags.map((t) => t.key).filter((k) => k.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()) { + const cleanedData = { + ...formData, + tags: formData.tags.filter((t) => t.key.trim()), + }; + onSubmit(cleanedData); + } + }; + + const updateField = ( + field: K, + value: FeatureViewFormData[K], + ) => { + setFormData((prev) => ({ ...prev, [field]: value })); + if (submitted) { + setErrors((prev) => { + const next = { ...prev }; + delete next[field]; + return next; + }); + } + }; + + const handleInlineEntityCreate = (entityData: EntityFormData) => { + const payload = { + name: entityData.name, + project: projectName || "", + join_key: entityData.joinKeys[0] || entityData.name, + value_type: parseInt(entityData.valueType, 10), + description: entityData.description, + tags: Object.fromEntries( + entityData.tags + .filter((t) => t.key.trim()) + .map((t) => [t.key, t.value]), + ), + }; + applyEntity.mutate(payload, { + onSuccess: () => { + setShowEntityForm(false); + updateField("entities", [...formData.entities, entityData.name]); + }, + }); + }; + + const handleInlineDataSourceCreate = (dsData: DataSourceFormData) => { + const payload: Record = { + name: dsData.name, + project: projectName || "", + type: parseInt(dsData.sourceType, 10), + timestamp_field: dsData.timestampField, + created_timestamp_column: dsData.createdTimestampColumn, + description: dsData.description, + owner: dsData.owner, + tags: Object.fromEntries( + dsData.tags.filter((t) => t.key.trim()).map((t) => [t.key, t.value]), + ), + }; + + const st = dsData.sourceType; + if (st === String(feast.core.DataSource.SourceType.BATCH_FILE)) { + payload.file_options = { uri: dsData.fileUri }; + } else if (st === String(feast.core.DataSource.SourceType.BATCH_BIGQUERY)) { + payload.bigquery_options = { + table: dsData.bigqueryTable, + query: dsData.bigqueryQuery, + }; + } else if ( + st === String(feast.core.DataSource.SourceType.BATCH_SNOWFLAKE) + ) { + payload.snowflake_options = { + table: dsData.snowflakeTable, + database: dsData.snowflakeDatabase, + schema_: dsData.snowflakeSchema, + }; + } else if (st === String(feast.core.DataSource.SourceType.BATCH_REDSHIFT)) { + payload.redshift_options = { + table: dsData.redshiftTable, + database: dsData.redshiftDatabase, + schema_: dsData.redshiftSchema, + }; + } else if (st === String(feast.core.DataSource.SourceType.STREAM_KAFKA)) { + payload.kafka_options = { + kafka_bootstrap_servers: dsData.kafkaBootstrapServers, + topic: dsData.kafkaTopic, + }; + } else if (st === String(feast.core.DataSource.SourceType.BATCH_SPARK)) { + payload.spark_options = { + table: dsData.sparkTable, + path: dsData.sparkPath, + }; + } + + applyDataSource.mutate(payload as any, { + onSuccess: () => { + setShowDataSourceForm(false); + updateField("batchSource", dsData.name); + }, + }); + }; + + const selectedEntityOptions = formData.entities.map((e) => ({ label: e })); + + return ( + <> + + updateField("name", v)} + onChangeDescription={(v) => updateField("description", v)} + onChangeOwner={(v) => updateField("owner", v)} + nameDisabled={isEdit} + nameError={errors.name} + nameHelpText="A unique name for this feature view." + namePlaceholder="e.g. customer_features" + descriptionPlaceholder="Describe what this feature view provides..." + /> + + {hasNoEntities && ( + <> + + +

+ Feature views typically reference entities. You can create one + now. +

+ setShowEntityForm(true)} + > + Create Entity + +
+ + + )} + + + + updateField( + "entities", + selected.map((s) => s.label), + ) + } + isClearable + isLoading={entitiesQuery.isLoading} + /> + + + {hasNoDataSources && ( + <> + + +

+ A batch source is required. You can create a data source now. +

+ setShowDataSourceForm(true)} + > + Create Data Source + +
+ + + )} + + + {dataSourceOptions.length > 0 ? ( + updateField("batchSource", e.target.value)} + isInvalid={!!errors.batchSource} + /> + ) : ( + updateField("batchSource", e.target.value)} + isInvalid={!!errors.batchSource} + placeholder="data_source_name" + /> + )} + + + + + updateField("features", features)} + error={errors.features} + /> + + + + +
+ + updateField("ttlValue", parseInt(e.target.value) || 0) + } + min={0} + style={{ width: 120 }} + /> + updateField("ttlUnit", e.target.value)} + style={{ width: 140 }} + /> +
+
+ + + updateField("online", e.target.checked)} + /> + + + + + updateField("tags", tags)} + error={errors.tags} + /> +
+ + {showEntityForm && ( + setShowEntityForm(false)} + onSubmit={handleInlineEntityCreate} + /> + )} + + {showDataSourceForm && ( + setShowDataSourceForm(false)} + onSubmit={handleInlineDataSourceCreate} + /> + )} + + ); +}; + +export default FeatureViewFormModal; +export type { FeatureViewFormData }; diff --git a/ui/src/components/forms/FeatureFieldEditor.tsx b/ui/src/components/forms/FeatureFieldEditor.tsx new file mode 100644 index 00000000000..be8b4f9f210 --- /dev/null +++ b/ui/src/components/forms/FeatureFieldEditor.tsx @@ -0,0 +1,163 @@ +import React from "react"; +import { + EuiFlexGroup, + EuiFlexItem, + EuiFieldText, + EuiSelect, + EuiButtonEmpty, + EuiButtonIcon, + EuiText, + EuiHorizontalRule, + EuiSpacer, + EuiCallOut, +} from "@elastic/eui"; +import { VALUE_TYPE_OPTIONS } from "./ValueTypeSelect"; +import { feast } from "../../protos"; + +interface FeatureFieldEntry { + name: string; + valueType: string; + description: string; +} + +interface FeatureFieldEditorProps { + features: FeatureFieldEntry[]; + onChange: (features: FeatureFieldEntry[]) => void; + error?: string; +} + +const EMPTY_FEATURE: FeatureFieldEntry = { + name: "", + valueType: String(feast.types.ValueType.Enum.INT64), + description: "", +}; + +const FeatureFieldEditor: React.FC = ({ + features, + onChange, + error, +}) => { + const addFeature = () => { + onChange([...features, { ...EMPTY_FEATURE }]); + }; + + const removeFeature = (index: number) => { + onChange(features.filter((_, i) => i !== index)); + }; + + const updateFeature = ( + index: number, + field: keyof FeatureFieldEntry, + val: string, + ) => { + const updated = [...features]; + updated[index] = { ...updated[index], [field]: val }; + onChange(updated); + }; + + return ( + <> + + + + +

Features

+
+
+ + + Add feature + + +
+ + {error && ( + <> + + + + )} + + {features.length > 0 && ( + + + + Name + + + + + Type + + + + + Description + + + + + )} + + {features.map((feature, index) => ( + + + updateFeature(index, "name", e.target.value)} + compressed + /> + + + + updateFeature(index, "valueType", e.target.value) + } + compressed + /> + + + + updateFeature(index, "description", e.target.value) + } + compressed + /> + + + removeFeature(index)} + /> + + + ))} + + {features.length === 0 && ( + + No features added yet. Click "Add feature" above. + + )} + + ); +}; + +export default FeatureFieldEditor; +export type { FeatureFieldEntry }; diff --git a/ui/src/components/forms/FormModal.tsx b/ui/src/components/forms/FormModal.tsx new file mode 100644 index 00000000000..b205f9f35a2 --- /dev/null +++ b/ui/src/components/forms/FormModal.tsx @@ -0,0 +1,54 @@ +import React from "react"; +import { + EuiModal, + EuiModalHeader, + EuiModalHeaderTitle, + EuiModalBody, + EuiModalFooter, + EuiButton, + EuiButtonEmpty, + EuiForm, +} from "@elastic/eui"; + +interface FormModalProps { + title: string; + submitLabel: string; + onClose: () => void; + onSubmit: () => void; + children: React.ReactNode; + width?: number; + isSubmitting?: boolean; +} + +const FormModal: React.FC = ({ + title, + submitLabel, + onClose, + onSubmit, + children, + width = 600, + isSubmitting = false, +}) => { + return ( + + + {title} + + + + {children} + + + + + Cancel + + + {submitLabel} + + + + ); +}; + +export default FormModal; diff --git a/ui/src/components/forms/NameDescriptionOwnerFields.tsx b/ui/src/components/forms/NameDescriptionOwnerFields.tsx new file mode 100644 index 00000000000..46d21c369ba --- /dev/null +++ b/ui/src/components/forms/NameDescriptionOwnerFields.tsx @@ -0,0 +1,70 @@ +import React from "react"; +import { EuiFormRow, EuiFieldText, EuiTextArea } from "@elastic/eui"; + +interface NameDescriptionOwnerFieldsProps { + name: string; + description: string; + owner?: string; + onChangeName: (value: string) => void; + onChangeDescription: (value: string) => void; + onChangeOwner?: (value: string) => void; + nameDisabled?: boolean; + nameError?: string; + nameHelpText?: string; + namePlaceholder?: string; + descriptionPlaceholder?: string; +} + +const NameDescriptionOwnerFields: React.FC = ({ + name, + description, + owner, + onChangeName, + onChangeDescription, + onChangeOwner, + nameDisabled = false, + nameError, + nameHelpText, + namePlaceholder = "e.g. my_resource", + descriptionPlaceholder = "Describe this resource...", +}) => { + return ( + <> + + onChangeName(e.target.value)} + isInvalid={!!nameError} + disabled={nameDisabled} + placeholder={namePlaceholder} + /> + + + + onChangeDescription(e.target.value)} + placeholder={descriptionPlaceholder} + rows={2} + /> + + + {onChangeOwner !== undefined && ( + + onChangeOwner(e.target.value)} + placeholder="e.g. team-ml-platform" + /> + + )} + + ); +}; + +export default NameDescriptionOwnerFields; diff --git a/ui/src/components/forms/TagsEditor.tsx b/ui/src/components/forms/TagsEditor.tsx new file mode 100644 index 00000000000..73ea5df9c82 --- /dev/null +++ b/ui/src/components/forms/TagsEditor.tsx @@ -0,0 +1,112 @@ +import React from "react"; +import { + EuiFlexGroup, + EuiFlexItem, + EuiFieldText, + EuiButtonEmpty, + EuiButtonIcon, + EuiText, + EuiHorizontalRule, + EuiSpacer, + EuiCallOut, +} from "@elastic/eui"; + +interface TagEntry { + key: string; + value: string; +} + +interface TagsEditorProps { + tags: TagEntry[]; + onChange: (tags: TagEntry[]) => void; + error?: string; +} + +const TagsEditor: React.FC = ({ tags, onChange, error }) => { + const addTag = () => { + onChange([...tags, { key: "", value: "" }]); + }; + + const removeTag = (index: number) => { + onChange(tags.filter((_, i) => i !== index)); + }; + + const updateTag = (index: number, field: "key" | "value", val: string) => { + const updated = [...tags]; + updated[index] = { ...updated[index], [field]: val }; + onChange(updated); + }; + + return ( + <> + + + + +

Labels

+
+
+ + + Add label + + +
+ + {error && ( + <> + + + + )} + + {tags.map((tag, index) => ( + + + updateTag(index, "key", e.target.value)} + compressed + /> + + + updateTag(index, "value", e.target.value)} + compressed + /> + + + removeTag(index)} + /> + + + ))} + + {tags.length === 0 && ( + + No labels added yet. + + )} + + ); +}; + +export default TagsEditor; +export type { TagEntry }; diff --git a/ui/src/components/forms/ValueTypeSelect.tsx b/ui/src/components/forms/ValueTypeSelect.tsx new file mode 100644 index 00000000000..2718b7c027e --- /dev/null +++ b/ui/src/components/forms/ValueTypeSelect.tsx @@ -0,0 +1,47 @@ +import React from "react"; +import { EuiFormRow, EuiSelect } from "@elastic/eui"; +import { feast } from "../../protos"; + +const VALUE_TYPE_OPTIONS = [ + { value: String(feast.types.ValueType.Enum.STRING), text: "STRING" }, + { value: String(feast.types.ValueType.Enum.INT32), text: "INT32" }, + { value: String(feast.types.ValueType.Enum.INT64), text: "INT64" }, + { value: String(feast.types.ValueType.Enum.FLOAT), text: "FLOAT" }, + { value: String(feast.types.ValueType.Enum.DOUBLE), text: "DOUBLE" }, + { value: String(feast.types.ValueType.Enum.BOOL), text: "BOOL" }, + { value: String(feast.types.ValueType.Enum.BYTES), text: "BYTES" }, + { + value: String(feast.types.ValueType.Enum.UNIX_TIMESTAMP), + text: "UNIX_TIMESTAMP", + }, +]; + +interface ValueTypeSelectProps { + value: string; + onChange: (value: string) => void; + label?: string; + helpText?: string; + compressed?: boolean; +} + +const ValueTypeSelect: React.FC = ({ + value, + onChange, + label = "Value Type", + helpText, + compressed = false, +}) => { + return ( + + onChange(e.target.value)} + compressed={compressed} + /> + + ); +}; + +export default ValueTypeSelect; +export { VALUE_TYPE_OPTIONS }; diff --git a/ui/src/pages/Layout.tsx b/ui/src/pages/Layout.tsx index 3eda278bb94..9825159aa96 100644 --- a/ui/src/pages/Layout.tsx +++ b/ui/src/pages/Layout.tsx @@ -266,7 +266,7 @@ const Layout = () => { width: "100%", }} > - + { categories={globalCategories} /> + )} diff --git a/ui/src/pages/data-sources/DataSourceOverviewTab.tsx b/ui/src/pages/data-sources/DataSourceOverviewTab.tsx index e23838e0070..dde04c35de7 100644 --- a/ui/src/pages/data-sources/DataSourceOverviewTab.tsx +++ b/ui/src/pages/data-sources/DataSourceOverviewTab.tsx @@ -4,6 +4,8 @@ import { EuiLoadingSpinner, EuiText, EuiTitle, + EuiButtonEmpty, + EuiCallOut, } from "@elastic/eui"; import { EuiPanel, @@ -13,23 +15,114 @@ import { EuiDescriptionListDescription, EuiSpacer, } from "@elastic/eui"; -import React, { useContext } from "react"; +import React, { useState } from "react"; import { useParams } from "react-router-dom"; -import PermissionsDisplay from "../../components/PermissionsDisplay"; -import RegistryPathContext from "../../contexts/RegistryPathContext"; -import { FEAST_FCO_TYPES } from "../../parsers/types"; +import DataSourceFormModal, { + DataSourceFormData, +} from "../../components/DataSourceFormModal"; import { feast } from "../../protos"; -import useLoadRegistry from "../../queries/useLoadRegistry"; -import { getEntityPermissions } from "../../utils/permissionUtils"; +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 || "", + }; +}; + +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, + }; + } + + return payload; +}; + const DataSourceOverviewTab = () => { - let { dataSourceName, projectName } = useParams(); - const registryUrl = useContext(RegistryPathContext); - const registryQuery = useLoadRegistry(registryUrl, projectName); + const { dataSourceName, projectName } = useParams(); const dsName = dataSourceName === undefined ? "" : dataSourceName; const { isLoading, isSuccess, isError, data, consumingFeatureViews } = @@ -45,6 +138,34 @@ 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) => { + const message = + err instanceof Error ? err.message : "An unexpected error occurred."; + setErrorMessage(message); + setTimeout(() => setErrorMessage(null), 8000); + }, + }); + }; + + const spec = data?.spec || data; + const sourceType = spec?.type; + return ( {isLoading && ( @@ -56,6 +177,39 @@ const DataSourceOverviewTab = () => { {isError &&

Error loading data source: {dataSourceName}

} {isSuccess && data && ( + {successMessage && ( + <> + + + + )} + {errorMessage && ( + <> + + + + )} + + + setIsEditModalOpen(true)} + > + Edit Data Source + + + + @@ -65,19 +219,16 @@ const DataSourceOverviewTab = () => {

Properties

- {data.fileOptions || data.bigqueryOptions ? ( - - ) : data.type ? ( + {spec?.fileOptions || spec?.bigqueryOptions ? ( + + ) : sourceType ? ( Source Type - {typeof data.type === "string" - ? data.type - : feast.core.DataSource.SourceType[data.type] || - String(data.type)} + {sourceType} @@ -90,7 +241,7 @@ const DataSourceOverviewTab = () => { - {data.requestDataOptions ? ( + {spec?.requestDataOptions ? (

Request Source Schema

@@ -130,30 +281,19 @@ const DataSourceOverviewTab = () => { No consuming views )}
- - - -

Permissions

-
- - {registryQuery.data?.permissions ? ( - - ) : ( - - No permissions defined for this data source. - - )} -
)} + + {isEditModalOpen && data && ( + setIsEditModalOpen(false)} + onSubmit={handleEditSubmit} + initialData={buildEditFormData(data)} + isEdit + /> + )}
); }; diff --git a/ui/src/pages/data-sources/Index.tsx b/ui/src/pages/data-sources/Index.tsx index 84309775e0b..51a5f33f174 100644 --- a/ui/src/pages/data-sources/Index.tsx +++ b/ui/src/pages/data-sources/Index.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useState } from "react"; import { useParams } from "react-router-dom"; import { @@ -9,6 +9,8 @@ import { EuiTitle, EuiFieldSearch, EuiSpacer, + EuiButton, + EuiCallOut, } from "@elastic/eui"; import DatasourcesListingTable from "./DataSourcesListingTable"; @@ -18,6 +20,10 @@ import { DataSourceIcon } from "../../graphics/DataSourceIcon"; import { useSearchQuery } from "../../hooks/useSearchInputWithTags"; import { feast } from "../../protos"; import ExportButton from "../../components/ExportButton"; +import DataSourceFormModal, { + DataSourceFormData, +} from "../../components/DataSourceFormModal"; +import { useApplyDataSource } from "../../queries/mutations/useDataSourceMutations"; import useResourceQuery, { dataSourceListPath, } from "../../queries/useResourceQuery"; @@ -32,24 +38,77 @@ const useLoadDatasources = () => { }); }; -const filterFn = (data: feast.core.IDataSource[], searchTokens: string[]) => { - let filteredByTags = data; - +const filterFn = (data: any[], searchTokens: string[]) => { if (searchTokens.length) { - return filteredByTags.filter((entry) => { + return data.filter((entry) => { + const name = entry.name || entry.spec?.name || ""; return searchTokens.find((token) => { - return ( - token.length >= 3 && entry.name && entry.name.indexOf(token) >= 0 - ); + return token.length >= 3 && name.indexOf(token) >= 0; }); }); } - return filteredByTags; + return data; +}; + +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, + }; + } + + return payload; }; const Index = () => { + const { projectName } = useParams(); const { isLoading, isSuccess, isError, data } = useLoadDatasources(); + const isAllProjects = projectName === "all"; + + const [isModalOpen, setIsModalOpen] = useState(false); + const [successMessage, setSuccessMessage] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + const applyDataSource = useApplyDataSource(); useDocumentTitle(`Data Sources | Feast`); @@ -57,6 +116,26 @@ const Index = () => { const filterResult = data ? filterFn(data, searchTokens) : data; + const handleCreateSubmit = (formData: DataSourceFormData) => { + const payload = formDataToPayload(formData, projectName || ""); + applyDataSource.mutate(payload as any, { + onSuccess: () => { + setIsModalOpen(false); + setErrorMessage(null); + setSuccessMessage( + `Data source "${formData.name}" created successfully.`, + ); + setTimeout(() => setSuccessMessage(null), 5000); + }, + onError: (err: unknown) => { + const message = + err instanceof Error ? err.message : "An unexpected error occurred."; + setErrorMessage(message); + setTimeout(() => setErrorMessage(null), 8000); + }, + }); + }; + return ( { iconType={DataSourceIcon} pageTitle="Data Sources" rightSideItems={[ + ...(isAllProjects + ? [] + : [ + setIsModalOpen(true)} + key="create" + > + Create Data Source + , + ]), , ]} /> + {successMessage && ( + <> + + + + )} + {errorMessage && ( + <> + + + + )} {isLoading && (

Loading @@ -100,6 +214,13 @@ const Index = () => { )} + + {isModalOpen && ( + setIsModalOpen(false)} + onSubmit={handleCreateSubmit} + /> + )} ); }; diff --git a/ui/src/pages/entities/EntityOverviewTab.tsx b/ui/src/pages/entities/EntityOverviewTab.tsx index c590eeb3b8e..d3baef90500 100644 --- a/ui/src/pages/entities/EntityOverviewTab.tsx +++ b/ui/src/pages/entities/EntityOverviewTab.tsx @@ -3,6 +3,8 @@ import { EuiHorizontalRule, EuiLoadingSpinner, EuiTitle, + EuiButtonEmpty, + EuiCallOut, } from "@elastic/eui"; import { EuiPanel, @@ -13,9 +15,12 @@ import { EuiDescriptionListTitle, EuiDescriptionListDescription, } from "@elastic/eui"; -import React, { useContext } from "react"; +import React, { useContext, useState } 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"; @@ -23,9 +28,30 @@ 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(); @@ -49,6 +75,39 @@ 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) => { + const message = + err instanceof Error ? err.message : "An unexpected error occurred."; + setErrorMessage(message); + setTimeout(() => setErrorMessage(null), 8000); + }, + }); + }; + return ( {isLoading && ( @@ -60,6 +119,39 @@ const EntityOverviewTab = () => { {isError &&

Error loading entity: {entityName}

} {isSuccess && data && ( + {successMessage && ( + <> + + + + )} + {errorMessage && ( + <> + + + + )} + + + setIsEditModalOpen(true)} + > + Edit Entity + + + + @@ -180,6 +272,15 @@ const EntityOverviewTab = () => { )} + + {isEditModalOpen && data && ( + setIsEditModalOpen(false)} + onSubmit={handleEditSubmit} + initialData={buildEditFormData(data)} + isEdit + /> + )} ); }; diff --git a/ui/src/pages/entities/Index.tsx b/ui/src/pages/entities/Index.tsx index 216e713f382..32d812e1539 100644 --- a/ui/src/pages/entities/Index.tsx +++ b/ui/src/pages/entities/Index.tsx @@ -1,7 +1,13 @@ -import React from "react"; +import React, { useState } from "react"; import { useParams } from "react-router-dom"; -import { EuiPageTemplate, EuiLoadingSpinner } from "@elastic/eui"; +import { + EuiPageTemplate, + EuiLoadingSpinner, + EuiButton, + EuiCallOut, + EuiSpacer, +} from "@elastic/eui"; import { EntityIcon } from "../../graphics/EntityIcon"; @@ -9,6 +15,10 @@ import EntitiesListingTable from "./EntitiesListingTable"; import { useDocumentTitle } from "../../hooks/useDocumentTitle"; import EntityIndexEmptyState from "./EntityIndexEmptyState"; import ExportButton from "../../components/ExportButton"; +import EntityFormModal, { + EntityFormData, +} from "../../components/EntityFormModal"; +import { useApplyEntity } from "../../queries/mutations/useEntityMutations"; import useResourceQuery, { entityListPath, } from "../../queries/useResourceQuery"; @@ -23,11 +33,50 @@ const useLoadEntities = () => { }); }; +const formDataToPayload = (formData: EntityFormData, project: string) => ({ + name: formData.name, + project, + 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: "", +}); + const Index = () => { + const { projectName } = useParams(); const { isLoading, isSuccess, isError, data } = useLoadEntities(); + const isAllProjects = projectName === "all"; + + const [isModalOpen, setIsModalOpen] = useState(false); + const [successMessage, setSuccessMessage] = useState(null); + const [submitErrorMessage, setSubmitErrorMessage] = useState( + null, + ); + const applyEntity = useApplyEntity(); useDocumentTitle(`Entities | Feast`); + const handleCreateSubmit = (formData: EntityFormData) => { + setSubmitErrorMessage(null); + const payload = formDataToPayload(formData, projectName || ""); + applyEntity.mutate(payload, { + onSuccess: () => { + setIsModalOpen(false); + setSubmitErrorMessage(null); + setSuccessMessage(`Entity "${formData.name}" created successfully.`); + setTimeout(() => setSuccessMessage(null), 5000); + }, + onError: (err: unknown) => { + const message = + err instanceof Error ? err.message : "An unexpected error occurred."; + setSubmitErrorMessage(message); + }, + }); + }; + return ( { iconType={EntityIcon} pageTitle="Entities" rightSideItems={[ + ...(isAllProjects + ? [] + : [ + setIsModalOpen(true)} + key="create" + > + Create Entity + , + ]), , ]} /> + {successMessage && ( + <> + + + + )} {isLoading && (

Loading @@ -52,6 +125,18 @@ const Index = () => { {isSuccess && !data && } {isSuccess && data && } + + {isModalOpen && ( + { + setIsModalOpen(false); + setSubmitErrorMessage(null); + }} + onSubmit={handleCreateSubmit} + isSubmitting={applyEntity.isLoading} + submitError={submitErrorMessage} + /> + )} ); }; diff --git a/ui/src/pages/feature-views/Index.tsx b/ui/src/pages/feature-views/Index.tsx index 849d1899a3e..e72b7d5f5e6 100644 --- a/ui/src/pages/feature-views/Index.tsx +++ b/ui/src/pages/feature-views/Index.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useState } from "react"; import { useParams } from "react-router-dom"; import { @@ -9,6 +9,8 @@ import { EuiFieldSearch, EuiFlexGroup, EuiFlexItem, + EuiButton, + EuiCallOut, } from "@elastic/eui"; import { FeatureViewIcon } from "../../graphics/FeatureViewIcon"; @@ -25,9 +27,15 @@ import FeatureViewIndexEmptyState from "./FeatureViewIndexEmptyState"; import { useFeatureViewTagsAggregation } from "../../hooks/useTagsAggregation"; import TagSearch from "../../components/TagSearch"; import ExportButton from "../../components/ExportButton"; +import FeatureViewFormModal, { + FeatureViewFormData, +} from "../../components/FeatureViewFormModal"; +import { useApplyFeatureView } from "../../queries/mutations/useFeatureViewMutations"; import useResourceQuery, { featureViewListPath, restFeatureViewsToMergedList, + entityListPath, + dataSourceListPath, } from "../../queries/useResourceQuery"; const useLoadFeatureViews = () => { @@ -45,13 +53,12 @@ const shouldIncludeFVsGivenTokenGroups = ( tagTokenGroups: Record, ) => { return Object.entries(tagTokenGroups).every(([key, values]) => { - const entryTagValue = entry?.object?.spec!.tags - ? entry.object.spec.tags[key] - : undefined; + const tags = entry?.object?.spec?.tags; + const entryTagValue = tags ? (tags as any)[key] : undefined; 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; @@ -70,7 +77,7 @@ const filterFn = (data: genericFVType[], filterInput: filterInputInterface) => { filterInput.tagTokenGroups, ); } else { - return false; // ODFVs don't have tags yet + return false; } }); } @@ -86,9 +93,72 @@ const filterFn = (data: genericFVType[], filterInput: filterInputInterface) => { return filteredByTags; }; +const TTL_UNITS: Record = { + days: 86400, + hours: 3600, + minutes: 60, + seconds: 1, +}; + +const formDataToPayload = (formData: FeatureViewFormData, project: string) => ({ + name: formData.name, + project, + 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]), + ), +}); + const Index = () => { + const { projectName } = useParams(); const { isLoading, isSuccess, isError, data } = useLoadFeatureViews(); + const isAllProjects = projectName === "all"; + + const entitiesQuery = useResourceQuery({ + resourceType: "entities-list-fv-prereq", + project: projectName, + restPath: entityListPath(projectName), + restSelect: (d) => d.entities, + }); + const dataSourcesQuery = useResourceQuery({ + resourceType: "data-sources-list-fv-prereq", + project: projectName, + restPath: dataSourceListPath(projectName), + restSelect: (d) => d.dataSources, + }); + const tagAggregationQuery = useFeatureViewTagsAggregation(); + const [isModalOpen, setIsModalOpen] = useState(false); + const [successMessage, setSuccessMessage] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + const [prereqWarning, setPrereqWarning] = useState(null); + const applyFeatureView = useApplyFeatureView(); + + const handleCreateClick = () => { + const missingDeps: string[] = []; + const entities = entitiesQuery.data || []; + const dataSources = dataSourcesQuery.data || []; + + if (entities.length === 0) missingDeps.push("entities"); + if (dataSources.length === 0) missingDeps.push("data sources"); + + if (missingDeps.length > 0) { + setPrereqWarning( + `Feature views require at least one entity and one data source. Missing: ${missingDeps.join(" and ")}. You can still proceed — the form will let you create them inline.`, + ); + } + setIsModalOpen(true); + }; useDocumentTitle(`Feature Views | Feast`); @@ -110,6 +180,27 @@ const Index = () => { ? filterFn(data, { tagTokenGroups, searchTokens }) : data; + const handleCreateSubmit = (formData: FeatureViewFormData) => { + const payload = formDataToPayload(formData, projectName || ""); + applyFeatureView.mutate(payload, { + onSuccess: () => { + setIsModalOpen(false); + setErrorMessage(null); + setPrereqWarning(null); + setSuccessMessage( + `Feature view "${formData.name}" created successfully.`, + ); + setTimeout(() => setSuccessMessage(null), 5000); + }, + onError: (err: unknown) => { + const message = + err instanceof Error ? err.message : "An unexpected error occurred."; + setErrorMessage(message); + setTimeout(() => setErrorMessage(null), 8000); + }, + }); + }; + return ( { iconType={FeatureViewIcon} pageTitle="Feature Views" rightSideItems={[ + ...(isAllProjects + ? [] + : [ + + Create Feature View + , + ]), , ]} /> + {prereqWarning && ( + <> + +

{prereqWarning}

+
+ + + )} + {successMessage && ( + <> + + + + )} + {errorMessage && ( + <> + + + + )} {isLoading && (

Loading @@ -167,6 +306,16 @@ const Index = () => { )} + + {isModalOpen && ( + { + setIsModalOpen(false); + setPrereqWarning(null); + }} + onSubmit={handleCreateSubmit} + /> + )} ); }; diff --git a/ui/src/pages/feature-views/RegularFeatureViewOverviewTab.tsx b/ui/src/pages/feature-views/RegularFeatureViewOverviewTab.tsx index e58e690c04e..9e852d4145d 100644 --- a/ui/src/pages/feature-views/RegularFeatureViewOverviewTab.tsx +++ b/ui/src/pages/feature-views/RegularFeatureViewOverviewTab.tsx @@ -1,5 +1,7 @@ import { EuiBadge, + EuiButtonEmpty, + EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, @@ -10,10 +12,13 @@ import { EuiTitle, EuiToolTip, } from "@elastic/eui"; -import React from "react"; +import React, { useState } 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"; @@ -21,6 +26,7 @@ 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"; @@ -42,6 +48,55 @@ 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, @@ -63,6 +118,55 @@ 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) => { + const message = + err instanceof Error ? err.message : "An unexpected error occurred."; + setErrorMessage(message); + setTimeout(() => setErrorMessage(null), 8000); + }, + }); + }; + const fvUsage = usageData?.feature_usage?.[fvName]; const runCount = fvUsage?.run_count ?? 0; const lastUsed = fvUsage?.last_used ?? null; @@ -71,6 +175,39 @@ const RegularFeatureViewOverviewTab = ({ return ( + {successMessage && ( + <> + + + + )} + {errorMessage && ( + <> + + + + )} + + + setIsEditModalOpen(true)} + > + Edit Feature View + + + + @@ -236,6 +373,15 @@ const RegularFeatureViewOverviewTab = ({ })} + + {isEditModalOpen && data && ( + setIsEditModalOpen(false)} + onSubmit={handleEditSubmit} + initialData={buildEditFormData(data)} + isEdit + /> + )} ); }; diff --git a/ui/src/queries/mutations/useDataSourceMutations.ts b/ui/src/queries/mutations/useDataSourceMutations.ts new file mode 100644 index 00000000000..737ced0dbfd --- /dev/null +++ b/ui/src/queries/mutations/useDataSourceMutations.ts @@ -0,0 +1,99 @@ +import { useMutation, useQueryClient } from "react-query"; + +interface ApplyDataSourcePayload { + name: string; + project: string; + type?: number; + timestamp_field?: string; + created_timestamp_column?: string; + description?: string; + tags?: Record; + owner?: string; + file_options?: { uri: string }; + bigquery_options?: { table: string; query: string }; + snowflake_options?: { table: string; database: string; schema_: string }; + redshift_options?: { table: string; database: string; schema_: string }; + kafka_options?: { kafka_bootstrap_servers: string; topic: string }; + spark_options?: { table: string; path: string }; +} + +interface DeleteDataSourcePayload { + name: string; + project: string; +} + +interface MutationResult { + name: string; + project: string; + status: string; +} + +const API_BASE = "/api/v1"; + +const applyDataSource = async ( + payload: ApplyDataSourcePayload, +): Promise => { + const response = await fetch(`${API_BASE}/data_sources`, { + 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 data source: ${response.status}`, + ); + } + + return response.json(); +}; + +const deleteDataSource = async ( + payload: DeleteDataSourcePayload, +): Promise => { + const response = await fetch( + `${API_BASE}/data_sources/${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 data source: ${response.status}`, + ); + } + + return response.json(); +}; + +const useApplyDataSource = () => { + const queryClient = useQueryClient(); + + return useMutation(applyDataSource, { + onSuccess: () => { + queryClient.invalidateQueries(["rest"]); + queryClient.invalidateQueries(["data-sources-rest"]); + queryClient.invalidateQueries(["data-source-rest"]); + }, + }); +}; + +const useDeleteDataSource = () => { + const queryClient = useQueryClient(); + + return useMutation(deleteDataSource, { + onSuccess: () => { + queryClient.invalidateQueries(["rest"]); + queryClient.invalidateQueries(["data-sources-rest"]); + queryClient.invalidateQueries(["data-source-rest"]); + }, + }); +}; + +export { useApplyDataSource, useDeleteDataSource }; +export type { ApplyDataSourcePayload, DeleteDataSourcePayload }; diff --git a/ui/src/queries/mutations/useEntityMutations.ts b/ui/src/queries/mutations/useEntityMutations.ts new file mode 100644 index 00000000000..659c367558e --- /dev/null +++ b/ui/src/queries/mutations/useEntityMutations.ts @@ -0,0 +1,92 @@ +import { useMutation, useQueryClient } from "react-query"; + +interface ApplyEntityPayload { + name: string; + project: string; + join_key?: string; + value_type?: number; + description?: string; + tags?: Record; + owner?: string; +} + +interface DeleteEntityPayload { + name: string; + project: string; +} + +interface MutationResult { + name: string; + project: string; + status: string; +} + +const API_BASE = "/api/v1"; + +const applyEntity = async ( + payload: ApplyEntityPayload, +): Promise => { + const response = await fetch(`${API_BASE}/entities`, { + 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 entity: ${response.status}`, + ); + } + + return response.json(); +}; + +const deleteEntity = async ( + payload: DeleteEntityPayload, +): Promise => { + const response = await fetch( + `${API_BASE}/entities/${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 entity: ${response.status}`, + ); + } + + return response.json(); +}; + +const useApplyEntity = () => { + const queryClient = useQueryClient(); + + return useMutation(applyEntity, { + onSuccess: () => { + queryClient.invalidateQueries(["rest"]); + queryClient.invalidateQueries(["entities-rest"]); + queryClient.invalidateQueries(["entity-rest"]); + }, + }); +}; + +const useDeleteEntity = () => { + const queryClient = useQueryClient(); + + return useMutation(deleteEntity, { + onSuccess: () => { + queryClient.invalidateQueries(["rest"]); + queryClient.invalidateQueries(["entities-rest"]); + queryClient.invalidateQueries(["entity-rest"]); + }, + }); +}; + +export { useApplyEntity, useDeleteEntity }; +export type { ApplyEntityPayload, DeleteEntityPayload }; diff --git a/ui/src/queries/mutations/useFeatureViewMutations.ts b/ui/src/queries/mutations/useFeatureViewMutations.ts new file mode 100644 index 00000000000..6ca31208093 --- /dev/null +++ b/ui/src/queries/mutations/useFeatureViewMutations.ts @@ -0,0 +1,101 @@ +import { useMutation, useQueryClient } from "react-query"; + +interface FeaturePayload { + name: string; + value_type: number; + description?: string; +} + +interface ApplyFeatureViewPayload { + name: string; + project: string; + entities?: string[]; + features?: FeaturePayload[]; + batch_source?: string; + ttl_seconds?: number; + online?: boolean; + description?: string; + tags?: Record; + owner?: string; +} + +interface DeleteFeatureViewPayload { + name: string; + project: string; +} + +interface MutationResult { + name: string; + project: string; + status: string; +} + +const API_BASE = "/api/v1"; + +const applyFeatureView = async ( + payload: ApplyFeatureViewPayload, +): Promise => { + const response = await fetch(`${API_BASE}/feature_views`, { + 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 view: ${response.status}`, + ); + } + + return response.json(); +}; + +const deleteFeatureView = async ( + payload: DeleteFeatureViewPayload, +): Promise => { + const response = await fetch( + `${API_BASE}/feature_views/${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 view: ${response.status}`, + ); + } + + return response.json(); +}; + +const useApplyFeatureView = () => { + const queryClient = useQueryClient(); + + return useMutation(applyFeatureView, { + onSuccess: () => { + queryClient.invalidateQueries(["rest"]); + queryClient.invalidateQueries(["feature-views-rest"]); + queryClient.invalidateQueries(["feature-view-rest"]); + }, + }); +}; + +const useDeleteFeatureView = () => { + const queryClient = useQueryClient(); + + return useMutation(deleteFeatureView, { + onSuccess: () => { + queryClient.invalidateQueries(["rest"]); + queryClient.invalidateQueries(["feature-views-rest"]); + queryClient.invalidateQueries(["feature-view-rest"]); + }, + }); +}; + +export { useApplyFeatureView, useDeleteFeatureView }; +export type { ApplyFeatureViewPayload, DeleteFeatureViewPayload }; diff --git a/ui/src/queries/restApi.ts b/ui/src/queries/restApi.ts new file mode 100644 index 00000000000..f3734962a00 --- /dev/null +++ b/ui/src/queries/restApi.ts @@ -0,0 +1,19 @@ +const API_BASE = "/api/v1"; + +export async function fetchApi( + path: string, + params?: Record, +): Promise { + const url = new URL(`${API_BASE}${path}`, window.location.origin); + if (params) { + Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v)); + } + const res = await fetch(url.toString(), { + headers: { "Content-Type": "application/json" }, + }); + if (!res.ok) { + const body = await res.json().catch(() => ({ detail: res.statusText })); + throw new Error(body.detail || `API error: ${res.status}`); + } + return res.json(); +} diff --git a/ui/src/queries/useLoadDataSourcesREST.ts b/ui/src/queries/useLoadDataSourcesREST.ts new file mode 100644 index 00000000000..5d3890cc503 --- /dev/null +++ b/ui/src/queries/useLoadDataSourcesREST.ts @@ -0,0 +1,41 @@ +import { useQuery } from "react-query"; +import { fetchApi } from "./restApi"; + +interface DataSourceListResponse { + dataSources: any[]; + pagination: Record; + relationships?: Record; +} + +const useLoadDataSourcesREST = (project: string) => { + return useQuery( + ["data-sources-rest", project], + () => + fetchApi("/data_sources", { + project, + allow_cache: "false", + }), + { + enabled: !!project, + staleTime: 30000, + }, + ); +}; + +const useLoadDataSourceREST = (name: string, project: string) => { + return useQuery( + ["data-source-rest", name, project], + () => + fetchApi(`/data_sources/${encodeURIComponent(name)}`, { + project, + include_relationships: "true", + allow_cache: "false", + }), + { + enabled: !!name && !!project, + staleTime: 30000, + }, + ); +}; + +export { useLoadDataSourcesREST, useLoadDataSourceREST }; diff --git a/ui/src/queries/useLoadEntitiesREST.ts b/ui/src/queries/useLoadEntitiesREST.ts new file mode 100644 index 00000000000..7127de656ee --- /dev/null +++ b/ui/src/queries/useLoadEntitiesREST.ts @@ -0,0 +1,41 @@ +import { useQuery } from "react-query"; +import { fetchApi } from "./restApi"; + +interface EntityListResponse { + entities: any[]; + pagination: Record; + relationships?: Record; +} + +const useLoadEntitiesREST = (project: string) => { + return useQuery( + ["entities-rest", project], + () => + fetchApi("/entities", { + project, + allow_cache: "false", + }), + { + enabled: !!project, + staleTime: 30000, + }, + ); +}; + +const useLoadEntityREST = (name: string, project: string) => { + return useQuery( + ["entity-rest", name, project], + () => + fetchApi(`/entities/${encodeURIComponent(name)}`, { + project, + include_relationships: "true", + allow_cache: "false", + }), + { + enabled: !!name && !!project, + staleTime: 30000, + }, + ); +}; + +export { useLoadEntitiesREST, useLoadEntityREST }; diff --git a/ui/src/queries/useLoadFeatureViewsREST.ts b/ui/src/queries/useLoadFeatureViewsREST.ts new file mode 100644 index 00000000000..0b67b960e11 --- /dev/null +++ b/ui/src/queries/useLoadFeatureViewsREST.ts @@ -0,0 +1,41 @@ +import { useQuery } from "react-query"; +import { fetchApi } from "./restApi"; + +interface FeatureViewListResponse { + featureViews: any[]; + pagination: Record; + relationships?: Record; +} + +const useLoadFeatureViewsREST = (project: string) => { + return useQuery( + ["feature-views-rest", project], + () => + fetchApi("/feature_views", { + project, + allow_cache: "false", + }), + { + enabled: !!project, + staleTime: 30000, + }, + ); +}; + +const useLoadFeatureViewREST = (name: string, project: string) => { + return useQuery( + ["feature-view-rest", name, project], + () => + fetchApi(`/feature_views/${encodeURIComponent(name)}`, { + project, + include_relationships: "true", + allow_cache: "false", + }), + { + enabled: !!name && !!project, + staleTime: 30000, + }, + ); +}; + +export { useLoadFeatureViewsREST, useLoadFeatureViewREST }; diff --git a/ui/src/setupProxy.js b/ui/src/setupProxy.js new file mode 100644 index 00000000000..94762f63557 --- /dev/null +++ b/ui/src/setupProxy.js @@ -0,0 +1,332 @@ +const fs = require("fs"); +const path = require("path"); +const express = require("express"); +const { feast } = require("./protos"); + +const registryBuf = fs.readFileSync( + path.resolve(__dirname, "../public/registry.db"), +); +const parsedRegistry = feast.core.Registry.decode(registryBuf); +const projectsList = JSON.parse( + fs.readFileSync(path.resolve(__dirname, "../public/projects-list.json")), +); + +const toJSON = (obj) => (obj && obj.toJSON ? obj.toJSON() : obj); + +const withType = (type) => (fv) => ({ + ...toJSON(fv), + type, +}); + +const state = { + entities: (parsedRegistry.entities || []).map(toJSON), + featureViews: (parsedRegistry.featureViews || []).map( + withType("featureView"), + ), + onDemandFeatureViews: (parsedRegistry.onDemandFeatureViews || []).map( + withType("onDemandFeatureView"), + ), + streamFeatureViews: (parsedRegistry.streamFeatureViews || []).map( + withType("streamFeatureView"), + ), + featureServices: (parsedRegistry.featureServices || []).map(toJSON), + dataSources: (parsedRegistry.dataSources || []).map(toJSON), + savedDatasets: (parsedRegistry.savedDatasets || []).map(toJSON), + projects: (parsedRegistry.projects || []).map(toJSON), +}; + +const allFeatureViews = () => [ + ...state.featureViews, + ...state.onDemandFeatureViews, + ...state.streamFeatureViews, +]; + +const objectProject = (obj) => obj?.spec?.project || obj?.project; + +const filterByProject = (items, project) => { + if (!project || project === "all") return items; + return items.filter((item) => objectProject(item) === project); +}; + +const allFeatures = (project) => + filterByProject(allFeatureViews(), project).flatMap((fv) => + (fv?.spec?.features || []).map((feature) => ({ + name: feature.name, + featureViewName: fv.spec?.name, + valueType: feature.valueType, + project: fv.spec?.project, + })), + ); + +const responseList = (res, key, items) => { + res.json({ + [key]: items, + pagination: {}, + relationships: {}, + }); +}; + +const findByName = (items, name) => + items.find((item) => item?.spec?.name === name || item?.name === name); + +const entityPayloadToResource = (payload) => ({ + spec: { + name: payload.name, + joinKey: payload.join_key || payload.name, + valueType: payload.value_type, + description: payload.description || "", + tags: payload.tags || {}, + owner: payload.owner || "", + project: payload.project, + }, + meta: {}, +}); + +const dataSourcePayloadToResource = (payload) => ({ + name: payload.name, + type: payload.type, + timestampField: payload.timestamp_field, + fieldMapping: payload.field_mapping || {}, + description: payload.description || "", + tags: payload.tags || {}, + owner: payload.owner || "", + project: payload.project, + fileOptions: payload.file_options, + bigqueryOptions: payload.bigquery_options, + snowflakeOptions: payload.snowflake_options, + redshiftOptions: payload.redshift_options, + kafkaOptions: payload.kafka_options, + sparkOptions: payload.spark_options, +}); + +const featureViewPayloadToResource = (payload) => ({ + spec: { + name: payload.name, + description: payload.description || "", + owner: payload.owner || "", + entities: payload.entities || [], + features: payload.features || [], + ttl: payload.ttl, + online: payload.online, + tags: payload.tags || {}, + project: payload.project, + batchSource: payload.batch_source + ? { name: payload.batch_source } + : undefined, + }, + meta: {}, + type: "featureView", +}); + +module.exports = function setupProxy(app) { + app.use("/api/v1", express.json()); + + app.get("/projects-list.json", (_req, res) => { + res.json({ + ...projectsList, + projects: projectsList.projects.map((project) => + project.id === "credit_scoring_aws" + ? { ...project, registryPath: "/api/v1" } + : project, + ), + }); + }); + + app.get("/api/v1/entities/all", (_req, res) => + responseList(res, "entities", state.entities), + ); + app.get("/api/v1/feature_views/all", (_req, res) => + responseList(res, "featureViews", allFeatureViews()), + ); + app.get("/api/v1/feature_services/all", (_req, res) => + responseList(res, "featureServices", state.featureServices), + ); + app.get("/api/v1/data_sources/all", (_req, res) => + responseList(res, "dataSources", state.dataSources), + ); + app.get("/api/v1/saved_datasets/all", (_req, res) => + responseList(res, "savedDatasets", state.savedDatasets), + ); + app.get("/api/v1/features/all", (_req, res) => + responseList(res, "features", allFeatures()), + ); + app.get("/api/v1/label_views/all", (_req, res) => + responseList(res, "featureViews", []), + ); + + app.get("/api/v1/entities", (req, res) => + responseList( + res, + "entities", + filterByProject(state.entities, req.query.project), + ), + ); + app.get("/api/v1/feature_views", (req, res) => + responseList( + res, + "featureViews", + filterByProject(allFeatureViews(), req.query.project), + ), + ); + app.get("/api/v1/feature_services", (req, res) => + responseList( + res, + "featureServices", + filterByProject(state.featureServices, req.query.project), + ), + ); + app.get("/api/v1/data_sources", (req, res) => + responseList( + res, + "dataSources", + filterByProject(state.dataSources, req.query.project), + ), + ); + app.get("/api/v1/saved_datasets", (req, res) => + responseList( + res, + "savedDatasets", + filterByProject(state.savedDatasets, req.query.project), + ), + ); + app.get("/api/v1/features", (req, res) => + responseList(res, "features", allFeatures(req.query.project)), + ); + app.get("/api/v1/label_views", (_req, res) => + responseList(res, "featureViews", []), + ); + app.get("/api/v1/labels", (_req, res) => responseList(res, "labels", [])); + app.get("/api/v1/projects", (_req, res) => + responseList(res, "projects", state.projects), + ); + app.get("/api/v1/permissions", (_req, res) => + responseList(res, "permissions", []), + ); + app.get("/api/v1/metrics/:type", (_req, res) => res.json({})); + + app.get("/api/v1/entities/:name", (req, res) => { + const entity = findByName(state.entities, req.params.name); + if (!entity) return res.status(404).json({ detail: "Not found" }); + return res.json(entity); + }); + app.get("/api/v1/feature_views/:name", (req, res) => { + const featureView = findByName(allFeatureViews(), req.params.name); + if (!featureView) return res.status(404).json({ detail: "Not found" }); + return res.json(featureView); + }); + app.get("/api/v1/feature_services/:name", (req, res) => { + const featureService = findByName(state.featureServices, req.params.name); + if (!featureService) return res.status(404).json({ detail: "Not found" }); + return res.json(featureService); + }); + app.get("/api/v1/data_sources/:name", (req, res) => { + const dataSource = findByName(state.dataSources, req.params.name); + if (!dataSource) return res.status(404).json({ detail: "Not found" }); + return res.json(dataSource); + }); + app.get("/api/v1/saved_datasets/:name", (req, res) => { + const savedDataset = findByName(state.savedDatasets, req.params.name); + if (!savedDataset) return res.status(404).json({ detail: "Not found" }); + return res.json(savedDataset); + }); + app.get("/api/v1/features/:fvName/:featureName", (req, res) => { + const featureView = findByName(allFeatureViews(), req.params.fvName); + const feature = featureView?.spec?.features?.find( + (f) => f.name === req.params.featureName, + ); + if (!feature) return res.status(404).json({ detail: "Not found" }); + return res.json({ + featureViewName: req.params.fvName, + featureName: req.params.featureName, + feature, + featureView, + }); + }); + + app.post("/api/v1/entities", (req, res) => { + const body = req.body || {}; + const existingIndex = state.entities.findIndex( + (entity) => entity?.spec?.name === body.name, + ); + const entity = entityPayloadToResource(body); + if (existingIndex >= 0) { + state.entities[existingIndex] = entity; + } else { + state.entities.push(entity); + } + res.json({ + name: body.name, + project: body.project, + status: "applied", + }); + }); + + app.post("/api/v1/data_sources", (req, res) => { + const body = req.body || {}; + const existingIndex = state.dataSources.findIndex( + (dataSource) => dataSource?.name === body.name, + ); + const dataSource = dataSourcePayloadToResource(body); + if (existingIndex >= 0) { + state.dataSources[existingIndex] = dataSource; + } else { + state.dataSources.push(dataSource); + } + res.json({ + name: body.name, + project: body.project, + status: "applied", + }); + }); + + app.post("/api/v1/feature_views", (req, res) => { + const body = req.body || {}; + const existingIndex = state.featureViews.findIndex( + (featureView) => featureView?.spec?.name === body.name, + ); + const featureView = featureViewPayloadToResource(body); + if (existingIndex >= 0) { + state.featureViews[existingIndex] = featureView; + } else { + state.featureViews.push(featureView); + } + res.json({ + name: body.name, + project: body.project, + status: "applied", + }); + }); + + app.delete("/api/v1/entities/:name", (req, res) => { + state.entities = state.entities.filter( + (entity) => entity?.spec?.name !== req.params.name, + ); + res.json({ + name: req.params.name, + project: req.query.project, + status: "deleted", + }); + }); + + app.delete("/api/v1/data_sources/:name", (req, res) => { + state.dataSources = state.dataSources.filter( + (dataSource) => dataSource?.name !== req.params.name, + ); + res.json({ + name: req.params.name, + project: req.query.project, + status: "deleted", + }); + }); + + app.delete("/api/v1/feature_views/:name", (req, res) => { + state.featureViews = state.featureViews.filter( + (featureView) => featureView?.spec?.name !== req.params.name, + ); + res.json({ + name: req.params.name, + project: req.query.project, + status: "deleted", + }); + }); +};