diff --git a/ui/src/components/RegistryVisualization.test.tsx b/ui/src/components/RegistryVisualization.test.tsx new file mode 100644 index 00000000000..01da8f415a3 --- /dev/null +++ b/ui/src/components/RegistryVisualization.test.tsx @@ -0,0 +1,372 @@ +import React from "react"; +import { render, screen, within } from "../test-utils"; +import { waitFor } from "@testing-library/react"; +import RegistryVisualization from "./RegistryVisualization"; +import { feast } from "../protos"; +import { FEAST_FCO_TYPES } from "../parsers/types"; +import { EntityRelation } from "../parsers/parseEntityRelationships"; +import { ThemeProvider } from "../contexts/ThemeContext"; + +// ReactFlow requires ResizeObserver +class ResizeObserverMock { + observe() {} + unobserve() {} + disconnect() {} +} +(global as any).ResizeObserver = ResizeObserverMock; + +// Helper to build minimal registry data +const makeRegistry = ( + overrides: Partial = {}, +): feast.core.Registry => { + return feast.core.Registry.create({ + featureViews: [], + onDemandFeatureViews: [], + streamFeatureViews: [], + featureServices: [], + entities: [], + dataSources: [], + ...overrides, + }); +}; + +const makeRelationship = ( + sourceName: string, + sourceType: FEAST_FCO_TYPES, + targetName: string, + targetType: FEAST_FCO_TYPES, +): EntityRelation => ({ + source: { name: sourceName, type: sourceType }, + target: { name: targetName, type: targetType }, +}); + +const renderVisualization = ( + registryData: feast.core.Registry, + relationships: EntityRelation[] = [], + indirectRelationships: EntityRelation[] = [], +) => { + return render( + + + , + ); +}; + +describe("RegistryVisualization version indicators", () => { + test("renders version badge on feature view with currentVersionNumber > 1", async () => { + const registry = makeRegistry({ + featureViews: [ + feast.core.FeatureView.create({ + spec: { name: "my_feature_view" }, + meta: { currentVersionNumber: 3 }, + }), + ], + entities: [ + feast.core.Entity.create({ + spec: { name: "my_entity" }, + }), + ], + }); + + const relationships = [ + makeRelationship( + "my_feature_view", + FEAST_FCO_TYPES.featureView, + "my_entity", + FEAST_FCO_TYPES.entity, + ), + ]; + + renderVisualization(registry, relationships); + + await waitFor(() => { + expect(screen.getByText("v3")).toBeInTheDocument(); + }); + }); + + test("does not render version badge when currentVersionNumber is 0", async () => { + const registry = makeRegistry({ + featureViews: [ + feast.core.FeatureView.create({ + spec: { name: "unversioned_fv" }, + meta: { currentVersionNumber: 0 }, + }), + ], + entities: [ + feast.core.Entity.create({ + spec: { name: "my_entity" }, + }), + ], + }); + + const relationships = [ + makeRelationship( + "unversioned_fv", + FEAST_FCO_TYPES.featureView, + "my_entity", + FEAST_FCO_TYPES.entity, + ), + ]; + + renderVisualization(registry, relationships); + + await waitFor(() => { + expect(screen.getByText("unversioned_fv")).toBeInTheDocument(); + }); + + expect(screen.queryByText("v0")).not.toBeInTheDocument(); + }); + + test("does not render version badge when currentVersionNumber is 1 (initial version)", async () => { + const registry = makeRegistry({ + featureViews: [ + feast.core.FeatureView.create({ + spec: { name: "initial_fv" }, + meta: { currentVersionNumber: 1 }, + }), + ], + entities: [ + feast.core.Entity.create({ + spec: { name: "my_entity" }, + }), + ], + }); + + const relationships = [ + makeRelationship( + "initial_fv", + FEAST_FCO_TYPES.featureView, + "my_entity", + FEAST_FCO_TYPES.entity, + ), + ]; + + renderVisualization(registry, relationships); + + await waitFor(() => { + expect(screen.getByText("initial_fv")).toBeInTheDocument(); + }); + + expect(screen.queryByText("v1")).not.toBeInTheDocument(); + }); + + test("does not render version badge when meta has no version", async () => { + const registry = makeRegistry({ + featureViews: [ + feast.core.FeatureView.create({ + spec: { name: "no_version_fv" }, + meta: {}, + }), + ], + entities: [ + feast.core.Entity.create({ + spec: { name: "my_entity" }, + }), + ], + }); + + const relationships = [ + makeRelationship( + "no_version_fv", + FEAST_FCO_TYPES.featureView, + "my_entity", + FEAST_FCO_TYPES.entity, + ), + ]; + + renderVisualization(registry, relationships); + + await waitFor(() => { + expect(screen.getByText("no_version_fv")).toBeInTheDocument(); + }); + + // No version badges should exist at all + const badges = screen.queryAllByText(/^v\d+$/); + expect(badges).toHaveLength(0); + }); + + test("renders version badge on on-demand feature view", async () => { + const registry = makeRegistry({ + featureViews: [ + feast.core.FeatureView.create({ + spec: { name: "regular_fv" }, + }), + ], + onDemandFeatureViews: [ + feast.core.OnDemandFeatureView.create({ + spec: { name: "odfv_versioned" }, + meta: { currentVersionNumber: 2 }, + }), + ], + entities: [ + feast.core.Entity.create({ + spec: { name: "my_entity" }, + }), + ], + }); + + // Connect regular FV to entity so nodes render, and ODFV to regular FV + const relationships = [ + makeRelationship( + "regular_fv", + FEAST_FCO_TYPES.featureView, + "my_entity", + FEAST_FCO_TYPES.entity, + ), + ]; + + // Use the full component and toggle isolated nodes to show ODFV + render( + + + , + ); + + // Enable isolated nodes to show the ODFV + const isolatedCheckbox = screen.getByLabelText( + /Show Objects Without Relationships/i, + ); + isolatedCheckbox.click(); + + await waitFor(() => { + expect(screen.getByText("v2")).toBeInTheDocument(); + }); + }); + + test("renders version badge on stream feature view", async () => { + const registry = makeRegistry({ + featureViews: [ + feast.core.FeatureView.create({ + spec: { name: "regular_fv" }, + }), + ], + streamFeatureViews: [ + feast.core.StreamFeatureView.create({ + spec: { name: "sfv_versioned" }, + meta: { currentVersionNumber: 5 }, + }), + ], + entities: [ + feast.core.Entity.create({ + spec: { name: "my_entity" }, + }), + ], + }); + + const relationships = [ + makeRelationship( + "regular_fv", + FEAST_FCO_TYPES.featureView, + "my_entity", + FEAST_FCO_TYPES.entity, + ), + ]; + + render( + + + , + ); + + // Enable isolated nodes to show the SFV + const isolatedCheckbox = screen.getByLabelText( + /Show Objects Without Relationships/i, + ); + isolatedCheckbox.click(); + + await waitFor(() => { + expect(screen.getByText("v5")).toBeInTheDocument(); + }); + }); + + test("renders version history info from featureViewVersionHistory", async () => { + const registry = makeRegistry({ + featureViews: [ + feast.core.FeatureView.create({ + spec: { name: "versioned_fv" }, + meta: { currentVersionNumber: 2 }, + }), + ], + entities: [ + feast.core.Entity.create({ + spec: { name: "my_entity" }, + }), + ], + featureViewVersionHistory: feast.core.FeatureViewVersionHistory.create({ + records: [ + feast.core.FeatureViewVersionRecord.create({ + featureViewName: "versioned_fv", + versionNumber: 1, + description: "Initial version", + }), + feast.core.FeatureViewVersionRecord.create({ + featureViewName: "versioned_fv", + versionNumber: 2, + description: "Added new features", + }), + ], + }), + }); + + const relationships = [ + makeRelationship( + "versioned_fv", + FEAST_FCO_TYPES.featureView, + "my_entity", + FEAST_FCO_TYPES.entity, + ), + ]; + + renderVisualization(registry, relationships); + + await waitFor(() => { + expect(screen.getByText("v2")).toBeInTheDocument(); + }); + }); +}); + +describe("RegistryVisualization legend", () => { + test("renders Version Changed entry in legend", async () => { + const registry = makeRegistry({ + featureViews: [ + feast.core.FeatureView.create({ + spec: { name: "some_fv" }, + }), + ], + entities: [ + feast.core.Entity.create({ + spec: { name: "some_entity" }, + }), + ], + }); + + const relationships = [ + makeRelationship( + "some_fv", + FEAST_FCO_TYPES.featureView, + "some_entity", + FEAST_FCO_TYPES.entity, + ), + ]; + + renderVisualization(registry, relationships); + + await waitFor(() => { + expect(screen.getByText("Version Changed")).toBeInTheDocument(); + }); + + expect(screen.getByText("vN")).toBeInTheDocument(); + }); +}); diff --git a/ui/src/components/RegistryVisualization.tsx b/ui/src/components/RegistryVisualization.tsx index 64dc9f5f9b2..d3479078618 100644 --- a/ui/src/components/RegistryVisualization.tsx +++ b/ui/src/components/RegistryVisualization.tsx @@ -21,6 +21,7 @@ import { EuiSpacer, EuiLoadingSpinner, EuiToolTip, + EuiBadge, } from "@elastic/eui"; import { FEAST_FCO_TYPES } from "../parsers/types"; import { EntityRelation } from "../parsers/parseEntityRelationships"; @@ -64,6 +65,8 @@ interface NodeData { type: FEAST_FCO_TYPES; metadata: any; permissions?: any[]; // Add permissions field + versionNumber?: number; + versionInfo?: { totalVersions: number; latestDescription?: string }; } const getNodeColor = (type: FEAST_FCO_TYPES) => { @@ -119,6 +122,7 @@ const CustomNode = ({ data }: { data: NodeData }) => { const icon = getNodeIcon(data.type); const [isHovered, setIsHovered] = useState(false); const hasPermissions = data.permissions && data.permissions.length > 0; + const hasVersion = data.versionNumber != null && data.versionNumber > 1; const handleClick = () => { let path; @@ -207,6 +211,40 @@ const CustomNode = ({ data }: { data: NodeData }) => { )} + {/* Version indicator */} + {hasVersion && ( + +
Version: {data.versionNumber}
+ {data.versionInfo && ( + <> +
Total versions: {data.versionInfo.totalVersions}
+ {data.versionInfo.latestDescription && ( +
Latest: {data.versionInfo.latestDescription}
+ )} + + )} +
+ Click node for full history +
+ + } + > +
+ v{data.versionNumber} +
+
+ )} + {
{item.label}
))} +
+ + vN + +
Version Changed
+
); }; @@ -482,10 +534,42 @@ const registryToFlow = ( objects: feast.core.Registry, relationships: EntityRelation[], permissions?: any[], + versionHistory?: feast.core.IFeatureViewVersionRecord[], ) => { const nodes: Node[] = []; const edges: Edge[] = []; + // Build a lookup of version info by feature view name + const versionInfoMap = new Map< + string, + { totalVersions: number; latestDescription?: string } + >(); + if (versionHistory) { + const grouped: Record = {}; + for (let i = 0; i < versionHistory.length; i++) { + const record = versionHistory[i]; + const name = record.featureViewName; + if (!name) continue; + if (!grouped[name]) grouped[name] = []; + grouped[name].push(record); + } + const groupedNames = Object.keys(grouped); + for (let i = 0; i < groupedNames.length; i++) { + const name = groupedNames[i]; + const records = grouped[name]; + records.sort( + ( + a: feast.core.IFeatureViewVersionRecord, + b: feast.core.IFeatureViewVersionRecord, + ) => (b.versionNumber ?? 0) - (a.versionNumber ?? 0), + ); + versionInfoMap.set(name, { + totalVersions: records.length, + latestDescription: records[0]?.description ?? undefined, + }); + } + } + objects.featureServices?.forEach((fs) => { nodes.push({ id: `fs-${fs.spec?.name}`, @@ -507,60 +591,69 @@ const registryToFlow = ( }); objects.featureViews?.forEach((fv) => { + const fvName = fv.spec?.name; nodes.push({ - id: `fv-${fv.spec?.name}`, + id: `fv-${fvName}`, type: "custom", data: { - label: fv.spec?.name, + label: fvName, type: FEAST_FCO_TYPES.featureView, metadata: fv, permissions: permissions ? getEntityPermissions( permissions, FEAST_FCO_TYPES.featureView, - fv.spec?.name, + fvName, ) : [], + versionNumber: fv.meta?.currentVersionNumber ?? undefined, + versionInfo: fvName ? versionInfoMap.get(fvName) : undefined, }, position: { x: 0, y: 0 }, }); }); objects.onDemandFeatureViews?.forEach((odfv) => { + const odfvName = odfv.spec?.name; nodes.push({ - id: `odfv-${odfv.spec?.name}`, + id: `odfv-${odfvName}`, type: "custom", data: { - label: odfv.spec?.name, + label: odfvName, type: FEAST_FCO_TYPES.featureView, metadata: odfv, permissions: permissions ? getEntityPermissions( permissions, FEAST_FCO_TYPES.featureView, - odfv.spec?.name, + odfvName, ) : [], + versionNumber: odfv.meta?.currentVersionNumber ?? undefined, + versionInfo: odfvName ? versionInfoMap.get(odfvName) : undefined, }, position: { x: 0, y: 0 }, }); }); objects.streamFeatureViews?.forEach((sfv) => { + const sfvName = sfv.spec?.name; nodes.push({ - id: `sfv-${sfv.spec?.name}`, + id: `sfv-${sfvName}`, type: "custom", data: { - label: sfv.spec?.name, + label: sfvName, type: FEAST_FCO_TYPES.featureView, metadata: sfv, permissions: permissions ? getEntityPermissions( permissions, FEAST_FCO_TYPES.featureView, - sfv.spec?.name, + sfvName, ) : [], + versionNumber: sfv.meta?.currentVersionNumber ?? undefined, + versionInfo: sfvName ? versionInfoMap.get(sfvName) : undefined, }, position: { x: 0, y: 0 }, }); @@ -750,10 +843,14 @@ const RegistryVisualization: React.FC = ({ return rel.source && rel.target && rel.source.name && rel.target.name; }); + const versionRecords = + registryData.featureViewVersionHistory?.records ?? undefined; + const { nodes: initialNodes, edges: initialEdges } = registryToFlow( registryData, validRelationships, permissions, + versionRecords as feast.core.IFeatureViewVersionRecord[] | undefined, ); const { nodes: layoutedNodes, edges: layoutedEdges } = @@ -775,6 +872,7 @@ const RegistryVisualization: React.FC = ({ showIndirectRelationships, showIsolatedNodes, filterNode, + permissions, setNodes, setEdges, ]);