From a35e52114ba6bd957ca23e8d6b9469fda9c44028 Mon Sep 17 00:00:00 2001 From: Yury Molodov Date: Tue, 19 Dec 2023 17:20:54 +0100 Subject: [PATCH] vmui: add vmanomaly explorer (#5401) --- app/vmui/Makefile | 8 + app/vmui/packages/vmui/config-overrides.js | 6 +- app/vmui/packages/vmui/package.json | 6 +- app/vmui/packages/vmui/src/AppAnomaly.tsx | 41 ++++ .../Line/LegendAnomaly/LegendAnomaly.tsx | 86 +++++++++ .../Chart/Line/LegendAnomaly/style.scss | 23 +++ .../Chart/Line/LineChart/LineChart.tsx | 13 +- .../GlobalSettings/GlobalSettings.tsx | 9 +- .../LimitsConfigurator/LimitsConfigurator.tsx | 6 +- .../ServerConfigurator/ServerConfigurator.tsx | 56 +++++- .../Configurators/GlobalSettings/style.scss | 16 ++ .../ExploreMetricItemGraph.tsx | 27 +-- .../vmui/src/components/Main/Icons/index.tsx | 10 + .../src/components/Main/Select/Select.tsx | 8 +- .../src/components/Main/Select/style.scss | 14 ++ .../components/Views/GraphView/GraphView.tsx | 29 ++- .../packages/vmui/src/constants/navigation.ts | 91 +++++---- .../vmui/src/hooks/uplot/useLineTooltip.ts | 7 +- .../packages/vmui/src/hooks/useFetchQuery.ts | 7 +- .../layouts/AnomalyLayout/AnomalyLayout.tsx | 59 ++++++ .../AnomalyLayout/ControlsAnomalyLayout.tsx | 38 ++++ .../vmui/src/layouts/Header/Header.tsx | 25 ++- .../layouts/Header/HeaderNav/HeaderNav.tsx | 27 ++- .../Header/SidebarNav/SidebarHeader.tsx | 7 +- .../src/layouts/LogsLayout/LogsLayout.tsx | 2 +- .../vmui/src/layouts/LogsLayout/style.scss | 27 --- .../src/layouts/MainLayout/MainLayout.tsx | 5 +- .../CustomPanel/CustomPanelTabs/GraphTab.tsx | 72 +++++++ .../CustomPanel/CustomPanelTabs/TableTab.tsx | 47 +++++ .../CustomPanel/CustomPanelTabs/index.tsx | 45 +++++ .../CustomPanelTraces/CustomPanelTraces.tsx | 43 +++++ .../pages/CustomPanel/DisplayTypeSwitch.tsx | 9 +- .../WarningLimitSeries/WarningLimitSeries.tsx | 50 +++++ .../vmui/src/pages/CustomPanel/index.tsx | 178 +++++------------- .../vmui/src/pages/CustomPanel/style.scss | 3 +- .../pages/ExploreAnomaly/ExploreAnomaly.tsx | 118 ++++++++++++ .../ExploreAnomalyHeader.tsx | 112 +++++++++++ .../ExploreAnomalyHeader/style.scss | 37 ++++ .../hooks/useFetchAnomalySeries.ts | 66 +++++++ .../ExploreAnomaly/hooks/useSetQueryParams.ts | 31 +++ .../PredefinedPanel/PredefinedPanel.tsx | 4 +- .../hooks/useFetchDashboards.ts | 3 +- app/vmui/packages/vmui/src/router/index.ts | 23 ++- .../vmui/src/state/customPanel/reducer.ts | 6 +- app/vmui/packages/vmui/src/types/appType.ts | 4 + app/vmui/packages/vmui/src/types/index.ts | 6 +- app/vmui/packages/vmui/src/types/uplot.ts | 13 +- app/vmui/packages/vmui/src/utils/color.ts | 31 +-- .../vmui/src/utils/default-server-url.ts | 10 +- app/vmui/packages/vmui/src/utils/storage.ts | 1 + .../packages/vmui/src/utils/uplot/bands.ts | 41 ++++ .../packages/vmui/src/utils/uplot/index.ts | 1 + .../packages/vmui/src/utils/uplot/scales.ts | 80 +++++++- .../packages/vmui/src/utils/uplot/series.ts | 108 ++++++++--- 54 files changed, 1452 insertions(+), 343 deletions(-) create mode 100644 app/vmui/packages/vmui/src/AppAnomaly.tsx create mode 100644 app/vmui/packages/vmui/src/components/Chart/Line/LegendAnomaly/LegendAnomaly.tsx create mode 100644 app/vmui/packages/vmui/src/components/Chart/Line/LegendAnomaly/style.scss create mode 100644 app/vmui/packages/vmui/src/layouts/AnomalyLayout/AnomalyLayout.tsx create mode 100644 app/vmui/packages/vmui/src/layouts/AnomalyLayout/ControlsAnomalyLayout.tsx delete mode 100644 app/vmui/packages/vmui/src/layouts/LogsLayout/style.scss create mode 100644 app/vmui/packages/vmui/src/pages/CustomPanel/CustomPanelTabs/GraphTab.tsx create mode 100644 app/vmui/packages/vmui/src/pages/CustomPanel/CustomPanelTabs/TableTab.tsx create mode 100644 app/vmui/packages/vmui/src/pages/CustomPanel/CustomPanelTabs/index.tsx create mode 100644 app/vmui/packages/vmui/src/pages/CustomPanel/CustomPanelTraces/CustomPanelTraces.tsx create mode 100644 app/vmui/packages/vmui/src/pages/CustomPanel/WarningLimitSeries/WarningLimitSeries.tsx create mode 100644 app/vmui/packages/vmui/src/pages/ExploreAnomaly/ExploreAnomaly.tsx create mode 100644 app/vmui/packages/vmui/src/pages/ExploreAnomaly/ExploreAnomalyHeader/ExploreAnomalyHeader.tsx create mode 100644 app/vmui/packages/vmui/src/pages/ExploreAnomaly/ExploreAnomalyHeader/style.scss create mode 100644 app/vmui/packages/vmui/src/pages/ExploreAnomaly/hooks/useFetchAnomalySeries.ts create mode 100644 app/vmui/packages/vmui/src/pages/ExploreAnomaly/hooks/useSetQueryParams.ts create mode 100644 app/vmui/packages/vmui/src/types/appType.ts create mode 100644 app/vmui/packages/vmui/src/utils/uplot/bands.ts diff --git a/app/vmui/Makefile b/app/vmui/Makefile index eb3354044c..7d4297a2b3 100644 --- a/app/vmui/Makefile +++ b/app/vmui/Makefile @@ -22,6 +22,14 @@ vmui-logs-build: vmui-package-base-image --entrypoint=/bin/bash \ vmui-builder-image -c "npm install && npm run build:logs" +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} diff --git a/app/vmui/packages/vmui/config-overrides.js b/app/vmui/packages/vmui/config-overrides.js index 4b7a1e1c3f..663e569e34 100644 --- a/app/vmui/packages/vmui/config-overrides.js +++ b/app/vmui/packages/vmui/config-overrides.js @@ -14,10 +14,12 @@ module.exports = override( new webpack.NormalModuleReplacementPlugin( /\.\/App/, function (resource) { - // eslint-disable-next-line no-undef - if (process.env.REACT_APP_LOGS === "true") { + if (process.env.REACT_APP_TYPE === "logs") { resource.request = "./AppLogs"; } + if (process.env.REACT_APP_TYPE === "anomaly") { + resource.request = "./AppAnomaly"; + } } ) ) diff --git a/app/vmui/packages/vmui/package.json b/app/vmui/packages/vmui/package.json index 7e9e58c068..ecf5e53c8b 100644 --- a/app/vmui/packages/vmui/package.json +++ b/app/vmui/packages/vmui/package.json @@ -32,9 +32,11 @@ "scripts": { "prestart": "npm run copy-metricsql-docs", "start": "react-app-rewired start", - "start:logs": "cross-env REACT_APP_LOGS=true npm run start", + "start:logs": "cross-env REACT_APP_TYPE=logs npm run start", + "start:anomaly": "cross-env REACT_APP_TYPE=anomaly npm run start", "build": "GENERATE_SOURCEMAP=false react-app-rewired build", - "build:logs": "cross-env REACT_APP_LOGS=true npm run build", + "build:logs": "cross-env REACT_APP_TYPE=logs npm run build", + "build:anomaly": "cross-env REACT_APP_TYPE=anomaly npm run build", "lint": "eslint src --ext tsx,ts", "lint:fix": "eslint src --ext tsx,ts --fix", "analyze": "source-map-explorer 'build/static/js/*.js'", diff --git a/app/vmui/packages/vmui/src/AppAnomaly.tsx b/app/vmui/packages/vmui/src/AppAnomaly.tsx new file mode 100644 index 0000000000..de139cd983 --- /dev/null +++ b/app/vmui/packages/vmui/src/AppAnomaly.tsx @@ -0,0 +1,41 @@ +import React, { 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 AppLogs: FC = () => { + const [loadedTheme, setLoadedTheme] = useState(false); + + return <> + + + <> + + {loadedTheme && ( + + } + > + } + /> + } + /> + + + )} + + + + ; +}; + +export default AppLogs; 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 new file mode 100644 index 0000000000..51d17fa73b --- /dev/null +++ b/app/vmui/packages/vmui/src/components/Chart/Line/LegendAnomaly/LegendAnomaly.tsx @@ -0,0 +1,86 @@ +import React, { FC, useMemo } from "preact/compat"; +import { ForecastType, SeriesItem } from "../../../../types"; +import { anomalyColors } from "../../../../utils/color"; +import "./style.scss"; + +type Props = { + series: SeriesItem[]; +}; + +const titles: Record = { + [ForecastType.yhat]: "yhat", + [ForecastType.yhatLower]: "yhat_lower/_upper", + [ForecastType.yhatUpper]: "yhat_lower/_upper", + [ForecastType.anomaly]: "anomalies", + [ForecastType.training]: "training data", + [ForecastType.actual]: "y" +}; + +const LegendAnomaly: FC = ({ 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], + forecast: titles[s.forecast || ForecastType.actual], + })); + }, [series]); + + const container = document.getElementById("legendAnomaly"); + if (!container) return null; + + return <> +
+ {/* TODO: remove .filter() after the correct training data has been added */} + {uniqSeriesStyles.filter(f => f.forecast !== titles[ForecastType.training]).map((s, i) => ( +
+ + {s.forecast === ForecastType.anomaly ? ( + + ) : ( + + )} + +
{s.forecast || "y"}
+
+ ))} +
+ ; +}; + +export default LegendAnomaly; diff --git a/app/vmui/packages/vmui/src/components/Chart/Line/LegendAnomaly/style.scss b/app/vmui/packages/vmui/src/components/Chart/Line/LegendAnomaly/style.scss new file mode 100644 index 0000000000..38e4730721 --- /dev/null +++ b/app/vmui/packages/vmui/src/components/Chart/Line/LegendAnomaly/style.scss @@ -0,0 +1,23 @@ +@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; + } + } +} diff --git a/app/vmui/packages/vmui/src/components/Chart/Line/LineChart/LineChart.tsx b/app/vmui/packages/vmui/src/components/Chart/Line/LineChart/LineChart.tsx index 967a719d9d..2ccff1f0a1 100644 --- a/app/vmui/packages/vmui/src/components/Chart/Line/LineChart/LineChart.tsx +++ b/app/vmui/packages/vmui/src/components/Chart/Line/LineChart/LineChart.tsx @@ -5,14 +5,15 @@ import uPlot, { Series as uPlotSeries, } from "uplot"; import { - getDefaultOptions, addSeries, delSeries, + getAxes, + getDefaultOptions, getRangeX, getRangeY, getScales, handleDestroy, - getAxes, + setBand, setSelect } from "../../../../utils/uplot"; import { MetricResult } from "../../../../api/types"; @@ -39,6 +40,7 @@ export interface LineChartProps { setPeriod: ({ from, to }: { from: Date, to: Date }) => void; layoutSize: ElementSize; height?: number; + anomalyView?: boolean; } const LineChart: FC = ({ @@ -50,7 +52,8 @@ const LineChart: FC = ({ unit, setPeriod, layoutSize, - height + height, + anomalyView }) => { const { isDarkTheme } = useAppState(); @@ -68,7 +71,7 @@ const LineChart: FC = ({ seriesFocus, setCursor, resetTooltips - } = useLineTooltip({ u: uPlotInst, metrics, series, unit }); + } = useLineTooltip({ u: uPlotInst, metrics, series, unit, anomalyView }); const options: uPlotOptions = { ...getDefaultOptions({ width: layoutSize.width, height }), @@ -82,6 +85,7 @@ const LineChart: FC = ({ setSelect: [setSelect(setPlotScale)], destroy: [handleDestroy], }, + bands: [] }; useEffect(() => { @@ -103,6 +107,7 @@ const LineChart: FC = ({ if (!uPlotInst) return; delSeries(uPlotInst); addSeries(uPlotInst, series); + setBand(uPlotInst, series); uPlotInst.redraw(); }, [series]); diff --git a/app/vmui/packages/vmui/src/components/Configurators/GlobalSettings/GlobalSettings.tsx b/app/vmui/packages/vmui/src/components/Configurators/GlobalSettings/GlobalSettings.tsx index fce3dac62a..f23d58b394 100644 --- a/app/vmui/packages/vmui/src/components/Configurators/GlobalSettings/GlobalSettings.tsx +++ b/app/vmui/packages/vmui/src/components/Configurators/GlobalSettings/GlobalSettings.tsx @@ -17,11 +17,14 @@ import ThemeControl from "../ThemeControl/ThemeControl"; import useDeviceDetect from "../../../hooks/useDeviceDetect"; import useBoolean from "../../../hooks/useBoolean"; import { getTenantIdFromUrl } from "../../../utils/tenants"; +import { AppType } from "../../../types/appType"; const title = "Settings"; +const { REACT_APP_TYPE } = process.env; +const isLogsApp = REACT_APP_TYPE === AppType.logs; + const GlobalSettings: FC = () => { - const { REACT_APP_LOGS } = process.env; const { isMobile } = useDeviceDetect(); const appModeEnable = getAppModeEnable(); @@ -77,7 +80,7 @@ const GlobalSettings: FC = () => { const controls = [ { - show: !appModeEnable && !REACT_APP_LOGS, + show: !appModeEnable && !isLogsApp, component: { /> }, { - show: !REACT_APP_LOGS, + show: !isLogsApp, component: = ({ limits, onChange , onEnter }) => { diff --git a/app/vmui/packages/vmui/src/components/Configurators/GlobalSettings/ServerConfigurator/ServerConfigurator.tsx b/app/vmui/packages/vmui/src/components/Configurators/GlobalSettings/ServerConfigurator/ServerConfigurator.tsx index 6f0600aad4..c3fa516f21 100644 --- a/app/vmui/packages/vmui/src/components/Configurators/GlobalSettings/ServerConfigurator/ServerConfigurator.tsx +++ b/app/vmui/packages/vmui/src/components/Configurators/GlobalSettings/ServerConfigurator/ServerConfigurator.tsx @@ -2,6 +2,11 @@ import React, { FC, useEffect, useState } from "preact/compat"; import { ErrorTypes } from "../../../../types"; import TextField from "../../../Main/TextField/TextField"; import { isValidHttpUrl } from "../../../../utils/url"; +import Button from "../../../Main/Button/Button"; +import { StorageIcon } from "../../../Main/Icons"; +import Tooltip from "../../../Main/Tooltip/Tooltip"; +import { getFromStorage, removeFromStorage, saveToStorage } from "../../../../utils/storage"; +import useBoolean from "../../../../hooks/useBoolean"; export interface ServerConfiguratorProps { serverUrl: string @@ -10,13 +15,21 @@ export interface ServerConfiguratorProps { onEnter: () => void } +const tooltipSave = { + enable: "Enable to save the modified server URL to local storage, preventing reset upon page refresh.", + disable: "Disable to stop saving the server URL to local storage, reverting to the default URL on page refresh." +}; + const ServerConfigurator: FC = ({ serverUrl, stateServerUrl, onChange , onEnter }) => { - + const { + value: enabledStorage, + toggle: handleToggleStorage, + } = useBoolean(!!getFromStorage("SERVER_URL")); const [error, setError] = useState(""); const onChangeServer = (val: string) => { @@ -30,16 +43,39 @@ const ServerConfigurator: FC = ({ if (!isValidHttpUrl(stateServerUrl)) setError(ErrorTypes.validServer); }, [stateServerUrl]); + useEffect(() => { + if (enabledStorage) { + saveToStorage("SERVER_URL", serverUrl); + } else { + removeFromStorage(["SERVER_URL"]); + } + }, [enabledStorage]); + return ( - +
+
+ Server URL +
+
+ + +
+
); }; diff --git a/app/vmui/packages/vmui/src/components/Configurators/GlobalSettings/style.scss b/app/vmui/packages/vmui/src/components/Configurators/GlobalSettings/style.scss index 9a17066150..0fc6b63b62 100644 --- a/app/vmui/packages/vmui/src/components/Configurators/GlobalSettings/style.scss +++ b/app/vmui/packages/vmui/src/components/Configurators/GlobalSettings/style.scss @@ -21,6 +21,12 @@ &__input { width: 100%; + + &_flex { + display: flex; + align-items: flex-start; + gap: $padding-global; + } } &__title { @@ -33,6 +39,16 @@ margin-bottom: $padding-global; } + &-url { + display: flex; + align-items: flex-start; + gap: $padding-small; + + &__button { + margin-top: $padding-small; + } + } + &-footer { display: flex; align-items: center; diff --git a/app/vmui/packages/vmui/src/components/ExploreMetrics/ExploreMetricGraph/ExploreMetricItemGraph.tsx b/app/vmui/packages/vmui/src/components/ExploreMetrics/ExploreMetricGraph/ExploreMetricItemGraph.tsx index 75aa6d4726..4bf51ec960 100644 --- a/app/vmui/packages/vmui/src/components/ExploreMetrics/ExploreMetricGraph/ExploreMetricItemGraph.tsx +++ b/app/vmui/packages/vmui/src/components/ExploreMetrics/ExploreMetricGraph/ExploreMetricItemGraph.tsx @@ -6,12 +6,11 @@ import { useTimeDispatch, useTimeState } from "../../../state/time/TimeStateCont import { AxisRange } from "../../../state/graph/reducer"; import Spinner from "../../Main/Spinner/Spinner"; import Alert from "../../Main/Alert/Alert"; -import Button from "../../Main/Button/Button"; import "./style.scss"; import classNames from "classnames"; import useDeviceDetect from "../../../hooks/useDeviceDetect"; import { getDurationFromMilliseconds, getSecondsFromDuration, getStepFromDuration } from "../../../utils/time"; -import useBoolean from "../../../hooks/useBoolean"; +import WarningLimitSeries from "../../../pages/CustomPanel/WarningLimitSeries/WarningLimitSeries"; interface ExploreMetricItemGraphProps { name: string, @@ -40,12 +39,9 @@ const ExploreMetricItem: FC = ({ const stepSeconds = getSecondsFromDuration(customStep); const heatmapStep = getDurationFromMilliseconds(stepSeconds * 10 * 1000); const [isHeatmap, setIsHeatmap] = useState(false); + const [showAllSeries, setShowAllSeries] = useState(false); const step = isHeatmap && customStep === defaultStep ? heatmapStep : customStep; - const { - value: showAllSeries, - setTrue: handleShowAll, - } = useBoolean(false); const query = useMemo(() => { const params = Object.entries({ job, instance }) @@ -99,18 +95,13 @@ with (q = ${queryBase}) ( {isLoading && } {error && {error}} {queryErrors[0] && {queryErrors[0]}} - {warning && -
-

{warning}

- -
-
} + {warning && ( + + )} {graphData && period && ( ( +); +export const LogoAnomalyIcon = () => ( + + + ); export const LogoShortIcon = () => ( diff --git a/app/vmui/packages/vmui/src/components/Main/Select/Select.tsx b/app/vmui/packages/vmui/src/components/Main/Select/Select.tsx index 4d93eddaac..186991412c 100644 --- a/app/vmui/packages/vmui/src/components/Main/Select/Select.tsx +++ b/app/vmui/packages/vmui/src/components/Main/Select/Select.tsx @@ -18,6 +18,7 @@ interface SelectProps { clearable?: boolean searchable?: boolean autofocus?: boolean + disabled?: boolean onChange: (value: string) => void } @@ -30,6 +31,7 @@ const Select: FC = ({ clearable = false, searchable = false, autofocus, + disabled, onChange }) => { const { isDarkTheme } = useAppState(); @@ -64,11 +66,12 @@ const Select: FC = ({ }; const handleFocus = () => { + if (disabled) return; setOpenList(true); }; const handleToggleList = (e: MouseEvent) => { - if (e.target instanceof HTMLInputElement) return; + if (e.target instanceof HTMLInputElement || disabled) return; setOpenList(prev => !prev); }; @@ -112,7 +115,8 @@ const Select: FC = ({
void - setPeriod: ({ from, to }: { from: Date, to: Date }) => void - fullWidth?: boolean - height?: number - isHistogram?: boolean + setYaxisLimits: (val: AxisRange) => void; + setPeriod: ({ from, to }: { from: Date, to: Date }) => void; + fullWidth?: boolean; + height?: number; + isHistogram?: boolean; + anomalyView?: boolean; } const GraphView: FC = ({ @@ -54,7 +56,8 @@ const GraphView: FC = ({ alias = [], fullWidth = true, height, - isHistogram + isHistogram, + anomalyView, }) => { const { isMobile } = useDeviceDetect(); const { timezone } = useTimeState(); @@ -69,8 +72,8 @@ const GraphView: FC = ({ const [legendValue, setLegendValue] = useState(null); const getSeriesItem = useMemo(() => { - return getSeriesItemContext(data, hideSeries, alias); - }, [data, hideSeries, alias]); + return getSeriesItemContext(data, hideSeries, alias, anomalyView); + }, [data, hideSeries, alias, anomalyView]); const setLimitsYaxis = (values: { [key: string]: number[] }) => { const limits = getLimitsYAxis(values, !isHistogram); @@ -148,7 +151,7 @@ const GraphView: FC = ({ const range = getMinMaxBuffer(getMinFromArray(resultAsNumber), getMaxFromArray(resultAsNumber)); const rangeStep = Math.abs(range[1] - range[0]); - return (avg > rangeStep * 1e10) ? results.map(() => avg) : results; + return (avg > rangeStep * 1e10) && !anomalyView ? results.map(() => avg) : results; }); timeDataSeries.unshift(timeSeries); setLimitsYaxis(tempValues); @@ -192,6 +195,7 @@ const GraphView: FC = ({ setPeriod={setPeriod} layoutSize={containerSize} height={height} + anomalyView={anomalyView} /> )} {isHistogram && ( @@ -206,7 +210,7 @@ const GraphView: FC = ({ onChangeLegend={setLegendValue} /> )} - {!isHistogram && showLegend && ( + {!isHistogram && !anomalyView && showLegend && ( = ({ legendValue={legendValue} /> )} + {anomalyView && showLegend && ( + + )}
); }; diff --git a/app/vmui/packages/vmui/src/constants/navigation.ts b/app/vmui/packages/vmui/src/constants/navigation.ts index 5974e91d60..f565c1964b 100644 --- a/app/vmui/packages/vmui/src/constants/navigation.ts +++ b/app/vmui/packages/vmui/src/constants/navigation.ts @@ -7,6 +7,46 @@ export interface NavigationItem { submenu?: NavigationItem[], } +const explore = { + label: "Explore", + submenu: [ + { + label: routerOptions[router.metrics].title, + value: router.metrics, + }, + { + label: routerOptions[router.cardinality].title, + value: router.cardinality, + }, + { + label: routerOptions[router.topQueries].title, + value: router.topQueries, + }, + { + label: routerOptions[router.activeQueries].title, + value: router.activeQueries, + }, + ] +}; + +const tools = { + label: "Tools", + submenu: [ + { + label: routerOptions[router.trace].title, + value: router.trace, + }, + { + label: routerOptions[router.withTemplate].title, + value: router.withTemplate, + }, + { + label: routerOptions[router.relabel].title, + value: router.relabel, + }, + ] +}; + export const logsNavigation: NavigationItem[] = [ { label: routerOptions[router.logs].title, @@ -14,47 +54,22 @@ export const logsNavigation: NavigationItem[] = [ }, ]; +export const anomalyNavigation: NavigationItem[] = [ + { + label: routerOptions[router.anomaly].title, + value: router.home, + }, + { + label: routerOptions[router.home].title, + value: router.query, + } +]; + export const defaultNavigation: NavigationItem[] = [ { label: routerOptions[router.home].title, value: router.home, }, - { - label: "Explore", - submenu: [ - { - label: routerOptions[router.metrics].title, - value: router.metrics, - }, - { - label: routerOptions[router.cardinality].title, - value: router.cardinality, - }, - { - label: routerOptions[router.topQueries].title, - value: router.topQueries, - }, - { - label: routerOptions[router.activeQueries].title, - value: router.activeQueries, - }, - ] - }, - { - label: "Tools", - submenu: [ - { - label: routerOptions[router.trace].title, - value: router.trace, - }, - { - label: routerOptions[router.withTemplate].title, - value: router.withTemplate, - }, - { - label: routerOptions[router.relabel].title, - value: router.relabel, - }, - ] - } + explore, + tools, ]; diff --git a/app/vmui/packages/vmui/src/hooks/uplot/useLineTooltip.ts b/app/vmui/packages/vmui/src/hooks/uplot/useLineTooltip.ts index a8fe0bee6d..bc6012019f 100644 --- a/app/vmui/packages/vmui/src/hooks/uplot/useLineTooltip.ts +++ b/app/vmui/packages/vmui/src/hooks/uplot/useLineTooltip.ts @@ -14,9 +14,10 @@ interface LineTooltipHook { metrics: MetricResult[]; series: uPlotSeries[]; unit?: string; + anomalyView?: boolean; } -const useLineTooltip = ({ u, metrics, series, unit }: LineTooltipHook) => { +const useLineTooltip = ({ u, metrics, series, unit, anomalyView }: LineTooltipHook) => { const [showTooltip, setShowTooltip] = useState(false); const [tooltipIdx, setTooltipIdx] = useState({ seriesIdx: -1, dataIdx: -1 }); const [stickyTooltips, setStickyToolTips] = useState([]); @@ -60,14 +61,14 @@ const useLineTooltip = ({ u, metrics, series, unit }: LineTooltipHook) => { point, u: u, id: `${seriesIdx}_${dataIdx}`, - title: groups.size > 1 ? `Query ${group}` : "", + title: groups.size > 1 && !anomalyView ? `Query ${group}` : "", dates: [date ? dayjs(date * 1000).tz().format(DATE_FULL_TIMEZONE_FORMAT) : "-"], value: formatPrettyNumber(value, min, max), info: getMetricName(metricItem), statsFormatted: seriesItem?.statsFormatted, marker: `${seriesItem?.stroke}`, }; - }, [u, tooltipIdx, metrics, series, unit]); + }, [u, tooltipIdx, metrics, series, unit, anomalyView]); const handleClick = useCallback(() => { if (!showTooltip) return; diff --git a/app/vmui/packages/vmui/src/hooks/useFetchQuery.ts b/app/vmui/packages/vmui/src/hooks/useFetchQuery.ts index 980527713f..638eb375b8 100644 --- a/app/vmui/packages/vmui/src/hooks/useFetchQuery.ts +++ b/app/vmui/packages/vmui/src/hooks/useFetchQuery.ts @@ -4,9 +4,8 @@ import { getQueryRangeUrl, getQueryUrl } from "../api/query-range"; import { useAppState } from "../state/common/StateContext"; import { InstantMetricResult, MetricBase, MetricResult, QueryStats } from "../api/types"; import { isValidHttpUrl } from "../utils/url"; -import { ErrorTypes, SeriesLimits } from "../types"; +import { DisplayType, ErrorTypes, SeriesLimits } from "../types"; import debounce from "lodash.debounce"; -import { DisplayType } from "../pages/CustomPanel/DisplayTypeSwitch"; import Trace from "../components/TraceQuery/Trace"; import { useQueryState } from "../state/query/QueryStateContext"; import { useTimeState } from "../state/time/TimeStateContext"; @@ -90,7 +89,7 @@ export const useFetchQuery = ({ const controller = new AbortController(); setFetchQueue([...fetchQueue, controller]); try { - const isDisplayChart = displayType === "chart"; + const isDisplayChart = displayType === DisplayType.chart; const defaultLimit = showAllSeries ? Infinity : (+stateSeriesLimits[displayType] || Infinity); let seriesLimit = defaultLimit; const tempData: MetricBase[] = []; @@ -165,7 +164,7 @@ export const useFetchQuery = ({ setQueryErrors([]); setQueryStats([]); const expr = predefinedQuery ?? query; - const displayChart = (display || displayType) === "chart"; + const displayChart = (display || displayType) === DisplayType.chart; if (!period) return; if (!serverUrl) { setError(ErrorTypes.emptyServer); diff --git a/app/vmui/packages/vmui/src/layouts/AnomalyLayout/AnomalyLayout.tsx b/app/vmui/packages/vmui/src/layouts/AnomalyLayout/AnomalyLayout.tsx new file mode 100644 index 0000000000..686e6e8899 --- /dev/null +++ b/app/vmui/packages/vmui/src/layouts/AnomalyLayout/AnomalyLayout.tsx @@ -0,0 +1,59 @@ +import Header from "../Header/Header"; +import React, { FC, useEffect } from "preact/compat"; +import { Outlet, useLocation, 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 { routerOptions } from "../../router"; +import { useFetchDashboards } from "../../pages/PredefinedPanels/hooks/useFetchDashboards"; +import useDeviceDetect from "../../hooks/useDeviceDetect"; +import ControlsAnomalyLayout from "./ControlsAnomalyLayout"; + +const AnomalyLayout: FC = () => { + const appModeEnable = getAppModeEnable(); + const { isMobile } = useDeviceDetect(); + const { pathname } = useLocation(); + const [searchParams, setSearchParams] = useSearchParams(); + + useFetchDashboards(); + + const setDocumentTitle = () => { + const defaultTitle = "vmui for vmanomaly"; + const routeTitle = routerOptions[pathname]?.title; + document.title = routeTitle ? `${routeTitle} - ${defaultTitle}` : defaultTitle; + }; + + // 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(setDocumentTitle, [pathname]); + useEffect(redirectSearchToHashParams, []); + + return
+
+
+ +
+ {!appModeEnable &&
} +
; +}; + +export default AnomalyLayout; diff --git a/app/vmui/packages/vmui/src/layouts/AnomalyLayout/ControlsAnomalyLayout.tsx b/app/vmui/packages/vmui/src/layouts/AnomalyLayout/ControlsAnomalyLayout.tsx new file mode 100644 index 0000000000..495ded5cdb --- /dev/null +++ b/app/vmui/packages/vmui/src/layouts/AnomalyLayout/ControlsAnomalyLayout.tsx @@ -0,0 +1,38 @@ +import React, { 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 = ({ + displaySidebar, + isMobile, + headerSetup, + accountIds +}) => { + + return ( +
+ {headerSetup?.tenant && } + {headerSetup?.stepControl && } + {headerSetup?.timeSelector && } + {headerSetup?.cardinalityDatePicker && } + {headerSetup?.executionControls && } + + {!displaySidebar && } +
+ ); +}; + +export default ControlsAnomalyLayout; diff --git a/app/vmui/packages/vmui/src/layouts/Header/Header.tsx b/app/vmui/packages/vmui/src/layouts/Header/Header.tsx index 278352164a..535750d7f8 100644 --- a/app/vmui/packages/vmui/src/layouts/Header/Header.tsx +++ b/app/vmui/packages/vmui/src/layouts/Header/Header.tsx @@ -2,7 +2,7 @@ import React, { FC, useMemo } from "preact/compat"; import { useNavigate } from "react-router-dom"; import router from "../../router"; import { getAppModeEnable, getAppModeParams } from "../../utils/app-mode"; -import { LogoIcon, LogoLogsIcon } from "../../components/Main/Icons"; +import { LogoAnomalyIcon, LogoIcon, LogoLogsIcon } from "../../components/Main/Icons"; import { getCssVariable } from "../../utils/theme"; import "./style.scss"; import classNames from "classnames"; @@ -13,13 +13,26 @@ import HeaderControls, { ControlsProps } from "./HeaderControls/HeaderControls"; import useDeviceDetect from "../../hooks/useDeviceDetect"; import useWindowSize from "../../hooks/useWindowSize"; import { ComponentType } from "react"; +import { AppType } from "../../types/appType"; export interface HeaderProps { controlsComponent: ComponentType } +const { REACT_APP_TYPE } = process.env; +const isCustomApp = REACT_APP_TYPE === AppType.logs || REACT_APP_TYPE === AppType.anomaly; + +const Logo = () => { + switch (REACT_APP_TYPE) { + case AppType.logs: + return ; + case AppType.anomaly: + return ; + default: + return ; + } +}; const Header: FC = ({ controlsComponent }) => { - const { REACT_APP_LOGS } = process.env; const { isMobile } = useDeviceDetect(); const windowSize = useWindowSize(); @@ -70,12 +83,12 @@ const Header: FC = ({ controlsComponent }) => {
- {REACT_APP_LOGS ? : } + {}
)} = ({ controlsComponent }) => { className={classNames({ "vm-header-logo": true, "vm-header-logo_mobile": true, - "vm-header-logo_logs": REACT_APP_LOGS + "vm-header-logo_logs": isCustomApp })} onClick={onClickLogo} style={{ color }} > - {REACT_APP_LOGS ? : } + {}
)} = ({ color, background, direction }) => { - const { REACT_APP_LOGS } = process.env; const appModeEnable = getAppModeEnable(); const { dashboardsSettings } = useDashboardsState(); const { pathname } = useLocation(); const [activeMenu, setActiveMenu] = useState(pathname); - const menu = useMemo(() => REACT_APP_LOGS ? logsNavigation : ([ - ...defaultNavigation, - { - label: routerOptions[router.dashboards].title, - value: router.dashboards, - hide: appModeEnable || !dashboardsSettings.length, + const menu = useMemo(() => { + switch (process.env.REACT_APP_TYPE) { + case AppType.logs: + return logsNavigation; + case AppType.anomaly: + return anomalyNavigation; + default: + return ([ + ...defaultNavigation, + { + label: routerOptions[router.dashboards].title, + value: router.dashboards, + hide: appModeEnable || !dashboardsSettings.length, + } + ].filter(r => !r.hide)); } - ].filter(r => !r.hide)), [appModeEnable, dashboardsSettings]); + }, [appModeEnable, dashboardsSettings]); useEffect(() => { setActiveMenu(pathname); diff --git a/app/vmui/packages/vmui/src/layouts/Header/SidebarNav/SidebarHeader.tsx b/app/vmui/packages/vmui/src/layouts/Header/SidebarNav/SidebarHeader.tsx index 104c32e464..3211754056 100644 --- a/app/vmui/packages/vmui/src/layouts/Header/SidebarNav/SidebarHeader.tsx +++ b/app/vmui/packages/vmui/src/layouts/Header/SidebarNav/SidebarHeader.tsx @@ -8,17 +8,20 @@ import MenuBurger from "../../../components/Main/MenuBurger/MenuBurger"; import useDeviceDetect from "../../../hooks/useDeviceDetect"; import "./style.scss"; import useBoolean from "../../../hooks/useBoolean"; +import { AppType } from "../../../types/appType"; interface SidebarHeaderProps { background: string color: string } +const { REACT_APP_TYPE } = process.env; +const isLogsApp = REACT_APP_TYPE === AppType.logs; + const SidebarHeader: FC = ({ background, color, }) => { - const { REACT_APP_LOGS } = process.env; const { pathname } = useLocation(); const { isMobile } = useDeviceDetect(); @@ -61,7 +64,7 @@ const SidebarHeader: FC = ({ />
- {!isMobile && !REACT_APP_LOGS && } + {!isMobile && !isLogsApp && }
; diff --git a/app/vmui/packages/vmui/src/layouts/LogsLayout/LogsLayout.tsx b/app/vmui/packages/vmui/src/layouts/LogsLayout/LogsLayout.tsx index 3e3962e52c..4d8a26eb19 100644 --- a/app/vmui/packages/vmui/src/layouts/LogsLayout/LogsLayout.tsx +++ b/app/vmui/packages/vmui/src/layouts/LogsLayout/LogsLayout.tsx @@ -1,7 +1,7 @@ import Header from "../Header/Header"; import React, { FC, useEffect } from "preact/compat"; import { Outlet, useLocation } from "react-router-dom"; -import "./style.scss"; +import "../MainLayout/style.scss"; import { getAppModeEnable } from "../../utils/app-mode"; import classNames from "classnames"; import Footer from "../Footer/Footer"; diff --git a/app/vmui/packages/vmui/src/layouts/LogsLayout/style.scss b/app/vmui/packages/vmui/src/layouts/LogsLayout/style.scss deleted file mode 100644 index 32e8ccf90a..0000000000 --- a/app/vmui/packages/vmui/src/layouts/LogsLayout/style.scss +++ /dev/null @@ -1,27 +0,0 @@ -@use "src/styles/variables" as *; - -.vm-container { - display: flex; - flex-direction: column; - min-height: calc(($vh * 100) - var(--scrollbar-height)); - - &-body { - flex-grow: 1; - min-height: 100%; - padding: $padding-medium; - background-color: $color-background-body; - - &_mobile { - padding: $padding-small 0 0; - } - - @media (max-width: 768px) { - padding: $padding-small 0 0; - } - - &_app { - padding: $padding-small 0; - background-color: transparent; - } - } -} diff --git a/app/vmui/packages/vmui/src/layouts/MainLayout/MainLayout.tsx b/app/vmui/packages/vmui/src/layouts/MainLayout/MainLayout.tsx index 6dd4579913..6d5f5fdc7a 100644 --- a/app/vmui/packages/vmui/src/layouts/MainLayout/MainLayout.tsx +++ b/app/vmui/packages/vmui/src/layouts/MainLayout/MainLayout.tsx @@ -6,13 +6,12 @@ import "./style.scss"; import { getAppModeEnable } from "../../utils/app-mode"; import classNames from "classnames"; import Footer from "../Footer/Footer"; -import router, { routerOptions } from "../../router"; +import { routerOptions } from "../../router"; import { useFetchDashboards } from "../../pages/PredefinedPanels/hooks/useFetchDashboards"; import useDeviceDetect from "../../hooks/useDeviceDetect"; import ControlsMainLayout from "./ControlsMainLayout"; const MainLayout: FC = () => { - const { REACT_APP_LOGS } = process.env; const appModeEnable = getAppModeEnable(); const { isMobile } = useDeviceDetect(); const { pathname } = useLocation(); @@ -22,7 +21,7 @@ const MainLayout: FC = () => { const setDocumentTitle = () => { const defaultTitle = "vmui"; - const routeTitle = REACT_APP_LOGS ? routerOptions[router.logs]?.title : routerOptions[pathname]?.title; + const routeTitle = routerOptions[pathname]?.title; document.title = routeTitle ? `${routeTitle} - ${defaultTitle}` : defaultTitle; }; diff --git a/app/vmui/packages/vmui/src/pages/CustomPanel/CustomPanelTabs/GraphTab.tsx b/app/vmui/packages/vmui/src/pages/CustomPanel/CustomPanelTabs/GraphTab.tsx new file mode 100644 index 0000000000..ba87299cb1 --- /dev/null +++ b/app/vmui/packages/vmui/src/pages/CustomPanel/CustomPanelTabs/GraphTab.tsx @@ -0,0 +1,72 @@ +import React, { FC } from "react"; +import GraphView from "../../../components/Views/GraphView/GraphView"; +import GraphTips from "../../../components/Chart/GraphTips/GraphTips"; +import GraphSettings from "../../../components/Configurators/GraphSettings/GraphSettings"; +import { AxisRange } from "../../../state/graph/reducer"; +import { useTimeDispatch, useTimeState } from "../../../state/time/TimeStateContext"; +import { useGraphDispatch, useGraphState } from "../../../state/graph/GraphStateContext"; +import useDeviceDetect from "../../../hooks/useDeviceDetect"; +import { useQueryState } from "../../../state/query/QueryStateContext"; +import { MetricResult } from "../../../api/types"; +import { createPortal } from "preact/compat"; + +type Props = { + isHistogram: boolean; + graphData: MetricResult[]; + controlsRef: React.RefObject; + anomalyView?: boolean; +} + +const GraphTab: FC = ({ isHistogram, graphData, controlsRef, anomalyView }) => { + const { isMobile } = useDeviceDetect(); + + const { customStep, yaxis } = useGraphState(); + const { period } = useTimeState(); + const { query } = useQueryState(); + + const timeDispatch = useTimeDispatch(); + const graphDispatch = useGraphDispatch(); + + const setYaxisLimits = (limits: AxisRange) => { + graphDispatch({ type: "SET_YAXIS_LIMITS", payload: limits }); + }; + + const toggleEnableLimits = () => { + graphDispatch({ type: "TOGGLE_ENABLE_YAXIS_LIMITS" }); + }; + + const setPeriod = ({ from, to }: {from: Date, to: Date}) => { + timeDispatch({ type: "SET_PERIOD", payload: { from, to } }); + }; + + const controls = ( +
+ + +
+ ); + + return ( + <> + {controlsRef.current && createPortal(controls, controlsRef.current)} + + + ); +}; + +export default GraphTab; diff --git a/app/vmui/packages/vmui/src/pages/CustomPanel/CustomPanelTabs/TableTab.tsx b/app/vmui/packages/vmui/src/pages/CustomPanel/CustomPanelTabs/TableTab.tsx new file mode 100644 index 0000000000..afa42c1b1c --- /dev/null +++ b/app/vmui/packages/vmui/src/pages/CustomPanel/CustomPanelTabs/TableTab.tsx @@ -0,0 +1,47 @@ +import React, { FC } from "react"; +import { InstantMetricResult } from "../../../api/types"; +import { createPortal, useMemo, useState } from "preact/compat"; +import TableView from "../../../components/Views/TableView/TableView"; +import TableSettings from "../../../components/Table/TableSettings/TableSettings"; +import { getColumns } from "../../../hooks/useSortedCategories"; +import { useCustomPanelDispatch, useCustomPanelState } from "../../../state/customPanel/CustomPanelStateContext"; + +type Props = { + liveData: InstantMetricResult[]; + controlsRef: React.RefObject; +} + +const TableTab: FC = ({ liveData, controlsRef }) => { + const { tableCompact } = useCustomPanelState(); + const customPanelDispatch = useCustomPanelDispatch(); + + const [displayColumns, setDisplayColumns] = useState(); + + const columns = useMemo(() => getColumns(liveData || []).map(c => c.key), [liveData]); + + const toggleTableCompact = () => { + customPanelDispatch({ type: "TOGGLE_TABLE_COMPACT" }); + }; + + const controls = ( + + ); + + return ( + <> + {controlsRef.current && createPortal(controls, controlsRef.current)} + + + ); +}; + +export default TableTab; diff --git a/app/vmui/packages/vmui/src/pages/CustomPanel/CustomPanelTabs/index.tsx b/app/vmui/packages/vmui/src/pages/CustomPanel/CustomPanelTabs/index.tsx new file mode 100644 index 0000000000..741b7c03cf --- /dev/null +++ b/app/vmui/packages/vmui/src/pages/CustomPanel/CustomPanelTabs/index.tsx @@ -0,0 +1,45 @@ +import React, { FC, RefObject } from "react"; +import GraphTab from "./GraphTab"; +import JsonView from "../../../components/Views/JsonView/JsonView"; +import TableTab from "./TableTab"; +import { InstantMetricResult, MetricResult } from "../../../api/types"; +import { DisplayType } from "../../../types"; + +type Props = { + graphData?: MetricResult[]; + liveData?: InstantMetricResult[]; + isHistogram: boolean; + displayType: DisplayType; + controlsRef: RefObject; +} + +const CustomPanelTabs: FC = ({ + graphData, + liveData, + isHistogram, + displayType, + controlsRef +}) => { + if (displayType === DisplayType.code && liveData) { + return ; + } + + if (displayType === DisplayType.table && liveData) { + return ; + } + + if (displayType === DisplayType.chart && graphData) { + return ; + } + + return null; +}; + +export default CustomPanelTabs; diff --git a/app/vmui/packages/vmui/src/pages/CustomPanel/CustomPanelTraces/CustomPanelTraces.tsx b/app/vmui/packages/vmui/src/pages/CustomPanel/CustomPanelTraces/CustomPanelTraces.tsx new file mode 100644 index 0000000000..73b9727f69 --- /dev/null +++ b/app/vmui/packages/vmui/src/pages/CustomPanel/CustomPanelTraces/CustomPanelTraces.tsx @@ -0,0 +1,43 @@ +import { useCustomPanelState } from "../../../state/customPanel/CustomPanelStateContext"; +import TracingsView from "../../../components/TraceQuery/TracingsView"; +import React, { FC, useEffect, useState } from "preact/compat"; +import Trace from "../../../components/TraceQuery/Trace"; +import { DisplayType } from "../../../types"; + +type Props = { + traces?: Trace[]; + displayType: DisplayType; +} + +const CustomPanelTraces: FC = ({ traces, displayType }) => { + const { isTracingEnabled } = useCustomPanelState(); + const [tracesState, setTracesState] = useState([]); + + const handleTraceDelete = (trace: Trace) => { + const updatedTraces = tracesState.filter((data) => data.idValue !== trace.idValue); + setTracesState([...updatedTraces]); + }; + + useEffect(() => { + if (traces) { + setTracesState([...tracesState, ...traces]); + } + }, [traces]); + + useEffect(() => { + setTracesState([]); + }, [displayType]); + + return <> + {isTracingEnabled && ( +
+ +
+ )} + ; +}; + +export default CustomPanelTraces; diff --git a/app/vmui/packages/vmui/src/pages/CustomPanel/DisplayTypeSwitch.tsx b/app/vmui/packages/vmui/src/pages/CustomPanel/DisplayTypeSwitch.tsx index 9580447589..3e81640e95 100644 --- a/app/vmui/packages/vmui/src/pages/CustomPanel/DisplayTypeSwitch.tsx +++ b/app/vmui/packages/vmui/src/pages/CustomPanel/DisplayTypeSwitch.tsx @@ -2,8 +2,7 @@ import React, { FC } from "preact/compat"; import { useCustomPanelDispatch, useCustomPanelState } from "../../state/customPanel/CustomPanelStateContext"; import { ChartIcon, CodeIcon, TableIcon } from "../../components/Main/Icons"; import Tabs from "../../components/Main/Tabs/Tabs"; - -export type DisplayType = "table" | "chart" | "code"; +import { DisplayType } from "../../types"; type DisplayTab = { value: DisplayType @@ -13,9 +12,9 @@ type DisplayTab = { } export const displayTypeTabs: DisplayTab[] = [ - { value: "chart", icon: , label: "Graph", prometheusCode: 0 }, - { value: "code", icon: , label: "JSON", prometheusCode: 3 }, - { value: "table", icon: , label: "Table", prometheusCode: 1 } + { value: DisplayType.chart, icon: , label: "Graph", prometheusCode: 0 }, + { value: DisplayType.code, icon: , label: "JSON", prometheusCode: 3 }, + { value: DisplayType.table, icon: , label: "Table", prometheusCode: 1 } ]; export const DisplayTypeSwitch: FC = () => { diff --git a/app/vmui/packages/vmui/src/pages/CustomPanel/WarningLimitSeries/WarningLimitSeries.tsx b/app/vmui/packages/vmui/src/pages/CustomPanel/WarningLimitSeries/WarningLimitSeries.tsx new file mode 100644 index 0000000000..fc94bcf60e --- /dev/null +++ b/app/vmui/packages/vmui/src/pages/CustomPanel/WarningLimitSeries/WarningLimitSeries.tsx @@ -0,0 +1,50 @@ +import classNames from "classnames"; +import Button from "../../../components/Main/Button/Button"; +import React, { FC, useEffect } from "preact/compat"; +import useBoolean from "../../../hooks/useBoolean"; +import useDeviceDetect from "../../../hooks/useDeviceDetect"; +import Alert from "../../../components/Main/Alert/Alert"; + +type Props = { + warning: string; + query: string[]; + onChange: (show: boolean) => void +} + +const WarningLimitSeries: FC = ({ warning, query, onChange }) => { + const { isMobile } = useDeviceDetect(); + + const { + value: showAllSeries, + setTrue: handleShowAll, + setFalse: resetShowAll, + } = useBoolean(false); + + useEffect(resetShowAll, [query]); + + useEffect(() => { + onChange(showAllSeries); + }, [showAllSeries]); + + return ( + +
+

{warning}

+ +
+
+ ); +}; + +export default WarningLimitSeries; diff --git a/app/vmui/packages/vmui/src/pages/CustomPanel/index.tsx b/app/vmui/packages/vmui/src/pages/CustomPanel/index.tsx index 795ef78e69..4bb9fdc2fa 100644 --- a/app/vmui/packages/vmui/src/pages/CustomPanel/index.tsx +++ b/app/vmui/packages/vmui/src/pages/CustomPanel/index.tsx @@ -1,53 +1,38 @@ -import React, { FC, useState, useEffect, useMemo } from "preact/compat"; -import GraphView from "../../components/Views/GraphView/GraphView"; +import React, { FC, useEffect, useState } from "preact/compat"; import QueryConfigurator from "./QueryConfigurator/QueryConfigurator"; import { useFetchQuery } from "../../hooks/useFetchQuery"; -import JsonView from "../../components/Views/JsonView/JsonView"; import { DisplayTypeSwitch } from "./DisplayTypeSwitch"; -import GraphSettings from "../../components/Configurators/GraphSettings/GraphSettings"; import { useGraphDispatch, useGraphState } from "../../state/graph/GraphStateContext"; -import { AxisRange } from "../../state/graph/reducer"; import Spinner from "../../components/Main/Spinner/Spinner"; -import TracingsView from "../../components/TraceQuery/TracingsView"; -import Trace from "../../components/TraceQuery/Trace"; -import TableSettings from "../../components/Table/TableSettings/TableSettings"; -import { useCustomPanelState, useCustomPanelDispatch } from "../../state/customPanel/CustomPanelStateContext"; +import { useCustomPanelState } from "../../state/customPanel/CustomPanelStateContext"; import { useQueryState } from "../../state/query/QueryStateContext"; -import { useTimeDispatch, useTimeState } from "../../state/time/TimeStateContext"; import { useSetQueryParams } from "./hooks/useSetQueryParams"; import "./style.scss"; import Alert from "../../components/Main/Alert/Alert"; -import TableView from "../../components/Views/TableView/TableView"; -import Button from "../../components/Main/Button/Button"; import classNames from "classnames"; import useDeviceDetect from "../../hooks/useDeviceDetect"; -import GraphTips from "../../components/Chart/GraphTips/GraphTips"; import InstantQueryTip from "./InstantQueryTip/InstantQueryTip"; -import useBoolean from "../../hooks/useBoolean"; -import { getColumns } from "../../hooks/useSortedCategories"; import useEventListener from "../../hooks/useEventListener"; +import { useRef } from "react"; +import CustomPanelTraces from "./CustomPanelTraces/CustomPanelTraces"; +import WarningLimitSeries from "./WarningLimitSeries/WarningLimitSeries"; +import CustomPanelTabs from "./CustomPanelTabs"; +import { DisplayType } from "../../types"; const CustomPanel: FC = () => { - const { displayType, isTracingEnabled } = useCustomPanelState(); - const { query } = useQueryState(); - const { period } = useTimeState(); - const timeDispatch = useTimeDispatch(); - const { isMobile } = useDeviceDetect(); useSetQueryParams(); + const { isMobile } = useDeviceDetect(); + + const { displayType } = useCustomPanelState(); + const { query } = useQueryState(); + const { customStep } = useGraphState(); + const graphDispatch = useGraphDispatch(); - const [displayColumns, setDisplayColumns] = useState(); - const [tracesState, setTracesState] = useState([]); const [hideQuery, setHideQuery] = useState([]); const [hideError, setHideError] = useState(!query[0]); + const [showAllSeries, setShowAllSeries] = useState(false); - const { - value: showAllSeries, - setTrue: handleShowAll, - setFalse: handleHideSeries, - } = useBoolean(false); - - const { customStep, yaxis } = useGraphState(); - const graphDispatch = useGraphDispatch(); + const controlsRef = useRef(null); const { isLoading, @@ -67,22 +52,8 @@ const CustomPanel: FC = () => { showAllSeries }); - const setYaxisLimits = (limits: AxisRange) => { - graphDispatch({ type: "SET_YAXIS_LIMITS", payload: limits }); - }; - - const toggleEnableLimits = () => { - graphDispatch({ type: "TOGGLE_ENABLE_YAXIS_LIMITS" }); - }; - - const setPeriod = ({ from, to }: {from: Date, to: Date}) => { - timeDispatch({ type: "SET_PERIOD", payload: { from, to } }); - }; - - const handleTraceDelete = (trace: Trace) => { - const updatedTraces = tracesState.filter((data) => data.idValue !== trace.idValue); - setTracesState([...updatedTraces]); - }; + const showInstantQueryTip = !liveData?.length && (displayType !== DisplayType.chart); + const showError = !hideError && error; const handleHideQuery = (queries: number[]) => { setHideQuery(queries); @@ -92,29 +63,9 @@ const CustomPanel: FC = () => { setHideError(false); }; - const columns = useMemo(() => getColumns(liveData || []).map(c => c.key), [liveData]); - const { tableCompact } = useCustomPanelState(); - const customPanelDispatch = useCustomPanelDispatch(); - - const toggleTableCompact = () => { - customPanelDispatch({ type: "TOGGLE_TABLE_COMPACT" }); - }; - const handleChangePopstate = () => window.location.reload(); useEventListener("popstate", handleChangePopstate); - useEffect(() => { - if (traces) { - setTracesState([...tracesState, ...traces]); - } - }, [traces]); - - useEffect(() => { - setTracesState([]); - }, [displayType]); - - useEffect(handleHideSeries, [query]); - useEffect(() => { graphDispatch({ type: "SET_IS_HISTOGRAM", payload: isHistogram }); }, [graphData]); @@ -134,34 +85,20 @@ const CustomPanel: FC = () => { onHideQuery={handleHideQuery} onRunQuery={handleRunQuery} /> - {isTracingEnabled && ( -
- -
- )} + {isLoading && } - {!hideError && error && {error}} - {!liveData?.length && (displayType !== "chart") && } - {warning && -
-

{warning}

- -
-
} + {showError && {error}} + {showInstantQueryTip && } + {warning && ( + + )}
{ "vm-block_mobile": isMobile, })} > -
- - {displayType === "chart" && ( -
- - -
- )} - {displayType === "table" && ( - - )} +
+ {}
- {graphData && period && (displayType === "chart") && ( - - )} - {liveData && (displayType === "code") && ( - - )} - {liveData && (displayType === "table") && ( - - )} +
); diff --git a/app/vmui/packages/vmui/src/pages/CustomPanel/style.scss b/app/vmui/packages/vmui/src/pages/CustomPanel/style.scss index 9d8da52074..2e8fc3f500 100644 --- a/app/vmui/packages/vmui/src/pages/CustomPanel/style.scss +++ b/app/vmui/packages/vmui/src/pages/CustomPanel/style.scss @@ -40,10 +40,11 @@ border-bottom: $border-divider; z-index: 1; - &__left { + &__graph-controls { display: flex; align-items: center; gap: $padding-small; + margin: 5px 10px; } } diff --git a/app/vmui/packages/vmui/src/pages/ExploreAnomaly/ExploreAnomaly.tsx b/app/vmui/packages/vmui/src/pages/ExploreAnomaly/ExploreAnomaly.tsx new file mode 100644 index 0000000000..277f391296 --- /dev/null +++ b/app/vmui/packages/vmui/src/pages/ExploreAnomaly/ExploreAnomaly.tsx @@ -0,0 +1,118 @@ +import React, { FC, useMemo, useRef } from "preact/compat"; +import classNames from "classnames"; +import useDeviceDetect from "../../hooks/useDeviceDetect"; +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 { useFetchQuery } from "../../hooks/useFetchQuery"; +import Spinner from "../../components/Main/Spinner/Spinner"; +import GraphTab from "../CustomPanel/CustomPanelTabs/GraphTab"; +import { useGraphState } from "../../state/graph/GraphStateContext"; +import { MetricResult } from "../../api/types"; +import { promValueToNumber } from "../../utils/metric"; +import { ForecastType } from "../../types"; +import { useFetchAnomalySeries } from "./hooks/useFetchAnomalySeries"; +import { useQueryDispatch } from "../../state/query/QueryStateContext"; +import { useTimeDispatch } from "../../state/time/TimeStateContext"; + +const ExploreAnomaly: FC = () => { + const { isMobile } = useDeviceDetect(); + + const queryDispatch = useQueryDispatch(); + const timeDispatch = useTimeDispatch(); + const { series, error: errorSeries, isLoading: isAnomalySeriesLoading } = useFetchAnomalySeries(); + const queries = useMemo(() => series ? Object.keys(series) : [], [series]); + + const controlsRef = useRef(null); + const { customStep } = useGraphState(); + + const { graphData, error, queryErrors, isHistogram, isLoading: isGraphDataLoading } = useFetchQuery({ + visible: true, + customStep, + showAllSeries: true, + }); + + 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); + }, [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 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 }); + timeDispatch({ type: "RUN_QUERY" }); + }; + + const handleChangePopstate = () => window.location.reload(); + useEventListener("popstate", handleChangePopstate); + + return ( +
+ + {(isGraphDataLoading || isAnomalySeriesLoading) && } + {(error || errorSeries) && {error || errorSeries}} + {!error && !errorSeries && queryErrors?.[0] && {queryErrors[0]}} +
+
+
+
+ {data && ( + + )} +
+
+ ); +}; + +export default ExploreAnomaly; diff --git a/app/vmui/packages/vmui/src/pages/ExploreAnomaly/ExploreAnomalyHeader/ExploreAnomalyHeader.tsx b/app/vmui/packages/vmui/src/pages/ExploreAnomaly/ExploreAnomalyHeader/ExploreAnomalyHeader.tsx new file mode 100644 index 0000000000..b7b432672a --- /dev/null +++ b/app/vmui/packages/vmui/src/pages/ExploreAnomaly/ExploreAnomalyHeader/ExploreAnomalyHeader.tsx @@ -0,0 +1,112 @@ +import React, { FC, useMemo, useState } from "preact/compat"; +import classNames from "classnames"; +import useDeviceDetect from "../../../hooks/useDeviceDetect"; +import Select from "../../../components/Main/Select/Select"; +import "./style.scss"; +import usePrevious from "../../../hooks/usePrevious"; +import { useEffect } from "react"; +import { arrayEquals } from "../../../utils/array"; +import { getQueryStringValue } from "../../../utils/query-string"; +import { useSetQueryParams } from "../hooks/useSetQueryParams"; + +type Props = { + queries: string[]; + series?: Record + onChange: (expr: Record) => void; +} + +const ExploreAnomalyHeader: FC = ({ queries, series, onChange }) => { + const { isMobile } = useDeviceDetect(); + const [alias, setAlias] = useState(queries[0]); + const [selectedValues, setSelectedValues] = useState>({}); + useSetQueryParams({ alias: alias, ...selectedValues }); + + const uniqueKeysWithValues = useMemo(() => { + if (!series) return {}; + return series[alias]?.reduce((accumulator, currentSeries) => { + const metric = Object.entries(currentSeries); + if (!metric.length) return accumulator; + const excludeMetrics = ["__name__", "for"]; + for (const [key, value] of metric) { + if (excludeMetrics.includes(key) || accumulator[key]?.includes(value)) continue; + + if (!accumulator[key]) { + accumulator[key] = []; + } + + accumulator[key].push(value); + } + return accumulator; + }, {} as Record) || {}; + }, [alias, series]); + const prevUniqueKeysWithValues = usePrevious(uniqueKeysWithValues); + + const createHandlerChangeSelect = (key: string) => (value: string) => { + setSelectedValues((prev) => ({ ...prev, [key]: value })); + }; + + useEffect(() => { + const nextValues = Object.values(uniqueKeysWithValues).flat(); + const prevValues = Object.values(prevUniqueKeysWithValues || {}).flat(); + if (arrayEquals(prevValues, nextValues)) return; + const newSelectedValues: Record = {}; + Object.keys(uniqueKeysWithValues).forEach((key) => { + const value = getQueryStringValue(key, "") as string; + newSelectedValues[key] = value || uniqueKeysWithValues[key]?.[0]; + }); + setSelectedValues(newSelectedValues); + }, [uniqueKeysWithValues, prevUniqueKeysWithValues]); + + useEffect(() => { + if (!alias || !Object.keys(selectedValues).length) return; + const __name__ = series?.[alias]?.[0]?.__name__ || ""; + onChange({ ...selectedValues, for: alias, __name__ }); + }, [selectedValues, alias]); + + useEffect(() => { + setAlias(getQueryStringValue("alias", queries[0]) as string); + }, [series]); + + return ( +
+
+
+ 2} + disabled={values.length === 1} + /> +
+ ))} +
+ ); +}; + +export default ExploreAnomalyHeader; diff --git a/app/vmui/packages/vmui/src/pages/ExploreAnomaly/ExploreAnomalyHeader/style.scss b/app/vmui/packages/vmui/src/pages/ExploreAnomaly/ExploreAnomalyHeader/style.scss new file mode 100644 index 0000000000..1f8e15bd8a --- /dev/null +++ b/app/vmui/packages/vmui/src/pages/ExploreAnomaly/ExploreAnomalyHeader/style.scss @@ -0,0 +1,37 @@ +@use "src/styles/variables" as *; + +.vm-explore-anomaly-header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-start; + gap: $padding-global calc($padding-small + 10px); + max-width: calc(100vw - var(--scrollbar-width)); + + &_mobile { + flex-direction: column; + align-items: stretch; + } + + &-main { + display: grid; + gap: $padding-large; + align-items: center; + justify-items: center; + flex-grow: 1; + width: 100%; + + &__config { + text-transform: lowercase; + } + } + + &__select { + flex-grow: 1; + min-width: 100%; + } + + &__values { + flex-grow: 1; + } +} diff --git a/app/vmui/packages/vmui/src/pages/ExploreAnomaly/hooks/useFetchAnomalySeries.ts b/app/vmui/packages/vmui/src/pages/ExploreAnomaly/hooks/useFetchAnomalySeries.ts new file mode 100644 index 0000000000..dbbb0d34ba --- /dev/null +++ b/app/vmui/packages/vmui/src/pages/ExploreAnomaly/hooks/useFetchAnomalySeries.ts @@ -0,0 +1,66 @@ +import { useMemo, useState } from "preact/compat"; +import { useAppState } from "../../../state/common/StateContext"; +import { ErrorTypes } from "../../../types"; +import { useEffect } from "react"; +import { MetricBase } from "../../../api/types"; + +// TODO: Change the method of retrieving aliases from the configuration after the API has been added +const seriesQuery = `{ + for!="", + __name__!~".*yhat.*|.*trend.*|.*anomaly_score.*|.*daily.*|.*additive_terms.*|.*multiplicative_terms.*|.*weekly.*" +}`; + +export const useFetchAnomalySeries = () => { + const { serverUrl } = useAppState(); + + const [series, setSeries] = useState>(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(); + + const fetchUrl = useMemo(() => { + const params = new URLSearchParams({ + "match[]": seriesQuery, + }); + + return `${serverUrl}/api/v1/series?${params}`; + }, [serverUrl]); + + useEffect(() => { + const fetchSeries = async () => { + setError(""); + setIsLoading(true); + try { + const response = await fetch(fetchUrl); + const resp = await response.json(); + const data = (resp?.data || []) as MetricBase["metric"][]; + const groupedByFor = data.reduce<{ [key: string]: MetricBase["metric"][] }>((acc, item) => { + const forKey = item["for"]; + if (!acc[forKey]) acc[forKey] = []; + acc[forKey].push(item); + return acc; + }, {}); + setSeries(groupedByFor); + + if (!response.ok) { + const errorType = resp.errorType ? `${resp.errorType}\r\n` : ""; + setError(`${errorType}${resp?.error || resp?.message}`); + } + } catch (e) { + if (e instanceof Error && e.name !== "AbortError") { + const message = e.name === "SyntaxError" ? ErrorTypes.unknownType : `${e.name}: ${e.message}`; + setError(`${message}`); + } + } finally { + setIsLoading(false); + } + }; + + fetchSeries(); + }, [fetchUrl]); + + return { + error, + series, + isLoading, + }; +}; diff --git a/app/vmui/packages/vmui/src/pages/ExploreAnomaly/hooks/useSetQueryParams.ts b/app/vmui/packages/vmui/src/pages/ExploreAnomaly/hooks/useSetQueryParams.ts new file mode 100644 index 0000000000..b2ad643e1f --- /dev/null +++ b/app/vmui/packages/vmui/src/pages/ExploreAnomaly/hooks/useSetQueryParams.ts @@ -0,0 +1,31 @@ +import { useEffect } from "react"; +import { compactObject } from "../../../utils/object"; +import { useTimeState } from "../../../state/time/TimeStateContext"; +import { useGraphState } from "../../../state/graph/GraphStateContext"; +import useSearchParamsFromObject from "../../../hooks/useSearchParamsFromObject"; + +interface stateParams extends Record { + alias: string; +} + +export const useSetQueryParams = ({ alias, ...args }: stateParams) => { + const { duration, relativeTime, period: { date } } = useTimeState(); + const { customStep } = useGraphState(); + const { setSearchParamsFromKeys } = useSearchParamsFromObject(); + + const setSearchParamsFromState = () => { + const params = compactObject({ + ["g0.range_input"]: duration, + ["g0.end_input"]: date, + ["g0.step_input"]: customStep, + ["g0.relative_time"]: relativeTime, + alias, + ...args, + }); + + setSearchParamsFromKeys(params); + }; + + useEffect(setSearchParamsFromState, [duration, relativeTime, date, customStep, alias, args]); + useEffect(setSearchParamsFromState, []); +}; diff --git a/app/vmui/packages/vmui/src/pages/PredefinedPanels/PredefinedPanel/PredefinedPanel.tsx b/app/vmui/packages/vmui/src/pages/PredefinedPanels/PredefinedPanel/PredefinedPanel.tsx index 8ad0d4f0d3..e5ba735b86 100644 --- a/app/vmui/packages/vmui/src/pages/PredefinedPanels/PredefinedPanel/PredefinedPanel.tsx +++ b/app/vmui/packages/vmui/src/pages/PredefinedPanels/PredefinedPanel/PredefinedPanel.tsx @@ -1,5 +1,5 @@ import React, { FC, useEffect, useMemo, useRef, useState } from "preact/compat"; -import { PanelSettings } from "../../../types"; +import { DisplayType, PanelSettings } from "../../../types"; import { AxisRange, YaxisState } from "../../../state/graph/reducer"; import GraphView from "../../../components/Views/GraphView/GraphView"; import { useFetchQuery } from "../../../hooks/useFetchQuery"; @@ -45,7 +45,7 @@ const PredefinedPanel: FC = ({ const { isLoading, graphData, error, warning } = useFetchQuery({ predefinedQuery: validExpr ? expr : [], - display: "chart", + display: DisplayType.chart, visible, customStep, }); diff --git a/app/vmui/packages/vmui/src/pages/PredefinedPanels/hooks/useFetchDashboards.ts b/app/vmui/packages/vmui/src/pages/PredefinedPanels/hooks/useFetchDashboards.ts index bffcc61639..94b7dee6ab 100755 --- a/app/vmui/packages/vmui/src/pages/PredefinedPanels/hooks/useFetchDashboards.ts +++ b/app/vmui/packages/vmui/src/pages/PredefinedPanels/hooks/useFetchDashboards.ts @@ -15,7 +15,6 @@ export const useFetchDashboards = (): { error?: ErrorTypes | string, dashboardsSettings: DashboardSettings[], } => { - const { REACT_APP_LOGS } = process.env; const appModeEnable = getAppModeEnable(); const { serverUrl } = useAppState(); const dispatch = useDashboardsDispatch(); @@ -35,7 +34,7 @@ export const useFetchDashboards = (): { }; const fetchRemoteDashboards = async () => { - if (!serverUrl || REACT_APP_LOGS) return; + if (!serverUrl || process.env.REACT_APP_TYPE) return; setError(""); setIsLoading(true); diff --git a/app/vmui/packages/vmui/src/router/index.ts b/app/vmui/packages/vmui/src/router/index.ts index 4af8accd7c..517c45aa0d 100644 --- a/app/vmui/packages/vmui/src/router/index.ts +++ b/app/vmui/packages/vmui/src/router/index.ts @@ -1,3 +1,5 @@ +import { AppType } from "../types/appType"; + const router = { home: "/", metrics: "/metrics", @@ -9,7 +11,9 @@ const router = { relabel: "/relabeling", logs: "/logs", activeQueries: "/active-queries", - icons: "/icons" + icons: "/icons", + anomaly: "/anomaly", + query: "/query", }; export interface RouterOptionsHeader { @@ -26,14 +30,15 @@ export interface RouterOptions { header: RouterOptionsHeader } -const { REACT_APP_LOGS } = process.env; +const { REACT_APP_TYPE } = process.env; +const isLogsApp = REACT_APP_TYPE === AppType.logs; const routerOptionsDefault = { header: { tenant: true, - stepControl: !REACT_APP_LOGS, - timeSelector: !REACT_APP_LOGS, - executionControls: !REACT_APP_LOGS, + stepControl: !isLogsApp, + timeSelector: !isLogsApp, + executionControls: !isLogsApp, } }; @@ -90,6 +95,14 @@ export const routerOptions: {[key: string]: RouterOptions} = { [router.icons]: { title: "Icons", header: {} + }, + [router.anomaly]: { + title: "Anomaly exploration", + ...routerOptionsDefault + }, + [router.query]: { + title: "Query", + ...routerOptionsDefault } }; diff --git a/app/vmui/packages/vmui/src/state/customPanel/reducer.ts b/app/vmui/packages/vmui/src/state/customPanel/reducer.ts index b9854d626f..b80aaeaa30 100644 --- a/app/vmui/packages/vmui/src/state/customPanel/reducer.ts +++ b/app/vmui/packages/vmui/src/state/customPanel/reducer.ts @@ -1,7 +1,7 @@ -import { DisplayType, displayTypeTabs } from "../../pages/CustomPanel/DisplayTypeSwitch"; +import { displayTypeTabs } from "../../pages/CustomPanel/DisplayTypeSwitch"; import { getQueryStringValue } from "../../utils/query-string"; import { getFromStorage, saveToStorage } from "../../utils/storage"; -import { SeriesLimits } from "../../types"; +import { DisplayType, SeriesLimits } from "../../types"; import { DEFAULT_MAX_SERIES } from "../../constants/graph"; export interface CustomPanelState { @@ -24,7 +24,7 @@ const displayType = displayTypeTabs.find(t => t.prometheusCode === +queryTab || const limitsStorage = getFromStorage("SERIES_LIMITS") as string; export const initialCustomPanelState: CustomPanelState = { - displayType: (displayType?.value || "chart") as DisplayType, + displayType: (displayType?.value || DisplayType.chart), nocache: false, isTracingEnabled: false, seriesLimits: limitsStorage ? JSON.parse(limitsStorage) : DEFAULT_MAX_SERIES, diff --git a/app/vmui/packages/vmui/src/types/appType.ts b/app/vmui/packages/vmui/src/types/appType.ts new file mode 100644 index 0000000000..0679afd864 --- /dev/null +++ b/app/vmui/packages/vmui/src/types/appType.ts @@ -0,0 +1,4 @@ +export enum AppType { + logs = "logs", + anomaly = "anomaly", +} diff --git a/app/vmui/packages/vmui/src/types/index.ts b/app/vmui/packages/vmui/src/types/index.ts index f4bd758730..6d71c6b200 100644 --- a/app/vmui/packages/vmui/src/types/index.ts +++ b/app/vmui/packages/vmui/src/types/index.ts @@ -7,7 +7,11 @@ declare global { } } -export type DisplayType = "table" | "chart" | "code"; +export enum DisplayType { + table = "table", + chart = "chart", + code = "code", +} export interface TimeParams { start: number; // timestamp in seconds diff --git a/app/vmui/packages/vmui/src/types/uplot.ts b/app/vmui/packages/vmui/src/types/uplot.ts index f027c4c51c..e86417af0f 100644 --- a/app/vmui/packages/vmui/src/types/uplot.ts +++ b/app/vmui/packages/vmui/src/types/uplot.ts @@ -1,5 +1,14 @@ 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" +} + export interface SeriesItemStatsFormatted { min: string, max: string, @@ -10,7 +19,9 @@ export interface SeriesItemStatsFormatted { export interface SeriesItem extends Series { freeFormFields: {[key: string]: string}; statsFormatted: SeriesItemStatsFormatted; - median: number + median: number; + forecast?: ForecastType | null; + forecastGroup?: string; } export interface HideSeriesArgs { diff --git a/app/vmui/packages/vmui/src/utils/color.ts b/app/vmui/packages/vmui/src/utils/color.ts index 03776d7e05..9f351297bf 100644 --- a/app/vmui/packages/vmui/src/utils/color.ts +++ b/app/vmui/packages/vmui/src/utils/color.ts @@ -1,4 +1,4 @@ -import { ArrayRGB } from "../types"; +import { ArrayRGB, ForecastType } from "../types"; export const baseContrastColors = [ "#e54040", @@ -13,6 +13,23 @@ export const baseContrastColors = [ "#a44e0c", ]; +export const hexToRGB = (hex: string): string => { + if (hex.length != 7) return "0, 0, 0"; + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return `${r}, ${g}, ${b}`; +}; + +export const anomalyColors: Record = { + [ForecastType.yhatUpper]: "#7126a1", + [ForecastType.yhatLower]: "#7126a1", + [ForecastType.yhat]: "#da42a6", + [ForecastType.anomaly]: "#da4242", + [ForecastType.actual]: "#203ea9", + [ForecastType.training]: `rgba(${hexToRGB("#203ea9")}, 0.2)`, +}; + export const getColorFromString = (text: string): string => { const SEED = 16777215; const FACTOR = 49979693; @@ -34,14 +51,6 @@ export const getColorFromString = (text: string): string => { return `#${hex}`; }; -export const hexToRGB = (hex: string): string => { - if (hex.length != 7) return "0, 0, 0"; - const r = parseInt(hex.slice(1, 3), 16); - const g = parseInt(hex.slice(3, 5), 16); - const b = parseInt(hex.slice(5, 7), 16); - return `${r}, ${g}, ${b}`; -}; - export const getContrastColor = (value: string) => { let hex = value.replace("#", "").trim(); @@ -55,7 +64,7 @@ export const getContrastColor = (value: string) => { const r = parseInt(hex.slice(0, 2), 16); const g = parseInt(hex.slice(2, 4), 16); const b = parseInt(hex.slice(4, 6), 16); - const yiq = ((r*299)+(g*587)+(b*114))/1000; + const yiq = ((r * 299) + (g * 587) + (b * 114)) / 1000; return yiq >= 128 ? "#000000" : "#FFFFFF"; }; @@ -66,7 +75,7 @@ export const generateGradient = (start: ArrayRGB, end: ArrayRGB, steps: number) const r = start[0] + (end[0] - start[0]) * k; const g = start[1] + (end[1] - start[1]) * k; const b = start[2] + (end[2] - start[2]) * k; - gradient.push([r,g,b].map(n => Math.round(n)).join(", ")); + gradient.push([r, g, b].map(n => Math.round(n)).join(", ")); } return gradient.map(c => `rgb(${c})`); }; diff --git a/app/vmui/packages/vmui/src/utils/default-server-url.ts b/app/vmui/packages/vmui/src/utils/default-server-url.ts index a62b07f4da..8fd036803c 100644 --- a/app/vmui/packages/vmui/src/utils/default-server-url.ts +++ b/app/vmui/packages/vmui/src/utils/default-server-url.ts @@ -1,12 +1,16 @@ import { getAppModeParams } from "./app-mode"; import { replaceTenantId } from "./tenants"; -const { REACT_APP_LOGS } = process.env; +import { AppType } from "../types/appType"; +import { getFromStorage } from "./storage"; +const { REACT_APP_TYPE } = process.env; export const getDefaultServer = (tenantId?: string): string => { const { serverURL } = getAppModeParams(); + const storageURL = getFromStorage("SERVER_URL") as string; const logsURL = window.location.href.replace(/\/(select\/)?(vmui)\/.*/, ""); - const url = serverURL || window.location.href.replace(/\/(?:prometheus\/)?(?:graph|vmui)\/.*/, "/prometheus"); - if (REACT_APP_LOGS) return logsURL; + const defaultURL = window.location.href.replace(/\/(?:prometheus\/)?(?:graph|vmui)\/.*/, "/prometheus"); + const url = serverURL || storageURL || defaultURL; + if (REACT_APP_TYPE === AppType.logs) return logsURL; if (tenantId) return replaceTenantId(url, tenantId); return url; }; diff --git a/app/vmui/packages/vmui/src/utils/storage.ts b/app/vmui/packages/vmui/src/utils/storage.ts index 844ac5b757..17f9c991f5 100644 --- a/app/vmui/packages/vmui/src/utils/storage.ts +++ b/app/vmui/packages/vmui/src/utils/storage.ts @@ -9,6 +9,7 @@ export type StorageKeys = "AUTOCOMPLETE" | "EXPLORE_METRICS_TIPS" | "QUERY_HISTORY" | "QUERY_FAVORITES" + | "SERVER_URL" export const saveToStorage = (key: StorageKeys, value: string | boolean | Record): void => { if (value) { diff --git a/app/vmui/packages/vmui/src/utils/uplot/bands.ts b/app/vmui/packages/vmui/src/utils/uplot/bands.ts new file mode 100644 index 0000000000..5105829917 --- /dev/null +++ b/app/vmui/packages/vmui/src/utils/uplot/bands.ts @@ -0,0 +1,41 @@ +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)`; +} diff --git a/app/vmui/packages/vmui/src/utils/uplot/index.ts b/app/vmui/packages/vmui/src/utils/uplot/index.ts index 47f727870a..6e26fe6b44 100644 --- a/app/vmui/packages/vmui/src/utils/uplot/index.ts +++ b/app/vmui/packages/vmui/src/utils/uplot/index.ts @@ -5,3 +5,4 @@ export * from "./hooks"; export * from "./instnance"; export * from "./scales"; export * from "./series"; +export * from "./bands"; diff --git a/app/vmui/packages/vmui/src/utils/uplot/scales.ts b/app/vmui/packages/vmui/src/utils/uplot/scales.ts index eb0d4c0249..b3ab369925 100644 --- a/app/vmui/packages/vmui/src/utils/uplot/scales.ts +++ b/app/vmui/packages/vmui/src/utils/uplot/scales.ts @@ -1,7 +1,8 @@ import uPlot, { Range, Scale, Scales } from "uplot"; import { getMinMaxBuffer } from "./axes"; import { YaxisState } from "../../state/graph/reducer"; -import { MinMax, SetMinMax } from "../../types"; +import { ForecastType, MinMax, SetMinMax } from "../../types"; +import { anomalyColors } from "../color"; export const getRangeX = ({ min, max }: MinMax): Range.MinMax => [min, max]; @@ -24,3 +25,80 @@ 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; +}; diff --git a/app/vmui/packages/vmui/src/utils/uplot/series.ts b/app/vmui/packages/vmui/src/utils/uplot/series.ts index 0a04e1d3ef..50a630e07c 100644 --- a/app/vmui/packages/vmui/src/utils/uplot/series.ts +++ b/app/vmui/packages/vmui/src/utils/uplot/series.ts @@ -1,45 +1,103 @@ -import { MetricResult } from "../../api/types"; +import { MetricBase, MetricResult } from "../../api/types"; import uPlot, { Series as uPlotSeries } from "uplot"; import { getNameForMetric, promValueToNumber } from "../metric"; -import { HideSeriesArgs, BarSeriesItem, Disp, Fill, LegendItemType, Stroke, SeriesItem } from "../../types"; -import { baseContrastColors, getColorFromString } from "../color"; -import { getMedianFromArray, getMaxFromArray, getMinFromArray, getLastFromArray } from "../math"; +import { BarSeriesItem, Disp, Fill, ForecastType, HideSeriesArgs, LegendItemType, SeriesItem, Stroke } from "../../types"; +import { anomalyColors, baseContrastColors, getColorFromString } from "../color"; +import { getLastFromArray, getMaxFromArray, getMedianFromArray, getMinFromArray } from "../math"; import { formatPrettyNumber } from "./helpers"; -export const getSeriesItemContext = (data: MetricResult[], hideSeries: string[], alias: string[]) => { - const colorState: {[key: string]: string} = {}; - const stats = data.map(d => { - const values = d.values.map(v => promValueToNumber(v[1])); - return { - min: getMinFromArray(values), - max: getMaxFromArray(values), - median: getMedianFromArray(values), - last: getLastFromArray(values), - }; - }); +// Helper function to extract freeFormFields values as a comma-separated string +export const extractFields = (metric: MetricBase["metric"]): string => { + const excludeMetrics = ["__name__", "for"]; + return Object.entries(metric) + .filter(([key]) => !excludeMetrics.includes(key)) + .map(([key, value]) => `${key}: ${value}`).join(","); +}; + +const isForecast = (metric: MetricBase["metric"]) => { + const metricName = metric?.__name__ || ""; + const forecastRegex = new RegExp(`(${Object.values(ForecastType).join("|")})$`); + const match = metricName.match(forecastRegex); + const value = match && match[0] as ForecastType; + return { + value, + isUpper: value === ForecastType.yhatUpper, + isLower: value === ForecastType.yhatLower, + isYhat: value === ForecastType.yhat, + isAnomaly: value === ForecastType.anomaly, + group: extractFields(metric) + }; +}; + +export const getSeriesItemContext = (data: MetricResult[], hideSeries: string[], alias: string[], isAnomaly?: boolean) => { + const colorState: {[key: string]: string} = {}; + const maxColors = isAnomaly ? 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 label = getNameForMetric(d, alias[d.group - 1]); - const color = colorState[label] || getColorFromString(label); - const { min, max, median, last } = stats[i]; + const forecast = isForecast(data[i].metric); + const label = isAnomaly ? forecast.group : getNameForMetric(d, alias[d.group - 1]); + + const values = d.values.map(v => promValueToNumber(v[1])); + const { min, max, median, last } = { + min: getMinFromArray(values), + max: getMaxFromArray(values), + median: getMedianFromArray(values), + last: getLastFromArray(values), + }; + + let dash: number[] = []; + if (forecast.isLower || forecast.isUpper) { + dash = [10, 5]; + } else if (forecast.isYhat) { + dash = [10, 2]; + } + + let width = 1.4; + if (forecast.isUpper || forecast.isLower) { + width = 0.7; + } else if (forecast.isYhat) { + width = 1; + } else if (forecast.isAnomaly) { + width = 0; + } + + let points: uPlotSeries.Points = { size: 4.2, width: 1.4 }; + if (forecast.isAnomaly) { + points = { size: 8, width: 4, space: 0 }; + } + + let stroke: uPlotSeries.Stroke = colorState[label] || getColorFromString(label); + if (isAnomaly && forecast.isAnomaly) { + stroke = anomalyColors[ForecastType.anomaly]; + } else if (isAnomaly && !forecast.isAnomaly && !forecast.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); + stroke = anomalyColors[ForecastType.actual]; + } else if (forecast.value) { + stroke = forecast.value ? anomalyColors[forecast.value] : stroke; + } return { label, + dash, + width, + stroke, + points, + forecast: forecast.value, + forecastGroup: forecast.group, freeFormFields: d.metric, - width: 1.4, - stroke: color, show: !includesHideSeries(label, hideSeries), scale: "1", - points: { - size: 4.2, - width: 1.4 - }, statsFormatted: { min: formatPrettyNumber(min, min, max), max: formatPrettyNumber(max, min, max),