Skip to content

Commit 221e0ed

Browse files
feat(ui): Add version display and Versions tab to feature view pages
Show current version badge in feature view headers and listing table, and add a Versions tab with expandable version history across all feature view types (Regular, Stream, OnDemand). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c9aea43 commit 221e0ed

File tree

7 files changed

+351
-9
lines changed

7 files changed

+351
-9
lines changed

ui/public/projects-list.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
{
44
"name": "Credit Score Project",
55
"description": "Project for credit scoring team and associated models.",
6-
"id": "credit_score_project",
6+
"id": "credit_scoring_aws",
77
"registryPath": "/registry.db"
88
},
99
{

ui/public/registry.db

3.51 KB
Binary file not shown.

ui/src/pages/feature-views/FeatureViewListingTable.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,13 @@ const FeatureViewListingTable = ({
4949
return features.length;
5050
},
5151
},
52+
{
53+
name: "Version",
54+
render: (item: genericFVType) => {
55+
const ver = (item.object as any)?.meta?.currentVersionNumber;
56+
return ver != null && ver > 0 ? `v${ver}` : "—";
57+
},
58+
},
5259
];
5360

5461
// Add Project column when viewing all projects
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
import React, { useContext, useState, useMemo } from "react";
2+
import {
3+
EuiBasicTable,
4+
EuiText,
5+
EuiPanel,
6+
EuiTitle,
7+
EuiHorizontalRule,
8+
EuiCodeBlock,
9+
EuiSpacer,
10+
EuiFlexGroup,
11+
EuiFlexItem,
12+
EuiBadge,
13+
} from "@elastic/eui";
14+
import RegistryPathContext from "../../contexts/RegistryPathContext";
15+
import useLoadRegistry from "../../queries/useLoadRegistry";
16+
import { feast } from "../../protos";
17+
import { toDate } from "../../utils/timestamp";
18+
import { useParams } from "react-router-dom";
19+
20+
interface FeatureViewVersionsTabProps {
21+
featureViewName: string;
22+
}
23+
24+
interface DecodedVersion {
25+
record: feast.core.IFeatureViewVersionRecord;
26+
features: feast.core.IFeatureSpecV2[];
27+
entities: string[];
28+
description: string;
29+
udfBody: string | null;
30+
}
31+
32+
const decodeVersionProto = (
33+
record: feast.core.IFeatureViewVersionRecord,
34+
): DecodedVersion => {
35+
const result: DecodedVersion = {
36+
record,
37+
features: [],
38+
entities: [],
39+
description: "",
40+
udfBody: null,
41+
};
42+
43+
if (!record.featureViewProto || record.featureViewProto.length === 0) {
44+
return result;
45+
}
46+
47+
try {
48+
const bytes =
49+
record.featureViewProto instanceof Uint8Array
50+
? record.featureViewProto
51+
: new Uint8Array(record.featureViewProto);
52+
53+
if (record.featureViewType === "on_demand_feature_view") {
54+
const odfv = feast.core.OnDemandFeatureView.decode(bytes);
55+
result.features = odfv.spec?.features || [];
56+
result.description = odfv.spec?.description || "";
57+
result.udfBody =
58+
odfv.spec?.featureTransformation?.userDefinedFunction?.bodyText ||
59+
odfv.spec?.userDefinedFunction?.bodyText ||
60+
null;
61+
} else if (record.featureViewType === "stream_feature_view") {
62+
const sfv = feast.core.StreamFeatureView.decode(bytes);
63+
result.features = sfv.spec?.features || [];
64+
result.entities = sfv.spec?.entities || [];
65+
result.description = sfv.spec?.description || "";
66+
} else {
67+
const fv = feast.core.FeatureView.decode(bytes);
68+
result.features = fv.spec?.features || [];
69+
result.entities = fv.spec?.entities || [];
70+
result.description = fv.spec?.description || "";
71+
}
72+
} catch (e) {
73+
console.error("Failed to decode version proto:", e);
74+
}
75+
76+
return result;
77+
};
78+
79+
const VersionDetail = ({ decoded }: { decoded: DecodedVersion }) => {
80+
return (
81+
<EuiFlexGroup direction="column" gutterSize="m">
82+
{decoded.description && (
83+
<EuiFlexItem>
84+
<EuiText size="s" color="subdued">
85+
{decoded.description}
86+
</EuiText>
87+
</EuiFlexItem>
88+
)}
89+
{decoded.udfBody && (
90+
<EuiFlexItem>
91+
<EuiPanel hasBorder paddingSize="s">
92+
<EuiTitle size="xxs">
93+
<h4>Transformation</h4>
94+
</EuiTitle>
95+
<EuiHorizontalRule margin="xs" />
96+
<EuiCodeBlock language="py" fontSize="s" paddingSize="s">
97+
{decoded.udfBody}
98+
</EuiCodeBlock>
99+
</EuiPanel>
100+
</EuiFlexItem>
101+
)}
102+
<EuiFlexGroup>
103+
<EuiFlexItem>
104+
<EuiPanel hasBorder paddingSize="s">
105+
<EuiTitle size="xxs">
106+
<h4>Features ({decoded.features.length})</h4>
107+
</EuiTitle>
108+
<EuiHorizontalRule margin="xs" />
109+
{decoded.features.length > 0 ? (
110+
<EuiBasicTable
111+
items={decoded.features}
112+
columns={[
113+
{ field: "name", name: "Name" },
114+
{
115+
field: "valueType",
116+
name: "Value Type",
117+
render: (vt: feast.types.ValueType.Enum) =>
118+
feast.types.ValueType.Enum[vt],
119+
},
120+
]}
121+
/>
122+
) : (
123+
<EuiText size="s">No features in this version.</EuiText>
124+
)}
125+
</EuiPanel>
126+
</EuiFlexItem>
127+
{decoded.entities.length > 0 && (
128+
<EuiFlexItem grow={false}>
129+
<EuiPanel hasBorder paddingSize="s">
130+
<EuiTitle size="xxs">
131+
<h4>Entities</h4>
132+
</EuiTitle>
133+
<EuiHorizontalRule margin="xs" />
134+
<EuiFlexGroup wrap responsive={false} gutterSize="xs">
135+
{decoded.entities.map((entity) => (
136+
<EuiFlexItem grow={false} key={entity}>
137+
<EuiBadge color="primary">{entity}</EuiBadge>
138+
</EuiFlexItem>
139+
))}
140+
</EuiFlexGroup>
141+
</EuiPanel>
142+
</EuiFlexItem>
143+
)}
144+
</EuiFlexGroup>
145+
</EuiFlexGroup>
146+
);
147+
};
148+
149+
const FeatureViewVersionsTab = ({
150+
featureViewName,
151+
}: FeatureViewVersionsTabProps) => {
152+
const registryUrl = useContext(RegistryPathContext);
153+
const { projectName } = useParams();
154+
const registryQuery = useLoadRegistry(registryUrl, projectName);
155+
const [expandedRows, setExpandedRows] = useState<Record<number, boolean>>({});
156+
157+
const records =
158+
registryQuery.data?.objects?.featureViewVersionHistory?.records?.filter(
159+
(r: feast.core.IFeatureViewVersionRecord) =>
160+
r.featureViewName === featureViewName,
161+
) || [];
162+
163+
const decodedVersions = useMemo(
164+
() => records.map(decodeVersionProto),
165+
[records],
166+
);
167+
168+
if (records.length === 0) {
169+
return <EuiText>No version history for this feature view.</EuiText>;
170+
}
171+
172+
const toggleRow = (versionNumber: number) => {
173+
setExpandedRows((prev) => ({
174+
...prev,
175+
[versionNumber]: !prev[versionNumber],
176+
}));
177+
};
178+
179+
const columns = [
180+
{
181+
field: "record.versionNumber",
182+
name: "Version",
183+
render: (_: unknown, item: DecodedVersion) =>
184+
`v${item.record.versionNumber}`,
185+
sortable: true,
186+
width: "80px",
187+
},
188+
{
189+
name: "Features",
190+
render: (item: DecodedVersion) => `${item.features.length}`,
191+
width: "80px",
192+
},
193+
{
194+
field: "record.featureViewType",
195+
name: "Type",
196+
render: (_: unknown, item: DecodedVersion) =>
197+
item.record.featureViewType || "—",
198+
},
199+
{
200+
field: "record.createdTimestamp",
201+
name: "Created",
202+
render: (_: unknown, item: DecodedVersion) =>
203+
item.record.createdTimestamp
204+
? toDate(item.record.createdTimestamp).toLocaleString()
205+
: "—",
206+
},
207+
{
208+
field: "record.versionId",
209+
name: "Version ID",
210+
render: (_: unknown, item: DecodedVersion) =>
211+
item.record.versionId || "—",
212+
},
213+
];
214+
215+
const itemIdToExpandedRowMap: Record<string, React.ReactNode> = {};
216+
decodedVersions.forEach((decoded) => {
217+
const vn = decoded.record.versionNumber!;
218+
if (expandedRows[vn]) {
219+
itemIdToExpandedRowMap[String(vn)] = <VersionDetail decoded={decoded} />;
220+
}
221+
});
222+
223+
return (
224+
<EuiBasicTable
225+
items={decodedVersions}
226+
itemId={(item: DecodedVersion) => String(item.record.versionNumber)}
227+
itemIdToExpandedRowMap={itemIdToExpandedRowMap}
228+
columns={[
229+
{
230+
isExpander: true,
231+
width: "40px",
232+
render: (item: DecodedVersion) => {
233+
const vn = item.record.versionNumber!;
234+
return (
235+
<button
236+
onClick={() => toggleRow(vn)}
237+
aria-label={expandedRows[vn] ? "Collapse" : "Expand"}
238+
style={{
239+
background: "none",
240+
border: "none",
241+
cursor: "pointer",
242+
fontSize: "16px",
243+
}}
244+
>
245+
{expandedRows[vn] ? "▾" : "▸"}
246+
</button>
247+
);
248+
},
249+
},
250+
...columns,
251+
]}
252+
/>
253+
);
254+
};
255+
256+
export default FeatureViewVersionsTab;

ui/src/pages/feature-views/OnDemandFeatureViewInstance.tsx

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import React from "react";
22
import { Route, Routes, useNavigate } from "react-router-dom";
33
import { useParams } from "react-router-dom";
4-
import { EuiPageTemplate } from "@elastic/eui";
4+
import { EuiBadge, EuiPageTemplate } from "@elastic/eui";
55

66
import { FeatureViewIcon } from "../../graphics/FeatureViewIcon";
7-
import { useMatchExact } from "../../hooks/useMatchSubpath";
7+
import { useMatchExact, useMatchSubpath } from "../../hooks/useMatchSubpath";
88
import OnDemandFeatureViewOverviewTab from "./OnDemandFeatureViewOverviewTab";
9+
import FeatureViewVersionsTab from "./FeatureViewVersionsTab";
910

1011
import {
1112
useOnDemandFeatureViewCustomTabs,
@@ -29,7 +30,17 @@ const OnDemandFeatureInstance = ({ data }: OnDemandFeatureInstanceProps) => {
2930
<EuiPageTemplate.Header
3031
restrictWidth
3132
iconType={FeatureViewIcon}
32-
pageTitle={`${featureViewName}`}
33+
pageTitle={
34+
<>
35+
{featureViewName}
36+
{data?.meta?.currentVersionNumber != null &&
37+
data.meta.currentVersionNumber > 0 && (
38+
<EuiBadge color="hollow" style={{ marginLeft: 8 }}>
39+
v{data.meta.currentVersionNumber}
40+
</EuiBadge>
41+
)}
42+
</>
43+
}
3344
tabs={[
3445
{
3546
label: "Overview",
@@ -38,6 +49,13 @@ const OnDemandFeatureInstance = ({ data }: OnDemandFeatureInstanceProps) => {
3849
navigate("");
3950
},
4051
},
52+
{
53+
label: "Versions",
54+
isSelected: useMatchSubpath("versions"),
55+
onClick: () => {
56+
navigate("versions");
57+
},
58+
},
4159
...customNavigationTabs,
4260
]}
4361
/>
@@ -47,6 +65,14 @@ const OnDemandFeatureInstance = ({ data }: OnDemandFeatureInstanceProps) => {
4765
path="/"
4866
element={<OnDemandFeatureViewOverviewTab data={data} />}
4967
/>
68+
<Route
69+
path="/versions"
70+
element={
71+
<FeatureViewVersionsTab
72+
featureViewName={featureViewName!}
73+
/>
74+
}
75+
/>
5076
{CustomTabRoutes}
5177
</Routes>
5278
</EuiPageTemplate.Section>

0 commit comments

Comments
 (0)