app/vmui: removed anomaly ui (#10316)

The vmanomaly has been moved to a separate repository. This means that the functionality related to vmanomaly is no longer needed in the app/vmui located in the VictoriaMetrics repository.

This commit removes all the functionality and unnecessary abstractions related to vmanomaly from the app/vmui repository. This should help improving long-term maintenance of the code.

fixes https://github.com/VictoriaMetrics/VictoriaMetrics/issues/9755
This commit is contained in:
Andrii Chubatiuk
2026-01-20 22:46:09 +02:00
committed by GitHub
parent 2a0e382a99
commit 1c77ee9527
42 changed files with 76 additions and 1108 deletions

View File

@@ -14,14 +14,6 @@ vmui-build: copy-metricsql-docs vmui-package-base-image
--entrypoint=/bin/bash \
vmui-builder-image -c "npm install && npm run build"
vmui-anomaly-build: vmui-package-base-image
docker run --rm \
--user $(shell id -u):$(shell id -g) \
--mount type=bind,src="$(shell pwd)/app/vmui",dst=/build \
-w /build/packages/vmui \
--entrypoint=/bin/bash \
vmui-builder-image -c "npm install && npm run build:anomaly"
vmui-release: vmui-build
docker build -t ${DOCKER_NAMESPACE}/vmui:latest -f app/vmui/Dockerfile-web ./app/vmui/packages/vmui
docker tag ${DOCKER_NAMESPACE}/vmui:latest ${DOCKER_NAMESPACE}/vmui:${PKG_TAG}

View File

@@ -1 +0,0 @@
VITE_APP_TYPE=vmanomaly

View File

@@ -1,23 +0,0 @@
import { readFile } from "fs/promises";
import { IndexHtmlTransform } from "vite";
/**
* Vite plugin to dynamically load index.html based on the current mode.
* If a specific mode-based index file (e.g., index.vmanomaly.html) exists, it is used.
* Otherwise, the default index.html is loaded.
*/
export default function dynamicIndexHtmlPlugin({ mode }) {
return {
name: "vm-dynamic-index-html",
transformIndexHtml: {
order: "pre",
handler: async () => {
try {
return await readFile(`./index.${mode}.html`, "utf8");
} catch (error) {
return await readFile("./index.html", "utf8");
}
}
} as IndexHtmlTransform
};
}

View File

@@ -1,54 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<link rel="icon" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/favicon.svg" />
<link rel="mask-icon" href="/favicon.svg" color="#000000">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5"/>
<meta name="theme-color" content="#000000"/>
<meta name="description" content="Detect anomalies in your metrics with VictoriaMetrics Anomaly Detection UI"/>
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials"/>
<!--
Notice the use of in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>UI for VictoriaMetrics Anomaly Detection</title>
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="UI for VictoriaMetrics Anomaly Detection">
<meta name="twitter:site" content="@https://victoriametrics.com/products/enterprise/anomaly-detection/">
<meta name="twitter:description" content="Detect anomalies in your metrics with VictoriaMetrics Anomaly Detection UI">
<meta name="twitter:image" content="/preview.jpg">
<meta property="og:type" content="website">
<meta property="og:title" content="UI for VictoriaMetrics Anomaly Detection">
<meta property="og:url" content="https://victoriametrics.com/products/enterprise/anomaly-detection/">
<meta property="og:description" content="Detect anomalies in your metrics with VictoriaMetrics Anomaly Detection UI">
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

View File

@@ -7,10 +7,8 @@
"scripts": {
"prestart": "npm run copy-metricsql-docs",
"start": "vite",
"start:playground": "cross-env PLAYGROUND=METRICS npm run start",
"start:anomaly": "vite --mode vmanomaly",
"start:playground": "cross-env PLAYGROUND=true npm run start",
"build": "vite build",
"build:anomaly": "vite build --mode vmanomaly",
"lint": "eslint --output-file vmui-lint-report.json --format json 'src/**/*.{ts,tsx}'",
"lint:local": "eslint --ext .ts,.tsx -f stylish src",
"lint:fix": "eslint 'src/**/*.{ts,tsx}' --fix",

View File

@@ -1,41 +0,0 @@
import { FC, useState } from "preact/compat";
import { HashRouter, Route, Routes } from "react-router-dom";
import AppContextProvider from "./contexts/AppContextProvider";
import ThemeProvider from "./components/Main/ThemeProvider/ThemeProvider";
import AnomalyLayout from "./layouts/AnomalyLayout/AnomalyLayout";
import ExploreAnomaly from "./pages/ExploreAnomaly/ExploreAnomaly";
import router from "./router";
import CustomPanel from "./pages/CustomPanel";
const AppAnomaly: FC = () => {
const [loadedTheme, setLoadedTheme] = useState(false);
return <>
<HashRouter>
<AppContextProvider>
<>
<ThemeProvider onLoaded={setLoadedTheme}/>
{loadedTheme && (
<Routes>
<Route
path={"/"}
element={<AnomalyLayout/>}
>
<Route
path={"/"}
element={<ExploreAnomaly/>}
/>
<Route
path={router.query}
element={<CustomPanel/>}
/>
</Route>
</Routes>
)}
</>
</AppContextProvider>
</HashRouter>
</>;
};
export default AppAnomaly;

View File

@@ -14,12 +14,11 @@ export type QueryGroup = {
interface LegendProps {
labels: LegendItemType[];
query: string[];
isAnomalyView?: boolean;
isPredefinedPanel?: boolean;
onChange: (item: LegendItemType, metaKey: boolean) => void;
}
const Legend: FC<LegendProps> = ({ labels, query, isAnomalyView, isPredefinedPanel, onChange }) => {
const Legend: FC<LegendProps> = ({ labels, query, isPredefinedPanel, onChange }) => {
const { groupByLabel } = useLegendGroup();
const groupSeries = useGroupSeries({ labels, query, groupByLabel });
@@ -33,7 +32,6 @@ const Legend: FC<LegendProps> = ({ labels, query, isAnomalyView, isPredefinedPan
key={group}
labels={items}
group={group}
isAnomalyView={isAnomalyView}
onChange={onChange}
/>
))}

View File

@@ -13,7 +13,6 @@ import { getFromStorage } from "../../../../utils/storage";
export type LegendProps = {
labels: LegendItemType[];
isAnomalyView?: boolean;
duplicateFields?: string[];
onChange: (item: LegendItemType, metaKey: boolean) => void;
}
@@ -22,7 +21,7 @@ interface LegendGroupProps extends LegendProps {
group: string | number;
}
const LegendGroup: FC<LegendGroupProps> = ({ labels, group, isAnomalyView, onChange }) => {
const LegendGroup: FC<LegendGroupProps> = ({ labels, group, onChange }) => {
const { isTableView } = useLegendView();
const { groupByLabel } = useLegendGroup();
const copyToClipboard = useCopyToClipboard();
@@ -81,7 +80,6 @@ const LegendGroup: FC<LegendGroupProps> = ({ labels, group, isAnomalyView, onCha
>
<Content
labels={sortedLabels}
isAnomalyView={isAnomalyView}
duplicateFields={duplicateFields}
onChange={onChange}
/>

View File

@@ -13,11 +13,10 @@ import { getLabelAlias } from "../../../../../utils/metric";
interface LegendItemProps {
legend: LegendItemType;
onChange?: (item: LegendItemType, metaKey: boolean) => void;
isAnomalyView?: boolean;
duplicateFields?: string[];
}
const LegendItem: FC<LegendItemProps> = ({ legend, onChange, duplicateFields, isAnomalyView }) => {
const LegendItem: FC<LegendItemProps> = ({ legend, onChange, duplicateFields }) => {
const copyToClipboard = useCopyToClipboard();
const { hideStats } = useShowStats();
@@ -52,12 +51,10 @@ const LegendItem: FC<LegendItemProps> = ({ legend, onChange, duplicateFields, is
})}
onClick={createHandlerClick(legend)}
>
{!isAnomalyView && (
<div
className="vm-legend-item__marker"
style={{ backgroundColor: legend.color }}
/>
)}
<div
className="vm-legend-item__marker"
style={{ backgroundColor: legend.color }}
/>
<div className="vm-legend-item-info">
<span className="vm-legend-item-info__label">
{legend.hasAlias && legend.label}

View File

@@ -2,7 +2,7 @@ import { FC } from "preact/compat";
import LegendItem from "../LegendItem/LegendItem";
import { LegendProps } from "../LegendGroup";
const LegendLines: FC<LegendProps> = ({ labels, isAnomalyView, duplicateFields, onChange }) => {
const LegendLines: FC<LegendProps> = ({ labels, duplicateFields, onChange }) => {
return (
<div className="vm-legend-item-container">
@@ -10,7 +10,6 @@ const LegendLines: FC<LegendProps> = ({ labels, isAnomalyView, duplicateFields,
<LegendItem
key={legendItem.label}
legend={legendItem}
isAnomalyView={isAnomalyView}
duplicateFields={duplicateFields}
onChange={onChange}
/>

View File

@@ -1,82 +0,0 @@
import { FC, useMemo } from "preact/compat";
import { ForecastType, SeriesItem } from "../../../../types";
import { anomalyColors } from "../../../../utils/color";
import "./style.scss";
type Props = {
series: SeriesItem[];
};
const titles: Partial<Record<ForecastType, string>> = {
[ForecastType.yhat]: "yhat",
[ForecastType.yhatLower]: "yhat_upper - yhat_lower",
[ForecastType.yhatUpper]: "yhat_upper - yhat_lower",
[ForecastType.anomaly]: "anomalies",
[ForecastType.training]: "training data",
[ForecastType.actual]: "y"
};
const LegendAnomaly: FC<Props> = ({ series }) => {
const uniqSeriesStyles = useMemo(() => {
const uniqSeries = series.reduce((accumulator, currentSeries) => {
const hasForecast = Object.prototype.hasOwnProperty.call(currentSeries, "forecast");
const isNotUpper = currentSeries.forecast !== ForecastType.yhatUpper;
const isUniqForecast = !accumulator.find(s => s.forecast === currentSeries.forecast);
if (hasForecast && isUniqForecast && isNotUpper) {
accumulator.push(currentSeries);
}
return accumulator;
}, [] as SeriesItem[]);
const trainingSeries = {
...uniqSeries[0],
forecast: ForecastType.training,
color: anomalyColors[ForecastType.training],
};
uniqSeries.splice(1, 0, trainingSeries);
return uniqSeries.map(s => ({
...s,
color: typeof s.stroke === "string" ? s.stroke : anomalyColors[s.forecast || ForecastType.actual],
}));
}, [series]);
return <>
<div className="vm-legend-anomaly">
{/* TODO: remove .filter() after the correct training data has been added */}
{uniqSeriesStyles.filter(f => f.forecast !== ForecastType.training).map((s, i) => (
<div
key={`${i}_${s.forecast}`}
className="vm-legend-anomaly-item"
>
<svg>
{s.forecast === ForecastType.anomaly ? (
<circle
cx="15"
cy="7"
r="4"
fill={s.color}
stroke={s.color}
strokeWidth="1.4"
/>
) : (
<line
x1="0"
y1="7"
x2="30"
y2="7"
stroke={s.color}
strokeWidth={s.width || 1}
strokeDasharray={s.dash?.join(",")}
/>
)}
</svg>
<div className="vm-legend-anomaly-item__title">{titles[s.forecast || ForecastType.actual]}</div>
</div>
))}
</div>
</>;
};
export default LegendAnomaly;

View File

@@ -1,23 +0,0 @@
@use "src/styles/variables" as *;
.vm-legend-anomaly {
position: relative;
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: calc($padding-large * 2);
cursor: default;
&-item {
display: flex;
align-items: center;
justify-content: center;
gap: $padding-small;
svg {
width: 30px;
height: 14px;
}
}
}

View File

@@ -13,7 +13,6 @@ import {
getRangeY,
getScales,
handleDestroy,
setBand,
setSelect
} from "../../../../utils/uplot";
import { MetricResult } from "../../../../api/types";
@@ -40,7 +39,6 @@ export interface LineChartProps {
setPeriod: ({ from, to }: { from: Date, to: Date }) => void;
layoutSize: ElementSize;
height?: number;
isAnomalyView?: boolean;
spanGaps?: boolean;
showAllPoints?: boolean;
}
@@ -55,7 +53,6 @@ const LineChart: FC<LineChartProps> = ({
setPeriod,
layoutSize,
height,
isAnomalyView,
spanGaps = false,
showAllPoints = false,
}) => {
@@ -75,7 +72,7 @@ const LineChart: FC<LineChartProps> = ({
seriesFocus,
setCursor,
resetTooltips
} = useLineTooltip({ u: uPlotInst, metrics, series, unit, isAnomalyView });
} = useLineTooltip({ u: uPlotInst, metrics, series, unit });
const options: uPlotOptions = {
...getDefaultOptions({ width: layoutSize.width, height }),
@@ -111,7 +108,6 @@ const LineChart: FC<LineChartProps> = ({
if (!uPlotInst) return;
delSeries(uPlotInst);
addSeries(uPlotInst, series, spanGaps, showAllPoints);
setBand(uPlotInst, series);
uPlotInst.redraw();
}, [series, spanGaps, showAllPoints]);

View File

@@ -1,132 +0,0 @@
import { FC, useState } from "preact/compat";
import Button from "../Main/Button/Button";
import TextField from "../Main/TextField/TextField";
import Modal from "../Main/Modal/Modal";
import Spinner from "../Main/Spinner/Spinner";
import { DownloadIcon, ErrorIcon } from "../Main/Icons";
import useBoolean from "../../hooks/useBoolean";
import useDeviceDetect from "../../hooks/useDeviceDetect";
import { useAppState } from "../../state/common/StateContext";
import classNames from "classnames";
import "./style.scss";
import { useQueryState } from "../../state/query/QueryStateContext";
import { useTimeState } from "../../state/time/TimeStateContext";
import { getStepFromDuration } from "../../utils/time";
const AnomalyConfig: FC = () => {
const { serverUrl } = useAppState();
const { isMobile } = useDeviceDetect();
const {
value: isModalOpen,
setTrue: setOpenModal,
setFalse: setCloseModal,
} = useBoolean(false);
const { query } = useQueryState();
const { period } = useTimeState();
const [isLoading, setIsLoading] = useState(false);
const [textConfig, setTextConfig] = useState<string>("");
const [downloadUrl, setDownloadUrl] = useState<string>("");
const [error, setError] = useState<string>("");
const fetchConfig = async () => {
setIsLoading(true);
try {
const queryParam = encodeURIComponent(query[0] || "");
const stepParam = encodeURIComponent(period.step || getStepFromDuration(period.end - period.start, false));
const url = `${serverUrl}/api/vmanomaly/config.yaml?query=${queryParam}&step=${stepParam}`;
const response = await fetch(url);
const contentType = response.headers.get("Content-Type");
if (!response.ok) {
const bodyText = await response.text();
setError(` ${response.status} ${response.statusText}: ${bodyText}`);
} else if (contentType == "application/yaml") {
const blob = await response.blob();
const yamlAsString = await blob.text();
setTextConfig(yamlAsString);
setDownloadUrl(URL.createObjectURL(blob));
} else {
setError("Response Content-Type is not YAML, does `Server URL` point to VMAnomaly server?");
}
} catch (error) {
console.error(error);
setError(String(error));
}
setIsLoading(false);
};
const handleOpenModal = () => {
setOpenModal();
setError("");
URL.revokeObjectURL(downloadUrl);
setTextConfig("");
setDownloadUrl("");
return fetchConfig();
};
return (
<>
<Button
color="secondary"
variant="outlined"
onClick={handleOpenModal}
>
Open Config
</Button>
{isModalOpen && (
<Modal
title="Download config"
onClose={setCloseModal}
>
<div
className={classNames({
"vm-anomaly-config": true,
"vm-anomaly-config_mobile": isMobile,
})}
>
{isLoading && (
<Spinner
containerStyles={{ position: "relative" }}
message={"Loading config..."}
/>
)}
{!isLoading && error && (
<div className="vm-anomaly-config-error">
<div className="vm-anomaly-config-error__icon"><ErrorIcon/></div>
<h3 className="vm-anomaly-config-error__title">Cannot download config</h3>
<p className="vm-anomaly-config-error__text">{error}</p>
</div>
)}
{!isLoading && textConfig && (
<TextField
value={textConfig}
label={"config.yaml"}
type="textarea"
disabled={true}
/>
)}
<div className="vm-anomaly-config-footer">
{downloadUrl && (
<a
href={downloadUrl}
download={"config.yaml"}
>
<Button
variant="contained"
startIcon={<DownloadIcon/>}
>
download
</Button>
</a>
)}
</div>
</div>
</Modal>
)}
</>
);
};
export default AnomalyConfig;

View File

@@ -1,61 +0,0 @@
@use "src/styles/variables" as *;
.vm-anomaly-config {
display: grid;
grid-template-rows: calc(($vh * 70) - 78px - ($padding-medium*3)) auto;
gap: $padding-global;
min-width: 400px;
max-width: 80vw;
min-height: 300px;
&_mobile {
width: 100%;
max-width: none;
min-height: 100%;
grid-template-rows: calc(($vh * 100) - 78px - ($padding-global*3)) auto;
}
textarea {
overflow: auto;
width: 100%;
height: 100%;
max-height: 900px;
}
&-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
gap: $padding-small;
text-align: center;
&__icon {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
margin-bottom: $padding-small;
color: $color-error;
}
&__title {
font-size: $font-size-medium;
font-weight: bold;
}
&__text {
max-width: 700px;
line-height: 1.3;
}
}
&-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: $padding-small;
}
}

File diff suppressed because one or more lines are too long

View File

@@ -12,7 +12,7 @@ import {
getMinMaxBuffer,
getTimeSeries,
} from "../../../utils/uplot";
import { TimeParams, SeriesItem, LegendItemType } from "../../../types";
import { TimeParams, LegendItemType } from "../../../types";
import { AxisRange, YaxisState } from "../../../state/graph/reducer";
import { getMathStats } from "../../../utils/math";
import classNames from "classnames";
@@ -23,8 +23,6 @@ import { promValueToNumber } from "../../../utils/metric";
import useDeviceDetect from "../../../hooks/useDeviceDetect";
import useElementSize from "../../../hooks/useElementSize";
import { ChartTooltipProps } from "../../Chart/ChartTooltip/ChartTooltip";
import LegendAnomaly from "../../Chart/Line/LegendAnomaly/LegendAnomaly";
import { groupByMultipleKeys } from "../../../utils/array";
import { useGraphDispatch } from "../../../state/graph/GraphStateContext";
import { sameTs } from "../../../utils/time";
import { useLocation } from "react-router-dom";
@@ -44,7 +42,6 @@ export interface GraphViewProps {
fullWidth?: boolean;
height?: number;
isHistogram?: boolean;
isAnomalyView?: boolean;
isPredefinedPanel?: boolean;
spanGaps?: boolean;
showAllPoints?: boolean;
@@ -64,7 +61,6 @@ const GraphView: FC<GraphViewProps> = ({
fullWidth = true,
height,
isHistogram,
isAnomalyView,
isPredefinedPanel,
spanGaps,
showAllPoints
@@ -89,8 +85,8 @@ const GraphView: FC<GraphViewProps> = ({
const [legendValue, setLegendValue] = useState<ChartTooltipProps | null>(null);
const getSeriesItem = useMemo(() => {
return getSeriesItemContext(data, hideSeries, alias, showAllPoints, isAnomalyView, isRawQuery);
}, [data, hideSeries, alias, showAllPoints, isAnomalyView, isRawQuery]);
return getSeriesItemContext(data, hideSeries, alias, showAllPoints, isRawQuery);
}, [data, hideSeries, alias, showAllPoints, isRawQuery]);
const setLimitsYaxis = (minVal: number, maxVal: number) => {
let min = Number.isFinite(minVal) ? minVal : 0;
@@ -102,7 +98,7 @@ const GraphView: FC<GraphViewProps> = ({
};
const onChangeLegend = (legend: LegendItemType, metaKey: boolean) => {
setHideSeries(getHideSeries({ hideSeries, legend, metaKey, series, isAnomalyView }));
setHideSeries(getHideSeries({ hideSeries, legend, metaKey, series }));
};
const prepareHistogramData = (data: (number | null)[][]) => {
@@ -127,20 +123,6 @@ const GraphView: FC<GraphViewProps> = ({
return [null, [xs, ys, counts]];
};
const prepareAnomalyLegend = (legend: LegendItemType[]): LegendItemType[] => {
if (!isAnomalyView) return legend;
// For vmanomaly: Only select the first series per group (due to API specs) and clear __name__ in freeFormFields.
const grouped = groupByMultipleKeys(legend, ["group", "label"]);
return grouped.map((group) => {
const firstEl = group.values[0];
return {
...firstEl,
freeFormFields: { ...firstEl.freeFormFields, __name__: "" }
};
});
};
useEffect(() => {
const dLen = data.length;
@@ -155,7 +137,7 @@ const GraphView: FC<GraphViewProps> = ({
for (let i = 0; i < dLen; i++) {
const d = data[i];
const seriesItem = getSeriesItem(d, i);
const seriesItem = getSeriesItem(d);
tempSeries[i + 1] = seriesItem;
tempLegend[i] = getLegendItem(seriesItem, d.group);
@@ -206,7 +188,7 @@ const GraphView: FC<GraphViewProps> = ({
const avg = Math.abs(Number(avgRaw));
const range = getMinMaxBuffer(min, max);
const rangeStep = Math.abs(range[1] - range[0]);
const needStabilize = (avg > rangeStep * 1e10) && !isAnomalyView;
const needStabilize = (avg > rangeStep * 1e10);
return needStabilize ? results.fill(avg) : results;
});
@@ -214,13 +196,11 @@ const GraphView: FC<GraphViewProps> = ({
timeDataSeries.unshift(timeSeries);
const result = isHistogram ? prepareHistogramData(timeDataSeries) : timeDataSeries;
const legend = prepareAnomalyLegend(tempLegend);
setLimitsYaxis(minVal, maxVal);
setDataChart(result as uPlotData);
setSeries(tempSeries);
setLegend(legend);
isAnomalyView && setHideSeries(legend.map(s => s.label || "").slice(1));
setLegend(tempLegend);
}, [data, timezone, isHistogram, currentStep, isRawQuery]);
useEffect(() => {
@@ -232,13 +212,13 @@ const GraphView: FC<GraphViewProps> = ({
for (let i = 0; i < dLen; i++) {
const d = data[i];
const seriesItem = getSeriesItem(d, i);
const seriesItem = getSeriesItem(d);
tempSeries[i + 1] = seriesItem;
tempLegend[i] = getLegendItem(seriesItem, d.group);
}
setSeries(tempSeries);
setLegend(prepareAnomalyLegend(tempLegend));
setLegend(tempLegend);
}, [hideSeries]);
const hasTimeData = dataChart[0]?.length > 0;
@@ -281,7 +261,6 @@ const GraphView: FC<GraphViewProps> = ({
setPeriod={setPeriod}
layoutSize={containerSize}
height={height}
isAnomalyView={isAnomalyView}
spanGaps={spanGaps}
showAllPoints={isRawQuery ? true : showAllPoints}
/>
@@ -298,12 +277,10 @@ const GraphView: FC<GraphViewProps> = ({
onChangeLegend={setLegendValue}
/>
)}
{isAnomalyView && showLegend && (<LegendAnomaly series={series as SeriesItem[]}/>)}
{!isHistogram && showLegend && (
<Legend
labels={legend}
query={query}
isAnomalyView={isAnomalyView}
onChange={onChangeLegend}
isPredefinedPanel={isPredefinedPanel}
/>

View File

@@ -1,8 +0,0 @@
export enum AppType {
victoriametrics = "victoriametrics",
vmanomaly = "vmanomaly",
}
export const APP_TYPE = import.meta.env.VITE_APP_TYPE;
export const APP_TYPE_VM = APP_TYPE === AppType.victoriametrics;
export const APP_TYPE_ANOMALY = APP_TYPE === AppType.vmanomaly;

View File

@@ -13,10 +13,9 @@ interface LineTooltipHook {
metrics: MetricResult[];
series: uPlotSeries[];
unit?: string;
isAnomalyView?: boolean;
}
const useLineTooltip = ({ u, metrics, series, unit, isAnomalyView }: LineTooltipHook) => {
const useLineTooltip = ({ u, metrics, series, unit }: LineTooltipHook) => {
const [showTooltip, setShowTooltip] = useState(false);
const [tooltipIdx, setTooltipIdx] = useState({ seriesIdx: -1, dataIdx: -1 });
const [stickyTooltips, setStickyToolTips] = useState<ChartTooltipProps[]>([]);
@@ -79,7 +78,7 @@ const useLineTooltip = ({ u, metrics, series, unit, isAnomalyView }: LineTooltip
point,
u: u,
id: `${seriesIdx}_${dataIdx}`,
title: groups.size > 1 && !isAnomalyView ? `Query ${group}` : "",
title: groups.size > 1 ? `Query ${group}` : "",
dates: [date ? dayjs(date * 1000).tz().format(DATE_FULL_TIMEZONE_FORMAT) : "-"],
value: formatPrettyNumber(value, min, max),
info: getMetricName(metricItem, seriesItem),
@@ -87,7 +86,7 @@ const useLineTooltip = ({ u, metrics, series, unit, isAnomalyView }: LineTooltip
marker: `${seriesItem?.stroke}`,
duplicateCount,
};
}, [u, tooltipIdx, metrics, series, unit, isAnomalyView]);
}, [u, tooltipIdx, metrics, series, unit]);
const handleClick = useCallback(() => {
if (!showTooltip) return;

View File

@@ -1,7 +1,6 @@
import { useAppDispatch, useAppState } from "../state/common/StateContext";
import { useEffect, useState } from "preact/compat";
import { ErrorTypes } from "../types";
import { APP_TYPE_VM } from "../constants/appType";
const useFetchAppConfig = () => {
const { serverUrl } = useAppState();
@@ -12,7 +11,6 @@ const useFetchAppConfig = () => {
useEffect(() => {
const fetchAppConfig = async () => {
if (!APP_TYPE_VM) return;
setError("");
setIsLoading(true);

View File

@@ -5,7 +5,6 @@ import { useTimeDispatch } from "../state/time/TimeStateContext";
import { getFromStorage } from "../utils/storage";
import dayjs from "dayjs";
import { getBrowserTimezone } from "../utils/time";
import { APP_TYPE_VM } from "../constants/appType";
const disabledDefaultTimezone = Boolean(getFromStorage("DISABLED_DEFAULT_TIMEZONE"));
@@ -29,7 +28,7 @@ const useFetchDefaultTimezone = () => {
};
const fetchDefaultTimezone = async () => {
if (!serverUrl || !APP_TYPE_VM) return;
if (!serverUrl) return;
setError("");
setIsLoading(true);

View File

@@ -13,7 +13,6 @@ import { isHistogramData } from "../utils/metric";
import { useGraphState } from "../state/graph/GraphStateContext";
import { getStepFromDuration } from "../utils/time";
import { getQueryStringValue } from "../utils/query-string";
import { APP_TYPE_ANOMALY } from "../constants/appType";
interface FetchQueryParams {
predefinedQuery?: string[]
@@ -135,7 +134,7 @@ export const useFetchQuery = ({
}
const preventChangeType = !!getQueryStringValue("display_mode", null);
isHistogramResult = !APP_TYPE_ANOMALY && isDisplayChart && !preventChangeType && isHistogramData(resp.data.result);
isHistogramResult = isDisplayChart && !preventChangeType && isHistogramData(resp.data.result);
seriesLimit = isHistogramResult ? Infinity : defaultLimit;
const freeTempSize = Math.max(0, seriesLimit - tempData.length);
resp.data.result.slice(0, freeTempSize).forEach((d: MetricBase) => {

View File

@@ -3,20 +3,9 @@ import "./constants/dayjsPlugins";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
import "./styles/style.scss";
import { APP_TYPE, AppType } from "./constants/appType";
import AppAnomaly from "./AppAnomaly";
const getAppComponent = () => {
switch (APP_TYPE) {
case AppType.vmanomaly:
return <AppAnomaly/>;
default:
return <App/>;
}
};
const root = document.getElementById("root");
if (root) render(getAppComponent(), root);
if (root) render(<App/>, root);
// If you want to start measuring performance in your app, pass a function

View File

@@ -1,50 +0,0 @@
import Header from "../Header/Header";
import { FC, useEffect } from "preact/compat";
import { Outlet, useSearchParams } from "react-router-dom";
import qs from "qs";
import "../MainLayout/style.scss";
import { getAppModeEnable } from "../../utils/app-mode";
import classNames from "classnames";
import Footer from "../Footer/Footer";
import useFetchDefaultTimezone from "../../hooks/useFetchDefaultTimezone";
import useDeviceDetect from "../../hooks/useDeviceDetect";
import ControlsAnomalyLayout from "./ControlsAnomalyLayout";
const AnomalyLayout: FC = () => {
const appModeEnable = getAppModeEnable();
const { isMobile } = useDeviceDetect();
const [searchParams, setSearchParams] = useSearchParams();
useFetchDefaultTimezone();
// for support old links with search params
const redirectSearchToHashParams = () => {
const { search, href } = window.location;
if (search) {
const query = qs.parse(search, { ignoreQueryPrefix: true });
Object.entries(query).forEach(([key, value]) => searchParams.set(key, value as string));
setSearchParams(searchParams);
window.location.search = "";
}
const newHref = href.replace(/\/\?#\//, "/#/");
if (newHref !== href) window.location.replace(newHref);
};
useEffect(redirectSearchToHashParams, []);
return <section className="vm-container">
<Header controlsComponent={ControlsAnomalyLayout}/>
<div
className={classNames({
"vm-container-body": true,
"vm-container-body_mobile": isMobile,
"vm-container-body_app": appModeEnable
})}
>
<Outlet/>
</div>
{!appModeEnable && <Footer/>}
</section>;
};
export default AnomalyLayout;

View File

@@ -1,43 +0,0 @@
import { FC } from "preact/compat";
import classNames from "classnames";
import TenantsConfiguration
from "../../components/Configurators/GlobalSettings/TenantsConfiguration/TenantsConfiguration";
import StepConfigurator from "../../components/Configurators/StepConfigurator/StepConfigurator";
import { TimeSelector } from "../../components/Configurators/TimeRangeSettings/TimeSelector/TimeSelector";
import CardinalityDatePicker from "../../components/Configurators/CardinalityDatePicker/CardinalityDatePicker";
import { ExecutionControls } from "../../components/Configurators/TimeRangeSettings/ExecutionControls/ExecutionControls";
import GlobalSettings from "../../components/Configurators/GlobalSettings/GlobalSettings";
import ShortcutKeys from "../../components/Main/ShortcutKeys/ShortcutKeys";
import { ControlsProps } from "../Header/HeaderControls/HeaderControls";
const ControlsAnomalyLayout: FC<ControlsProps> = ({
displaySidebar,
isMobile,
headerSetup,
accountIds,
closeModal,
}) => {
return (
<div
className={classNames({
"vm-header-controls": true,
"vm-header-controls_mobile": isMobile,
})}
>
{headerSetup?.tenant && <TenantsConfiguration accountIds={accountIds || []}/>}
{headerSetup?.stepControl && <StepConfigurator/>}
{headerSetup?.timeSelector && <TimeSelector/>}
{headerSetup?.cardinalityDatePicker && <CardinalityDatePicker/>}
{headerSetup?.executionControls && <ExecutionControls
tooltip={headerSetup?.executionControls?.tooltip}
useAutorefresh={headerSetup?.executionControls?.useAutorefresh}
closeModal={closeModal}
/>}
<GlobalSettings/>
{!displaySidebar && <ShortcutKeys/>}
</div>
);
};
export default ControlsAnomalyLayout;

View File

@@ -2,7 +2,7 @@ import { FC, useMemo } from "preact/compat";
import { useNavigate } from "react-router-dom";
import router from "../../router";
import { getAppModeEnable, getAppModeParams } from "../../utils/app-mode";
import { LogoAnomalyIcon, LogoIcon } from "../../components/Main/Icons";
import { LogoIcon } from "../../components/Main/Icons";
import { getCssVariable } from "../../utils/theme";
import "./style.scss";
import classNames from "classnames";
@@ -13,19 +13,10 @@ import HeaderControls, { ControlsProps } from "./HeaderControls/HeaderControls";
import useDeviceDetect from "../../hooks/useDeviceDetect";
import useWindowSize from "../../hooks/useWindowSize";
import { ComponentType } from "react";
import { APP_TYPE, AppType } from "../../constants/appType";
export interface HeaderProps {
controlsComponent: ComponentType<ControlsProps>
}
const Logo = () => {
switch (APP_TYPE) {
case AppType.vmanomaly:
return <LogoAnomalyIcon/>;
default:
return <LogoIcon/>;
}
};
const Header: FC<HeaderProps> = ({ controlsComponent }) => {
const { isMobile } = useDeviceDetect();
@@ -75,7 +66,7 @@ const Header: FC<HeaderProps> = ({ controlsComponent }) => {
onClick={onClickLogo}
style={{ color }}
>
{<Logo/>}
{<LogoIcon/>}
</div>
{displaySidebar ? (

View File

@@ -13,10 +13,9 @@ type Props = {
isHistogram: boolean;
graphData: MetricResult[];
controlsRef: RefObject<HTMLDivElement>;
isAnomalyView?: boolean;
}
const GraphTab: FC<Props> = ({ isHistogram, graphData, controlsRef, isAnomalyView }) => {
const GraphTab: FC<Props> = ({ isHistogram, graphData, controlsRef }) => {
const { isMobile } = useDeviceDetect();
const { customStep, yaxis, spanGaps, showAllPoints } = useGraphState();
@@ -74,7 +73,6 @@ const GraphTab: FC<Props> = ({ isHistogram, graphData, controlsRef, isAnomalyVie
setPeriod={setPeriod}
height={isMobile ? window.innerHeight * 0.5 : 500}
isHistogram={isHistogram}
isAnomalyView={isAnomalyView}
spanGaps={spanGaps}
showAllPoints={showAllPoints}
/>

View File

@@ -26,7 +26,6 @@ import useSearchParamsFromObject from "../../../hooks/useSearchParamsFromObject"
import { QueryStats } from "../../../api/types";
import { usePrettifyQuery } from "./hooks/usePrettifyQuery";
import QueryHistory from "../../../components/QueryHistory/QueryHistory";
import AnomalyConfig from "../../../components/ExploreAnomaly/AnomalyConfig";
import QueryEditorAutocomplete from "../../../components/Configurators/QueryEditor/QueryEditorAutocomplete";
import { getUpdatedHistory } from "../../../components/QueryHistory/utils";
@@ -46,7 +45,6 @@ export interface QueryConfiguratorProps {
prettify?: boolean;
autocomplete?: boolean;
traceQuery?: boolean;
anomalyConfig?: boolean;
disableCache?: boolean;
reduceMemUsage?: boolean;
}
@@ -278,7 +276,6 @@ const QueryConfigurator: FC<QueryConfiguratorProps> = ({
handleSelectQuery={handleSelectHistory}
historyKey={"METRICS_QUERY_HISTORY"}
/>
{hideButtons?.anomalyConfig && <AnomalyConfig/>}
{!hideButtons?.addQuery && stateQuery.length < MAX_QUERY_FIELDS && (
<Button
variant="outlined"

View File

@@ -1,135 +0,0 @@
import { FC, useMemo, useRef, useState } from "preact/compat";
import classNames from "classnames";
import useDeviceDetect from "../../hooks/useDeviceDetect";
import { ForecastType } from "../../types";
import { useSetQueryParams } from "../CustomPanel/hooks/useSetQueryParams";
import QueryConfigurator from "../CustomPanel/QueryConfigurator/QueryConfigurator";
import "../CustomPanel/style.scss";
import { useQueryState } from "../../state/query/QueryStateContext";
import { useFetchQuery } from "../../hooks/useFetchQuery";
import { useGraphState } from "../../state/graph/GraphStateContext";
import Spinner from "../../components/Main/Spinner/Spinner";
import Alert from "../../components/Main/Alert/Alert";
import WarningLimitSeries from "../CustomPanel/WarningLimitSeries/WarningLimitSeries";
import GraphTab from "../CustomPanel/CustomPanelTabs/GraphTab";
import { extractFields, isForecast } from "../../utils/uplot";
import { MetricResult } from "../../api/types";
import { promValueToNumber } from "../../utils/metric";
// 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 = () => {
useSetQueryParams();
const { isMobile } = useDeviceDetect();
const { query } = useQueryState();
const { customStep } = useGraphState();
const controlsRef = useRef<HTMLDivElement>(null);
const [hideQuery] = useState<number[]>([]);
const [hideError, setHideError] = useState(!query[0]);
const [showAllSeries, setShowAllSeries] = useState(false);
const {
isLoading,
graphData,
error,
queryErrors,
setQueryErrors,
queryStats,
warning,
} = useFetchQuery({
visible: true,
customStep,
hideQuery,
showAllSeries
});
const data = useMemo(() => {
if (!graphData) return [];
const detectedData = graphData.map(d => ({ ...isForecast(d.metric), ...d }));
const realData = detectedData.filter(d => d.value === ForecastType.actual);
const anomalyScoreData = detectedData.filter(d => d.value === ForecastType.anomaly);
const anomalyData: MetricResult[] = realData.map((d) => {
const id = extractFields(d.metric);
const anomalyScoreDataByLabels = anomalyScoreData.find(du => extractFields(du.metric) === id);
return {
group: 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;
})
};
});
const filterData = detectedData.filter(d => (d.value !== ForecastType.anomaly) && d.value) as MetricResult[];
return filterData.concat(anomalyData);
}, [graphData]);
const handleRunQuery = () => {
setHideError(false);
};
return (
<div
className={classNames({
"vm-custom-panel": true,
"vm-custom-panel_mobile": isMobile,
})}
>
<QueryConfigurator
queryErrors={!hideError ? queryErrors : []}
setQueryErrors={setQueryErrors}
setHideError={setHideError}
stats={queryStats}
onRunQuery={handleRunQuery}
hideButtons={{
addQuery: true,
prettify: false,
autocomplete: false,
traceQuery: true,
anomalyConfig: true,
reduceMemUsage: true,
}}
/>
{isLoading && <Spinner/>}
{(!hideError && error) && <Alert variant="error">{error}</Alert>}
{warning && (
<WarningLimitSeries
warning={warning}
query={query}
onChange={setShowAllSeries}
/>
)}
<div
className={classNames({
"vm-custom-panel-body": true,
"vm-custom-panel-body_mobile": isMobile,
"vm-block": true,
"vm-block_mobile": isMobile,
})}
>
<div
className="vm-custom-panel-body-header"
ref={controlsRef}
>
<div/>
</div>
{data && (
<GraphTab
graphData={data}
isHistogram={false}
controlsRef={controlsRef}
isAnomalyView={true}
/>
)}
</div>
</div>
);
};
export default ExploreAnomaly;

View File

@@ -3,7 +3,6 @@ import { DashboardSettings, ErrorTypes } from "../../../types";
import { useAppState } from "../../../state/common/StateContext";
import { useDashboardsDispatch } from "../../../state/dashboards/DashboardsStateContext";
import { getAppModeEnable } from "../../../utils/app-mode";
import { APP_TYPE_VM } from "../../../constants/appType";
const importModule = async (filename: string) => {
const data = await fetch(`./dashboards/${filename}`);
@@ -35,7 +34,7 @@ export const useFetchDashboards = (): {
};
const fetchRemoteDashboards = async () => {
if (!serverUrl || !APP_TYPE_VM) return;
if (!serverUrl) return;
setError("");
setIsLoading(true);

View File

@@ -1,4 +1,3 @@
import { APP_TYPE, AppType } from "../constants/appType";
const router = {
home: "/",
@@ -12,7 +11,6 @@ const router = {
activeQueries: "/active-queries",
queryAnalyzer: "/query-analyzer",
icons: "/icons",
anomaly: "/anomaly",
query: "/query",
rawQuery: "/raw-query",
downsamplingDebug: "/downsampling-filters-debug",
@@ -52,23 +50,11 @@ const routerOptionsDefault = {
},
};
const getDefaultOptions = (appType: AppType) => {
switch (appType) {
case AppType.vmanomaly:
return {
title: "Anomaly exploration",
...routerOptionsDefault,
};
default:
return {
title: "Query",
...routerOptionsDefault,
};
}
};
export const routerOptions: { [key: string]: RouterOptions } = {
[router.home]: getDefaultOptions(APP_TYPE),
[router.home]: {
title: "Query",
...routerOptionsDefault,
},
[router.rawQuery]: {
title: "Raw query",
header: {
@@ -148,7 +134,6 @@ export const routerOptions: { [key: string]: RouterOptions } = {
title: "Icons",
header: {},
},
[router.anomaly]: getDefaultOptions(AppType.vmanomaly),
[router.query]: {
title: "Query",
...routerOptionsDefault,

View File

@@ -1,4 +1,4 @@
import router, { routerOptions } from "./index";
import router from "./index";
export enum NavigationItemType {
internalLink,
@@ -66,13 +66,3 @@ export const getDefaultNavigation = ({
{ value: router.dashboards, hide: !showPredefinedDashboards },
{ value: "Alerting", submenu: getAlertingNav(), hide: !showAlerting },
];
/**
* vmanomaly navigation menu
*/
export const getAnomalyNavigation = (): NavigationItem[] => [
{
label: routerOptions[router.anomaly].title,
value: router.home,
},
];

View File

@@ -3,8 +3,7 @@ import { useDashboardsState } from "../state/dashboards/DashboardsStateContext";
import { useAppState } from "../state/common/StateContext";
import { useMemo } from "preact/compat";
import { processNavigationItems } from "./utils";
import { getAnomalyNavigation, getDefaultNavigation } from "./navigation";
import { APP_TYPE, AppType } from "../constants/appType";
import { getDefaultNavigation } from "./navigation";
const useNavigationMenu = () => {
const appModeEnable = getAppModeEnable();
@@ -23,12 +22,7 @@ const useNavigationMenu = () => {
const menu = useMemo(() => {
switch (APP_TYPE) {
case AppType.vmanomaly:
return getAnomalyNavigation();
default:
return getDefaultNavigation(navigationConfig);
}
return getDefaultNavigation(navigationConfig);
}, [navigationConfig]);
return processNavigationItems(menu);

View File

@@ -1,15 +1,5 @@
import { Axis, Series } from "uplot";
export enum ForecastType {
yhat = "yhat",
yhatUpper = "yhat_upper",
yhatLower = "yhat_lower",
anomaly = "vmui_anomalies_points",
training = "vmui_training_data",
actual = "actual",
anomalyScore = "anomaly_score",
}
export interface SeriesItemStatsFormatted {
min: string,
max: string,
@@ -20,8 +10,6 @@ export interface SeriesItem extends Series {
freeFormFields: {[key: string]: string};
statsFormatted: SeriesItemStatsFormatted;
median: number;
forecast?: ForecastType | null;
forecastGroup?: string;
hasAlias?: boolean;
}
@@ -30,7 +18,6 @@ export interface HideSeriesArgs {
legend: LegendItemType,
metaKey: boolean,
series: Series[],
isAnomalyView?: boolean,
}
export type MinMax = { min: number, max: number }

View File

@@ -2,21 +2,6 @@ export const arrayEquals = (a: (string | number)[], b: (string | number)[]) => {
return a.length === b.length && a.every((val, index) => val === b[index]);
};
export function groupByMultipleKeys<T>(items: T[], keys: (keyof T)[]): { keys: string[], values: T[] }[] {
const groups = items.reduce((result, item) => {
const compositeKey = keys.map(key => `${String(key)}: ${item[key] || "-"}`).join("|");
(result[compositeKey] = result[compositeKey] || []).push(item);
return result;
}, {} as { [key: string]: T[] });
return Object.entries(groups).map(([keyString, values]) => ({
keys: keyString.split("|"),
values
}));
}
export const isDecreasing = (arr: number[]): boolean => {
if (arr.length < 2) return false;

View File

@@ -1,4 +1,4 @@
import { ArrayRGB, ForecastType } from "../types";
import { ArrayRGB } from "../types";
export const baseContrastColors = [
"#e54040",
@@ -21,16 +21,6 @@ export const hexToRGB = (hex: string): string => {
return `${r}, ${g}, ${b}`;
};
export const anomalyColors: Record<ForecastType, string> = {
[ForecastType.yhatUpper]: "#7126a1",
[ForecastType.yhatLower]: "#7126a1",
[ForecastType.yhat]: "#da42a6",
[ForecastType.anomaly]: "#da4242",
[ForecastType.anomalyScore]: "#7126a1",
[ForecastType.actual]: "#203ea9",
[ForecastType.training]: `rgba(${hexToRGB("#203ea9")}, 0.2)`,
};
export const getColorFromString = (text: string): string => {
const SEED = 16777215;
const FACTOR = 49979693;

View File

@@ -1,5 +1,4 @@
import { getAppModeParams } from "./app-mode";
import { APP_TYPE, AppType } from "../constants/appType";
import { getFromStorage } from "./storage";
export const getDefaultURL = (u: string) => {
@@ -9,14 +8,6 @@ export const getDefaultURL = (u: string) => {
export const getDefaultServer = (): string => {
const { serverURL } = getAppModeParams();
const storageURL = getFromStorage("SERVER_URL") as string;
const anomalyURL = `${window.location.origin}${window.location.pathname.replace(/^\/vmui/, "")}`;
const defaultURL = getDefaultURL(window.location.href);
const url = serverURL || storageURL || defaultURL;
switch (APP_TYPE) {
case AppType.vmanomaly:
return storageURL || anomalyURL;
default:
return url;
}
return serverURL || storageURL || defaultURL;
};

View File

@@ -1,41 +0,0 @@
import uPlot, { Series as uPlotSeries } from "uplot";
import { ForecastType, SeriesItem } from "../../types";
import { anomalyColors, hexToRGB } from "../color";
export const setBand = (plot: uPlot, series: uPlotSeries[]) => {
// First, remove any existing bands
plot.delBand();
// If there aren't at least two series, we can't create a band
if (series.length < 2) return;
// Cast and enrich each series item with its index
const seriesItems = (series as SeriesItem[]).map((s, index) => ({ ...s, index }));
const upperSeries = seriesItems.filter(s => s.forecast === ForecastType.yhatUpper);
const lowerSeries = seriesItems.filter(s => s.forecast === ForecastType.yhatLower);
// Create bands by matching upper and lower series based on their freeFormFields
const bands = upperSeries.map((upper) => {
const correspondingLower = lowerSeries.find(lower => lower.forecastGroup === upper.forecastGroup);
if (!correspondingLower) return null;
return {
series: [upper.index, correspondingLower.index] as [number, number],
fill: createBandFill(ForecastType.yhatUpper),
};
}).filter(band => band !== null) as uPlot.Band[]; // Filter out any nulls from failed matches
// If there are no bands to add, exit the function
if (!bands.length) return;
// Add each band to the plot
bands.forEach(band => {
plot.addBand(band);
});
};
// Helper function to create the fill color for a band
function createBandFill(forecastType: ForecastType): string {
const rgb = hexToRGB(anomalyColors[forecastType]);
return `rgba(${rgb}, 0.05)`;
}

View File

@@ -5,4 +5,3 @@ export * from "./hooks";
export * from "./instance";
export * from "./scales";
export * from "./series";
export * from "./bands";

View File

@@ -1,8 +1,7 @@
import uPlot, { Range, Scale, Scales } from "uplot";
import { getMinMaxBuffer } from "./axes";
import { YaxisState } from "../../state/graph/reducer";
import { ForecastType, MinMax, SetMinMax } from "../../types";
import { anomalyColors } from "../color";
import { MinMax, SetMinMax } from "../../types";
export const getRangeX = ({ min, max }: MinMax): Range.MinMax => [min, max];
@@ -25,80 +24,3 @@ export const setSelect = (setPlotScale: SetMinMax) => (u: uPlot) => {
const max = u.posToVal(u.select.left + u.select.width, "x");
setPlotScale({ min, max });
};
export const scaleGradient = (
scaleKey: string,
ori: number,
scaleStops: [number, string][],
discrete = false
) => (u: uPlot): CanvasGradient | string => {
const can = document.createElement("canvas");
const ctx = can.getContext("2d");
if (!ctx) return "";
const scale = u.scales[scaleKey];
// we want the stop below or at the scaleMax
// and the stop below or at the scaleMin, else the stop above scaleMin
let minStopIdx = 0;
let maxStopIdx = 1;
for (let i = 0; i < scaleStops.length; i++) {
const stopVal = scaleStops[i][0];
if (stopVal <= (scale.min || 0) || minStopIdx == null)
minStopIdx = i;
maxStopIdx = i;
if (stopVal >= (scale.max || 1))
break;
}
if (minStopIdx == maxStopIdx)
return scaleStops[minStopIdx][1];
let minStopVal = scaleStops[minStopIdx][0];
let maxStopVal = scaleStops[maxStopIdx][0];
if (minStopVal == -Infinity)
minStopVal = scale.min || 0;
if (maxStopVal == Infinity)
maxStopVal = scale.max || 1;
const minStopPos = u.valToPos(minStopVal, scaleKey, true) || 0;
const maxStopPos = u.valToPos(maxStopVal, scaleKey, true) || 1;
const range = minStopPos - maxStopPos;
let x0, y0, x1, y1;
if (ori == 1) {
x0 = x1 = 0;
y0 = minStopPos;
y1 = maxStopPos;
} else {
y0 = y1 = 0;
x0 = minStopPos;
x1 = maxStopPos;
}
const grd = ctx.createLinearGradient(x0, y0, x1, y1);
let prevColor = anomalyColors[ForecastType.actual];
for (let i = minStopIdx; i <= maxStopIdx; i++) {
const s = scaleStops[i];
const stopPos = i == minStopIdx ? minStopPos : i == maxStopIdx ? maxStopPos : u.valToPos(s[0], scaleKey, true) | 1;
const pct = Math.min(1, Math.max(0, (minStopPos - stopPos) / range));
if (discrete && i > minStopIdx) {
grd.addColorStop(pct, prevColor);
}
grd.addColorStop(pct, prevColor = s[1]);
}
return grd;
};

View File

@@ -1,8 +1,8 @@
import { MetricBase, MetricResult } from "../../api/types";
import uPlot, { Series as uPlotSeries } from "uplot";
import { getNameForMetric, promValueToNumber } from "../metric";
import { ForecastType, HideSeriesArgs, LegendItemType, SeriesItem } from "../../types";
import { anomalyColors, baseContrastColors, getColorFromString } from "../color";
import { HideSeriesArgs, LegendItemType, SeriesItem } from "../../types";
import { baseContrastColors, getColorFromString } from "../color";
import { getMathStats } from "../math";
import { formatPrettyNumber } from "./helpers";
import { drawPoints } from "./scatter";
@@ -15,47 +15,26 @@ export const extractFields = (metric: MetricBase["metric"]): string => {
.map(([key, value]) => `${key}: ${value}`).join(",");
};
type ForecastMetricInfo = {
value: ForecastType | null;
group: string;
}
export const isForecast = (metric: MetricBase["metric"]): ForecastMetricInfo => {
const metricName = metric?.__name__ || "";
const forecastRegex = new RegExp(`(${Object.values(ForecastType).join("|")})$`);
const match = metricName.match(forecastRegex);
const value = match && match[0] as ForecastType;
const isY = /(?:^|[^a-zA-Z0-9_])y(?:$|[^a-zA-Z0-9_])/.test(metricName);
return {
value: isY ? ForecastType.actual : value,
group: extractFields(metric)
};
};
export const getSeriesItemContext = (data: MetricResult[], hideSeries: string[], alias: string[], showPoints?: boolean, isAnomalyUI?: boolean, isRawQuery?: boolean) => {
export const getSeriesItemContext = (data: MetricResult[], hideSeries: string[], alias: string[], showPoints?: boolean, isRawQuery?: boolean) => {
const colorState: {[key: string]: string} = {};
const maxColors = isAnomalyUI ? 0 : Math.min(data.length, baseContrastColors.length);
const maxColors = Math.min(data.length, baseContrastColors.length);
for (let i = 0; i < maxColors; i++) {
const label = getNameForMetric(data[i], alias[data[i].group - 1]);
colorState[label] = baseContrastColors[i];
}
return (d: MetricResult, i: number): SeriesItem => {
const metricInfo = isAnomalyUI ? isForecast(data[i].metric) : null;
return (d: MetricResult): SeriesItem => {
const aliasValue = alias[d.group - 1];
const label = isAnomalyUI ? metricInfo?.group || "" : getNameForMetric(d, aliasValue);
const label = getNameForMetric(d, aliasValue);
return {
label,
hasAlias: Boolean(aliasValue),
dash: getDashSeries(metricInfo),
width: getWidthSeries(metricInfo),
stroke: getStrokeSeries({ metricInfo, label, isAnomalyUI, colorState }),
points: getPointsSeries(metricInfo, showPoints, isRawQuery),
width: 1.4,
stroke: colorState[label] || getColorFromString(label),
points: getPointsSeries(showPoints, isRawQuery),
spanGaps: false,
forecast: metricInfo?.value,
forecastGroup: metricInfo?.group,
freeFormFields: d.metric,
show: !includesHideSeries(label, hideSeries),
scale: "1",
@@ -91,16 +70,11 @@ export const getLegendItem = (s: SeriesItem, group: number): LegendItemType => (
hasAlias: s.hasAlias || false,
});
export const getHideSeries = ({ hideSeries, legend, metaKey, series, isAnomalyView }: HideSeriesArgs): string[] => {
export const getHideSeries = ({ hideSeries, legend, metaKey, series }: HideSeriesArgs): string[] => {
const { label } = legend;
const include = includesHideSeries(label, hideSeries);
const labels = series.map(getLabelForSeries);
// if anomalyView is true, always return all series except the one specified by `label`
if (isAnomalyView) {
return labels.filter(l => l !== label);
}
if (metaKey) {
return include ? hideSeries.filter(l => l !== label) : [...hideSeries, label];
} else if (hideSeries.length) {
@@ -128,43 +102,7 @@ export const addSeries = (u: uPlot, series: uPlotSeries[], spanGaps = false, sho
});
};
// Helpers
const getDashSeries = (metricInfo: ForecastMetricInfo | null): number[] => {
const isLower = metricInfo?.value === ForecastType.yhatLower;
const isUpper = metricInfo?.value === ForecastType.yhatUpper;
const isYhat = metricInfo?.value === ForecastType.yhat;
if (isLower || isUpper) {
return [10, 5];
} else if (isYhat) {
return [10, 2];
}
return [];
};
const getWidthSeries = (metricInfo: ForecastMetricInfo | null): number => {
const isLower = metricInfo?.value === ForecastType.yhatLower;
const isUpper = metricInfo?.value === ForecastType.yhatUpper;
const isYhat = metricInfo?.value === ForecastType.yhat;
const isAnomalyMetric = metricInfo?.value === ForecastType.anomaly;
if (isUpper || isLower) {
return 0.7;
} else if (isYhat) {
return 1;
} else if (isAnomalyMetric) {
return 0;
}
return 1.4;
};
const getPointsSeries = (metricInfo: ForecastMetricInfo | null, showPoints: boolean = false, isRawQuery?: boolean): uPlotSeries.Points => {
const isAnomalyMetric = metricInfo?.value === ForecastType.anomaly;
if (isAnomalyMetric) {
return { size: 8, width: 4, space: 0 };
}
const getPointsSeries = (showPoints: boolean = false, isRawQuery?: boolean): uPlotSeries.Points => {
return {
size: isRawQuery ? 0 : 4,
width: 0,
@@ -187,31 +125,3 @@ const filterPoints = (self: uPlot, seriesIdx: number): number[] | null => {
return indices;
};
type GetStrokeSeriesArgs = {
metricInfo: ForecastMetricInfo | null,
label: string,
colorState: {[p: string]: string},
isAnomalyUI?: boolean
}
const getStrokeSeries = ({ metricInfo, label, isAnomalyUI, colorState }: GetStrokeSeriesArgs): uPlotSeries.Stroke => {
const stroke: uPlotSeries.Stroke = colorState[label] || getColorFromString(label);
const isAnomalyMetric = metricInfo?.value === ForecastType.anomaly;
if (isAnomalyUI && isAnomalyMetric) {
return anomalyColors[ForecastType.anomaly];
} else if (isAnomalyUI && !isAnomalyMetric && !metricInfo?.value) {
// TODO add stroke for training data
// const hzGrad: [number, string][] = [
// [time, anomalyColors[ForecastType.actual]],
// [time, anomalyColors[ForecastType.training]],
// [time, anomalyColors[ForecastType.actual]],
// ];
// stroke = scaleGradient("x", 0, hzGrad, true);
return anomalyColors[ForecastType.actual];
} else if (metricInfo?.value) {
return metricInfo?.value ? anomalyColors[metricInfo?.value] : stroke;
}
return colorState[label] || getColorFromString(label);
};

View File

@@ -2,44 +2,40 @@ import * as path from "path";
import { defineConfig, ProxyOptions } from "vite";
import preact from "@preact/preset-vite";
import dynamicIndexHtmlPlugin from "./config/plugins/dynamicIndexHtml";
const getProxy = (): Record<string, ProxyOptions> | undefined => {
const playground = process.env.PLAYGROUND;
const playground = process.env.PLAYGROUND.toLowerCase();
switch (playground) {
case "METRICS": {
return {
"^/(api|vmalert)/.*": {
target: "https://play.victoriametrics.com/select/0/prometheus",
changeOrigin: true,
configure: (proxy) => {
proxy.on("error", (err) => {
console.error("[proxy error]", err.message);
});
},
},
"/vmui/config.json": {
target: "https://play.victoriametrics.com/select/0",
changeOrigin: true,
configure: (proxy) => {
proxy.on("error", (err) => {
console.error("[proxy error]", err.message);
});
},
},
};
}
default: {
return undefined;
}
if (playground !== "true") {
return undefined;
}
return {
"^/(api|vmalert)/.*": {
target: "https://play.victoriametrics.com/select/0/prometheus",
changeOrigin: true,
configure: (proxy) => {
proxy.on("error", (err) => {
console.error("[proxy error]", err.message);
});
},
},
"/prometheus/vmui/config.json": {
target: "https://play.victoriametrics.com/select/0",
changeOrigin: true,
configure: (proxy) => {
proxy.on("error", (err) => {
console.error("[proxy error]", err.message);
});
},
},
};
};
export default defineConfig(({ mode }) => {
export default defineConfig(() => {
return {
base: "",
plugins: [preact(), dynamicIndexHtmlPlugin({ mode })],
plugins: [preact()],
assetsInclude: ["**/*.md"],
server: {
open: true,