From 5fd083e28ca23179b63de0107dd4fb2679f026f0 Mon Sep 17 00:00:00 2001 From: Jitendra Yejare <11752425+jyejare@users.noreply.github.com> Date: Tue, 19 May 2026 21:43:41 +0530 Subject: [PATCH 1/6] feat: Feature Store Monitoring UI support is added Signed-off-by: Jitendra Yejare <11752425+jyejare@users.noreply.github.com> --- ui/src/FeastUISansProviders.tsx | 222 +++++++------ ui/src/contexts/MonitoringContext.ts | 14 + ui/src/pages/Sidebar.tsx | 18 ++ ui/src/pages/features/FeatureInstance.tsx | 11 +- .../pages/features/FeatureMonitoringTab.tsx | 122 ++++++++ .../pages/monitoring/FeatureMetricsDetail.tsx | 249 +++++++++++++++ .../pages/monitoring/FeatureMetricsTable.tsx | 296 ++++++++++++++++++ .../monitoring/FeatureServiceMetricsPanel.tsx | 224 +++++++++++++ .../monitoring/FeatureViewMetricsPanel.tsx | 240 ++++++++++++++ ui/src/pages/monitoring/Index.tsx | 265 ++++++++++++++++ .../monitoring/components/HistogramChart.tsx | 245 +++++++++++++++ .../monitoring/components/MetricsFilters.tsx | 128 ++++++++ .../monitoring/components/StatsPanel.tsx | 130 ++++++++ ui/src/queries/useMonitoringApi.ts | 250 +++++++++++++++ 14 files changed, 2314 insertions(+), 100 deletions(-) create mode 100644 ui/src/contexts/MonitoringContext.ts create mode 100644 ui/src/pages/features/FeatureMonitoringTab.tsx create mode 100644 ui/src/pages/monitoring/FeatureMetricsDetail.tsx create mode 100644 ui/src/pages/monitoring/FeatureMetricsTable.tsx create mode 100644 ui/src/pages/monitoring/FeatureServiceMetricsPanel.tsx create mode 100644 ui/src/pages/monitoring/FeatureViewMetricsPanel.tsx create mode 100644 ui/src/pages/monitoring/Index.tsx create mode 100644 ui/src/pages/monitoring/components/HistogramChart.tsx create mode 100644 ui/src/pages/monitoring/components/MetricsFilters.tsx create mode 100644 ui/src/pages/monitoring/components/StatsPanel.tsx create mode 100644 ui/src/queries/useMonitoringApi.ts diff --git a/ui/src/FeastUISansProviders.tsx b/ui/src/FeastUISansProviders.tsx index 6b16ad17afd..ac53dee5bf2 100644 --- a/ui/src/FeastUISansProviders.tsx +++ b/ui/src/FeastUISansProviders.tsx @@ -27,10 +27,15 @@ import LabelViewInstance from "./pages/label-views/LabelViewInstance"; import PermissionsIndex from "./pages/permissions/Index"; import LineageIndex from "./pages/lineage/Index"; import NoProjectGuard from "./components/NoProjectGuard"; +import MonitoringIndex from "./pages/monitoring/Index"; +import FeatureMetricsDetail from "./pages/monitoring/FeatureMetricsDetail"; import TabsRegistryContext, { FeastTabsRegistryInterface, } from "./custom-tabs/TabsRegistryContext"; +import MonitoringContext, { + MonitoringConfig, +} from "./contexts/MonitoringContext"; import CurlGeneratorTab from "./pages/feature-views/CurlGeneratorTab"; import FeatureFlagsContext, { FeatureFlags, @@ -47,6 +52,7 @@ interface FeastUIConfigs { featureFlags?: FeatureFlags; projectListPromise?: Promise; fetchOptions?: FetchOptions; + monitoringConfig?: MonitoringConfig; } const defaultProjectListPromise = (basename: string) => { @@ -107,110 +113,128 @@ const FeastUISansProvidersInner = ({ - - - - - }> - } /> - } - > - } /> - } - /> - } - /> - } /> - } - /> - } - > - } - /> - } - /> - } - /> - } /> - } - /> + + + + }> + } /> + }> + } /> + } /> + } + > + } /> + } + /> + } + /> + } /> + } + /> + } + > + } + /> + } + /> + } + /> + } /> + } + /> - } /> - } - /> - } - /> - } /> - } - /> - } - /> - } /> + } /> + } + /> + } + /> + } /> + } + /> + } /> + } /> + } + /> + } + /> + + - - } /> - - - - + } /> + + + + + - - + + ); }; diff --git a/ui/src/contexts/MonitoringContext.ts b/ui/src/contexts/MonitoringContext.ts new file mode 100644 index 00000000000..985f00080e9 --- /dev/null +++ b/ui/src/contexts/MonitoringContext.ts @@ -0,0 +1,14 @@ +import React from "react"; + +interface MonitoringConfig { + apiBaseUrl: string; + enabled: boolean; +} + +const MonitoringContext = React.createContext({ + apiBaseUrl: "/api/v1", + enabled: true, +}); + +export default MonitoringContext; +export type { MonitoringConfig }; diff --git a/ui/src/pages/Sidebar.tsx b/ui/src/pages/Sidebar.tsx index 82510840263..58477cbd293 100644 --- a/ui/src/pages/Sidebar.tsx +++ b/ui/src/pages/Sidebar.tsx @@ -176,6 +176,24 @@ const SideNav = () => { renderItem: (props) => , isSelected: useMatchSubpath(`${baseUrl}/data-set`), }, + { + name: "Monitoring", + id: htmlIdGenerator("monitoring")(), + icon: , + renderItem: (props) => ( + + ), + isSelected: useMatchSubpath(`${baseUrl}/monitoring`), + }, + { + name: "Data Labeling", + id: htmlIdGenerator("dataLabeling")(), + icon: , + renderItem: (props) => ( + + ), + isSelected: useMatchSubpath(`${baseUrl}/data-labeling`), + }, { name: "Permissions", id: htmlIdGenerator("permissions")(), diff --git a/ui/src/pages/features/FeatureInstance.tsx b/ui/src/pages/features/FeatureInstance.tsx index fe81c6e619f..aa73db7c8c1 100644 --- a/ui/src/pages/features/FeatureInstance.tsx +++ b/ui/src/pages/features/FeatureInstance.tsx @@ -3,8 +3,9 @@ import { Route, Routes, useNavigate, useParams } from "react-router-dom"; import { EuiPageTemplate } from "@elastic/eui"; import { FeatureIcon } from "../../graphics/FeatureIcon"; -import { useMatchExact } from "../../hooks/useMatchSubpath"; +import { useMatchExact, useMatchSubpath } from "../../hooks/useMatchSubpath"; import FeatureOverviewTab from "./FeatureOverviewTab"; +import FeatureMonitoringTab from "./FeatureMonitoringTab"; import { useDocumentTitle } from "../../hooks/useDocumentTitle"; import { useFeatureCustomTabs, @@ -34,12 +35,20 @@ const FeatureInstance = () => { navigate(""); }, }, + { + label: "Monitoring", + isSelected: useMatchSubpath("monitoring"), + onClick: () => { + navigate("monitoring"); + }, + }, ...customNavigationTabs, ]} /> } /> + } /> {CustomTabRoutes} diff --git a/ui/src/pages/features/FeatureMonitoringTab.tsx b/ui/src/pages/features/FeatureMonitoringTab.tsx new file mode 100644 index 00000000000..fdf7b38bc86 --- /dev/null +++ b/ui/src/pages/features/FeatureMonitoringTab.tsx @@ -0,0 +1,122 @@ +import React from "react"; +import { useParams } from "react-router-dom"; +import { + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiSkeletonText, + EuiEmptyPrompt, + EuiButton, +} from "@elastic/eui"; +import { + useFeatureMetrics, + useBaselineMetrics, +} from "../../queries/useMonitoringApi"; +import type { + NumericHistogram, + CategoricalHistogram, +} from "../../queries/useMonitoringApi"; +import { + NumericHistogramChart, + CategoricalHistogramChart, +} from "../monitoring/components/HistogramChart"; +import StatsPanel from "../monitoring/components/StatsPanel"; + +const FeatureMonitoringTab = () => { + const { projectName, FeatureViewName, FeatureName } = useParams(); + + const { + data: metrics, + isLoading, + isError, + } = useFeatureMetrics({ + project: projectName || "", + feature_view_name: FeatureViewName, + feature_name: FeatureName, + }); + + const { data: baselineMetrics } = useBaselineMetrics( + projectName || "", + FeatureViewName, + FeatureName, + ); + + if (isLoading) { + return ; + } + + const latestMetric = (() => { + if (!metrics || metrics.length === 0) return null; + const withData = metrics.filter((m) => m.row_count > 0); + const candidates = withData.length > 0 ? withData : metrics; + return candidates.reduce((a, b) => + a.metric_date > b.metric_date ? a : b, + ); + })(); + + const baselineMetric = + baselineMetrics && baselineMetrics.length > 0 + ? baselineMetrics[0] + : null; + + if (isError || !latestMetric) { + return ( + No Monitoring Data} + body={ +

+ No monitoring metrics available for this feature. Run a + monitoring compute job to generate data quality metrics. +

+ } + actions={ + + Go to Monitoring + + } + /> + ); + } + + const isNumeric = latestMetric.feature_type === "numeric"; + + return ( + <> + + + {isNumeric && latestMetric.histogram && ( + + )} + {!isNumeric && latestMetric.histogram && ( + + )} + {!latestMetric.histogram && ( + No Histogram} + body={

Histogram data is not available.

} + /> + )} +
+ + + +
+ + + ); +}; + +export default FeatureMonitoringTab; diff --git a/ui/src/pages/monitoring/FeatureMetricsDetail.tsx b/ui/src/pages/monitoring/FeatureMetricsDetail.tsx new file mode 100644 index 00000000000..7ace799742b --- /dev/null +++ b/ui/src/pages/monitoring/FeatureMetricsDetail.tsx @@ -0,0 +1,249 @@ +import React from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import { + EuiPageTemplate, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiSkeletonText, + EuiEmptyPrompt, + EuiButton, + EuiBreadcrumbs, +} from "@elastic/eui"; +import { FeatureIcon } from "../../graphics/FeatureIcon"; +import { + useFeatureMetrics, + useBaselineMetrics, +} from "../../queries/useMonitoringApi"; +import type { + NumericHistogram, + CategoricalHistogram, +} from "../../queries/useMonitoringApi"; +import { + NumericHistogramChart, + CategoricalHistogramChart, +} from "./components/HistogramChart"; +import StatsPanel from "./components/StatsPanel"; +import { useDocumentTitle } from "../../hooks/useDocumentTitle"; + +const FeatureMetricsDetail = () => { + const { projectName, featureViewName, featureName } = useParams(); + const navigate = useNavigate(); + + useDocumentTitle( + `${featureName} Monitoring | ${featureViewName} | Feast`, + ); + + const { + data: metrics, + isLoading, + isError, + } = useFeatureMetrics({ + project: projectName || "", + feature_view_name: featureViewName, + feature_name: featureName, + }); + + const { data: baselineMetrics } = useBaselineMetrics( + projectName || "", + featureViewName, + featureName, + ); + + const latestMetric = (() => { + if (!metrics || metrics.length === 0) return null; + const withData = metrics.filter((m) => m.row_count > 0); + const candidates = withData.length > 0 ? withData : metrics; + return candidates.reduce((a, b) => + a.metric_date > b.metric_date ? a : b, + ); + })(); + + const baselineMetric = + baselineMetrics && baselineMetrics.length > 0 + ? baselineMetrics[0] + : null; + + const breadcrumbs = [ + { + text: "Monitoring", + onClick: () => navigate(`/p/${projectName}/monitoring`), + }, + { + text: featureViewName || "", + }, + { + text: featureName || "", + }, + ]; + + if (isLoading) { + return ( + + + + + + ); + } + + if (isError || !latestMetric) { + return ( + + + + + No Metrics Available} + body={ +

+ No monitoring metrics found for feature{" "} + {featureName} in feature view{" "} + {featureViewName}. Run a monitoring + compute job first. +

+ } + actions={ + navigate(`/p/${projectName}/monitoring`)} + > + Back to Monitoring + + } + /> +
+
+ ); + } + + const isNumeric = latestMetric.feature_type === "numeric"; + + return ( + + navigate(`/p/${projectName}/monitoring`)} + > + Back to Monitoring + , + ]} + /> + + + + + + + {isNumeric && latestMetric.histogram && ( + + )} + {!isNumeric && latestMetric.histogram && ( + + )} + {!latestMetric.histogram && ( + No Histogram Data} + body={

Histogram data is not available for this metric.

} + /> + )} +
+ + + + +
+ + {metrics && metrics.length > 1 && ( + <> + + + + )} +
+
+ ); +}; + +const NullRateTimeline = ({ + metrics, +}: { + metrics: { metric_date: string; null_rate: number }[]; +}) => { + const sorted = [...metrics].sort( + (a, b) => a.metric_date.localeCompare(b.metric_date), + ); + const maxRate = Math.max(...sorted.map((m) => m.null_rate), 0.01); + const chartWidth = Math.max(sorted.length * 50, 200); + const chartHeight = 80; + + const points = sorted.map((m, i) => { + const x = (i / Math.max(sorted.length - 1, 1)) * (chartWidth - 20) + 10; + const y = chartHeight - (m.null_rate / maxRate) * (chartHeight - 10); + return { x, y, ...m }; + }); + + const polyline = points.map((p) => `${p.x},${p.y}`).join(" "); + + return ( +
+

+ Null Rate Over Time +

+ + + {points.map((p, i) => ( + + ))} + + {points.length > 0 && ( + <> + + {points[0].metric_date} + + + {points[points.length - 1].metric_date} + + + )} + +
+ ); +}; + +export default FeatureMetricsDetail; diff --git a/ui/src/pages/monitoring/FeatureMetricsTable.tsx b/ui/src/pages/monitoring/FeatureMetricsTable.tsx new file mode 100644 index 00000000000..d0a2e4e9573 --- /dev/null +++ b/ui/src/pages/monitoring/FeatureMetricsTable.tsx @@ -0,0 +1,296 @@ +import React, { useState, useMemo, useEffect } from "react"; +import { + EuiBasicTable, + EuiBasicTableColumn, + EuiBadge, + EuiHealth, + EuiLink, + EuiProgress, + EuiToolTip, + Criteria, +} from "@elastic/eui"; +import type { + FeatureMetric, + NumericHistogram, + CategoricalHistogram, +} from "../../queries/useMonitoringApi"; + +const healthColor = (nullRate: number): string => { + if (nullRate >= 0.5) return "danger"; + if (nullRate >= 0.1) return "warning"; + return "success"; +}; + +const healthLabel = (nullRate: number): string => { + if (nullRate >= 0.5) return "High null rate"; + if (nullRate >= 0.1) return "Moderate null rate"; + return "Healthy"; +}; + +const formatNum = (val: number | null, decimals = 2): string => { + if (val === null || val === undefined) return "—"; + if (Number.isInteger(val)) return val.toLocaleString(); + return val.toFixed(decimals); +}; + +const MiniHistogram = ({ metric }: { metric: FeatureMetric }) => { + if (!metric.histogram) return ; + + const width = 120; + const height = 28; + + if (metric.feature_type === "numeric") { + const hist = metric.histogram as NumericHistogram; + const maxCount = Math.max(...hist.counts, 1); + const barW = Math.max(Math.floor(width / hist.counts.length) - 1, 2); + + return ( + + + {hist.counts.map((count, i) => { + const h = (count / maxCount) * (height - 2); + return ( + + ); + })} + + + ); + } + + const hist = metric.histogram as CategoricalHistogram; + const maxCount = Math.max(...hist.values.map((v) => v.count), 1); + const barW = Math.max( + Math.floor(width / Math.min(hist.values.length, 10)) - 1, + 6, + ); + + return ( + + + {hist.values.slice(0, 10).map((v, i) => { + const h = (v.count / maxCount) * (height - 2); + return ( + + ); + })} + + + ); +}; + +interface FeatureMetricsTableProps { + metrics: FeatureMetric[]; + isLoading: boolean; + onFeatureClick: (fvName: string, featureName: string) => void; +} + +const PAGE_SIZE_OPTIONS = [10, 20, 50]; + +const FeatureMetricsTable = ({ + metrics, + isLoading, + onFeatureClick, +}: FeatureMetricsTableProps) => { + const [sortField, setSortField] = + useState("feature_view_name"); + const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(20); + + useEffect(() => { + setPageIndex(0); + }, [metrics]); + + const latestMetrics = useMemo(() => { + const byKey = new Map(); + for (const m of metrics) { + const key = `${m.feature_view_name}::${m.feature_name}`; + const existing = byKey.get(key); + if (!existing) { + byKey.set(key, m); + } else { + const preferNew = + m.row_count > 0 && existing.row_count === 0 + ? true + : existing.row_count > 0 && m.row_count === 0 + ? false + : m.metric_date > existing.metric_date; + if (preferNew) byKey.set(key, m); + } + } + return Array.from(byKey.values()); + }, [metrics]); + + const sortedItems = useMemo(() => { + return [...latestMetrics].sort((a, b) => { + const aVal = a[sortField]; + const bVal = b[sortField]; + if (aVal == null && bVal == null) return 0; + if (aVal == null) return 1; + if (bVal == null) return -1; + if (aVal < bVal) return sortDirection === "asc" ? -1 : 1; + if (aVal > bVal) return sortDirection === "asc" ? 1 : -1; + return 0; + }); + }, [latestMetrics, sortField, sortDirection]); + + const pageOfItems = useMemo(() => { + const start = pageIndex * pageSize; + return sortedItems.slice(start, start + pageSize); + }, [sortedItems, pageIndex, pageSize]); + + const pagination = useMemo( + () => ({ + pageIndex, + pageSize, + totalItemCount: sortedItems.length, + pageSizeOptions: PAGE_SIZE_OPTIONS, + }), + [pageIndex, pageSize, sortedItems.length], + ); + + const onTableChange = ({ sort, page }: Criteria) => { + if (sort) { + setSortField(sort.field as keyof FeatureMetric); + setSortDirection(sort.direction); + } + if (page) { + setPageIndex(page.index); + setPageSize(page.size); + } + }; + + const columns: EuiBasicTableColumn[] = [ + { + field: "feature_name", + name: "Feature", + sortable: true, + render: (name: string, item: FeatureMetric) => ( + onFeatureClick(item.feature_view_name, name)} + > + {name} + + ), + }, + { + field: "feature_view_name", + name: "Feature View", + sortable: true, + }, + { + field: "feature_type", + name: "Type", + sortable: true, + width: "100px", + render: (type: string) => ( + + {type} + + ), + }, + { + name: "Distribution", + width: "140px", + render: (item: FeatureMetric) => , + }, + { + field: "row_count", + name: "Rows", + sortable: true, + width: "90px", + render: (val: number) => formatNum(val, 0), + }, + { + field: "null_rate", + name: "Null Rate", + sortable: true, + width: "150px", + render: (val: number) => ( +
+ + {(val * 100).toFixed(1)}% +
+ ), + }, + { + field: "null_rate", + name: "Health", + width: "130px", + render: (val: number) => ( + {healthLabel(val)} + ), + }, + { + field: "mean", + name: "Mean", + sortable: true, + width: "100px", + render: (val: number | null) => formatNum(val), + }, + { + field: "stddev", + name: "Std Dev", + sortable: true, + width: "100px", + render: (val: number | null) => formatNum(val), + }, + { + field: "data_source_type", + name: "Source", + width: "80px", + render: (val: string) => {val}, + }, + ]; + + return ( + ({ + "data-test-subj": `row-${item.feature_name}`, + })} + noItemsMessage={ + isLoading + ? "Loading metrics..." + : "No metrics found. Run a monitoring compute job to generate metrics." + } + /> + ); +}; + +export default FeatureMetricsTable; diff --git a/ui/src/pages/monitoring/FeatureServiceMetricsPanel.tsx b/ui/src/pages/monitoring/FeatureServiceMetricsPanel.tsx new file mode 100644 index 00000000000..c3fa61b25a8 --- /dev/null +++ b/ui/src/pages/monitoring/FeatureServiceMetricsPanel.tsx @@ -0,0 +1,224 @@ +import React, { useState, useMemo } from "react"; +import { + EuiPanel, + EuiTitle, + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, + EuiStat, + EuiBasicTable, + EuiBasicTableColumn, + EuiProgress, + EuiBadge, + EuiSkeletonText, + Criteria, +} from "@elastic/eui"; +import type { FeatureServiceMetric } from "../../queries/useMonitoringApi"; + +const healthColor = (nullRate: number): string => { + if (nullRate >= 0.5) return "danger"; + if (nullRate >= 0.1) return "warning"; + return "success"; +}; + +interface FeatureServiceMetricsPanelProps { + metrics: FeatureServiceMetric[]; + isLoading: boolean; +} + +const FeatureServiceMetricsPanel = ({ + metrics, + isLoading, +}: FeatureServiceMetricsPanelProps) => { + if (isLoading) { + return ( + + +

Feature Service Metrics

+
+ + +
+ ); + } + + const latestByFS = new Map(); + for (const m of metrics) { + const existing = latestByFS.get(m.feature_service_name); + if (!existing || m.metric_date > existing.metric_date) { + latestByFS.set(m.feature_service_name, m); + } + } + const latestMetrics = Array.from(latestByFS.values()); + + const totalViews = latestMetrics.reduce( + (sum, m) => sum + (m.total_feature_views || 0), + 0, + ); + const totalFeatures = latestMetrics.reduce( + (sum, m) => sum + (m.total_features || 0), + 0, + ); + const avgNullRate = + latestMetrics.length > 0 + ? latestMetrics.reduce((sum, m) => sum + (m.avg_null_rate || 0), 0) / + latestMetrics.length + : 0; + + const columns: EuiBasicTableColumn[] = [ + { + field: "feature_service_name", + name: "Feature Service", + sortable: true, + }, + { + field: "total_feature_views", + name: "Feature Views", + sortable: true, + width: "110px", + }, + { + field: "total_features", + name: "Features", + sortable: true, + width: "80px", + }, + { + field: "avg_null_rate", + name: "Avg Null Rate", + sortable: true, + render: (val: number) => ( +
+ + {((val || 0) * 100).toFixed(1)}% +
+ ), + }, + { + field: "max_null_rate", + name: "Max Null Rate", + sortable: true, + width: "110px", + render: (val: number) => `${((val || 0) * 100).toFixed(1)}%`, + }, + { + field: "metric_date", + name: "Date", + sortable: true, + width: "110px", + }, + { + field: "data_source_type", + name: "Source", + width: "80px", + render: (val: string) => ( + {val} + ), + }, + ]; + + return ( + + +

Feature Service Metrics

+
+

+ Aggregated data quality metrics across feature services. +

+ + + + + + + + + + + + + + + + + + + + {latestMetrics.length > 0 && ( + + )} +
+ ); +}; + +const SortableFSTable = ({ + items, + columns, +}: { + items: FeatureServiceMetric[]; + columns: EuiBasicTableColumn[]; +}) => { + const [sortField, setSortField] = useState("feature_service_name"); + const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); + + const sortedItems = useMemo(() => { + return [...items].sort((a, b) => { + const aVal = (a as any)[sortField]; + const bVal = (b as any)[sortField]; + if (aVal == null && bVal == null) return 0; + if (aVal == null) return 1; + if (bVal == null) return -1; + if (aVal < bVal) return sortDirection === "asc" ? -1 : 1; + if (aVal > bVal) return sortDirection === "asc" ? 1 : -1; + return 0; + }); + }, [items, sortField, sortDirection]); + + const onTableChange = ({ sort }: Criteria) => { + if (sort) { + setSortField(sort.field as string); + setSortDirection(sort.direction); + } + }; + + return ( + + ); +}; + +export default FeatureServiceMetricsPanel; diff --git a/ui/src/pages/monitoring/FeatureViewMetricsPanel.tsx b/ui/src/pages/monitoring/FeatureViewMetricsPanel.tsx new file mode 100644 index 00000000000..267f1292fe1 --- /dev/null +++ b/ui/src/pages/monitoring/FeatureViewMetricsPanel.tsx @@ -0,0 +1,240 @@ +import React, { useState, useMemo } from "react"; +import { + EuiPanel, + EuiTitle, + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, + EuiStat, + EuiBasicTable, + EuiBasicTableColumn, + EuiProgress, + EuiBadge, + EuiSkeletonText, + Criteria, +} from "@elastic/eui"; +import type { FeatureViewMetric } from "../../queries/useMonitoringApi"; + +const healthColor = (nullRate: number): string => { + if (nullRate >= 0.5) return "danger"; + if (nullRate >= 0.1) return "warning"; + return "success"; +}; + +interface FeatureViewMetricsPanelProps { + metrics: FeatureViewMetric[]; + isLoading: boolean; + title: string; + description?: string; +} + +const FeatureViewMetricsPanel = ({ + metrics, + isLoading, + title, + description, +}: FeatureViewMetricsPanelProps) => { + if (isLoading) { + return ( + + +

{title}

+
+ + +
+ ); + } + + const latestByFV = new Map(); + for (const m of metrics) { + const existing = latestByFV.get(m.feature_view_name); + if (!existing || m.metric_date > existing.metric_date) { + latestByFV.set(m.feature_view_name, m); + } + } + const latestMetrics = Array.from(latestByFV.values()); + + const totalRows = latestMetrics.reduce( + (sum, m) => sum + (m.total_row_count || 0), + 0, + ); + const totalFeatures = latestMetrics.reduce( + (sum, m) => sum + (m.total_features || 0), + 0, + ); + const avgNullRate = + latestMetrics.length > 0 + ? latestMetrics.reduce((sum, m) => sum + (m.avg_null_rate || 0), 0) / + latestMetrics.length + : 0; + const healthyViews = latestMetrics.filter( + (m) => m.avg_null_rate < 0.1, + ).length; + + const columns: EuiBasicTableColumn[] = [ + { + field: "feature_view_name", + name: "Feature View", + sortable: true, + }, + { + field: "total_row_count", + name: "Total Rows", + sortable: true, + render: (val: number) => (val || 0).toLocaleString(), + }, + { + field: "total_features", + name: "Features", + sortable: true, + width: "80px", + }, + { + field: "features_with_nulls", + name: "With Nulls", + sortable: true, + width: "90px", + }, + { + field: "avg_null_rate", + name: "Avg Null Rate", + sortable: true, + render: (val: number) => ( +
+ + {((val || 0) * 100).toFixed(1)}% +
+ ), + }, + { + field: "max_null_rate", + name: "Max Null Rate", + sortable: true, + width: "110px", + render: (val: number) => `${((val || 0) * 100).toFixed(1)}%`, + }, + { + field: "metric_date", + name: "Date", + sortable: true, + width: "110px", + }, + { + field: "data_source_type", + name: "Source", + width: "80px", + render: (val: string) => ( + {val} + ), + }, + ]; + + return ( + + +

{title}

+
+ {description && ( +

+ {description} +

+ )} + + + + + + + + + + + + + + + + + + + + {latestMetrics.length > 0 && ( + + )} +
+ ); +}; + +const SortableTable = ({ + items, + columns, +}: { + items: FeatureViewMetric[]; + columns: EuiBasicTableColumn[]; +}) => { + const [sortField, setSortField] = useState("feature_view_name"); + const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); + + const sortedItems = useMemo(() => { + return [...items].sort((a, b) => { + const aVal = (a as any)[sortField]; + const bVal = (b as any)[sortField]; + if (aVal == null && bVal == null) return 0; + if (aVal == null) return 1; + if (bVal == null) return -1; + if (aVal < bVal) return sortDirection === "asc" ? -1 : 1; + if (aVal > bVal) return sortDirection === "asc" ? 1 : -1; + return 0; + }); + }, [items, sortField, sortDirection]); + + const onTableChange = ({ sort }: Criteria) => { + if (sort) { + setSortField(sort.field as string); + setSortDirection(sort.direction); + } + }; + + return ( + + ); +}; + +export default FeatureViewMetricsPanel; diff --git a/ui/src/pages/monitoring/Index.tsx b/ui/src/pages/monitoring/Index.tsx new file mode 100644 index 00000000000..1af792b119b --- /dev/null +++ b/ui/src/pages/monitoring/Index.tsx @@ -0,0 +1,265 @@ +import React, { useState, useContext, useMemo } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import { + EuiPageTemplate, + EuiSpacer, + EuiTabbedContent, + EuiTabbedContentTab, + EuiEmptyPrompt, + EuiButton, + EuiCallOut, +} from "@elastic/eui"; + +import { useDocumentTitle } from "../../hooks/useDocumentTitle"; +import useLoadRegistry from "../../queries/useLoadRegistry"; +import RegistryPathContext from "../../contexts/RegistryPathContext"; +import { + useFeatureMetrics, + useFeatureViewMetrics, + useFeatureServiceMetrics, + useComputeMetrics, +} from "../../queries/useMonitoringApi"; +import FeatureMetricsTable from "./FeatureMetricsTable"; +import FeatureViewMetricsPanel from "./FeatureViewMetricsPanel"; +import FeatureServiceMetricsPanel from "./FeatureServiceMetricsPanel"; +import MetricsFilters from "./components/MetricsFilters"; + +const MonitoringIndex = () => { + useDocumentTitle("Monitoring | Feast"); + + const { projectName } = useParams(); + const navigate = useNavigate(); + const registryUrl = useContext(RegistryPathContext); + const { data: registryData } = useLoadRegistry(registryUrl, projectName); + + const [selectedFV, setSelectedFV] = useState(""); + const [granularity, setGranularity] = useState(""); + const [dataSourceType, setDataSourceType] = useState(""); + const [startDate, setStartDate] = useState(""); + const [endDate, setEndDate] = useState(""); + + const filters = useMemo( + () => ({ + project: projectName || "", + feature_view_name: selectedFV || undefined, + granularity: granularity || undefined, + data_source_type: dataSourceType || undefined, + start_date: startDate || undefined, + end_date: endDate || undefined, + }), + [projectName, selectedFV, granularity, dataSourceType, startDate, endDate], + ); + + const featureQuery = useFeatureMetrics(filters); + const fvQuery = useFeatureViewMetrics(filters); + const fsQuery = useFeatureServiceMetrics({ + project: projectName || "", + granularity: granularity || undefined, + data_source_type: dataSourceType || undefined, + start_date: startDate || undefined, + end_date: endDate || undefined, + }); + const computeMutation = useComputeMetrics(); + + const featureViews = useMemo(() => { + if (!registryData?.mergedFVList) return []; + return registryData.mergedFVList.map((fv: any) => fv.name as string); + }, [registryData]); + + const handleFeatureClick = (fvName: string, featureName: string) => { + navigate( + `/p/${projectName}/monitoring/feature/${fvName}/${featureName}`, + ); + }; + + const uniqueFeatureCount = useMemo(() => { + if (!featureQuery.data) return 0; + const seen = new Set(); + for (const m of featureQuery.data) { + seen.add(`${m.feature_view_name}::${m.feature_name}`); + } + return seen.size; + }, [featureQuery.data]); + + const handleRefresh = () => { + featureQuery.refetch(); + fvQuery.refetch(); + fsQuery.refetch(); + }; + + const handleCompute = () => { + computeMutation.mutate({ + project: projectName || "", + feature_view_name: selectedFV || undefined, + }); + }; + + const hasError = + featureQuery.isError && fvQuery.isError && fsQuery.isError; + const hasData = + (featureQuery.data && featureQuery.data.length > 0) || + (fvQuery.data && fvQuery.data.length > 0); + + const tabs: EuiTabbedContentTab[] = [ + { + id: "features", + name: `Features${uniqueFeatureCount > 0 ? ` (${uniqueFeatureCount})` : ""}`, + content: ( + <> + + + + ), + }, + { + id: "feature-views", + name: "Feature Views", + content: ( + <> + + + + ), + }, + { + id: "feature-services", + name: "Feature Services", + content: ( + <> + + + + ), + }, + ]; + + return ( + + + Compute Metrics + , + ]} + /> + + {hasError && ( + <> + +

+ Could not connect to the monitoring API. Make sure the Feast + registry server is running with monitoring enabled. +

+
+ + + )} + + + + + + {!hasData && !featureQuery.isLoading && !hasError && ( + No Metrics Yet} + body={ +

+ No monitoring data has been computed for this project. Click + "Compute Metrics" to run data quality analysis on your + feature views, or use the CLI:{" "} + feast monitor run --data-source batch +

+ } + actions={ + + Compute Metrics + + } + /> + )} + + {(hasData || featureQuery.isLoading) && ( + + )} + + {computeMutation.isSuccess && ( + <> + + +

+ Data quality metrics have been computed. The table above has + been refreshed. +

+
+ + )} + + {computeMutation.isError && ( + <> + + +

{(computeMutation.error as Error)?.message}

+
+ + )} +
+
+ ); +}; + +export default MonitoringIndex; diff --git a/ui/src/pages/monitoring/components/HistogramChart.tsx b/ui/src/pages/monitoring/components/HistogramChart.tsx new file mode 100644 index 00000000000..188bcba7c0b --- /dev/null +++ b/ui/src/pages/monitoring/components/HistogramChart.tsx @@ -0,0 +1,245 @@ +import React from "react"; +import { + EuiPanel, + EuiTitle, + EuiSpacer, + EuiText, +} from "@elastic/eui"; +import type { + NumericHistogram, + CategoricalHistogram, +} from "../../../queries/useMonitoringApi"; + +const BAR_COLOR = "#006BB4"; +const BAR_COLOR_BASELINE = "#BD271E55"; +const CHART_HEIGHT = 160; +const AXIS_HEIGHT = 24; +const LEFT_PAD = 50; + +const NumericHistogramChart = ({ + histogram, + baseline, + title, +}: { + histogram: NumericHistogram; + baseline?: NumericHistogram | null; + title?: string; +}) => { + const maxCount = Math.max(...histogram.counts, 1); + const numBars = histogram.counts.length; + const barWidth = Math.max(Math.floor(460 / numBars) - 2, 6); + const barsWidth = (barWidth + 2) * numBars; + const svgWidth = LEFT_PAD + barsWidth + 20; + + const yTicks = [0, 0.25, 0.5, 0.75, 1].map((f) => ({ + value: Math.round(maxCount * f), + y: CHART_HEIGHT - f * CHART_HEIGHT, + })); + + return ( + + {title && ( + <> + +

{title}

+
+ + + )} +
+ + {yTicks.map((t) => ( + + + + {t.value.toLocaleString()} + + + ))} + {histogram.counts.map((count, i) => { + const height = (count / maxCount) * CHART_HEIGHT; + const x = LEFT_PAD + i * (barWidth + 2); + const binStart = histogram.bins[i]; + const binEnd = + i < histogram.bins.length - 1 + ? histogram.bins[i + 1] + : binStart + histogram.bin_width; + const baselineHeight = + baseline && baseline.counts[i] + ? (baseline.counts[i] / maxCount) * CHART_HEIGHT + : 0; + + return ( + + {baselineHeight > 0 && ( + + )} + + {`${binStart.toFixed(2)} – ${binEnd.toFixed(2)}: ${count.toLocaleString()}`} + + + ); + })} + + + {histogram.bins[0]?.toLocaleString(undefined, { maximumFractionDigits: 1 })} + + + {histogram.bins[histogram.bins.length - 1]?.toLocaleString(undefined, { maximumFractionDigits: 1 })} + + +
+ {baseline && ( + + + Baseline + + )} +
+ ); +}; + +const LABEL_WIDTH = 60; +const BAR_MAX_WIDTH = 320; +const COUNT_PAD = 80; +const CAT_SVG_WIDTH = LABEL_WIDTH + BAR_MAX_WIDTH + COUNT_PAD; + +const CategoricalHistogramChart = ({ + histogram, + title, +}: { + histogram: CategoricalHistogram; + title?: string; +}) => { + const maxCount = Math.max( + ...histogram.values.map((v) => v.count), + 1, + ); + const barHeight = 24; + const rowHeight = barHeight + 6; + const chartHeight = histogram.values.length * rowHeight; + + return ( + + {title && ( + <> + +

{title}

+
+ + + )} +
+ + {histogram.values.map((v, i) => { + const width = (v.count / maxCount) * BAR_MAX_WIDTH; + const y = i * rowHeight; + return ( + + + {v.value.length > 8 ? v.value.slice(0, 8) + "…" : v.value} + + + {`${v.value}: ${v.count.toLocaleString()}`} + + + {v.count.toLocaleString()} + + + ); + })} + +
+ + {histogram.unique_count} unique values + {histogram.other_count > 0 && + ` (${histogram.other_count.toLocaleString()} in other categories)`} + +
+ ); +}; + +export { NumericHistogramChart, CategoricalHistogramChart }; diff --git a/ui/src/pages/monitoring/components/MetricsFilters.tsx b/ui/src/pages/monitoring/components/MetricsFilters.tsx new file mode 100644 index 00000000000..081e380da74 --- /dev/null +++ b/ui/src/pages/monitoring/components/MetricsFilters.tsx @@ -0,0 +1,128 @@ +import React from "react"; +import { + EuiFlexGroup, + EuiFlexItem, + EuiSelect, + EuiFieldText, + EuiFormRow, + EuiButton, +} from "@elastic/eui"; + +interface MetricsFiltersProps { + featureViews: string[]; + selectedFeatureView: string; + onFeatureViewChange: (fv: string) => void; + granularity: string; + onGranularityChange: (g: string) => void; + dataSourceType: string; + onDataSourceTypeChange: (ds: string) => void; + startDate: string; + onStartDateChange: (d: string) => void; + endDate: string; + onEndDateChange: (d: string) => void; + onRefresh: () => void; + isLoading?: boolean; +} + +const GRANULARITY_OPTIONS = [ + { value: "", text: "All" }, + { value: "daily", text: "Daily" }, + { value: "weekly", text: "Weekly" }, + { value: "biweekly", text: "Biweekly" }, + { value: "monthly", text: "Monthly" }, + { value: "quarterly", text: "Quarterly" }, +]; + +const DATA_SOURCE_OPTIONS = [ + { value: "", text: "All Sources" }, + { value: "batch", text: "Batch" }, + { value: "log", text: "Log" }, +]; + +const MetricsFilters = ({ + featureViews, + selectedFeatureView, + onFeatureViewChange, + granularity, + onGranularityChange, + dataSourceType, + onDataSourceTypeChange, + startDate, + onStartDateChange, + endDate, + onEndDateChange, + onRefresh, + isLoading, +}: MetricsFiltersProps) => { + const fvOptions = [ + { value: "", text: "All Feature Views" }, + ...featureViews.map((fv) => ({ value: fv, text: fv })), + ]; + + return ( + + + + onFeatureViewChange(e.target.value)} + compressed + /> + + + + + onGranularityChange(e.target.value)} + compressed + /> + + + + + onDataSourceTypeChange(e.target.value)} + compressed + /> + + + + + onStartDateChange(e.target.value)} + compressed + /> + + + + + onEndDateChange(e.target.value)} + compressed + /> + + + + + Refresh + + + + ); +}; + +export default MetricsFilters; diff --git a/ui/src/pages/monitoring/components/StatsPanel.tsx b/ui/src/pages/monitoring/components/StatsPanel.tsx new file mode 100644 index 00000000000..070b99373e7 --- /dev/null +++ b/ui/src/pages/monitoring/components/StatsPanel.tsx @@ -0,0 +1,130 @@ +import React from "react"; +import { + EuiPanel, + EuiTitle, + EuiSpacer, + EuiDescriptionList, + EuiDescriptionListTitle, + EuiDescriptionListDescription, + EuiFlexGroup, + EuiFlexItem, + EuiBadge, +} from "@elastic/eui"; +import type { FeatureMetric } from "../../../queries/useMonitoringApi"; + +const formatNumber = (val: number | null, decimals = 4): string => { + if (val === null || val === undefined) return "—"; + if (Number.isInteger(val)) return val.toLocaleString(); + return val.toFixed(decimals); +}; + +const formatPercent = (val: number | null): string => { + if (val === null || val === undefined) return "—"; + return `${(val * 100).toFixed(2)}%`; +}; + +const StatsPanel = ({ + metric, + baseline, +}: { + metric: FeatureMetric; + baseline?: FeatureMetric | null; +}) => { + const isNumeric = metric.feature_type === "numeric"; + + return ( + + + + +

Statistics

+
+
+ + + {metric.feature_type} + + +
+ + + Row Count + + {formatNumber(metric.row_count, 0)} + {baseline && ( + + (baseline: {formatNumber(baseline.row_count, 0)}) + + )} + + + Null Rate + + 0.1 ? "#BD271E" : "inherit", + fontWeight: metric.null_rate > 0.1 ? 600 : 400, + }} + > + {formatPercent(metric.null_rate)} + + {baseline && ( + + (baseline: {formatPercent(baseline.null_rate)}) + + )} + + + {isNumeric && ( + <> + Mean + + {formatNumber(metric.mean)} + {baseline && ( + + (baseline: {formatNumber(baseline.mean)}) + + )} + + + Std Dev + + {formatNumber(metric.stddev)} + + + Min / Max + + {formatNumber(metric.min_val)} / {formatNumber(metric.max_val)} + + + Percentiles + + P50: {formatNumber(metric.p50)} | P75: {formatNumber(metric.p75)}{" "} + | P90: {formatNumber(metric.p90)} | P95:{" "} + {formatNumber(metric.p95)} | P99: {formatNumber(metric.p99)} + + + )} + + Data Source + + {metric.data_source_type} + + + Granularity + + {metric.granularity} + + + Computed At + + {metric.computed_at + ? new Date(metric.computed_at).toLocaleString() + : "—"} + + +
+ ); +}; + +export default StatsPanel; diff --git a/ui/src/queries/useMonitoringApi.ts b/ui/src/queries/useMonitoringApi.ts new file mode 100644 index 00000000000..fde01f29d6d --- /dev/null +++ b/ui/src/queries/useMonitoringApi.ts @@ -0,0 +1,250 @@ +import { useContext } from "react"; +import { useQuery, useMutation, useQueryClient } from "react-query"; +import MonitoringContext from "../contexts/MonitoringContext"; + +interface FeatureMetric { + project_id: string; + feature_view_name: string; + feature_name: string; + metric_date: string; + granularity: string; + data_source_type: string; + computed_at: string; + is_baseline: boolean; + feature_type: string; + row_count: number; + null_count: number; + null_rate: number; + mean: number | null; + stddev: number | null; + min_val: number | null; + max_val: number | null; + p50: number | null; + p75: number | null; + p90: number | null; + p95: number | null; + p99: number | null; + histogram: NumericHistogram | CategoricalHistogram | null; +} + +interface NumericHistogram { + bins: number[]; + counts: number[]; + bin_width: number; +} + +interface CategoricalHistogram { + values: { value: string; count: number }[]; + other_count: number; + unique_count: number; +} + +interface FeatureViewMetric { + project_id: string; + feature_view_name: string; + metric_date: string; + granularity: string; + data_source_type: string; + computed_at: string; + is_baseline: boolean; + total_row_count: number; + total_features: number; + features_with_nulls: number; + avg_null_rate: number; + max_null_rate: number; +} + +interface FeatureServiceMetric { + project_id: string; + feature_service_name: string; + metric_date: string; + granularity: string; + data_source_type: string; + computed_at: string; + is_baseline: boolean; + total_feature_views: number; + total_features: number; + avg_null_rate: number; + max_null_rate: number; +} + +interface MonitoringFilters { + project: string; + feature_view_name?: string; + feature_name?: string; + feature_service_name?: string; + granularity?: string; + data_source_type?: string; + start_date?: string; + end_date?: string; +} + +const toQueryParams = ( + filters: MonitoringFilters, +): Record => { + return { + project: filters.project, + feature_view_name: filters.feature_view_name, + feature_name: filters.feature_name, + feature_service_name: filters.feature_service_name, + granularity: filters.granularity, + data_source_type: filters.data_source_type, + start_date: filters.start_date, + end_date: filters.end_date, + }; +}; + +const buildQueryString = (params: Record) => { + const entries = Object.entries(params).filter( + ([, v]) => v !== undefined && v !== "", + ); + if (entries.length === 0) return ""; + return "?" + entries.map(([k, v]) => `${k}=${encodeURIComponent(v!)}`).join("&"); +}; + +const fetchMonitoring = async ( + baseUrl: string, + path: string, + params: Record, +): Promise => { + const qs = buildQueryString(params); + const res = await fetch(`${baseUrl}${path}${qs}`); + if (!res.ok) { + throw new Error(`Failed to fetch ${path}: ${res.status} ${res.statusText}`); + } + const text = await res.text(); + const sanitized = text.replace(/:\s*NaN/g, ": null").replace(/:\s*Infinity/g, ": null").replace(/:\s*-Infinity/g, ": null"); + return JSON.parse(sanitized); +}; + +const STALE_TIME = 30_000; + +const useFeatureMetrics = (filters: MonitoringFilters) => { + const { apiBaseUrl, enabled } = useContext(MonitoringContext); + return useQuery( + ["monitoring-features", filters], + () => + fetchMonitoring( + apiBaseUrl, + "/monitoring/metrics/features", + toQueryParams(filters), + ), + { staleTime: STALE_TIME, enabled, retry: 1 }, + ); +}; + +const useFeatureViewMetrics = (filters: MonitoringFilters) => { + const { apiBaseUrl, enabled } = useContext(MonitoringContext); + return useQuery( + ["monitoring-feature-views", filters], + () => + fetchMonitoring( + apiBaseUrl, + "/monitoring/metrics/feature_views", + toQueryParams(filters), + ), + { staleTime: STALE_TIME, enabled, retry: 1 }, + ); +}; + +const useFeatureServiceMetrics = (filters: MonitoringFilters) => { + const { apiBaseUrl, enabled } = useContext(MonitoringContext); + return useQuery( + ["monitoring-feature-services", filters], + () => + fetchMonitoring( + apiBaseUrl, + "/monitoring/metrics/feature_services", + toQueryParams(filters), + ), + { staleTime: STALE_TIME, enabled, retry: 1 }, + ); +}; + +const useBaselineMetrics = ( + project: string, + featureViewName?: string, + featureName?: string, + dataSourceType?: string, +) => { + const { apiBaseUrl, enabled } = useContext(MonitoringContext); + return useQuery( + ["monitoring-baseline", project, featureViewName, featureName], + () => + fetchMonitoring( + apiBaseUrl, + "/monitoring/metrics/baseline", + { + project, + feature_view_name: featureViewName, + feature_name: featureName, + data_source_type: dataSourceType, + }, + ), + { staleTime: STALE_TIME, enabled, retry: 1 }, + ); +}; + +const useTimeseriesMetrics = (filters: MonitoringFilters) => { + const { apiBaseUrl, enabled } = useContext(MonitoringContext); + return useQuery( + ["monitoring-timeseries", filters], + () => + fetchMonitoring( + apiBaseUrl, + "/monitoring/metrics/timeseries", + toQueryParams(filters), + ), + { staleTime: STALE_TIME, enabled, retry: 1 }, + ); +}; + +const useComputeMetrics = () => { + const { apiBaseUrl } = useContext(MonitoringContext); + const queryClient = useQueryClient(); + return useMutation( + async (body: { + project: string; + feature_view_name?: string; + feature_names?: string[]; + start_date?: string; + end_date?: string; + granularity?: string; + set_baseline?: boolean; + }) => { + const res = await fetch(`${apiBaseUrl}/monitoring/compute`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + throw new Error(`Failed to trigger compute: ${res.status}`); + } + return res.json(); + }, + { + onSuccess: () => { + queryClient.invalidateQueries("monitoring-features"); + queryClient.invalidateQueries("monitoring-feature-views"); + queryClient.invalidateQueries("monitoring-feature-services"); + }, + }, + ); +}; + +export { + useFeatureMetrics, + useFeatureViewMetrics, + useFeatureServiceMetrics, + useBaselineMetrics, + useTimeseriesMetrics, + useComputeMetrics, +}; +export type { + FeatureMetric, + FeatureViewMetric, + FeatureServiceMetric, + NumericHistogram, + CategoricalHistogram, + MonitoringFilters, +}; From cf2f34e50ac68026c1478a57e890e71357c7678a Mon Sep 17 00:00:00 2001 From: Jitendra Yejare <11752425+jyejare@users.noreply.github.com> Date: Wed, 20 May 2026 21:38:43 +0530 Subject: [PATCH 2/6] fix: Build, test and review comments Signed-off-by: Jitendra Yejare <11752425+jyejare@users.noreply.github.com> --- ui/src/FeastUISansProviders.tsx | 212 ++++++++++++++++---------------- 1 file changed, 105 insertions(+), 107 deletions(-) diff --git a/ui/src/FeastUISansProviders.tsx b/ui/src/FeastUISansProviders.tsx index ac53dee5bf2..d9379bc66b6 100644 --- a/ui/src/FeastUISansProviders.tsx +++ b/ui/src/FeastUISansProviders.tsx @@ -113,128 +113,126 @@ const FeastUISansProvidersInner = ({ - - - }> } /> - }> + } + > } /> - } /> } - > - } /> - } - /> - } - /> - } /> - } - /> - } - > - } - /> - } - /> - } - /> - } /> - } - /> - - } /> - } - /> - } - /> - } /> - } - /> - } /> - } /> - } - /> - } - /> - + path="data-source/" + element={} + /> + } + /> + } /> + } + /> + } + > + } + /> + } + /> + } + /> + } /> + } + /> + } /> + } + /> + } + /> + } /> + } + /> + } + /> + } /> + } + /> + } + /> } /> - - - + + + - - + + ); }; From 0b4da4563f6f8f069c72beeeeadc9d661a5c3165 Mon Sep 17 00:00:00 2001 From: Jitendra Yejare <11752425+jyejare@users.noreply.github.com> Date: Tue, 26 May 2026 21:07:37 +0530 Subject: [PATCH 3/6] chore: Tuned UI changes for monitoring on testing Signed-off-by: Jitendra Yejare <11752425+jyejare@users.noreply.github.com> --- .../pages/monitoring/FeatureMetricsDetail.tsx | 190 +++--- .../pages/monitoring/FeatureMetricsTable.tsx | 130 +++- ui/src/pages/monitoring/Index.tsx | 12 +- .../monitoring/components/HistogramChart.tsx | 564 ++++++++++++------ .../monitoring/components/MetricsFilters.tsx | 2 +- ui/src/queries/useMonitoringApi.ts | 19 +- 6 files changed, 602 insertions(+), 315 deletions(-) diff --git a/ui/src/pages/monitoring/FeatureMetricsDetail.tsx b/ui/src/pages/monitoring/FeatureMetricsDetail.tsx index 7ace799742b..184fe113396 100644 --- a/ui/src/pages/monitoring/FeatureMetricsDetail.tsx +++ b/ui/src/pages/monitoring/FeatureMetricsDetail.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useState, useMemo } from "react"; import { useParams, useNavigate } from "react-router-dom"; import { EuiPageTemplate, @@ -9,6 +9,8 @@ import { EuiEmptyPrompt, EuiButton, EuiBreadcrumbs, + EuiSuperSelect, + EuiFormRow, } from "@elastic/eui"; import { FeatureIcon } from "../../graphics/FeatureIcon"; import { @@ -16,6 +18,7 @@ import { useBaselineMetrics, } from "../../queries/useMonitoringApi"; import type { + FeatureMetric, NumericHistogram, CategoricalHistogram, } from "../../queries/useMonitoringApi"; @@ -24,11 +27,24 @@ import { CategoricalHistogramChart, } from "./components/HistogramChart"; import StatsPanel from "./components/StatsPanel"; +import TimeSeriesAnalysis from "./components/TimeSeriesAnalysis"; import { useDocumentTitle } from "../../hooks/useDocumentTitle"; +const BASELINE_KEY = "__baseline__"; + +const GRANULARITY_LABELS: Record = { + daily: "Daily", + weekly: "Weekly", + biweekly: "Biweekly", + monthly: "Monthly", + quarterly: "Quarterly", + [BASELINE_KEY]: "Baseline", +}; + const FeatureMetricsDetail = () => { const { projectName, featureViewName, featureName } = useParams(); const navigate = useNavigate(); + const [selectedGranularity, setSelectedGranularity] = useState(""); useDocumentTitle( `${featureName} Monitoring | ${featureViewName} | Feast`, @@ -50,20 +66,59 @@ const FeatureMetricsDetail = () => { featureName, ); - const latestMetric = (() => { - if (!metrics || metrics.length === 0) return null; - const withData = metrics.filter((m) => m.row_count > 0); - const candidates = withData.length > 0 ? withData : metrics; - return candidates.reduce((a, b) => - a.metric_date > b.metric_date ? a : b, - ); - })(); - const baselineMetric = baselineMetrics && baselineMetrics.length > 0 ? baselineMetrics[0] : null; + const availableGranularities = useMemo(() => { + const granularities = new Set(); + if (metrics) { + for (const m of metrics) { + if (m.row_count > 0) granularities.add(m.granularity); + } + } + return Array.from(granularities).sort(); + }, [metrics]); + + const granularityOptions = useMemo(() => { + const options = availableGranularities.map((g) => ({ + value: g, + inputDisplay: GRANULARITY_LABELS[g] || g, + dropdownDisplay: GRANULARITY_LABELS[g] || g, + })); + if (baselineMetric) { + options.push({ + value: BASELINE_KEY, + inputDisplay: "Baseline", + dropdownDisplay: "Baseline (all data)", + }); + } + return options; + }, [availableGranularities, baselineMetric]); + + const effectiveGranularity = selectedGranularity || availableGranularities[0] || ""; + + const activeMetric = useMemo(() => { + if (effectiveGranularity === BASELINE_KEY && baselineMetric) { + return baselineMetric; + } + if (!metrics || metrics.length === 0) return null; + const matching = metrics.filter( + (m) => m.granularity === effectiveGranularity && m.row_count > 0, + ); + if (matching.length === 0) { + const withData = metrics.filter((m) => m.row_count > 0); + const candidates = withData.length > 0 ? withData : metrics; + return candidates.reduce((a, b) => + a.metric_date > b.metric_date ? a : b, + ); + } + return matching.reduce((a, b) => + a.metric_date > b.metric_date ? a : b, + ); + }, [metrics, effectiveGranularity, baselineMetric]); + const breadcrumbs = [ { text: "Monitoring", @@ -87,7 +142,7 @@ const FeatureMetricsDetail = () => { ); } - if (isError || !latestMetric) { + if (isError || !activeMetric) { return ( @@ -117,7 +172,7 @@ const FeatureMetricsDetail = () => { ); } - const isNumeric = latestMetric.feature_type === "numeric"; + const isNumeric = activeMetric.feature_type === "numeric"; return ( @@ -140,24 +195,47 @@ const FeatureMetricsDetail = () => { + {granularityOptions.length > 0 && ( + <> + + + + setSelectedGranularity(val)} + compressed + /> + + + + + + )} + - {isNumeric && latestMetric.histogram && ( + {isNumeric && activeMetric.histogram && ( )} - {!isNumeric && latestMetric.histogram && ( + {!isNumeric && activeMetric.histogram && ( )} - {!latestMetric.histogram && ( + {!activeMetric.histogram && ( No Histogram Data} body={

Histogram data is not available for this metric.

} @@ -167,8 +245,7 @@ const FeatureMetricsDetail = () => {
@@ -176,7 +253,10 @@ const FeatureMetricsDetail = () => { {metrics && metrics.length > 1 && ( <> - + )}
@@ -184,66 +264,4 @@ const FeatureMetricsDetail = () => { ); }; -const NullRateTimeline = ({ - metrics, -}: { - metrics: { metric_date: string; null_rate: number }[]; -}) => { - const sorted = [...metrics].sort( - (a, b) => a.metric_date.localeCompare(b.metric_date), - ); - const maxRate = Math.max(...sorted.map((m) => m.null_rate), 0.01); - const chartWidth = Math.max(sorted.length * 50, 200); - const chartHeight = 80; - - const points = sorted.map((m, i) => { - const x = (i / Math.max(sorted.length - 1, 1)) * (chartWidth - 20) + 10; - const y = chartHeight - (m.null_rate / maxRate) * (chartHeight - 10); - return { x, y, ...m }; - }); - - const polyline = points.map((p) => `${p.x},${p.y}`).join(" "); - - return ( -
-

- Null Rate Over Time -

- - - {points.map((p, i) => ( - - ))} - - {points.length > 0 && ( - <> - - {points[0].metric_date} - - - {points[points.length - 1].metric_date} - - - )} - -
- ); -}; - export default FeatureMetricsDetail; diff --git a/ui/src/pages/monitoring/FeatureMetricsTable.tsx b/ui/src/pages/monitoring/FeatureMetricsTable.tsx index d0a2e4e9573..76c18cddc93 100644 --- a/ui/src/pages/monitoring/FeatureMetricsTable.tsx +++ b/ui/src/pages/monitoring/FeatureMetricsTable.tsx @@ -3,9 +3,15 @@ import { EuiBasicTable, EuiBasicTableColumn, EuiBadge, + EuiButtonIcon, + EuiDescriptionList, + EuiFlexGroup, + EuiFlexItem, EuiHealth, EuiLink, + EuiPopover, EuiProgress, + EuiTitle, EuiToolTip, Criteria, } from "@elastic/eui"; @@ -33,6 +39,27 @@ const formatNum = (val: number | null, decimals = 2): string => { return val.toFixed(decimals); }; +const formatFreshness = (computedAt: string | null): string => { + if (!computedAt) return "—"; + const diff = Date.now() - new Date(computedAt).getTime(); + const mins = Math.floor(diff / 60_000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + const days = Math.floor(hrs / 24); + if (days < 30) return `${days}d ago`; + return `${Math.floor(days / 30)}mo ago`; +}; + +const freshnessColor = (computedAt: string | null): string => { + if (!computedAt) return "subdued"; + const hrs = (Date.now() - new Date(computedAt).getTime()) / 3_600_000; + if (hrs < 24) return "success"; + if (hrs < 72) return "warning"; + return "danger"; +}; + const MiniHistogram = ({ metric }: { metric: FeatureMetric }) => { if (!metric.histogram) return ; @@ -179,6 +206,22 @@ const FeatureMetricsTable = ({ } }; + const [isLegendOpen, setIsLegendOpen] = useState(false); + + const columnLegend = [ + { title: "Feature", description: "Name of the individual feature. Click to view full distribution and detailed statistics." }, + { title: "Feature View", description: "The feature view this feature belongs to — a logical grouping of related features sharing the same data source." }, + { title: "Type", description: "Data type: numeric (continuous/discrete numbers) or categorical (strings/labels)." }, + { title: "Distribution", description: "Compact histogram showing the value distribution. Blue bars = numeric, orange bars = categorical." }, + { title: "Rows", description: "Total number of rows (data points) observed for this feature in the computed time window." }, + { title: "Null Rate", description: "Percentage of rows with missing (null) values. Shown as a progress bar colored by severity." }, + { title: "Health", description: "Data quality indicator based on null rate: Healthy (< 10%), Moderate (10–49%), High (>= 50%)." }, + { title: "Mean", description: "Arithmetic mean of the feature values. Only shown for numeric features." }, + { title: "Std Dev", description: "Standard deviation — measures how spread out the values are from the mean. Only for numeric features." }, + { title: "Freshness", description: "Recency of the underlying data. Green (< 24h old), Yellow (24–72h), Red (> 72h). Hover for the data date." }, + { title: "Source", description: "Data source type used for metric computation (e.g. batch, stream)." }, + ]; + const columns: EuiBasicTableColumn[] = [ { field: "feature_name", @@ -260,6 +303,19 @@ const FeatureMetricsTable = ({ width: "100px", render: (val: number | null) => formatNum(val), }, + { + field: "metric_date", + name: "Freshness", + sortable: true, + width: "110px", + render: (val: string) => ( + + + {formatFreshness(val)} + + + ), + }, { field: "data_source_type", name: "Source", @@ -269,27 +325,59 @@ const FeatureMetricsTable = ({ ]; return ( - ({ - "data-test-subj": `row-${item.feature_name}`, - })} - noItemsMessage={ - isLoading - ? "Loading metrics..." - : "No metrics found. Run a monitoring compute job to generate metrics." - } - /> + <> + + + setIsLegendOpen(!isLegendOpen)} + /> + } + isOpen={isLegendOpen} + closePopover={() => setIsLegendOpen(false)} + anchorPosition="downRight" + panelPaddingSize="m" + panelStyle={{ maxWidth: 420 }} + > + +

Column Legend

+
+ +
+
+
+ ({ + "data-test-subj": `row-${item.feature_name}`, + })} + noItemsMessage={ + isLoading + ? "Loading metrics..." + : "No metrics found. Run a monitoring compute job to generate metrics." + } + /> + ); }; diff --git a/ui/src/pages/monitoring/Index.tsx b/ui/src/pages/monitoring/Index.tsx index 1af792b119b..1443df71b8e 100644 --- a/ui/src/pages/monitoring/Index.tsx +++ b/ui/src/pages/monitoring/Index.tsx @@ -33,31 +33,35 @@ const MonitoringIndex = () => { const { data: registryData } = useLoadRegistry(registryUrl, projectName); const [selectedFV, setSelectedFV] = useState(""); - const [granularity, setGranularity] = useState(""); + const [granularity, setGranularity] = useState("baseline"); const [dataSourceType, setDataSourceType] = useState(""); const [startDate, setStartDate] = useState(""); const [endDate, setEndDate] = useState(""); + const isBaseline = granularity === "baseline"; + const filters = useMemo( () => ({ project: projectName || "", feature_view_name: selectedFV || undefined, - granularity: granularity || undefined, + granularity: isBaseline ? undefined : granularity || undefined, data_source_type: dataSourceType || undefined, start_date: startDate || undefined, end_date: endDate || undefined, + is_baseline: isBaseline || undefined, }), - [projectName, selectedFV, granularity, dataSourceType, startDate, endDate], + [projectName, selectedFV, granularity, isBaseline, dataSourceType, startDate, endDate], ); const featureQuery = useFeatureMetrics(filters); const fvQuery = useFeatureViewMetrics(filters); const fsQuery = useFeatureServiceMetrics({ project: projectName || "", - granularity: granularity || undefined, + granularity: isBaseline ? undefined : granularity || undefined, data_source_type: dataSourceType || undefined, start_date: startDate || undefined, end_date: endDate || undefined, + is_baseline: isBaseline || undefined, }); const computeMutation = useComputeMetrics(); diff --git a/ui/src/pages/monitoring/components/HistogramChart.tsx b/ui/src/pages/monitoring/components/HistogramChart.tsx index 188bcba7c0b..6b573fe82e6 100644 --- a/ui/src/pages/monitoring/components/HistogramChart.tsx +++ b/ui/src/pages/monitoring/components/HistogramChart.tsx @@ -1,9 +1,17 @@ -import React from "react"; +import React, { useState } from "react"; import { EuiPanel, EuiTitle, EuiSpacer, EuiText, + EuiModal, + EuiModalHeader, + EuiModalHeaderTitle, + EuiModalBody, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiToolTip, } from "@elastic/eui"; import type { NumericHistogram, @@ -12,9 +20,181 @@ import type { const BAR_COLOR = "#006BB4"; const BAR_COLOR_BASELINE = "#BD271E55"; -const CHART_HEIGHT = 160; -const AXIS_HEIGHT = 24; -const LEFT_PAD = 50; + +interface ChartDimensions { + chartHeight: number; + axisHeight: number; + leftPad: number; + barGap: number; + minBarWidth: number; + targetBarsWidth: number; + fontSize: number; + xTickCount: number; +} + +const COMPACT: ChartDimensions = { + chartHeight: 160, + axisHeight: 28, + leftPad: 54, + barGap: 2, + minBarWidth: 6, + targetBarsWidth: 460, + fontSize: 10, + xTickCount: 2, +}; + +const EXPANDED: ChartDimensions = { + chartHeight: 400, + axisHeight: 48, + leftPad: 72, + barGap: 3, + minBarWidth: 12, + targetBarsWidth: 800, + fontSize: 12, + xTickCount: 6, +}; + +const formatNumber = (val: number, compact: boolean): string => { + if (val === 0) return "0"; + const abs = Math.abs(val); + if (compact && abs >= 1_000_000) return (val / 1_000_000).toFixed(1) + "M"; + if (compact && abs >= 1_000) return (val / 1_000).toFixed(1) + "K"; + if (abs >= 1) return val.toLocaleString(undefined, { maximumFractionDigits: 1 }); + if (abs >= 0.01) return val.toFixed(2); + return val.toExponential(1); +}; + +const renderNumericSvg = ( + histogram: NumericHistogram, + baseline: NumericHistogram | null | undefined, + dim: ChartDimensions, +) => { + const maxCount = Math.max( + ...histogram.counts, + ...(baseline ? baseline.counts : []), + 1, + ); + const numBars = histogram.counts.length; + const barWidth = Math.max( + Math.floor(dim.targetBarsWidth / numBars) - dim.barGap, + dim.minBarWidth, + ); + const barsWidth = (barWidth + dim.barGap) * numBars; + const svgWidth = dim.leftPad + barsWidth + 24; + const isCompact = dim === COMPACT; + + const yTickFractions = [0, 0.25, 0.5, 0.75, 1]; + const yTicks = yTickFractions.map((f) => ({ + label: formatNumber(Math.round(maxCount * f), isCompact), + y: dim.chartHeight - f * dim.chartHeight, + })); + + const xTickStep = Math.max(1, Math.floor(numBars / dim.xTickCount)); + const xTicks: { label: string; x: number }[] = []; + for (let i = 0; i < numBars; i += xTickStep) { + xTicks.push({ + label: formatNumber(histogram.bins[i], isCompact), + x: dim.leftPad + i * (barWidth + dim.barGap) + barWidth / 2, + }); + } + if (numBars > 0) { + const lastBin = histogram.bins[histogram.bins.length - 1]; + xTicks.push({ + label: formatNumber(lastBin, isCompact), + x: dim.leftPad + (numBars - 1) * (barWidth + dim.barGap) + barWidth / 2, + }); + } + + return ( + + {yTicks.map((t, i) => ( + + + + {t.label} + + + ))} + {histogram.counts.map((count, i) => { + const height = (count / maxCount) * dim.chartHeight; + const x = dim.leftPad + i * (barWidth + dim.barGap); + const binStart = histogram.bins[i]; + const binEnd = + i < histogram.bins.length - 1 + ? histogram.bins[i + 1] + : binStart + histogram.bin_width; + const baselineHeight = + baseline && baseline.counts[i] + ? (baseline.counts[i] / maxCount) * dim.chartHeight + : 0; + + return ( + + {baselineHeight > 0 && ( + + )} + + {`${formatNumber(binStart, false)} – ${formatNumber(binEnd, false)}: ${count.toLocaleString()}`} + + + ); + })} + + {xTicks.map((t, i) => ( + + {t.label} + + ))} + + ); +}; const NumericHistogramChart = ({ histogram, @@ -25,136 +205,81 @@ const NumericHistogramChart = ({ baseline?: NumericHistogram | null; title?: string; }) => { - const maxCount = Math.max(...histogram.counts, 1); - const numBars = histogram.counts.length; - const barWidth = Math.max(Math.floor(460 / numBars) - 2, 6); - const barsWidth = (barWidth + 2) * numBars; - const svgWidth = LEFT_PAD + barsWidth + 20; - - const yTicks = [0, 0.25, 0.5, 0.75, 1].map((f) => ({ - value: Math.round(maxCount * f), - y: CHART_HEIGHT - f * CHART_HEIGHT, - })); + const [expanded, setExpanded] = useState(false); return ( - - {title && ( - <> - -

{title}

-
- - - )} -
- - {yTicks.map((t) => ( - - + + + + {title && ( + +

{title}

+ + )} + + + + setExpanded(true)} /> - - {t.value.toLocaleString()} - - - ))} - {histogram.counts.map((count, i) => { - const height = (count / maxCount) * CHART_HEIGHT; - const x = LEFT_PAD + i * (barWidth + 2); - const binStart = histogram.bins[i]; - const binEnd = - i < histogram.bins.length - 1 - ? histogram.bins[i + 1] - : binStart + histogram.bin_width; - const baselineHeight = - baseline && baseline.counts[i] - ? (baseline.counts[i] / maxCount) * CHART_HEIGHT - : 0; - - return ( - - {baselineHeight > 0 && ( - + + + {title && } +
+ {renderNumericSvg(histogram, baseline, COMPACT)} +
+ {baseline && ( + + + Baseline + + )} + + + {expanded && ( + setExpanded(false)} maxWidth={960}> + + {title || "Histogram"} + + +
+ {renderNumericSvg(histogram, baseline, EXPANDED)} +
+ {baseline && ( + <> + + + - )} - - {`${binStart.toFixed(2)} – ${binEnd.toFixed(2)}: ${count.toLocaleString()}`} - - - ); - })} - - - {histogram.bins[0]?.toLocaleString(undefined, { maximumFractionDigits: 1 })} - - - {histogram.bins[histogram.bins.length - 1]?.toLocaleString(undefined, { maximumFractionDigits: 1 })} - - -
- {baseline && ( - - - Baseline - + Baseline + + + )} + + )} -
+ ); }; @@ -163,6 +288,69 @@ const BAR_MAX_WIDTH = 320; const COUNT_PAD = 80; const CAT_SVG_WIDTH = LABEL_WIDTH + BAR_MAX_WIDTH + COUNT_PAD; +const LABEL_WIDTH_EXP = 120; +const BAR_MAX_WIDTH_EXP = 560; +const COUNT_PAD_EXP = 100; +const CAT_SVG_WIDTH_EXP = LABEL_WIDTH_EXP + BAR_MAX_WIDTH_EXP + COUNT_PAD_EXP; + +const renderCategoricalSvg = ( + histogram: CategoricalHistogram, + isExpanded: boolean, +) => { + const labelW = isExpanded ? LABEL_WIDTH_EXP : LABEL_WIDTH; + const barMax = isExpanded ? BAR_MAX_WIDTH_EXP : BAR_MAX_WIDTH; + const countPad = isExpanded ? COUNT_PAD_EXP : COUNT_PAD; + const totalW = labelW + barMax + countPad; + const truncLen = isExpanded ? 20 : 8; + const fontSize = isExpanded ? 13 : 12; + const barHeight = isExpanded ? 30 : 24; + const rowHeight = barHeight + 6; + + const maxCount = Math.max(...histogram.values.map((v) => v.count), 1); + const chartHeight = histogram.values.length * rowHeight; + + return ( + + {histogram.values.map((v, i) => { + const width = (v.count / maxCount) * barMax; + const y = i * rowHeight; + return ( + + + {v.value.length > truncLen ? v.value.slice(0, truncLen) + "…" : v.value} + + + {`${v.value}: ${v.count.toLocaleString()}`} + + + {v.count.toLocaleString()} + + + ); + })} + + ); +}; + const CategoricalHistogramChart = ({ histogram, title, @@ -170,75 +358,61 @@ const CategoricalHistogramChart = ({ histogram: CategoricalHistogram; title?: string; }) => { - const maxCount = Math.max( - ...histogram.values.map((v) => v.count), - 1, - ); - const barHeight = 24; - const rowHeight = barHeight + 6; - const chartHeight = histogram.values.length * rowHeight; + const [expanded, setExpanded] = useState(false); return ( - - {title && ( - <> - -

{title}

-
- - + <> + + + + {title && ( + +

{title}

+
+ )} +
+ + + setExpanded(true)} + /> + + +
+ {title && } +
+ {renderCategoricalSvg(histogram, false)} +
+ + {histogram.unique_count} unique values + {histogram.other_count > 0 && + ` (${histogram.other_count.toLocaleString()} in other categories)`} + +
+ + {expanded && ( + setExpanded(false)} maxWidth={960}> + + {title || "Category Distribution"} + + +
+ {renderCategoricalSvg(histogram, true)} +
+ + + {histogram.unique_count} unique values + {histogram.other_count > 0 && + ` (${histogram.other_count.toLocaleString()} in other categories)`} + +
+
)} -
- - {histogram.values.map((v, i) => { - const width = (v.count / maxCount) * BAR_MAX_WIDTH; - const y = i * rowHeight; - return ( - - - {v.value.length > 8 ? v.value.slice(0, 8) + "…" : v.value} - - - {`${v.value}: ${v.count.toLocaleString()}`} - - - {v.count.toLocaleString()} - - - ); - })} - -
- - {histogram.unique_count} unique values - {histogram.other_count > 0 && - ` (${histogram.other_count.toLocaleString()} in other categories)`} - -
+ ); }; diff --git a/ui/src/pages/monitoring/components/MetricsFilters.tsx b/ui/src/pages/monitoring/components/MetricsFilters.tsx index 081e380da74..90a06e18ad4 100644 --- a/ui/src/pages/monitoring/components/MetricsFilters.tsx +++ b/ui/src/pages/monitoring/components/MetricsFilters.tsx @@ -25,7 +25,7 @@ interface MetricsFiltersProps { } const GRANULARITY_OPTIONS = [ - { value: "", text: "All" }, + { value: "baseline", text: "Baseline" }, { value: "daily", text: "Daily" }, { value: "weekly", text: "Weekly" }, { value: "biweekly", text: "Biweekly" }, diff --git a/ui/src/queries/useMonitoringApi.ts b/ui/src/queries/useMonitoringApi.ts index fde01f29d6d..2a7b71be135 100644 --- a/ui/src/queries/useMonitoringApi.ts +++ b/ui/src/queries/useMonitoringApi.ts @@ -77,6 +77,7 @@ interface MonitoringFilters { data_source_type?: string; start_date?: string; end_date?: string; + is_baseline?: boolean; } const toQueryParams = ( @@ -91,6 +92,7 @@ const toQueryParams = ( data_source_type: filters.data_source_type, start_date: filters.start_date, end_date: filters.end_date, + is_baseline: filters.is_baseline ? "true" : undefined, }; }; @@ -121,12 +123,15 @@ const STALE_TIME = 30_000; const useFeatureMetrics = (filters: MonitoringFilters) => { const { apiBaseUrl, enabled } = useContext(MonitoringContext); + const path = filters.is_baseline + ? "/monitoring/metrics/baseline" + : "/monitoring/metrics/features"; return useQuery( ["monitoring-features", filters], () => fetchMonitoring( apiBaseUrl, - "/monitoring/metrics/features", + path, toQueryParams(filters), ), { staleTime: STALE_TIME, enabled, retry: 1 }, @@ -135,12 +140,15 @@ const useFeatureMetrics = (filters: MonitoringFilters) => { const useFeatureViewMetrics = (filters: MonitoringFilters) => { const { apiBaseUrl, enabled } = useContext(MonitoringContext); + const path = filters.is_baseline + ? "/monitoring/metrics/baseline" + : "/monitoring/metrics/feature_views"; return useQuery( ["monitoring-feature-views", filters], () => fetchMonitoring( apiBaseUrl, - "/monitoring/metrics/feature_views", + path, toQueryParams(filters), ), { staleTime: STALE_TIME, enabled, retry: 1 }, @@ -206,13 +214,8 @@ const useComputeMetrics = () => { async (body: { project: string; feature_view_name?: string; - feature_names?: string[]; - start_date?: string; - end_date?: string; - granularity?: string; - set_baseline?: boolean; }) => { - const res = await fetch(`${apiBaseUrl}/monitoring/compute`, { + const res = await fetch(`${apiBaseUrl}/monitoring/auto_compute`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), From 806915fb991fe593c97aaad32c97369e8797e9a4 Mon Sep 17 00:00:00 2001 From: Jitendra Yejare <11752425+jyejare@users.noreply.github.com> Date: Thu, 28 May 2026 17:32:35 +0530 Subject: [PATCH 4/6] chore: Almost final distribution/stats changes for UI Signed-off-by: Jitendra Yejare <11752425+jyejare@users.noreply.github.com> --- .../components/TimeSeriesAnalysis.tsx | 594 ++++++++++++++++++ ui/src/queries/useMonitoringApi.ts | 54 +- 2 files changed, 641 insertions(+), 7 deletions(-) create mode 100644 ui/src/pages/monitoring/components/TimeSeriesAnalysis.tsx diff --git a/ui/src/pages/monitoring/components/TimeSeriesAnalysis.tsx b/ui/src/pages/monitoring/components/TimeSeriesAnalysis.tsx new file mode 100644 index 00000000000..3fb82d94a45 --- /dev/null +++ b/ui/src/pages/monitoring/components/TimeSeriesAnalysis.tsx @@ -0,0 +1,594 @@ +import React, { useState, useMemo } from "react"; +import { + EuiPanel, + EuiTitle, + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, + EuiSuperSelect, +} from "@elastic/eui"; +import type { + FeatureMetric, + CategoricalHistogram, +} from "../../../queries/useMonitoringApi"; + +const COLORS = ["#006BB4", "#54B399", "#E7664C", "#9170B8", "#D36086", "#6092C0", "#D6BF57", "#B9A888"]; + +const RANGE_OPTIONS = [ + { value: "24h", inputDisplay: "Last 24 hours" }, + { value: "7d", inputDisplay: "Last 7 days" }, + { value: "30d", inputDisplay: "Last 30 days" }, + { value: "90d", inputDisplay: "Last 90 days" }, + { value: "all", inputDisplay: "All time" }, +]; + +const rangeToMs: Record = { + "24h": 24 * 3600_000, + "7d": 7 * 86400_000, + "30d": 30 * 86400_000, + "90d": 90 * 86400_000, + all: Infinity, +}; + +interface ChartDims { + width: number; + height: number; + padLeft: number; + padRight: number; + padTop: number; + padBottom: number; +} + +const DIMS: ChartDims = { + width: 860, + height: 220, + padLeft: 60, + padRight: 20, + padTop: 10, + padBottom: 30, +}; + +const formatDate = (d: string): string => { + const dt = new Date(d); + const mm = String(dt.getMonth() + 1).padStart(2, "0"); + const dd = String(dt.getDate()).padStart(2, "0"); + const hh = String(dt.getHours()).padStart(2, "0"); + const mi = String(dt.getMinutes()).padStart(2, "0"); + return `${mm}-${dd} ${hh}:${mi}`; +}; + +const formatAxisVal = (v: number): string => { + if (v === 0) return "0"; + const abs = Math.abs(v); + if (abs >= 1_000_000) return (v / 1_000_000).toFixed(1) + "M"; + if (abs >= 1_000) return (v / 1_000).toFixed(1) + "K"; + if (abs >= 1) return v.toFixed(1); + return (v * 100).toFixed(1) + "%"; +}; + +const niceYTicks = (min: number, max: number, count = 5): number[] => { + if (max === min) return [min]; + const step = (max - min) / (count - 1); + return Array.from({ length: count }, (_, i) => min + step * i); +}; + +interface LineSeriesData { + label: string; + color: string; + points: { x: number; y: number; date: string; value: number }[]; +} + +const renderMultiLineChart = ( + series: LineSeriesData[], + dims: ChartDims, + yLabel: string, + yFormatter: (v: number) => string = formatAxisVal, +) => { + const allPoints = series.flatMap((s) => s.points); + if (allPoints.length === 0) return null; + + const xMin = Math.min(...allPoints.map((p) => p.x)); + const xMax = Math.max(...allPoints.map((p) => p.x)); + const yMin = Math.min(...allPoints.map((p) => p.value), 0); + const yMax = Math.max(...allPoints.map((p) => p.value), 0.01); + + const plotW = dims.width - dims.padLeft - dims.padRight; + const plotH = dims.height - dims.padTop - dims.padBottom; + + const scaleX = (x: number) => + xMax === xMin + ? dims.padLeft + plotW / 2 + : dims.padLeft + ((x - xMin) / (xMax - xMin)) * plotW; + const scaleY = (v: number) => + dims.padTop + plotH - ((v - yMin) / (yMax - yMin || 1)) * plotH; + + const yTicks = niceYTicks(yMin, yMax); + const xDates = allPoints + .map((p) => ({ x: p.x, date: p.date })) + .filter((v, i, arr) => arr.findIndex((a) => a.date === v.date) === i) + .sort((a, b) => a.x - b.x); + + const maxXLabels = Math.floor(plotW / 80); + const xStep = Math.max(1, Math.ceil(xDates.length / maxXLabels)); + const xLabels = xDates.filter((_, i) => i % xStep === 0); + + return ( + + {/* Y axis grid + labels */} + {yTicks.map((v, i) => { + const y = scaleY(v); + return ( + + + + {yFormatter(v)} + + + ); + })} + + {/* Y axis label */} + + {yLabel} + + + {/* X axis labels */} + {xLabels.map((xl, i) => ( + + {formatDate(xl.date)} + + ))} + + {/* Lines */} + {series.map((s, si) => { + if (s.points.length === 0) return null; + const sorted = [...s.points].sort((a, b) => a.x - b.x); + const pathD = sorted + .map((p, i) => `${i === 0 ? "M" : "L"} ${scaleX(p.x)} ${scaleY(p.value)}`) + .join(" "); + return ( + + + {sorted.map((p, i) => ( + + ))} + + ); + })} + + ); +}; + +const renderAreaChart = ( + points: { x: number; value: number; date: string }[], + dims: ChartDims, + yLabel: string, + lineColor: string, + fillColor: string, +) => { + if (points.length === 0) return null; + + const sorted = [...points].sort((a, b) => a.x - b.x); + const xMin = Math.min(...sorted.map((p) => p.x)); + const xMax = Math.max(...sorted.map((p) => p.x)); + const yMin = 0; + const yMax = Math.max(...sorted.map((p) => p.value), 0.01); + + const plotW = dims.width - dims.padLeft - dims.padRight; + const plotH = dims.height - dims.padTop - dims.padBottom; + + const scaleX = (x: number) => + xMax === xMin + ? dims.padLeft + plotW / 2 + : dims.padLeft + ((x - xMin) / (xMax - xMin)) * plotW; + const scaleY = (v: number) => + dims.padTop + plotH - ((v - yMin) / (yMax - yMin || 1)) * plotH; + + const yTicks = niceYTicks(yMin, yMax); + const baseline = scaleY(0); + + const areaPath = + `M ${scaleX(sorted[0].x)} ${baseline} ` + + sorted.map((p) => `L ${scaleX(p.x)} ${scaleY(p.value)}`).join(" ") + + ` L ${scaleX(sorted[sorted.length - 1].x)} ${baseline} Z`; + + const linePath = sorted + .map((p, i) => `${i === 0 ? "M" : "L"} ${scaleX(p.x)} ${scaleY(p.value)}`) + .join(" "); + + const maxXLabels = Math.floor(plotW / 80); + const xStep = Math.max(1, Math.ceil(sorted.length / maxXLabels)); + const xLabels = sorted.filter((_, i) => i % xStep === 0); + + return ( + + {yTicks.map((v, i) => { + const y = scaleY(v); + return ( + + + + {(v * 100).toFixed(0)}% + + + ); + })} + + + {yLabel} + + + {xLabels.map((xl, i) => ( + + {formatDate(xl.date)} + + ))} + + + + + ); +}; + +const Legend = ({ items }: { items: { label: string; color: string }[] }) => ( +
+ {items.map((item, i) => ( +
+
+ {item.label} +
+ ))} +
+); + +interface TimeSeriesAnalysisProps { + metrics: FeatureMetric[]; + featureType: string; +} + +const TimeSeriesAnalysis = ({ metrics, featureType }: TimeSeriesAnalysisProps) => { + const [range, setRange] = useState("all"); + + const filteredMetrics = useMemo(() => { + if (range === "all") return metrics; + const cutoff = Date.now() - rangeToMs[range]; + return metrics.filter((m) => new Date(m.metric_date).getTime() >= cutoff); + }, [metrics, range]); + + const sorted = useMemo( + () => + [...filteredMetrics] + .filter((m) => m.row_count > 0) + .sort((a, b) => a.metric_date.localeCompare(b.metric_date)), + [filteredMetrics], + ); + + const toX = (m: FeatureMetric) => new Date(m.metric_date).getTime(); + const isNumeric = featureType === "numeric"; + const hasData = sorted.length >= 2; + + return ( + + + + +

Time-Series Analysis

+
+

+ Historical trends for central aggregates and quality signals. +

+
+ + + +
+ + + + {!hasData ? ( +

+ No data points available for the selected time range. Try a wider range. +

+ ) : isNumeric ? ( + + ) : ( + + )} +
+ ); +}; + +const NumericTimeSeries = ({ + metrics, + toX, +}: { + metrics: FeatureMetric[]; + toX: (m: FeatureMetric) => number; +}) => { + const driftSeries: LineSeriesData[] = useMemo(() => { + const build = ( + label: string, + color: string, + accessor: (m: FeatureMetric) => number | null, + ): LineSeriesData => ({ + label, + color, + points: metrics + .filter((m) => accessor(m) !== null) + .map((m) => ({ + x: toX(m), + y: 0, + value: accessor(m)!, + date: m.metric_date, + })), + }); + return [ + build("Mean", COLORS[0], (m) => m.mean), + build("P50", COLORS[1], (m) => m.p50), + build("P95", COLORS[2], (m) => m.p95), + ]; + }, [metrics, toX]); + + const nullPoints = useMemo( + () => + metrics.map((m) => ({ + x: toX(m), + value: m.null_rate, + date: m.metric_date, + })), + [metrics, toX], + ); + + return ( + <> +

+ Aggregate Metrics Drift (Mean/P50/P95) +

+
+ {renderMultiLineChart(driftSeries, DIMS, "Metric Value")} +
+ + + + +

+ Null Rate Evolution (%) +

+
+ {renderAreaChart( + nullPoints, + { ...DIMS, height: 160 }, + "Percentage (%)", + "#BD271E", + "rgba(189, 39, 30, 0.15)", + )} +
+ + ); +}; + +const CategoricalTimeSeries = ({ + metrics, + toX, +}: { + metrics: FeatureMetric[]; + toX: (m: FeatureMetric) => number; +}) => { + const { cardinalitySeries, shareSeries, topCategories } = useMemo(() => { + const catCounts = new Map(); + for (const m of metrics) { + const hist = m.histogram as CategoricalHistogram | null; + if (!hist) continue; + for (const v of hist.values) { + catCounts.set(v.value, (catCounts.get(v.value) || 0) + v.count); + } + } + const topCats = Array.from(catCounts.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 5) + .map(([v]) => v); + + const cardSeries: LineSeriesData[] = [ + { + label: "Cardinality", + color: COLORS[0], + points: metrics + .filter((m) => m.histogram) + .map((m) => ({ + x: toX(m), + y: 0, + value: (m.histogram as CategoricalHistogram).unique_count || 0, + date: m.metric_date, + })), + }, + ...topCats.map((cat, i) => ({ + label: cat, + color: COLORS[(i + 1) % COLORS.length], + points: metrics + .filter((m) => m.histogram) + .map((m) => { + const hist = m.histogram as CategoricalHistogram; + const entry = hist.values.find((v) => v.value === cat); + return { + x: toX(m), + y: 0, + value: entry?.count || 0, + date: m.metric_date, + }; + }), + })), + ]; + + const shrSeries: LineSeriesData[] = topCats.map((cat, i) => ({ + label: cat, + color: COLORS[(i + 1) % COLORS.length], + points: metrics + .filter((m) => m.histogram && m.row_count > 0) + .map((m) => { + const hist = m.histogram as CategoricalHistogram; + const entry = hist.values.find((v) => v.value === cat); + return { + x: toX(m), + y: 0, + value: ((entry?.count || 0) / m.row_count) * 100, + date: m.metric_date, + }; + }), + })); + + return { + cardinalitySeries: cardSeries, + shareSeries: shrSeries, + topCategories: topCats, + }; + }, [metrics, toX]); + + const nullPoints = useMemo( + () => + metrics.map((m) => ({ + x: toX(m), + value: m.null_rate, + date: m.metric_date, + })), + [metrics, toX], + ); + + const shareYFormatter = (v: number) => `${v.toFixed(0)}%`; + + return ( + <> +

+ Cardinality over time +

+
+ {renderMultiLineChart(cardinalitySeries, DIMS, "Count")} +
+ ({ + label: s.label, + color: s.color, + }))} + /> + + + +

+ Top category share over time (%) +

+
+ {renderMultiLineChart(shareSeries, DIMS, "Percentage (%)", shareYFormatter)} +
+ ({ + label: cat, + color: COLORS[(i + 1) % COLORS.length], + }))} + /> + + + +

+ Null Rate Evolution (%) +

+
+ {renderAreaChart( + nullPoints, + { ...DIMS, height: 160 }, + "Percentage (%)", + "#BD271E", + "rgba(189, 39, 30, 0.15)", + )} +
+ + ); +}; + +export default TimeSeriesAnalysis; diff --git a/ui/src/queries/useMonitoringApi.ts b/ui/src/queries/useMonitoringApi.ts index 2a7b71be135..7d93b26c9e3 100644 --- a/ui/src/queries/useMonitoringApi.ts +++ b/ui/src/queries/useMonitoringApi.ts @@ -138,19 +138,59 @@ const useFeatureMetrics = (filters: MonitoringFilters) => { ); }; +const aggregateToFeatureViewMetrics = ( + features: FeatureMetric[], +): FeatureViewMetric[] => { + const grouped = new Map(); + for (const f of features) { + const key = f.feature_view_name; + if (!grouped.has(key)) grouped.set(key, []); + grouped.get(key)!.push(f); + } + return Array.from(grouped.entries()).map(([fvName, feats]) => { + const nullRates = feats.map((f) => f.null_rate ?? 0); + const maxRowCount = Math.max(...feats.map((f) => f.row_count ?? 0)); + return { + project_id: feats[0].project_id, + feature_view_name: fvName, + metric_date: feats[0].metric_date, + granularity: feats[0].granularity, + data_source_type: feats[0].data_source_type, + computed_at: feats[0].computed_at, + is_baseline: feats[0].is_baseline, + total_row_count: maxRowCount, + total_features: feats.length, + features_with_nulls: feats.filter((f) => (f.null_count ?? 0) > 0).length, + avg_null_rate: + nullRates.length > 0 + ? nullRates.reduce((a, b) => a + b, 0) / nullRates.length + : 0, + max_null_rate: + nullRates.length > 0 ? Math.max(...nullRates) : 0, + }; + }); +}; + const useFeatureViewMetrics = (filters: MonitoringFilters) => { const { apiBaseUrl, enabled } = useContext(MonitoringContext); - const path = filters.is_baseline - ? "/monitoring/metrics/baseline" - : "/monitoring/metrics/feature_views"; + const isBaseline = !!filters.is_baseline; return useQuery( ["monitoring-feature-views", filters], - () => - fetchMonitoring( + async () => { + if (isBaseline) { + const features = await fetchMonitoring( + apiBaseUrl, + "/monitoring/metrics/baseline", + toQueryParams(filters), + ); + return aggregateToFeatureViewMetrics(features); + } + return fetchMonitoring( apiBaseUrl, - path, + "/monitoring/metrics/feature_views", toQueryParams(filters), - ), + ); + }, { staleTime: STALE_TIME, enabled, retry: 1 }, ); }; From 3de7c9251dedeea8b530adce1de4885ff1adc31d Mon Sep 17 00:00:00 2001 From: Jitendra Yejare <11752425+jyejare@users.noreply.github.com> Date: Wed, 10 Jun 2026 18:27:11 +0530 Subject: [PATCH 5/6] fix: Monitoring backend check and review comments fixed Signed-off-by: Jitendra Yejare <11752425+jyejare@users.noreply.github.com> --- docs/how-to-guides/feature-monitoring.md | 14 +-- .../feast/api/registry/rest/monitoring.py | 23 +++++ .../feast/monitoring/monitoring_service.py | 2 +- sdk/python/feast/ui_server.py | 2 +- ui/src/FeastUISansProviders.tsx | 33 +++++-- ui/src/contexts/MonitoringContext.ts | 2 +- ui/src/pages/Sidebar.tsx | 37 ++++--- .../pages/features/FeatureMonitoringTab.tsx | 21 ++-- .../pages/monitoring/FeatureMetricsDetail.tsx | 23 ++--- .../pages/monitoring/FeatureMetricsTable.tsx | 70 ++++++++++--- .../monitoring/FeatureServiceMetricsPanel.tsx | 9 +- .../monitoring/FeatureViewMetricsPanel.tsx | 9 +- ui/src/pages/monitoring/Index.tsx | 32 ++++-- .../monitoring/components/HistogramChart.tsx | 30 ++++-- .../monitoring/components/MetricsFilters.tsx | 14 ++- .../components/TimeSeriesAnalysis.tsx | 97 +++++++++++++++---- ui/src/queries/useMonitoringApi.ts | 60 +++++++----- 17 files changed, 326 insertions(+), 152 deletions(-) diff --git a/docs/how-to-guides/feature-monitoring.md b/docs/how-to-guides/feature-monitoring.md index 7e2ebe4199d..1396f585fb3 100644 --- a/docs/how-to-guides/feature-monitoring.md +++ b/docs/how-to-guides/feature-monitoring.md @@ -43,16 +43,16 @@ Done! The baseline reads all available source data and stores the resulting statistics with `is_baseline=TRUE`. This serves as the reference distribution for future drift detection. Baseline computation is: -- **Non-blocking** — `feast apply` returns immediately; computation runs asynchronously +- **Threaded** — runs in a background thread but completes before `feast apply` exits - **Idempotent** — only features without existing baselines are computed; re-running `feast apply` won't recompute existing baselines -### Disabling auto-baseline +### Enabling auto-baseline -To skip automatic baseline computation on `feast apply`, set the DQM config in `feature_store.yaml`: +To enable automatic baseline computation on `feast apply`, set the DQM config in `feature_store.yaml`: ```yaml -DataQualityMonitoring: - auto_baseline: false +data_quality_monitoring: + auto_baseline: true ``` When using the Feast operator, set this in the `FeatureStore` CR: @@ -63,9 +63,11 @@ kind: FeatureStore spec: feastProject: my_project dataQualityMonitoring: - autoBaseline: false + autoBaseline: true ``` +To disable it, set `auto_baseline: false` (or `autoBaseline: false` in the CR). + ## 3. Scheduled monitoring with the CLI ### Auto mode (recommended for production) diff --git a/sdk/python/feast/api/registry/rest/monitoring.py b/sdk/python/feast/api/registry/rest/monitoring.py index e4b614d5e52..aaec5143ce2 100644 --- a/sdk/python/feast/api/registry/rest/monitoring.py +++ b/sdk/python/feast/api/registry/rest/monitoring.py @@ -77,6 +77,29 @@ def _get_store(): ) return store + @router.get("/monitoring/config", tags=["Monitoring"]) + def monitoring_config(): + """Report whether DQM is configured, checking the live config file.""" + import os + + import yaml + + s = _get_store() + dqm = getattr(s.config, "data_quality_monitoring_config", None) + if dqm is not None: + return {"enabled": True} + + repo_path = getattr(s, "repo_path", None) + if repo_path: + cfg_file = os.path.join(str(repo_path), "feature_store.yaml") + if os.path.exists(cfg_file): + with open(cfg_file) as f: + cfg = yaml.safe_load(f) + if cfg and cfg.get("data_quality_monitoring"): + return {"enabled": True} + + return {"enabled": False} + # ------------------------------------------------------------------ # # DQM Job: submit and track # ------------------------------------------------------------------ # diff --git a/sdk/python/feast/monitoring/monitoring_service.py b/sdk/python/feast/monitoring/monitoring_service.py index 088cd86ce40..1e284647732 100644 --- a/sdk/python/feast/monitoring/monitoring_service.py +++ b/sdk/python/feast/monitoring/monitoring_service.py @@ -367,7 +367,7 @@ def compute_baseline( feature_view=fv, metrics_list=metrics_list, metric_date=date.today(), - granularity="daily", + granularity="baseline", set_baseline=True, now=now, ) diff --git a/sdk/python/feast/ui_server.py b/sdk/python/feast/ui_server.py index 0d4643ca113..046b960b461 100644 --- a/sdk/python/feast/ui_server.py +++ b/sdk/python/feast/ui_server.py @@ -81,7 +81,7 @@ def _setup_rest_mode(app: FastAPI, store: "feast.FeatureStore"): grpc_handler = RegistryServer(store.registry) rest_app = FastAPI(root_path="/api/v1") - register_all_routes(rest_app, grpc_handler) + register_all_routes(rest_app, grpc_handler, store=store) class PushRequest(BaseModel): push_source_name: str diff --git a/ui/src/FeastUISansProviders.tsx b/ui/src/FeastUISansProviders.tsx index d9379bc66b6..6025d918a6b 100644 --- a/ui/src/FeastUISansProviders.tsx +++ b/ui/src/FeastUISansProviders.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useEffect, useState } from "react"; import "./index.css"; @@ -109,6 +109,23 @@ const FeastUISansProvidersInner = ({ fetchOptions: feastUIConfigs?.fetchOptions, }; + const [autoMonitoringEnabled, setAutoMonitoringEnabled] = useState(false); + useEffect(() => { + if (feastUIConfigs?.monitoringConfig) return; + fetch("/api/v1/monitoring/config") + .then((r) => r.json()) + .then((data) => { + if (data?.enabled) setAutoMonitoringEnabled(true); + }) + .catch(() => {}); + }, [feastUIConfigs?.monitoringConfig]); + + const monitoringConfig: MonitoringConfig = + feastUIConfigs?.monitoringConfig || { + apiBaseUrl: "/api/v1", + enabled: autoMonitoringEnabled, + }; + return ( @@ -144,14 +161,7 @@ const FeastUISansProvidersInner = ({ - + }> @@ -195,7 +205,10 @@ const FeastUISansProvidersInner = ({ path="entity/:entityName/*" element={} /> - } /> + } + /> } diff --git a/ui/src/contexts/MonitoringContext.ts b/ui/src/contexts/MonitoringContext.ts index 985f00080e9..f701cbcd5bf 100644 --- a/ui/src/contexts/MonitoringContext.ts +++ b/ui/src/contexts/MonitoringContext.ts @@ -7,7 +7,7 @@ interface MonitoringConfig { const MonitoringContext = React.createContext({ apiBaseUrl: "/api/v1", - enabled: true, + enabled: false, }); export default MonitoringContext; diff --git a/ui/src/pages/Sidebar.tsx b/ui/src/pages/Sidebar.tsx index 58477cbd293..4aa43119ab0 100644 --- a/ui/src/pages/Sidebar.tsx +++ b/ui/src/pages/Sidebar.tsx @@ -1,8 +1,9 @@ -import React, { useState } from "react"; +import React, { useContext, useState } from "react"; import { EuiIcon, EuiSideNav, htmlIdGenerator } from "@elastic/eui"; import { Link, useParams } from "react-router-dom"; import { useMatchSubpath } from "../hooks/useMatchSubpath"; +import MonitoringContext from "../contexts/MonitoringContext"; import useResourceQuery, { entityListPath, featureViewListPath, @@ -84,6 +85,8 @@ const SideNav = () => { restSelect: restLabelViewsFromResponse, }); + const { enabled: monitoringEnabled } = useContext(MonitoringContext); + const [isSideNavOpenOnMobile, setisSideNavOpenOnMobile] = useState(false); const toggleOpenOnMobile = () => { @@ -99,6 +102,7 @@ const SideNav = () => { const labelViewsLabel = `Label Views ${lvSuccess && labelViews && labelViews.length > 0 ? `(${labelViews.length})` : ""}`; const baseUrl = `/p/${projectName}`; + const monitoringSelected = useMatchSubpath(`${baseUrl}/monitoring`); const sideNav: React.ComponentProps["items"] = [ { @@ -176,24 +180,19 @@ const SideNav = () => { renderItem: (props) => , isSelected: useMatchSubpath(`${baseUrl}/data-set`), }, - { - name: "Monitoring", - id: htmlIdGenerator("monitoring")(), - icon: , - renderItem: (props) => ( - - ), - isSelected: useMatchSubpath(`${baseUrl}/monitoring`), - }, - { - name: "Data Labeling", - id: htmlIdGenerator("dataLabeling")(), - icon: , - renderItem: (props) => ( - - ), - isSelected: useMatchSubpath(`${baseUrl}/data-labeling`), - }, + ...(monitoringEnabled + ? [ + { + name: "Monitoring", + id: htmlIdGenerator("monitoring")(), + icon: , + renderItem: (props: any) => ( + + ), + isSelected: monitoringSelected, + }, + ] + : []), { name: "Permissions", id: htmlIdGenerator("permissions")(), diff --git a/ui/src/pages/features/FeatureMonitoringTab.tsx b/ui/src/pages/features/FeatureMonitoringTab.tsx index fdf7b38bc86..bc8e2c2cf9f 100644 --- a/ui/src/pages/features/FeatureMonitoringTab.tsx +++ b/ui/src/pages/features/FeatureMonitoringTab.tsx @@ -49,15 +49,11 @@ const FeatureMonitoringTab = () => { if (!metrics || metrics.length === 0) return null; const withData = metrics.filter((m) => m.row_count > 0); const candidates = withData.length > 0 ? withData : metrics; - return candidates.reduce((a, b) => - a.metric_date > b.metric_date ? a : b, - ); + return candidates.reduce((a, b) => (a.metric_date > b.metric_date ? a : b)); })(); const baselineMetric = - baselineMetrics && baselineMetrics.length > 0 - ? baselineMetrics[0] - : null; + baselineMetrics && baselineMetrics.length > 0 ? baselineMetrics[0] : null; if (isError || !latestMetric) { return ( @@ -66,15 +62,12 @@ const FeatureMonitoringTab = () => { title={

No Monitoring Data

} body={

- No monitoring metrics available for this feature. Run a - monitoring compute job to generate data quality metrics. + No monitoring metrics available for this feature. Run a monitoring + compute job to generate data quality metrics.

} actions={ - + Go to Monitoring } @@ -91,9 +84,7 @@ const FeatureMonitoringTab = () => { {isNumeric && latestMetric.histogram && ( )} diff --git a/ui/src/pages/monitoring/FeatureMetricsDetail.tsx b/ui/src/pages/monitoring/FeatureMetricsDetail.tsx index 184fe113396..88d464d8faa 100644 --- a/ui/src/pages/monitoring/FeatureMetricsDetail.tsx +++ b/ui/src/pages/monitoring/FeatureMetricsDetail.tsx @@ -46,9 +46,7 @@ const FeatureMetricsDetail = () => { const navigate = useNavigate(); const [selectedGranularity, setSelectedGranularity] = useState(""); - useDocumentTitle( - `${featureName} Monitoring | ${featureViewName} | Feast`, - ); + useDocumentTitle(`${featureName} Monitoring | ${featureViewName} | Feast`); const { data: metrics, @@ -67,9 +65,7 @@ const FeatureMetricsDetail = () => { ); const baselineMetric = - baselineMetrics && baselineMetrics.length > 0 - ? baselineMetrics[0] - : null; + baselineMetrics && baselineMetrics.length > 0 ? baselineMetrics[0] : null; const availableGranularities = useMemo(() => { const granularities = new Set(); @@ -97,7 +93,8 @@ const FeatureMetricsDetail = () => { return options; }, [availableGranularities, baselineMetric]); - const effectiveGranularity = selectedGranularity || availableGranularities[0] || ""; + const effectiveGranularity = + selectedGranularity || availableGranularities[0] || ""; const activeMetric = useMemo(() => { if (effectiveGranularity === BASELINE_KEY && baselineMetric) { @@ -114,9 +111,7 @@ const FeatureMetricsDetail = () => { a.metric_date > b.metric_date ? a : b, ); } - return matching.reduce((a, b) => - a.metric_date > b.metric_date ? a : b, - ); + return matching.reduce((a, b) => (a.metric_date > b.metric_date ? a : b)); }, [metrics, effectiveGranularity, baselineMetric]); const breadcrumbs = [ @@ -155,8 +150,8 @@ const FeatureMetricsDetail = () => {

No monitoring metrics found for feature{" "} {featureName} in feature view{" "} - {featureViewName}. Run a monitoring - compute job first. + {featureViewName}. Run a monitoring compute job + first.

} actions={ @@ -244,9 +239,7 @@ const FeatureMetricsDetail = () => { - + diff --git a/ui/src/pages/monitoring/FeatureMetricsTable.tsx b/ui/src/pages/monitoring/FeatureMetricsTable.tsx index 76c18cddc93..5b998c98aee 100644 --- a/ui/src/pages/monitoring/FeatureMetricsTable.tsx +++ b/ui/src/pages/monitoring/FeatureMetricsTable.tsx @@ -209,17 +209,61 @@ const FeatureMetricsTable = ({ const [isLegendOpen, setIsLegendOpen] = useState(false); const columnLegend = [ - { title: "Feature", description: "Name of the individual feature. Click to view full distribution and detailed statistics." }, - { title: "Feature View", description: "The feature view this feature belongs to — a logical grouping of related features sharing the same data source." }, - { title: "Type", description: "Data type: numeric (continuous/discrete numbers) or categorical (strings/labels)." }, - { title: "Distribution", description: "Compact histogram showing the value distribution. Blue bars = numeric, orange bars = categorical." }, - { title: "Rows", description: "Total number of rows (data points) observed for this feature in the computed time window." }, - { title: "Null Rate", description: "Percentage of rows with missing (null) values. Shown as a progress bar colored by severity." }, - { title: "Health", description: "Data quality indicator based on null rate: Healthy (< 10%), Moderate (10–49%), High (>= 50%)." }, - { title: "Mean", description: "Arithmetic mean of the feature values. Only shown for numeric features." }, - { title: "Std Dev", description: "Standard deviation — measures how spread out the values are from the mean. Only for numeric features." }, - { title: "Freshness", description: "Recency of the underlying data. Green (< 24h old), Yellow (24–72h), Red (> 72h). Hover for the data date." }, - { title: "Source", description: "Data source type used for metric computation (e.g. batch, stream)." }, + { + title: "Feature", + description: + "Name of the individual feature. Click to view full distribution and detailed statistics.", + }, + { + title: "Feature View", + description: + "The feature view this feature belongs to — a logical grouping of related features sharing the same data source.", + }, + { + title: "Type", + description: + "Data type: numeric (continuous/discrete numbers) or categorical (strings/labels).", + }, + { + title: "Distribution", + description: + "Compact histogram showing the value distribution. Blue bars = numeric, orange bars = categorical.", + }, + { + title: "Rows", + description: + "Total number of rows (data points) observed for this feature in the computed time window.", + }, + { + title: "Null Rate", + description: + "Percentage of rows with missing (null) values. Shown as a progress bar colored by severity.", + }, + { + title: "Health", + description: + "Data quality indicator based on null rate: Healthy (< 10%), Moderate (10–49%), High (>= 50%).", + }, + { + title: "Mean", + description: + "Arithmetic mean of the feature values. Only shown for numeric features.", + }, + { + title: "Std Dev", + description: + "Standard deviation — measures how spread out the values are from the mean. Only for numeric features.", + }, + { + title: "Freshness", + description: + "Recency of the underlying data. Green (< 24h old), Yellow (24–72h), Red (> 72h). Hover for the data date.", + }, + { + title: "Source", + description: + "Data source type used for metric computation (e.g. batch, stream).", + }, ]; const columns: EuiBasicTableColumn[] = [ @@ -228,9 +272,7 @@ const FeatureMetricsTable = ({ name: "Feature", sortable: true, render: (name: string, item: FeatureMetric) => ( - onFeatureClick(item.feature_view_name, name)} - > + onFeatureClick(item.feature_view_name, name)}> {name} ), diff --git a/ui/src/pages/monitoring/FeatureServiceMetricsPanel.tsx b/ui/src/pages/monitoring/FeatureServiceMetricsPanel.tsx index c3fa61b25a8..536c5d56c6c 100644 --- a/ui/src/pages/monitoring/FeatureServiceMetricsPanel.tsx +++ b/ui/src/pages/monitoring/FeatureServiceMetricsPanel.tsx @@ -117,9 +117,7 @@ const FeatureServiceMetricsPanel = ({ field: "data_source_type", name: "Source", width: "80px", - render: (val: string) => ( - {val} - ), + render: (val: string) => {val}, }, ]; @@ -213,7 +211,10 @@ const SortableFSTable = ({ items={sortedItems} columns={columns} sorting={{ - sort: { field: sortField as keyof FeatureServiceMetric, direction: sortDirection }, + sort: { + field: sortField as keyof FeatureServiceMetric, + direction: sortDirection, + }, }} onChange={onTableChange} compressed diff --git a/ui/src/pages/monitoring/FeatureViewMetricsPanel.tsx b/ui/src/pages/monitoring/FeatureViewMetricsPanel.tsx index 267f1292fe1..a0dcc78a8ab 100644 --- a/ui/src/pages/monitoring/FeatureViewMetricsPanel.tsx +++ b/ui/src/pages/monitoring/FeatureViewMetricsPanel.tsx @@ -130,9 +130,7 @@ const FeatureViewMetricsPanel = ({ field: "data_source_type", name: "Source", width: "80px", - render: (val: string) => ( - {val} - ), + render: (val: string) => {val}, }, ]; @@ -229,7 +227,10 @@ const SortableTable = ({ items={sortedItems} columns={columns} sorting={{ - sort: { field: sortField as keyof FeatureViewMetric, direction: sortDirection }, + sort: { + field: sortField as keyof FeatureViewMetric, + direction: sortDirection, + }, }} onChange={onTableChange} compressed diff --git a/ui/src/pages/monitoring/Index.tsx b/ui/src/pages/monitoring/Index.tsx index 1443df71b8e..848d6ee9ae8 100644 --- a/ui/src/pages/monitoring/Index.tsx +++ b/ui/src/pages/monitoring/Index.tsx @@ -40,17 +40,33 @@ const MonitoringIndex = () => { const isBaseline = granularity === "baseline"; + const handleGranularityChange = (g: string) => { + setGranularity(g); + if (g === "baseline") { + setStartDate(""); + setEndDate(""); + } + }; + const filters = useMemo( () => ({ project: projectName || "", feature_view_name: selectedFV || undefined, granularity: isBaseline ? undefined : granularity || undefined, data_source_type: dataSourceType || undefined, - start_date: startDate || undefined, - end_date: endDate || undefined, + start_date: isBaseline ? undefined : startDate || undefined, + end_date: isBaseline ? undefined : endDate || undefined, is_baseline: isBaseline || undefined, }), - [projectName, selectedFV, granularity, isBaseline, dataSourceType, startDate, endDate], + [ + projectName, + selectedFV, + granularity, + isBaseline, + dataSourceType, + startDate, + endDate, + ], ); const featureQuery = useFeatureMetrics(filters); @@ -71,9 +87,7 @@ const MonitoringIndex = () => { }, [registryData]); const handleFeatureClick = (fvName: string, featureName: string) => { - navigate( - `/p/${projectName}/monitoring/feature/${fvName}/${featureName}`, - ); + navigate(`/p/${projectName}/monitoring/feature/${fvName}/${featureName}`); }; const uniqueFeatureCount = useMemo(() => { @@ -98,8 +112,7 @@ const MonitoringIndex = () => { }); }; - const hasError = - featureQuery.isError && fvQuery.isError && fsQuery.isError; + const hasError = featureQuery.isError && fvQuery.isError && fsQuery.isError; const hasData = (featureQuery.data && featureQuery.data.length > 0) || (fvQuery.data && fvQuery.data.length > 0); @@ -191,7 +204,7 @@ const MonitoringIndex = () => { selectedFeatureView={selectedFV} onFeatureViewChange={setSelectedFV} granularity={granularity} - onGranularityChange={setGranularity} + onGranularityChange={handleGranularityChange} dataSourceType={dataSourceType} onDataSourceTypeChange={setDataSourceType} startDate={startDate} @@ -200,6 +213,7 @@ const MonitoringIndex = () => { onEndDateChange={setEndDate} onRefresh={handleRefresh} isLoading={featureQuery.isLoading} + datesDisabled={isBaseline} /> diff --git a/ui/src/pages/monitoring/components/HistogramChart.tsx b/ui/src/pages/monitoring/components/HistogramChart.tsx index 6b573fe82e6..bed612df0dc 100644 --- a/ui/src/pages/monitoring/components/HistogramChart.tsx +++ b/ui/src/pages/monitoring/components/HistogramChart.tsx @@ -59,7 +59,8 @@ const formatNumber = (val: number, compact: boolean): string => { const abs = Math.abs(val); if (compact && abs >= 1_000_000) return (val / 1_000_000).toFixed(1) + "M"; if (compact && abs >= 1_000) return (val / 1_000).toFixed(1) + "K"; - if (abs >= 1) return val.toLocaleString(undefined, { maximumFractionDigits: 1 }); + if (abs >= 1) + return val.toLocaleString(undefined, { maximumFractionDigits: 1 }); if (abs >= 0.01) return val.toFixed(2); return val.toExponential(1); }; @@ -210,7 +211,11 @@ const NumericHistogramChart = ({ return ( <> - + {title && ( @@ -310,7 +315,12 @@ const renderCategoricalSvg = ( const chartHeight = histogram.values.length * rowHeight; return ( - + {histogram.values.map((v, i) => { const width = (v.count / maxCount) * barMax; const y = i * rowHeight; @@ -323,7 +333,9 @@ const renderCategoricalSvg = ( fill="#343741" textAnchor="end" > - {v.value.length > truncLen ? v.value.slice(0, truncLen) + "…" : v.value} + {v.value.length > truncLen + ? v.value.slice(0, truncLen) + "…" + : v.value} - + {title && ( @@ -397,7 +413,9 @@ const CategoricalHistogramChart = ({ {expanded && ( setExpanded(false)} maxWidth={960}> - {title || "Category Distribution"} + + {title || "Category Distribution"} +
diff --git a/ui/src/pages/monitoring/components/MetricsFilters.tsx b/ui/src/pages/monitoring/components/MetricsFilters.tsx index 90a06e18ad4..977044d495a 100644 --- a/ui/src/pages/monitoring/components/MetricsFilters.tsx +++ b/ui/src/pages/monitoring/components/MetricsFilters.tsx @@ -22,6 +22,7 @@ interface MetricsFiltersProps { onEndDateChange: (d: string) => void; onRefresh: () => void; isLoading?: boolean; + datesDisabled?: boolean; } const GRANULARITY_OPTIONS = [ @@ -53,6 +54,7 @@ const MetricsFilters = ({ onEndDateChange, onRefresh, isLoading, + datesDisabled, }: MetricsFiltersProps) => { const fvOptions = [ { value: "", text: "All Feature Views" }, @@ -92,22 +94,30 @@ const MetricsFilters = ({ - + onStartDateChange(e.target.value)} compressed + disabled={datesDisabled} /> - + onEndDateChange(e.target.value)} compressed + disabled={datesDisabled} /> diff --git a/ui/src/pages/monitoring/components/TimeSeriesAnalysis.tsx b/ui/src/pages/monitoring/components/TimeSeriesAnalysis.tsx index 3fb82d94a45..23acd0be434 100644 --- a/ui/src/pages/monitoring/components/TimeSeriesAnalysis.tsx +++ b/ui/src/pages/monitoring/components/TimeSeriesAnalysis.tsx @@ -12,7 +12,16 @@ import type { CategoricalHistogram, } from "../../../queries/useMonitoringApi"; -const COLORS = ["#006BB4", "#54B399", "#E7664C", "#9170B8", "#D36086", "#6092C0", "#D6BF57", "#B9A888"]; +const COLORS = [ + "#006BB4", + "#54B399", + "#E7664C", + "#9170B8", + "#D36086", + "#6092C0", + "#D6BF57", + "#B9A888", +]; const RANGE_OPTIONS = [ { value: "24h", inputDisplay: "Last 24 hours" }, @@ -75,6 +84,7 @@ const niceYTicks = (min: number, max: number, count = 5): number[] => { interface LineSeriesData { label: string; color: string; + dashArray?: string; points: { x: number; y: number; date: string; value: number }[]; } @@ -172,18 +182,29 @@ const renderMultiLineChart = ( if (s.points.length === 0) return null; const sorted = [...s.points].sort((a, b) => a.x - b.x); const pathD = sorted - .map((p, i) => `${i === 0 ? "M" : "L"} ${scaleX(p.x)} ${scaleY(p.value)}`) + .map( + (p, i) => + `${i === 0 ? "M" : "L"} ${scaleX(p.x)} ${scaleY(p.value)}`, + ) .join(" "); return ( - + {sorted.map((p, i) => ( ))} @@ -292,7 +313,11 @@ const renderAreaChart = ( ); }; -const Legend = ({ items }: { items: { label: string; color: string }[] }) => ( +const Legend = ({ + items, +}: { + items: { label: string; color: string; dashed?: boolean }[]; +}) => (
( > {items.map((item, i) => (
-
+ {item.dashed ? ( + + + + ) : ( +
+ )} {item.label}
))} @@ -323,7 +362,10 @@ interface TimeSeriesAnalysisProps { featureType: string; } -const TimeSeriesAnalysis = ({ metrics, featureType }: TimeSeriesAnalysisProps) => { +const TimeSeriesAnalysis = ({ + metrics, + featureType, +}: TimeSeriesAnalysisProps) => { const [range, setRange] = useState("all"); const filteredMetrics = useMemo(() => { @@ -342,7 +384,7 @@ const TimeSeriesAnalysis = ({ metrics, featureType }: TimeSeriesAnalysisProps) = const toX = (m: FeatureMetric) => new Date(m.metric_date).getTime(); const isNumeric = featureType === "numeric"; - const hasData = sorted.length >= 2; + const hasData = sorted.length >= 1; return ( @@ -368,8 +410,16 @@ const TimeSeriesAnalysis = ({ metrics, featureType }: TimeSeriesAnalysisProps) = {!hasData ? ( -

- No data points available for the selected time range. Try a wider range. +

+ No data points available for the selected time range. Try a wider + range.

) : isNumeric ? ( @@ -392,9 +442,11 @@ const NumericTimeSeries = ({ label: string, color: string, accessor: (m: FeatureMetric) => number | null, + dashArray?: string, ): LineSeriesData => ({ label, color, + dashArray, points: metrics .filter((m) => accessor(m) !== null) .map((m) => ({ @@ -405,7 +457,7 @@ const NumericTimeSeries = ({ })), }); return [ - build("Mean", COLORS[0], (m) => m.mean), + build("Mean", COLORS[0], (m) => m.mean, "6,3"), build("P50", COLORS[1], (m) => m.p50), build("P95", COLORS[2], (m) => m.p95), ]; @@ -431,7 +483,7 @@ const NumericTimeSeries = ({
- {renderMultiLineChart(shareSeries, DIMS, "Percentage (%)", shareYFormatter)} + {renderMultiLineChart( + shareSeries, + DIMS, + "Percentage (%)", + shareYFormatter, + )}
({ diff --git a/ui/src/queries/useMonitoringApi.ts b/ui/src/queries/useMonitoringApi.ts index 7d93b26c9e3..6b20d6f759c 100644 --- a/ui/src/queries/useMonitoringApi.ts +++ b/ui/src/queries/useMonitoringApi.ts @@ -1,6 +1,8 @@ import { useContext } from "react"; import { useQuery, useMutation, useQueryClient } from "react-query"; import MonitoringContext from "../contexts/MonitoringContext"; +import { useDataMode } from "../contexts/DataModeContext"; +import type { FetchOptions } from "../contexts/DataModeContext"; interface FeatureMetric { project_id: string; @@ -101,21 +103,34 @@ const buildQueryString = (params: Record) => { ([, v]) => v !== undefined && v !== "", ); if (entries.length === 0) return ""; - return "?" + entries.map(([k, v]) => `${k}=${encodeURIComponent(v!)}`).join("&"); + return ( + "?" + entries.map(([k, v]) => `${k}=${encodeURIComponent(v!)}`).join("&") + ); }; const fetchMonitoring = async ( baseUrl: string, path: string, params: Record, + fetchOptions?: FetchOptions, ): Promise => { const qs = buildQueryString(params); - const res = await fetch(`${baseUrl}${path}${qs}`); + const res = await fetch(`${baseUrl}${path}${qs}`, { + method: "GET", + headers: { + Accept: "application/json", + ...fetchOptions?.headers, + }, + credentials: fetchOptions?.credentials, + }); if (!res.ok) { throw new Error(`Failed to fetch ${path}: ${res.status} ${res.statusText}`); } const text = await res.text(); - const sanitized = text.replace(/:\s*NaN/g, ": null").replace(/:\s*Infinity/g, ": null").replace(/:\s*-Infinity/g, ": null"); + const sanitized = text + .replace(/:\s*NaN/g, ": null") + .replace(/:\s*Infinity/g, ": null") + .replace(/:\s*-Infinity/g, ": null"); return JSON.parse(sanitized); }; @@ -123,6 +138,7 @@ const STALE_TIME = 30_000; const useFeatureMetrics = (filters: MonitoringFilters) => { const { apiBaseUrl, enabled } = useContext(MonitoringContext); + const { fetchOptions } = useDataMode(); const path = filters.is_baseline ? "/monitoring/metrics/baseline" : "/monitoring/metrics/features"; @@ -133,6 +149,7 @@ const useFeatureMetrics = (filters: MonitoringFilters) => { apiBaseUrl, path, toQueryParams(filters), + fetchOptions, ), { staleTime: STALE_TIME, enabled, retry: 1 }, ); @@ -165,14 +182,14 @@ const aggregateToFeatureViewMetrics = ( nullRates.length > 0 ? nullRates.reduce((a, b) => a + b, 0) / nullRates.length : 0, - max_null_rate: - nullRates.length > 0 ? Math.max(...nullRates) : 0, + max_null_rate: nullRates.length > 0 ? Math.max(...nullRates) : 0, }; }); }; const useFeatureViewMetrics = (filters: MonitoringFilters) => { const { apiBaseUrl, enabled } = useContext(MonitoringContext); + const { fetchOptions } = useDataMode(); const isBaseline = !!filters.is_baseline; return useQuery( ["monitoring-feature-views", filters], @@ -182,6 +199,7 @@ const useFeatureViewMetrics = (filters: MonitoringFilters) => { apiBaseUrl, "/monitoring/metrics/baseline", toQueryParams(filters), + fetchOptions, ); return aggregateToFeatureViewMetrics(features); } @@ -189,6 +207,7 @@ const useFeatureViewMetrics = (filters: MonitoringFilters) => { apiBaseUrl, "/monitoring/metrics/feature_views", toQueryParams(filters), + fetchOptions, ); }, { staleTime: STALE_TIME, enabled, retry: 1 }, @@ -197,6 +216,7 @@ const useFeatureViewMetrics = (filters: MonitoringFilters) => { const useFeatureServiceMetrics = (filters: MonitoringFilters) => { const { apiBaseUrl, enabled } = useContext(MonitoringContext); + const { fetchOptions } = useDataMode(); return useQuery( ["monitoring-feature-services", filters], () => @@ -204,6 +224,7 @@ const useFeatureServiceMetrics = (filters: MonitoringFilters) => { apiBaseUrl, "/monitoring/metrics/feature_services", toQueryParams(filters), + fetchOptions, ), { staleTime: STALE_TIME, enabled, retry: 1 }, ); @@ -216,6 +237,7 @@ const useBaselineMetrics = ( dataSourceType?: string, ) => { const { apiBaseUrl, enabled } = useContext(MonitoringContext); + const { fetchOptions } = useDataMode(); return useQuery( ["monitoring-baseline", project, featureViewName, featureName], () => @@ -228,20 +250,7 @@ const useBaselineMetrics = ( feature_name: featureName, data_source_type: dataSourceType, }, - ), - { staleTime: STALE_TIME, enabled, retry: 1 }, - ); -}; - -const useTimeseriesMetrics = (filters: MonitoringFilters) => { - const { apiBaseUrl, enabled } = useContext(MonitoringContext); - return useQuery( - ["monitoring-timeseries", filters], - () => - fetchMonitoring( - apiBaseUrl, - "/monitoring/metrics/timeseries", - toQueryParams(filters), + fetchOptions, ), { staleTime: STALE_TIME, enabled, retry: 1 }, ); @@ -249,15 +258,17 @@ const useTimeseriesMetrics = (filters: MonitoringFilters) => { const useComputeMetrics = () => { const { apiBaseUrl } = useContext(MonitoringContext); + const { fetchOptions } = useDataMode(); const queryClient = useQueryClient(); return useMutation( - async (body: { - project: string; - feature_view_name?: string; - }) => { + async (body: { project: string; feature_view_name?: string }) => { const res = await fetch(`${apiBaseUrl}/monitoring/auto_compute`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + ...fetchOptions?.headers, + }, + credentials: fetchOptions?.credentials, body: JSON.stringify(body), }); if (!res.ok) { @@ -280,7 +291,6 @@ export { useFeatureViewMetrics, useFeatureServiceMetrics, useBaselineMetrics, - useTimeseriesMetrics, useComputeMetrics, }; export type { From f12d26194b6efe8097a38ea1f922e5565cbed30f Mon Sep 17 00:00:00 2001 From: Jitendra Yejare <11752425+jyejare@users.noreply.github.com> Date: Fri, 12 Jun 2026 18:57:30 +0530 Subject: [PATCH 6/6] chore: Removing unnecessary monitoring config api Signed-off-by: Jitendra Yejare <11752425+jyejare@users.noreply.github.com> --- .../feast/api/registry/rest/monitoring.py | 23 ---------------- ui/src/FeastUISansProviders.tsx | 15 ++--------- ui/src/pages/Sidebar.tsx | 27 +++++++------------ ui/src/queries/useMonitoringApi.ts | 16 +++++------ 4 files changed, 20 insertions(+), 61 deletions(-) diff --git a/sdk/python/feast/api/registry/rest/monitoring.py b/sdk/python/feast/api/registry/rest/monitoring.py index aaec5143ce2..e4b614d5e52 100644 --- a/sdk/python/feast/api/registry/rest/monitoring.py +++ b/sdk/python/feast/api/registry/rest/monitoring.py @@ -77,29 +77,6 @@ def _get_store(): ) return store - @router.get("/monitoring/config", tags=["Monitoring"]) - def monitoring_config(): - """Report whether DQM is configured, checking the live config file.""" - import os - - import yaml - - s = _get_store() - dqm = getattr(s.config, "data_quality_monitoring_config", None) - if dqm is not None: - return {"enabled": True} - - repo_path = getattr(s, "repo_path", None) - if repo_path: - cfg_file = os.path.join(str(repo_path), "feature_store.yaml") - if os.path.exists(cfg_file): - with open(cfg_file) as f: - cfg = yaml.safe_load(f) - if cfg and cfg.get("data_quality_monitoring"): - return {"enabled": True} - - return {"enabled": False} - # ------------------------------------------------------------------ # # DQM Job: submit and track # ------------------------------------------------------------------ # diff --git a/ui/src/FeastUISansProviders.tsx b/ui/src/FeastUISansProviders.tsx index 6025d918a6b..ace29f9e856 100644 --- a/ui/src/FeastUISansProviders.tsx +++ b/ui/src/FeastUISansProviders.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React from "react"; import "./index.css"; @@ -109,21 +109,10 @@ const FeastUISansProvidersInner = ({ fetchOptions: feastUIConfigs?.fetchOptions, }; - const [autoMonitoringEnabled, setAutoMonitoringEnabled] = useState(false); - useEffect(() => { - if (feastUIConfigs?.monitoringConfig) return; - fetch("/api/v1/monitoring/config") - .then((r) => r.json()) - .then((data) => { - if (data?.enabled) setAutoMonitoringEnabled(true); - }) - .catch(() => {}); - }, [feastUIConfigs?.monitoringConfig]); - const monitoringConfig: MonitoringConfig = feastUIConfigs?.monitoringConfig || { apiBaseUrl: "/api/v1", - enabled: autoMonitoringEnabled, + enabled: true, }; return ( diff --git a/ui/src/pages/Sidebar.tsx b/ui/src/pages/Sidebar.tsx index 4aa43119ab0..c7fffeb1fd4 100644 --- a/ui/src/pages/Sidebar.tsx +++ b/ui/src/pages/Sidebar.tsx @@ -1,9 +1,8 @@ -import React, { useContext, useState } from "react"; +import React, { useState } from "react"; import { EuiIcon, EuiSideNav, htmlIdGenerator } from "@elastic/eui"; import { Link, useParams } from "react-router-dom"; import { useMatchSubpath } from "../hooks/useMatchSubpath"; -import MonitoringContext from "../contexts/MonitoringContext"; import useResourceQuery, { entityListPath, featureViewListPath, @@ -85,8 +84,6 @@ const SideNav = () => { restSelect: restLabelViewsFromResponse, }); - const { enabled: monitoringEnabled } = useContext(MonitoringContext); - const [isSideNavOpenOnMobile, setisSideNavOpenOnMobile] = useState(false); const toggleOpenOnMobile = () => { @@ -180,19 +177,6 @@ const SideNav = () => { renderItem: (props) => , isSelected: useMatchSubpath(`${baseUrl}/data-set`), }, - ...(monitoringEnabled - ? [ - { - name: "Monitoring", - id: htmlIdGenerator("monitoring")(), - icon: , - renderItem: (props: any) => ( - - ), - isSelected: monitoringSelected, - }, - ] - : []), { name: "Permissions", id: htmlIdGenerator("permissions")(), @@ -202,6 +186,15 @@ const SideNav = () => { ), isSelected: useMatchSubpath(`${baseUrl}/permissions`), }, + { + name: "Monitoring", + id: htmlIdGenerator("monitoring")(), + icon: , + renderItem: (props: any) => ( + + ), + isSelected: monitoringSelected, + }, ], }, ]; diff --git a/ui/src/queries/useMonitoringApi.ts b/ui/src/queries/useMonitoringApi.ts index 6b20d6f759c..c4f8f7636a6 100644 --- a/ui/src/queries/useMonitoringApi.ts +++ b/ui/src/queries/useMonitoringApi.ts @@ -137,7 +137,7 @@ const fetchMonitoring = async ( const STALE_TIME = 30_000; const useFeatureMetrics = (filters: MonitoringFilters) => { - const { apiBaseUrl, enabled } = useContext(MonitoringContext); + const { apiBaseUrl } = useContext(MonitoringContext); const { fetchOptions } = useDataMode(); const path = filters.is_baseline ? "/monitoring/metrics/baseline" @@ -151,7 +151,7 @@ const useFeatureMetrics = (filters: MonitoringFilters) => { toQueryParams(filters), fetchOptions, ), - { staleTime: STALE_TIME, enabled, retry: 1 }, + { staleTime: STALE_TIME, retry: 1 }, ); }; @@ -188,7 +188,7 @@ const aggregateToFeatureViewMetrics = ( }; const useFeatureViewMetrics = (filters: MonitoringFilters) => { - const { apiBaseUrl, enabled } = useContext(MonitoringContext); + const { apiBaseUrl } = useContext(MonitoringContext); const { fetchOptions } = useDataMode(); const isBaseline = !!filters.is_baseline; return useQuery( @@ -210,12 +210,12 @@ const useFeatureViewMetrics = (filters: MonitoringFilters) => { fetchOptions, ); }, - { staleTime: STALE_TIME, enabled, retry: 1 }, + { staleTime: STALE_TIME, retry: 1 }, ); }; const useFeatureServiceMetrics = (filters: MonitoringFilters) => { - const { apiBaseUrl, enabled } = useContext(MonitoringContext); + const { apiBaseUrl } = useContext(MonitoringContext); const { fetchOptions } = useDataMode(); return useQuery( ["monitoring-feature-services", filters], @@ -226,7 +226,7 @@ const useFeatureServiceMetrics = (filters: MonitoringFilters) => { toQueryParams(filters), fetchOptions, ), - { staleTime: STALE_TIME, enabled, retry: 1 }, + { staleTime: STALE_TIME, retry: 1 }, ); }; @@ -236,7 +236,7 @@ const useBaselineMetrics = ( featureName?: string, dataSourceType?: string, ) => { - const { apiBaseUrl, enabled } = useContext(MonitoringContext); + const { apiBaseUrl } = useContext(MonitoringContext); const { fetchOptions } = useDataMode(); return useQuery( ["monitoring-baseline", project, featureViewName, featureName], @@ -252,7 +252,7 @@ const useBaselineMetrics = ( }, fetchOptions, ), - { staleTime: STALE_TIME, enabled, retry: 1 }, + { staleTime: STALE_TIME, retry: 1 }, ); };