mirror of
https://github.com/VictoriaMetrics/VictoriaMetrics.git
synced 2024-11-21 14:44:00 +00:00
vmui/vmanomaly: add support models that produce only anomaly_score
(#5594)
* vmui/vmanomaly: add support models that produce only `anomaly_score` * vmui/vmanomaly: fix display legend * vmui/vmanomaly: update comment on anomaly threshold
This commit is contained in:
parent
846d5a3ab8
commit
d365157381
6 changed files with 48 additions and 30 deletions
|
@ -7,7 +7,7 @@ type Props = {
|
|||
series: SeriesItem[];
|
||||
};
|
||||
|
||||
const titles: Record<ForecastType, string> = {
|
||||
const titles: Partial<Record<ForecastType, string>> = {
|
||||
[ForecastType.yhat]: "yhat",
|
||||
[ForecastType.yhatLower]: "yhat_lower/_upper",
|
||||
[ForecastType.yhatUpper]: "yhat_lower/_upper",
|
||||
|
@ -39,7 +39,6 @@ const LegendAnomaly: FC<Props> = ({ 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<Props> = ({ series }) => {
|
|||
return <>
|
||||
<div className="vm-legend-anomaly">
|
||||
{/* 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) => (
|
||||
<div
|
||||
key={`${i}_${s.forecast}`}
|
||||
className="vm-legend-anomaly-item"
|
||||
|
@ -76,7 +75,7 @@ const LegendAnomaly: FC<Props> = ({ series }) => {
|
|||
/>
|
||||
)}
|
||||
</svg>
|
||||
<div className="vm-legend-anomaly-item__title">{s.forecast || "y"}</div>
|
||||
<div className="vm-legend-anomaly-item__title">{titles[s.forecast || ForecastType.actual]}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
@ -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<string, string>) => {
|
||||
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 });
|
||||
|
|
|
@ -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<Record<string, MetricBase["metric"][]>>();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<ErrorTypes | string>();
|
||||
|
||||
// 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 () => {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -26,6 +26,7 @@ export const anomalyColors: Record<ForecastType, string> = {
|
|||
[ForecastType.yhatLower]: "#7126a1",
|
||||
[ForecastType.yhat]: "#da42a6",
|
||||
[ForecastType.anomaly]: "#da4242",
|
||||
[ForecastType.anomalyScore]: "#7126a1",
|
||||
[ForecastType.actual]: "#203ea9",
|
||||
[ForecastType.training]: `rgba(${hexToRGB("#203ea9")}, 0.2)`,
|
||||
};
|
||||
|
|
|
@ -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)
|
||||
};
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue