Skip to content

Commit 08a8f91

Browse files
committed
added model to features table
Signed-off-by: Vanshika Vanshika <vvanshik@redhat.com> rh-pre-commit.version: 2.3.2 rh-pre-commit.check-secrets: ENABLED
1 parent 80deb5b commit 08a8f91

File tree

7 files changed

+254
-3
lines changed

7 files changed

+254
-3
lines changed

sdk/python/feast/ui_server.py

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import json
2+
import logging
23
import threading
34
from importlib import resources as importlib_resources
4-
from typing import Callable, Optional
5+
from typing import Callable, Dict, List, Optional
56

67
import uvicorn
78
from fastapi import FastAPI, Response, status
@@ -10,6 +11,8 @@
1011

1112
import feast
1213

14+
_logger = logging.getLogger(__name__)
15+
1316

1417
def get_app(
1518
store: "feast.FeatureStore",
@@ -147,6 +150,25 @@ def get_mlflow_runs(max_results: int = 50):
147150
order_by=["start_time DESC"],
148151
)
149152

153+
run_id_to_models: Dict[str, List[dict]] = {}
154+
try:
155+
for rm in client.search_registered_models():
156+
for mv in rm.latest_versions or []:
157+
if mv.run_id:
158+
run_id_to_models.setdefault(mv.run_id, []).append(
159+
{
160+
"model_name": rm.name,
161+
"version": mv.version,
162+
"stage": mv.current_stage,
163+
"mlflow_url": (
164+
f"{tracking_uri}/#/models/"
165+
f"{rm.name}/versions/{mv.version}"
166+
),
167+
}
168+
)
169+
except Exception:
170+
pass
171+
150172
result = []
151173
mlflow_ui_base = tracking_uri
152174
for run in runs:
@@ -175,6 +197,9 @@ def get_mlflow_runs(max_results: int = 50):
175197
f"{mlflow_ui_base}/#/experiments/"
176198
f"{run.info.experiment_id}/runs/{run.info.run_id}"
177199
),
200+
"registered_models": run_id_to_models.get(
201+
run.info.run_id, []
202+
),
178203
}
179204
)
180205

@@ -192,6 +217,72 @@ def get_mlflow_runs(max_results: int = 50):
192217
"error": "Failed to fetch MLflow runs",
193218
}
194219

220+
@app.get("/api/mlflow-feature-models")
221+
def get_mlflow_feature_models():
222+
"""Return a mapping of feature_ref -> registered models that use it.
223+
224+
Walks the MLflow Model Registry, inspects the training run for each
225+
model's latest version(s), reads the ``feast.feature_refs`` tag, and
226+
inverts it into a reverse index so the UI can show which registered
227+
models depend on a given feature.
228+
"""
229+
mlflow_cfg = getattr(store.config, "mlflow", None)
230+
if not mlflow_cfg or not mlflow_cfg.enabled:
231+
return {"feature_models": {}}
232+
233+
try:
234+
import mlflow
235+
236+
tracking_uri = mlflow_cfg.tracking_uri or "http://127.0.0.1:5000"
237+
client = mlflow.MlflowClient(tracking_uri=tracking_uri)
238+
project_name = store.config.project
239+
240+
feature_models: Dict[str, List[dict]] = {}
241+
242+
for rm in client.search_registered_models():
243+
model_name = rm.name
244+
latest_versions = rm.latest_versions or []
245+
for mv in latest_versions:
246+
if not mv.run_id:
247+
continue
248+
try:
249+
run = client.get_run(mv.run_id)
250+
except Exception:
251+
continue
252+
253+
tags = run.data.tags
254+
if tags.get("feast.project") != project_name:
255+
continue
256+
257+
refs_raw = tags.get("feast.feature_refs", "")
258+
feature_refs = [r for r in refs_raw.split(",") if r]
259+
260+
model_info = {
261+
"model_name": model_name,
262+
"version": mv.version,
263+
"stage": mv.current_stage,
264+
"mlflow_url": (
265+
f"{tracking_uri}/#/models/"
266+
f"{model_name}/versions/{mv.version}"
267+
),
268+
}
269+
270+
for ref in feature_refs:
271+
feature_models.setdefault(ref, []).append(model_info)
272+
273+
return {"feature_models": feature_models}
274+
except ImportError:
275+
return {
276+
"feature_models": {},
277+
"error": "mlflow is not installed",
278+
}
279+
except Exception as e:
280+
_logger.debug("Failed to fetch MLflow feature-model mapping: %s", e)
281+
return {
282+
"feature_models": {},
283+
"error": "Failed to fetch model data",
284+
}
285+
195286
# For all other paths (such as paths that would otherwise be handled by react router), pass to React
196287
@app.api_route("/p/{path_name:path}", methods=["GET"])
197288
def catch_all():

ui/src/components/RegistryVisualization.tsx

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ const getNodeColor = (type: FEAST_FCO_TYPES) => {
8282
return "#cc0000"; // Red
8383
case FEAST_FCO_TYPES.mlflowRun:
8484
return "#0194e2"; // MLflow brand blue
85+
case FEAST_FCO_TYPES.mlflowModel:
86+
return "#7b2d8e"; // Purple
8587
default:
8688
return "#666666"; // Gray
8789
}
@@ -99,6 +101,8 @@ const getLightNodeColor = (type: FEAST_FCO_TYPES) => {
99101
return "#ffe6e6"; // Light red
100102
case FEAST_FCO_TYPES.mlflowRun:
101103
return "#e6f6fd"; // Light MLflow blue
104+
case FEAST_FCO_TYPES.mlflowModel:
105+
return "#f3e6f9"; // Light purple
102106
default:
103107
return "#f0f0f0"; // Light gray
104108
}
@@ -116,6 +120,8 @@ const getNodeIcon = (type: FEAST_FCO_TYPES) => {
116120
return "◆"; // Diamond for data source
117121
case FEAST_FCO_TYPES.mlflowRun:
118122
return "⬡"; // Hexagon for MLflow run
123+
case FEAST_FCO_TYPES.mlflowModel:
124+
return "⬢"; // Filled hexagon for registered model
119125
default:
120126
return "●"; // Default circle
121127
}
@@ -132,7 +138,11 @@ const CustomNode = ({ data }: { data: NodeData }) => {
132138
const hasVersion = data.versionNumber != null && data.versionNumber > 1;
133139

134140
const handleClick = () => {
135-
if (data.type === FEAST_FCO_TYPES.mlflowRun && data.metadata?.mlflow_url) {
141+
if (
142+
(data.type === FEAST_FCO_TYPES.mlflowRun ||
143+
data.type === FEAST_FCO_TYPES.mlflowModel) &&
144+
data.metadata?.mlflow_url
145+
) {
136146
window.open(data.metadata.mlflow_url, "_blank", "noopener,noreferrer");
137147
return;
138148
}
@@ -194,7 +204,8 @@ const CustomNode = ({ data }: { data: NodeData }) => {
194204
zIndex: 5,
195205
}}
196206
>
197-
{data.type === FEAST_FCO_TYPES.mlflowRun
207+
{data.type === FEAST_FCO_TYPES.mlflowRun ||
208+
data.type === FEAST_FCO_TYPES.mlflowModel
198209
? "Open in MLflow ↗"
199210
: "View Details"}
200211
</div>
@@ -412,6 +423,7 @@ const getLayoutedElements = (
412423
[FEAST_FCO_TYPES.featureView]: [],
413424
[FEAST_FCO_TYPES.featureService]: [],
414425
[FEAST_FCO_TYPES.mlflowRun]: [],
426+
[FEAST_FCO_TYPES.mlflowModel]: [],
415427
};
416428

417429
isolatedNodes.forEach((node) => {
@@ -469,6 +481,7 @@ const Legend = () => {
469481
{ type: FEAST_FCO_TYPES.entity, label: "Entity" },
470482
{ type: FEAST_FCO_TYPES.dataSource, label: "Data Source" },
471483
{ type: FEAST_FCO_TYPES.mlflowRun, label: "MLflow Run" },
484+
{ type: FEAST_FCO_TYPES.mlflowModel, label: "Registered Model" },
472485
];
473486

474487
const isDarkMode = colorMode === "dark";
@@ -805,6 +818,52 @@ const registryToFlow = (
805818
});
806819
}
807820
}
821+
822+
if (run.registered_models && run.registered_models.length > 0) {
823+
run.registered_models.forEach((model) => {
824+
const modelNodeId = `model-${model.model_name}-v${model.version}`;
825+
const modelExists = nodes.some((n) => n.id === modelNodeId);
826+
if (!modelExists) {
827+
nodes.push({
828+
id: modelNodeId,
829+
type: "custom",
830+
data: {
831+
label: `${model.model_name} v${model.version}`,
832+
type: FEAST_FCO_TYPES.mlflowModel,
833+
metadata: {
834+
mlflow_url: model.mlflow_url,
835+
model_name: model.model_name,
836+
version: model.version,
837+
stage: model.stage,
838+
},
839+
},
840+
position: { x: 0, y: 0 },
841+
});
842+
}
843+
844+
edges.push({
845+
id: `edge-model-${run.run_id}-${model.model_name}-v${model.version}`,
846+
source: `mlflow-${run.run_id}`,
847+
sourceHandle: "source",
848+
target: modelNodeId,
849+
targetHandle: "target",
850+
animated: true,
851+
style: {
852+
strokeWidth: 3,
853+
stroke: "#7b2d8e",
854+
strokeDasharray: "10 5",
855+
animation: "dataflow 2s linear infinite",
856+
},
857+
type: "smoothstep",
858+
markerEnd: {
859+
type: MarkerType.ArrowClosed,
860+
width: 20,
861+
height: 20,
862+
color: "#7b2d8e",
863+
},
864+
});
865+
});
866+
}
808867
});
809868
}
810869

@@ -823,6 +882,8 @@ const getNodePrefix = (type: FEAST_FCO_TYPES) => {
823882
return "ds";
824883
case FEAST_FCO_TYPES.mlflowRun:
825884
return "mlflow";
885+
case FEAST_FCO_TYPES.mlflowModel:
886+
return "model";
826887
default:
827888
return "unknown";
828889
}

ui/src/hooks/useFCOExploreSuggestions.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const FCO_TO_URL_NAME_MAP: Record<FEAST_FCO_TYPES, string> = {
2323
featureView: "/feature-view",
2424
featureService: "/feature-service",
2525
mlflowRun: "/mlflow-run",
26+
mlflowModel: "/mlflow-model",
2627
};
2728

2829
const createSearchLink = (

ui/src/pages/features/FeatureListPage.tsx

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,16 @@ import {
1515
EuiFlexGroup,
1616
EuiFlexItem,
1717
EuiFormRow,
18+
EuiBadge,
1819
} from "@elastic/eui";
1920
import EuiCustomLink from "../../components/EuiCustomLink";
2021
import ExportButton from "../../components/ExportButton";
2122
import { useParams } from "react-router-dom";
2223
import useLoadRegistry from "../../queries/useLoadRegistry";
2324
import RegistryPathContext from "../../contexts/RegistryPathContext";
25+
import useLoadFeatureModels, {
26+
FeatureModelInfo,
27+
} from "../../queries/useLoadFeatureModels";
2428
import { FeatureIcon } from "../../graphics/FeatureIcon";
2529
import { FEAST_FCO_TYPES } from "../../parsers/types";
2630
import {
@@ -35,6 +39,7 @@ interface Feature {
3539
type: string;
3640
project?: string;
3741
permissions?: any[];
42+
models?: FeatureModelInfo[];
3843
}
3944

4045
type FeatureColumn =
@@ -48,6 +53,7 @@ const FeatureListPage = () => {
4853
registryUrl,
4954
projectName,
5055
);
56+
const { data: featureModelsData } = useLoadFeatureModels();
5157
const [searchText, setSearchText] = useState("");
5258
const [selectedPermissionAction, setSelectedPermissionAction] = useState("");
5359

@@ -59,8 +65,11 @@ const FeatureListPage = () => {
5965

6066
const featuresWithPermissions: Feature[] = (data?.allFeatures || []).map(
6167
(feature) => {
68+
const featureRef = `${feature.featureView}:${feature.name}`;
6269
return {
6370
...feature,
71+
models:
72+
featureModelsData?.feature_models?.[featureRef] || [],
6473
permissions: getEntityPermissions(
6574
selectedPermissionAction
6675
? filterPermissionsByAction(
@@ -126,6 +135,49 @@ const FeatureListPage = () => {
126135
},
127136
},
128137
{ name: "Type", field: "type", sortable: true },
138+
{
139+
name: "Models",
140+
field: "models",
141+
sortable: false,
142+
render: (models: FeatureModelInfo[]) => {
143+
if (!models || models.length === 0) {
144+
return (
145+
<EuiText size="xs" color="subdued">
146+
--
147+
</EuiText>
148+
);
149+
}
150+
if (models.length === 1) {
151+
return (
152+
<EuiBadge
153+
color="hollow"
154+
href={models[0].mlflow_url}
155+
target="_blank"
156+
>
157+
{models[0].model_name} v{models[0].version}
158+
</EuiBadge>
159+
);
160+
}
161+
return (
162+
<EuiToolTip
163+
position="top"
164+
content={
165+
<div>
166+
{models.map((m) => (
167+
<div key={`${m.model_name}_v${m.version}`}>
168+
{m.model_name} v{m.version}
169+
</div>
170+
))}
171+
</div>
172+
}
173+
>
174+
<EuiBadge color="hollow">
175+
{models.length} models
176+
</EuiBadge>
177+
</EuiToolTip>
178+
);
179+
},
180+
},
129181
{
130182
name: "Permissions",
131183
field: "permissions",

ui/src/parsers/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ enum FEAST_FCO_TYPES {
44
featureView = "featureView",
55
featureService = "featureService",
66
mlflowRun = "mlflowRun",
7+
mlflowModel = "mlflowModel",
78
}
89

910
export { FEAST_FCO_TYPES };

0 commit comments

Comments
 (0)