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..a6f313c4a5d --- /dev/null +++ b/ui/src/components/EntityFormModal.tsx @@ -0,0 +1,239 @@ +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; +} + +const EMPTY_FORM: EntityFormData = { + name: "", + description: "", + joinKeys: [""], + valueType: String(feast.types.ValueType.Enum.STRING), + tags: [], +}; + +const EntityFormModal: 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 = "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 ( + + 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..ba51ea3b3b3 --- /dev/null +++ b/ui/src/components/FeatureViewFormModal.tsx @@ -0,0 +1,426 @@ +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.STREAM_KAFKA)) { + payload.kafka_options = { + kafka_bootstrap_servers: dsData.kafkaBootstrapServers, + topic: dsData.kafkaTopic, + }; + } + + 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..83a9b9befec --- /dev/null +++ b/ui/src/components/forms/FormModal.tsx @@ -0,0 +1,50 @@ +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; +} + +const FormModal: React.FC = ({ + title, + submitLabel, + onClose, + onSubmit, + children, + width = 600, +}) => { + 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 0e3341b8820..e3ac00b2dd8 100644 --- a/ui/src/pages/Layout.tsx +++ b/ui/src/pages/Layout.tsx @@ -246,7 +246,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 8d570f3f26d..f28e631cfee 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,29 +15,148 @@ 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 } = useLoadDataSource(dsName); const isEmpty = data === 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 && ( @@ -47,6 +168,39 @@ const DataSourceOverviewTab = () => { {isError &&

Error loading data source: {dataSourceName}

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

Properties

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

Request Source Schema

@@ -117,30 +271,19 @@ const DataSourceOverviewTab = () => { No consuming feature 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/DataSourcesListingTable.tsx b/ui/src/pages/data-sources/DataSourcesListingTable.tsx index c314a4dfb94..5096f5d0bf9 100644 --- a/ui/src/pages/data-sources/DataSourcesListingTable.tsx +++ b/ui/src/pages/data-sources/DataSourcesListingTable.tsx @@ -32,7 +32,8 @@ const DatasourcesListingTable = ({ name: "Type", field: "type", sortable: true, - render: (valueType: feast.core.DataSource.SourceType) => { + render: (valueType: feast.core.DataSource.SourceType | string) => { + if (typeof valueType === "string") return valueType; return feast.core.DataSource.SourceType[valueType]; }, }, diff --git a/ui/src/pages/data-sources/Index.tsx b/ui/src/pages/data-sources/Index.tsx index 84309775e0b..4bcff46e83a 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,31 +38,105 @@ const useLoadDatasources = () => { }); }; -const filterFn = (data: feast.core.IDataSource[], searchTokens: string[]) => { +const filterFn = (data: any[], searchTokens: string[]) => { let filteredByTags = data; 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 [isModalOpen, setIsModalOpen] = useState(false); + const [successMessage, setSuccessMessage] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + const applyDataSource = useApplyDataSource(); + useDocumentTitle(`Data Sources | Feast`); const { searchString, searchTokens, setSearchString } = useSearchQuery(); 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={[ + setIsModalOpen(true)} + key="create" + > + Create Data Source + , , ]} /> + {successMessage && ( + <> + + + + )} + {errorMessage && ( + <> + + + + )} {isLoading && (

Loading @@ -100,6 +211,13 @@ const Index = () => { )} + + {isModalOpen && ( + setIsModalOpen(false)} + onSubmit={handleCreateSubmit} + /> + )} ); }; diff --git a/ui/src/pages/entities/EntitiesListingTable.tsx b/ui/src/pages/entities/EntitiesListingTable.tsx index d5c28b0ea33..b3f929e4d9e 100644 --- a/ui/src/pages/entities/EntitiesListingTable.tsx +++ b/ui/src/pages/entities/EntitiesListingTable.tsx @@ -33,7 +33,8 @@ const EntitiesListingTable = ({ entities }: EntitiesListingTableProps) => { name: "Type", field: "spec.valueType", sortable: true, - render: (valueType: feast.types.ValueType.Enum) => { + render: (valueType: feast.types.ValueType.Enum | string) => { + if (typeof valueType === "string") return valueType; return feast.types.ValueType.Enum[valueType]; }, }, diff --git a/ui/src/pages/entities/EntityOverviewTab.tsx b/ui/src/pages/entities/EntityOverviewTab.tsx index 8a20688d140..07826f4d79b 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"; @@ -26,6 +31,23 @@ import { toDate } from "../../utils/timestamp"; 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 })) + : []; + + 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(); @@ -40,6 +62,39 @@ const EntityOverviewTab = () => { const fvEdgesSuccess = fvEdges.isSuccess; const fvEdgesData = fvEdges.data; + 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 && ( @@ -51,6 +106,39 @@ const EntityOverviewTab = () => { {isError &&

Error loading entity: {entityName}

} {isSuccess && data && ( + {successMessage && ( + <> + + + + )} + {errorMessage && ( + <> + + + + )} + + + setIsEditModalOpen(true)} + > + Edit Entity + + + + @@ -162,6 +250,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..82811e0ecea 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,47 @@ 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 [isModalOpen, setIsModalOpen] = useState(false); + const [successMessage, setSuccessMessage] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + const applyEntity = useApplyEntity(); + useDocumentTitle(`Entities | Feast`); + const handleCreateSubmit = (formData: EntityFormData) => { + const payload = formDataToPayload(formData, projectName || ""); + applyEntity.mutate(payload, { + onSuccess: () => { + setIsModalOpen(false); + setErrorMessage(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."; + setErrorMessage(message); + setTimeout(() => setErrorMessage(null), 8000); + }, + }); + }; + return ( { iconType={EntityIcon} pageTitle="Entities" rightSideItems={[ + setIsModalOpen(true)} + key="create" + > + Create Entity + , , ]} /> + {successMessage && ( + <> + + + + )} + {errorMessage && ( + <> + + + + )} {isLoading && (

Loading @@ -52,6 +129,13 @@ const Index = () => { {isSuccess && !data && } {isSuccess && data && } + + {isModalOpen && ( + setIsModalOpen(false)} + onSubmit={handleCreateSubmit} + /> + )} ); }; diff --git a/ui/src/pages/feature-views/Index.tsx b/ui/src/pages/feature-views/Index.tsx index 849d1899a3e..fec0bc90dbc 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"; @@ -19,15 +21,25 @@ import { useSearchQuery, useTagsWithSuggestions, } from "../../hooks/useSearchInputWithTags"; -import { genericFVType, regularFVInterface } from "../../parsers/mergedFVTypes"; +import { + FEAST_FV_TYPES, + genericFVType, + regularFVInterface, +} from "../../parsers/mergedFVTypes"; import { useDocumentTitle } from "../../hooks/useDocumentTitle"; 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 +57,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 +81,7 @@ const filterFn = (data: genericFVType[], filterInput: filterInputInterface) => { filterInput.tagTokenGroups, ); } else { - return false; // ODFVs don't have tags yet + return false; } }); } @@ -86,9 +97,70 @@ 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), + })), + 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 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 +182,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={[ + + Create Feature View + , , ]} /> + {prereqWarning && ( + <> + +

{prereqWarning}

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

Loading @@ -167,6 +304,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..b01bd4387a7 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,54 @@ 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), + })), + 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 +174,39 @@ const RegularFeatureViewOverviewTab = ({ return ( + {successMessage && ( + <> + + + + )} + {errorMessage && ( + <> + + + + )} + + + setIsEditModalOpen(true)} + > + Edit Feature View + + + + @@ -236,6 +372,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..019431278c5 --- /dev/null +++ b/ui/src/queries/mutations/useDataSourceMutations.ts @@ -0,0 +1,97 @@ +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(["data-sources-rest"]); + queryClient.invalidateQueries(["data-source-rest"]); + }, + }); +}; + +const useDeleteDataSource = () => { + const queryClient = useQueryClient(); + + return useMutation(deleteDataSource, { + onSuccess: () => { + 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..05498a2f556 --- /dev/null +++ b/ui/src/queries/mutations/useEntityMutations.ts @@ -0,0 +1,90 @@ +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(["entities-rest"]); + queryClient.invalidateQueries(["entity-rest"]); + }, + }); +}; + +const useDeleteEntity = () => { + const queryClient = useQueryClient(); + + return useMutation(deleteEntity, { + onSuccess: () => { + 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..47b9eb8fbef --- /dev/null +++ b/ui/src/queries/mutations/useFeatureViewMutations.ts @@ -0,0 +1,98 @@ +import { useMutation, useQueryClient } from "react-query"; + +interface FeaturePayload { + name: string; + value_type: number; +} + +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(["feature-views-rest"]); + queryClient.invalidateQueries(["feature-view-rest"]); + }, + }); +}; + +const useDeleteFeatureView = () => { + const queryClient = useQueryClient(); + + return useMutation(deleteFeatureView, { + onSuccess: () => { + 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 };