diff --git a/app/vmui/packages/vmui/src/components/Chart/Line/LegendAnomaly/LegendAnomaly.tsx b/app/vmui/packages/vmui/src/components/Chart/Line/LegendAnomaly/LegendAnomaly.tsx index 51d17fa73..1b23bfb4b 100644 --- a/app/vmui/packages/vmui/src/components/Chart/Line/LegendAnomaly/LegendAnomaly.tsx +++ b/app/vmui/packages/vmui/src/components/Chart/Line/LegendAnomaly/LegendAnomaly.tsx @@ -7,7 +7,7 @@ type Props = { series: SeriesItem[]; }; -const titles: Record = { +const titles: Partial> = { [ForecastType.yhat]: "yhat", [ForecastType.yhatLower]: "yhat_lower/_upper", [ForecastType.yhatUpper]: "yhat_lower/_upper", @@ -39,7 +39,6 @@ const LegendAnomaly: FC = ({ series }) => { return uniqSeries.map(s => ({ ...s, color: typeof s.stroke === "string" ? s.stroke : anomalyColors[s.forecast || ForecastType.actual], - forecast: titles[s.forecast || ForecastType.actual], })); }, [series]); @@ -49,7 +48,7 @@ const LegendAnomaly: FC = ({ series }) => { return <>
{/* TODO: remove .filter() after the correct training data has been added */} - {uniqSeriesStyles.filter(f => f.forecast !== titles[ForecastType.training]).map((s, i) => ( + {uniqSeriesStyles.filter(f => f.forecast !== ForecastType.training).map((s, i) => (
= ({ series }) => { /> )} -
{s.forecast || "y"}
+
{titles[s.forecast || ForecastType.actual]}
))}
diff --git a/app/vmui/packages/vmui/src/pages/ExploreAnomaly/ExploreAnomaly.tsx b/app/vmui/packages/vmui/src/pages/ExploreAnomaly/ExploreAnomaly.tsx index 277f39129..72fa675dd 100644 --- a/app/vmui/packages/vmui/src/pages/ExploreAnomaly/ExploreAnomaly.tsx +++ b/app/vmui/packages/vmui/src/pages/ExploreAnomaly/ExploreAnomaly.tsx @@ -5,7 +5,7 @@ import useEventListener from "../../hooks/useEventListener"; import "../CustomPanel/style.scss"; import ExploreAnomalyHeader from "./ExploreAnomalyHeader/ExploreAnomalyHeader"; import Alert from "../../components/Main/Alert/Alert"; -import { extractFields } from "../../utils/uplot"; +import { extractFields, isForecast } from "../../utils/uplot"; import { useFetchQuery } from "../../hooks/useFetchQuery"; import Spinner from "../../components/Main/Spinner/Spinner"; import GraphTab from "../CustomPanel/CustomPanelTabs/GraphTab"; @@ -17,6 +17,16 @@ import { useFetchAnomalySeries } from "./hooks/useFetchAnomalySeries"; import { useQueryDispatch } from "../../state/query/QueryStateContext"; import { useTimeDispatch } from "../../state/time/TimeStateContext"; +const anomalySeries = [ + ForecastType.yhat, + ForecastType.yhatUpper, + ForecastType.yhatLower, + ForecastType.anomalyScore +]; + +// Hardcoded to 1.0 for now; consider adding a UI slider for threshold adjustment in the future. +const ANOMALY_SCORE_THRESHOLD = 1; + const ExploreAnomaly: FC = () => { const { isMobile } = useDeviceDetect(); @@ -36,34 +46,31 @@ const ExploreAnomaly: FC = () => { const data = useMemo(() => { if (!graphData) return; - const group = queries.length + 1; - const realData = graphData.filter(d => d.group === 1); - const upperData = graphData.filter(d => d.group === 3); - const lowerData = graphData.filter(d => d.group === 4); - const anomalyData: MetricResult[] = realData.map((d) => ({ - group, - metric: { ...d.metric, __name__: ForecastType.anomaly }, - values: d.values.filter(([t, v]) => { - const id = extractFields(d.metric); - const upperDataByLabels = upperData.find(du => extractFields(du.metric) === id); - const lowerDataByLabels = lowerData.find(du => extractFields(du.metric) === id); - if (!upperDataByLabels || !lowerDataByLabels) return false; - const max = upperDataByLabels.values.find(([tMax]) => tMax === t) as [number, string]; - const min = lowerDataByLabels.values.find(([tMin]) => tMin === t) as [number, string]; - const num = v && promValueToNumber(v); - const numMin = min && promValueToNumber(min[1]); - const numMax = max && promValueToNumber(max[1]); - return num < numMin || num > numMax; - }) - })); - return graphData.concat(anomalyData); + const detectedData = graphData.map(d => ({ ...isForecast(d.metric), ...d })); + const realData = detectedData.filter(d => d.value === null); + const anomalyScoreData = detectedData.filter(d => d.isAnomalyScore); + const anomalyData: MetricResult[] = realData.map((d) => { + const id = extractFields(d.metric); + const anomalyScoreDataByLabels = anomalyScoreData.find(du => extractFields(du.metric) === id); + + return { + group: queries.length + 1, + metric: { ...d.metric, __name__: ForecastType.anomaly }, + values: d.values.filter(([t]) => { + if (!anomalyScoreDataByLabels) return false; + const anomalyScore = anomalyScoreDataByLabels.values.find(([tMax]) => tMax === t) as [number, string]; + return anomalyScore && promValueToNumber(anomalyScore[1]) > ANOMALY_SCORE_THRESHOLD; + }) + }; + }); + return graphData.filter(d => d.group !== anomalyScoreData[0]?.group).concat(anomalyData); }, [graphData]); const onChangeFilter = (expr: Record) => { const { __name__ = "", ...labelValue } = expr; let prefix = __name__.replace(/y|_y/, ""); if (prefix) prefix += "_"; - const metrics = [__name__, ForecastType.yhat, ForecastType.yhatUpper, ForecastType.yhatLower]; + const metrics = [__name__, ...anomalySeries]; const filters = Object.entries(labelValue).map(([key, value]) => `${key}="${value}"`).join(","); const queries = metrics.map((m, i) => `${i ? prefix : ""}${m}{${filters}}`); queryDispatch({ type: "SET_QUERY", payload: queries }); diff --git a/app/vmui/packages/vmui/src/pages/ExploreAnomaly/hooks/useFetchAnomalySeries.ts b/app/vmui/packages/vmui/src/pages/ExploreAnomaly/hooks/useFetchAnomalySeries.ts index dbbb0d34b..ba9e69479 100644 --- a/app/vmui/packages/vmui/src/pages/ExploreAnomaly/hooks/useFetchAnomalySeries.ts +++ b/app/vmui/packages/vmui/src/pages/ExploreAnomaly/hooks/useFetchAnomalySeries.ts @@ -3,6 +3,8 @@ import { useAppState } from "../../../state/common/StateContext"; import { ErrorTypes } from "../../../types"; import { useEffect } from "react"; import { MetricBase } from "../../../api/types"; +import { useTimeState } from "../../../state/time/TimeStateContext"; +import dayjs from "dayjs"; // TODO: Change the method of retrieving aliases from the configuration after the API has been added const seriesQuery = `{ @@ -12,18 +14,25 @@ const seriesQuery = `{ export const useFetchAnomalySeries = () => { const { serverUrl } = useAppState(); + const { period: { start, end } } = useTimeState(); const [series, setSeries] = useState>(); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(); + // TODO add cached metrics by date const fetchUrl = useMemo(() => { + const startDay = dayjs(start * 1000).startOf("day").valueOf() / 1000; + const endDay = dayjs(end * 1000).endOf("day").valueOf() / 1000; + const params = new URLSearchParams({ "match[]": seriesQuery, + start: `${startDay}`, + end: `${endDay}` }); return `${serverUrl}/api/v1/series?${params}`; - }, [serverUrl]); + }, [serverUrl, start, end]); useEffect(() => { const fetchSeries = async () => { diff --git a/app/vmui/packages/vmui/src/types/uplot.ts b/app/vmui/packages/vmui/src/types/uplot.ts index e86417af0..89b510093 100644 --- a/app/vmui/packages/vmui/src/types/uplot.ts +++ b/app/vmui/packages/vmui/src/types/uplot.ts @@ -6,7 +6,8 @@ export enum ForecastType { yhatLower = "yhat_lower", anomaly = "vmui_anomalies_points", training = "vmui_training_data", - actual = "actual" + actual = "actual", + anomalyScore = "anomaly_score", } export interface SeriesItemStatsFormatted { diff --git a/app/vmui/packages/vmui/src/utils/color.ts b/app/vmui/packages/vmui/src/utils/color.ts index 9f351297b..b6c1c85eb 100644 --- a/app/vmui/packages/vmui/src/utils/color.ts +++ b/app/vmui/packages/vmui/src/utils/color.ts @@ -26,6 +26,7 @@ export const anomalyColors: Record = { [ForecastType.yhatLower]: "#7126a1", [ForecastType.yhat]: "#da42a6", [ForecastType.anomaly]: "#da4242", + [ForecastType.anomalyScore]: "#7126a1", [ForecastType.actual]: "#203ea9", [ForecastType.training]: `rgba(${hexToRGB("#203ea9")}, 0.2)`, }; diff --git a/app/vmui/packages/vmui/src/utils/uplot/series.ts b/app/vmui/packages/vmui/src/utils/uplot/series.ts index 50a630e07..16163c8ff 100644 --- a/app/vmui/packages/vmui/src/utils/uplot/series.ts +++ b/app/vmui/packages/vmui/src/utils/uplot/series.ts @@ -14,7 +14,7 @@ export const extractFields = (metric: MetricBase["metric"]): string => { .map(([key, value]) => `${key}: ${value}`).join(","); }; -const isForecast = (metric: MetricBase["metric"]) => { +export const isForecast = (metric: MetricBase["metric"]) => { const metricName = metric?.__name__ || ""; const forecastRegex = new RegExp(`(${Object.values(ForecastType).join("|")})$`); const match = metricName.match(forecastRegex); @@ -25,6 +25,7 @@ const isForecast = (metric: MetricBase["metric"]) => { isLower: value === ForecastType.yhatLower, isYhat: value === ForecastType.yhat, isAnomaly: value === ForecastType.anomaly, + isAnomalyScore: value === ForecastType.anomalyScore, group: extractFields(metric) }; };