mirror of
https://github.com/VictoriaMetrics/VictoriaMetrics.git
synced 2024-11-27 02:46:47 +01:00
vmui: add vmanomaly explorer (#5401)
This commit is contained in:
parent
df012f1553
commit
a35e52114b
@ -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}
|
||||
|
@ -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";
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
@ -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'",
|
||||
|
41
app/vmui/packages/vmui/src/AppAnomaly.tsx
Normal file
41
app/vmui/packages/vmui/src/AppAnomaly.tsx
Normal file
@ -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 <>
|
||||
<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 AppLogs;
|
@ -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, string> = {
|
||||
[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<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],
|
||||
forecast: titles[s.forecast || ForecastType.actual],
|
||||
}));
|
||||
}, [series]);
|
||||
|
||||
const container = document.getElementById("legendAnomaly");
|
||||
if (!container) return null;
|
||||
|
||||
return <>
|
||||
<div className="vm-legend-anomaly">
|
||||
{/* TODO: remove .filter() after the correct training data has been added */}
|
||||
{uniqSeriesStyles.filter(f => f.forecast !== titles[ForecastType.training]).map((s, i) => (
|
||||
<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">{s.forecast || "y"}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>;
|
||||
};
|
||||
|
||||
export default LegendAnomaly;
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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<LineChartProps> = ({
|
||||
@ -50,7 +52,8 @@ const LineChart: FC<LineChartProps> = ({
|
||||
unit,
|
||||
setPeriod,
|
||||
layoutSize,
|
||||
height
|
||||
height,
|
||||
anomalyView
|
||||
}) => {
|
||||
const { isDarkTheme } = useAppState();
|
||||
|
||||
@ -68,7 +71,7 @@ const LineChart: FC<LineChartProps> = ({
|
||||
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<LineChartProps> = ({
|
||||
setSelect: [setSelect(setPlotScale)],
|
||||
destroy: [handleDestroy],
|
||||
},
|
||||
bands: []
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@ -103,6 +107,7 @@ const LineChart: FC<LineChartProps> = ({
|
||||
if (!uPlotInst) return;
|
||||
delSeries(uPlotInst);
|
||||
addSeries(uPlotInst, series);
|
||||
setBand(uPlotInst, series);
|
||||
uPlotInst.redraw();
|
||||
}, [series]);
|
||||
|
||||
|
@ -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: <ServerConfigurator
|
||||
stateServerUrl={stateServerUrl}
|
||||
serverUrl={serverUrl}
|
||||
@ -86,7 +89,7 @@ const GlobalSettings: FC = () => {
|
||||
/>
|
||||
},
|
||||
{
|
||||
show: !REACT_APP_LOGS,
|
||||
show: !isLogsApp,
|
||||
component: <LimitsConfigurator
|
||||
limits={limits}
|
||||
onChange={setLimits}
|
||||
|
@ -16,9 +16,9 @@ export interface ServerConfiguratorProps {
|
||||
}
|
||||
|
||||
const fields: {label: string, type: DisplayType}[] = [
|
||||
{ label: "Graph", type: "chart" },
|
||||
{ label: "JSON", type: "code" },
|
||||
{ label: "Table", type: "table" }
|
||||
{ label: "Graph", type: DisplayType.chart },
|
||||
{ label: "JSON", type: DisplayType.code },
|
||||
{ label: "Table", type: DisplayType.table }
|
||||
];
|
||||
|
||||
const LimitsConfigurator: FC<ServerConfiguratorProps> = ({ limits, onChange , onEnter }) => {
|
||||
|
@ -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<ServerConfiguratorProps> = ({
|
||||
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<ServerConfiguratorProps> = ({
|
||||
if (!isValidHttpUrl(stateServerUrl)) setError(ErrorTypes.validServer);
|
||||
}, [stateServerUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (enabledStorage) {
|
||||
saveToStorage("SERVER_URL", serverUrl);
|
||||
} else {
|
||||
removeFromStorage(["SERVER_URL"]);
|
||||
}
|
||||
}, [enabledStorage]);
|
||||
|
||||
return (
|
||||
<TextField
|
||||
autofocus
|
||||
label="Server URL"
|
||||
value={serverUrl}
|
||||
error={error}
|
||||
onChange={onChangeServer}
|
||||
onEnter={onEnter}
|
||||
inputmode="url"
|
||||
/>
|
||||
<div>
|
||||
<div className="vm-server-configurator__title">
|
||||
Server URL
|
||||
</div>
|
||||
<div className="vm-server-configurator-url">
|
||||
<TextField
|
||||
autofocus
|
||||
value={serverUrl}
|
||||
error={error}
|
||||
onChange={onChangeServer}
|
||||
onEnter={onEnter}
|
||||
inputmode="url"
|
||||
/>
|
||||
<Tooltip title={enabledStorage ? tooltipSave.disable : tooltipSave.enable}>
|
||||
<Button
|
||||
className="vm-server-configurator-url__button"
|
||||
variant="text"
|
||||
color={enabledStorage ? "primary" : "gray"}
|
||||
onClick={handleToggleStorage}
|
||||
startIcon={<StorageIcon/>}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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<ExploreMetricItemGraphProps> = ({
|
||||
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 && <Spinner />}
|
||||
{error && <Alert variant="error">{error}</Alert>}
|
||||
{queryErrors[0] && <Alert variant="error">{queryErrors[0]}</Alert>}
|
||||
{warning && <Alert variant="warning">
|
||||
<div className="vm-explore-metrics-graph__warning">
|
||||
<p>{warning}</p>
|
||||
<Button
|
||||
color="warning"
|
||||
variant="outlined"
|
||||
onClick={handleShowAll}
|
||||
>
|
||||
Show all
|
||||
</Button>
|
||||
</div>
|
||||
</Alert>}
|
||||
{warning && (
|
||||
<WarningLimitSeries
|
||||
warning={warning}
|
||||
query={[query]}
|
||||
onChange={setShowAllSeries}
|
||||
/>
|
||||
)}
|
||||
{graphData && period && (
|
||||
<GraphView
|
||||
data={graphData}
|
||||
|
File diff suppressed because one or more lines are too long
@ -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<SelectProps> = ({
|
||||
clearable = false,
|
||||
searchable = false,
|
||||
autofocus,
|
||||
disabled,
|
||||
onChange
|
||||
}) => {
|
||||
const { isDarkTheme } = useAppState();
|
||||
@ -64,11 +66,12 @@ const Select: FC<SelectProps> = ({
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
if (disabled) return;
|
||||
setOpenList(true);
|
||||
};
|
||||
|
||||
const handleToggleList = (e: MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target instanceof HTMLInputElement) return;
|
||||
if (e.target instanceof HTMLInputElement || disabled) return;
|
||||
setOpenList(prev => !prev);
|
||||
};
|
||||
|
||||
@ -112,7 +115,8 @@ const Select: FC<SelectProps> = ({
|
||||
<div
|
||||
className={classNames({
|
||||
"vm-select": true,
|
||||
"vm-select_dark": isDarkTheme
|
||||
"vm-select_dark": isDarkTheme,
|
||||
"vm-select_disabled": disabled
|
||||
})}
|
||||
>
|
||||
<div
|
||||
|
@ -126,4 +126,18 @@
|
||||
max-height: calc(($vh * 100) - 70px);
|
||||
}
|
||||
}
|
||||
|
||||
&_disabled {
|
||||
* {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.vm-select-input {
|
||||
&-content {
|
||||
input {
|
||||
color: $color-text-disabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -24,6 +24,7 @@ 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";
|
||||
|
||||
export interface GraphViewProps {
|
||||
data?: MetricResult[];
|
||||
@ -34,11 +35,12 @@ export interface GraphViewProps {
|
||||
yaxis: YaxisState;
|
||||
unit?: string;
|
||||
showLegend?: boolean;
|
||||
setYaxisLimits: (val: AxisRange) => 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<GraphViewProps> = ({
|
||||
@ -54,7 +56,8 @@ const GraphView: FC<GraphViewProps> = ({
|
||||
alias = [],
|
||||
fullWidth = true,
|
||||
height,
|
||||
isHistogram
|
||||
isHistogram,
|
||||
anomalyView,
|
||||
}) => {
|
||||
const { isMobile } = useDeviceDetect();
|
||||
const { timezone } = useTimeState();
|
||||
@ -69,8 +72,8 @@ const GraphView: FC<GraphViewProps> = ({
|
||||
const [legendValue, setLegendValue] = useState<ChartTooltipProps | null>(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<GraphViewProps> = ({
|
||||
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<GraphViewProps> = ({
|
||||
setPeriod={setPeriod}
|
||||
layoutSize={containerSize}
|
||||
height={height}
|
||||
anomalyView={anomalyView}
|
||||
/>
|
||||
)}
|
||||
{isHistogram && (
|
||||
@ -206,7 +210,7 @@ const GraphView: FC<GraphViewProps> = ({
|
||||
onChangeLegend={setLegendValue}
|
||||
/>
|
||||
)}
|
||||
{!isHistogram && showLegend && (
|
||||
{!isHistogram && !anomalyView && showLegend && (
|
||||
<Legend
|
||||
labels={legend}
|
||||
query={query}
|
||||
@ -221,6 +225,11 @@ const GraphView: FC<GraphViewProps> = ({
|
||||
legendValue={legendValue}
|
||||
/>
|
||||
)}
|
||||
{anomalyView && showLegend && (
|
||||
<LegendAnomaly
|
||||
series={series as SeriesItem[]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -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,
|
||||
];
|
||||
|
@ -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<ChartTooltipProps[]>([]);
|
||||
@ -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;
|
||||
|
@ -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);
|
||||
|
@ -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 <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;
|
@ -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<ControlsProps> = ({
|
||||
displaySidebar,
|
||||
isMobile,
|
||||
headerSetup,
|
||||
accountIds
|
||||
}) => {
|
||||
|
||||
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/>}
|
||||
<GlobalSettings/>
|
||||
{!displaySidebar && <ShortcutKeys/>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ControlsAnomalyLayout;
|
@ -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<ControlsProps>
|
||||
}
|
||||
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 <LogoLogsIcon/>;
|
||||
case AppType.anomaly:
|
||||
return <LogoAnomalyIcon/>;
|
||||
default:
|
||||
return <LogoIcon/>;
|
||||
}
|
||||
};
|
||||
|
||||
const Header: FC<HeaderProps> = ({ controlsComponent }) => {
|
||||
const { REACT_APP_LOGS } = process.env;
|
||||
const { isMobile } = useDeviceDetect();
|
||||
|
||||
const windowSize = useWindowSize();
|
||||
@ -70,12 +83,12 @@ const Header: FC<HeaderProps> = ({ controlsComponent }) => {
|
||||
<div
|
||||
className={classNames({
|
||||
"vm-header-logo": true,
|
||||
"vm-header-logo_logs": REACT_APP_LOGS
|
||||
"vm-header-logo_logs": isCustomApp
|
||||
})}
|
||||
onClick={onClickLogo}
|
||||
style={{ color }}
|
||||
>
|
||||
{REACT_APP_LOGS ? <LogoLogsIcon/> : <LogoIcon/>}
|
||||
{<Logo/>}
|
||||
</div>
|
||||
)}
|
||||
<HeaderNav
|
||||
@ -89,12 +102,12 @@ const Header: FC<HeaderProps> = ({ 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 ? <LogoLogsIcon/> : <LogoIcon/>}
|
||||
{<Logo/>}
|
||||
</div>
|
||||
)}
|
||||
<HeaderControls
|
||||
|
@ -8,7 +8,8 @@ import "./style.scss";
|
||||
import NavItem from "./NavItem";
|
||||
import NavSubItem from "./NavSubItem";
|
||||
import classNames from "classnames";
|
||||
import { defaultNavigation, logsNavigation } from "../../../constants/navigation";
|
||||
import { anomalyNavigation, defaultNavigation, logsNavigation } from "../../../constants/navigation";
|
||||
import { AppType } from "../../../types/appType";
|
||||
|
||||
interface HeaderNavProps {
|
||||
color: string
|
||||
@ -17,21 +18,29 @@ interface HeaderNavProps {
|
||||
}
|
||||
|
||||
const HeaderNav: FC<HeaderNavProps> = ({ 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);
|
||||
|
@ -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<SidebarHeaderProps> = ({
|
||||
background,
|
||||
color,
|
||||
}) => {
|
||||
const { REACT_APP_LOGS } = process.env;
|
||||
const { pathname } = useLocation();
|
||||
const { isMobile } = useDeviceDetect();
|
||||
|
||||
@ -61,7 +64,7 @@ const SidebarHeader: FC<SidebarHeaderProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div className="vm-header-sidebar-menu-settings">
|
||||
{!isMobile && !REACT_APP_LOGS && <ShortcutKeys showTitle={true}/>}
|
||||
{!isMobile && !isLogsApp && <ShortcutKeys showTitle={true}/>}
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
|
@ -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";
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
};
|
||||
|
||||
|
@ -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<HTMLDivElement>;
|
||||
anomalyView?: boolean;
|
||||
}
|
||||
|
||||
const GraphTab: FC<Props> = ({ 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 = (
|
||||
<div className="vm-custom-panel-body-header__graph-controls">
|
||||
<GraphTips/>
|
||||
<GraphSettings
|
||||
yaxis={yaxis}
|
||||
setYaxisLimits={setYaxisLimits}
|
||||
toggleEnableLimits={toggleEnableLimits}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{controlsRef.current && createPortal(controls, controlsRef.current)}
|
||||
<GraphView
|
||||
data={graphData}
|
||||
period={period}
|
||||
customStep={customStep}
|
||||
query={query}
|
||||
yaxis={yaxis}
|
||||
setYaxisLimits={setYaxisLimits}
|
||||
setPeriod={setPeriod}
|
||||
height={isMobile ? window.innerHeight * 0.5 : 500}
|
||||
isHistogram={isHistogram}
|
||||
anomalyView={anomalyView}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default GraphTab;
|
@ -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<HTMLDivElement>;
|
||||
}
|
||||
|
||||
const TableTab: FC<Props> = ({ liveData, controlsRef }) => {
|
||||
const { tableCompact } = useCustomPanelState();
|
||||
const customPanelDispatch = useCustomPanelDispatch();
|
||||
|
||||
const [displayColumns, setDisplayColumns] = useState<string[]>();
|
||||
|
||||
const columns = useMemo(() => getColumns(liveData || []).map(c => c.key), [liveData]);
|
||||
|
||||
const toggleTableCompact = () => {
|
||||
customPanelDispatch({ type: "TOGGLE_TABLE_COMPACT" });
|
||||
};
|
||||
|
||||
const controls = (
|
||||
<TableSettings
|
||||
columns={columns}
|
||||
defaultColumns={displayColumns}
|
||||
onChangeColumns={setDisplayColumns}
|
||||
tableCompact={tableCompact}
|
||||
toggleTableCompact={toggleTableCompact}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{controlsRef.current && createPortal(controls, controlsRef.current)}
|
||||
<TableView
|
||||
data={liveData}
|
||||
displayColumns={displayColumns}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TableTab;
|
@ -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<HTMLDivElement>;
|
||||
}
|
||||
|
||||
const CustomPanelTabs: FC<Props> = ({
|
||||
graphData,
|
||||
liveData,
|
||||
isHistogram,
|
||||
displayType,
|
||||
controlsRef
|
||||
}) => {
|
||||
if (displayType === DisplayType.code && liveData) {
|
||||
return <JsonView data={liveData} />;
|
||||
}
|
||||
|
||||
if (displayType === DisplayType.table && liveData) {
|
||||
return <TableTab
|
||||
liveData={liveData}
|
||||
controlsRef={controlsRef}
|
||||
/>;
|
||||
}
|
||||
|
||||
if (displayType === DisplayType.chart && graphData) {
|
||||
return <GraphTab
|
||||
graphData={graphData}
|
||||
isHistogram={isHistogram}
|
||||
controlsRef={controlsRef}
|
||||
/>;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default CustomPanelTabs;
|
@ -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<Props> = ({ traces, displayType }) => {
|
||||
const { isTracingEnabled } = useCustomPanelState();
|
||||
const [tracesState, setTracesState] = useState<Trace[]>([]);
|
||||
|
||||
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 && (
|
||||
<div className="vm-custom-panel__trace">
|
||||
<TracingsView
|
||||
traces={tracesState}
|
||||
onDeleteClick={handleTraceDelete}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>;
|
||||
};
|
||||
|
||||
export default CustomPanelTraces;
|
@ -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: <ChartIcon/>, label: "Graph", prometheusCode: 0 },
|
||||
{ value: "code", icon: <CodeIcon/>, label: "JSON", prometheusCode: 3 },
|
||||
{ value: "table", icon: <TableIcon/>, label: "Table", prometheusCode: 1 }
|
||||
{ value: DisplayType.chart, icon: <ChartIcon/>, label: "Graph", prometheusCode: 0 },
|
||||
{ value: DisplayType.code, icon: <CodeIcon/>, label: "JSON", prometheusCode: 3 },
|
||||
{ value: DisplayType.table, icon: <TableIcon/>, label: "Table", prometheusCode: 1 }
|
||||
];
|
||||
|
||||
export const DisplayTypeSwitch: FC = () => {
|
||||
|
@ -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<Props> = ({ warning, query, onChange }) => {
|
||||
const { isMobile } = useDeviceDetect();
|
||||
|
||||
const {
|
||||
value: showAllSeries,
|
||||
setTrue: handleShowAll,
|
||||
setFalse: resetShowAll,
|
||||
} = useBoolean(false);
|
||||
|
||||
useEffect(resetShowAll, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
onChange(showAllSeries);
|
||||
}, [showAllSeries]);
|
||||
|
||||
return (
|
||||
<Alert variant="warning">
|
||||
<div
|
||||
className={classNames({
|
||||
"vm-custom-panel__warning": true,
|
||||
"vm-custom-panel__warning_mobile": isMobile
|
||||
})}
|
||||
>
|
||||
<p>{warning}</p>
|
||||
<Button
|
||||
color="warning"
|
||||
variant="outlined"
|
||||
onClick={handleShowAll}
|
||||
>
|
||||
Show all
|
||||
</Button>
|
||||
</div>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
export default WarningLimitSeries;
|
@ -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<string[]>();
|
||||
const [tracesState, setTracesState] = useState<Trace[]>([]);
|
||||
const [hideQuery, setHideQuery] = useState<number[]>([]);
|
||||
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<HTMLDivElement>(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 && (
|
||||
<div className="vm-custom-panel__trace">
|
||||
<TracingsView
|
||||
traces={tracesState}
|
||||
onDeleteClick={handleTraceDelete}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<CustomPanelTraces
|
||||
traces={traces}
|
||||
displayType={displayType}
|
||||
/>
|
||||
{isLoading && <Spinner />}
|
||||
{!hideError && error && <Alert variant="error">{error}</Alert>}
|
||||
{!liveData?.length && (displayType !== "chart") && <Alert variant="info"><InstantQueryTip/></Alert>}
|
||||
{warning && <Alert variant="warning">
|
||||
<div
|
||||
className={classNames({
|
||||
"vm-custom-panel__warning": true,
|
||||
"vm-custom-panel__warning_mobile": isMobile
|
||||
})}
|
||||
>
|
||||
<p>{warning}</p>
|
||||
<Button
|
||||
color="warning"
|
||||
variant="outlined"
|
||||
onClick={handleShowAll}
|
||||
>
|
||||
Show all
|
||||
</Button>
|
||||
</div>
|
||||
</Alert>}
|
||||
{showError && <Alert variant="error">{error}</Alert>}
|
||||
{showInstantQueryTip && <Alert variant="info"><InstantQueryTip/></Alert>}
|
||||
{warning && (
|
||||
<WarningLimitSeries
|
||||
warning={warning}
|
||||
query={query}
|
||||
onChange={setShowAllSeries}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={classNames({
|
||||
"vm-custom-panel-body": true,
|
||||
@ -170,50 +107,19 @@ const CustomPanel: FC = () => {
|
||||
"vm-block_mobile": isMobile,
|
||||
})}
|
||||
>
|
||||
<div className="vm-custom-panel-body-header">
|
||||
<DisplayTypeSwitch/>
|
||||
{displayType === "chart" && (
|
||||
<div className="vm-custom-panel-body-header__left">
|
||||
<GraphTips/>
|
||||
<GraphSettings
|
||||
yaxis={yaxis}
|
||||
setYaxisLimits={setYaxisLimits}
|
||||
toggleEnableLimits={toggleEnableLimits}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{displayType === "table" && (
|
||||
<TableSettings
|
||||
columns={columns}
|
||||
defaultColumns={displayColumns}
|
||||
onChangeColumns={setDisplayColumns}
|
||||
tableCompact={tableCompact}
|
||||
toggleTableCompact={toggleTableCompact}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="vm-custom-panel-body-header"
|
||||
ref={controlsRef}
|
||||
>
|
||||
{<DisplayTypeSwitch/>}
|
||||
</div>
|
||||
{graphData && period && (displayType === "chart") && (
|
||||
<GraphView
|
||||
data={graphData}
|
||||
period={period}
|
||||
customStep={customStep}
|
||||
query={query}
|
||||
yaxis={yaxis}
|
||||
setYaxisLimits={setYaxisLimits}
|
||||
setPeriod={setPeriod}
|
||||
height={isMobile ? window.innerHeight * 0.5 : 500}
|
||||
isHistogram={isHistogram}
|
||||
/>
|
||||
)}
|
||||
{liveData && (displayType === "code") && (
|
||||
<JsonView data={liveData}/>
|
||||
)}
|
||||
{liveData && (displayType === "table") && (
|
||||
<TableView
|
||||
data={liveData}
|
||||
displayColumns={displayColumns}
|
||||
/>
|
||||
)}
|
||||
<CustomPanelTabs
|
||||
graphData={graphData}
|
||||
liveData={liveData}
|
||||
isHistogram={isHistogram}
|
||||
displayType={displayType}
|
||||
controlsRef={controlsRef}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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<HTMLDivElement>(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<string, string>) => {
|
||||
const { __name__ = "", ...labelValue } = expr;
|
||||
let prefix = __name__.replace(/y|_y/, "");
|
||||
if (prefix) prefix += "_";
|
||||
const metrics = [__name__, ForecastType.yhat, ForecastType.yhatUpper, ForecastType.yhatLower];
|
||||
const 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 (
|
||||
<div
|
||||
className={classNames({
|
||||
"vm-custom-panel": true,
|
||||
"vm-custom-panel_mobile": isMobile,
|
||||
})}
|
||||
>
|
||||
<ExploreAnomalyHeader
|
||||
queries={queries}
|
||||
series={series}
|
||||
onChange={onChangeFilter}
|
||||
/>
|
||||
{(isGraphDataLoading || isAnomalySeriesLoading) && <Spinner />}
|
||||
{(error || errorSeries) && <Alert variant="error">{error || errorSeries}</Alert>}
|
||||
{!error && !errorSeries && queryErrors?.[0] && <Alert variant="error">{queryErrors[0]}</Alert>}
|
||||
<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={isHistogram}
|
||||
controlsRef={controlsRef}
|
||||
anomalyView={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExploreAnomaly;
|
@ -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<string, {[p: string]: string}[]>
|
||||
onChange: (expr: Record<string, string>) => void;
|
||||
}
|
||||
|
||||
const ExploreAnomalyHeader: FC<Props> = ({ queries, series, onChange }) => {
|
||||
const { isMobile } = useDeviceDetect();
|
||||
const [alias, setAlias] = useState(queries[0]);
|
||||
const [selectedValues, setSelectedValues] = useState<Record<string, string>>({});
|
||||
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<string, string[]>) || {};
|
||||
}, [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<string, string> = {};
|
||||
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 (
|
||||
<div
|
||||
id="legendAnomaly"
|
||||
className={classNames({
|
||||
"vm-explore-anomaly-header": true,
|
||||
"vm-explore-anomaly-header_mobile": isMobile,
|
||||
"vm-block": true,
|
||||
"vm-block_mobile": isMobile,
|
||||
})}
|
||||
>
|
||||
<div className="vm-explore-anomaly-header-main">
|
||||
<div className="vm-explore-anomaly-header__select">
|
||||
<Select
|
||||
value={alias}
|
||||
list={queries}
|
||||
label="Query"
|
||||
placeholder="Please select query"
|
||||
onChange={setAlias}
|
||||
searchable
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{Object.entries(uniqueKeysWithValues).map(([key, values]) => (
|
||||
<div
|
||||
className="vm-explore-anomaly-header__values"
|
||||
key={key}
|
||||
>
|
||||
<Select
|
||||
value={selectedValues[key] || ""}
|
||||
list={values}
|
||||
label={key}
|
||||
placeholder={`Please select ${key}`}
|
||||
onChange={createHandlerChangeSelect(key)}
|
||||
searchable={values.length > 2}
|
||||
disabled={values.length === 1}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExploreAnomalyHeader;
|
@ -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;
|
||||
}
|
||||
}
|
@ -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<Record<string, MetricBase["metric"][]>>();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<ErrorTypes | string>();
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
@ -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<string, string> {
|
||||
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, []);
|
||||
};
|
@ -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<PredefinedPanelsProps> = ({
|
||||
|
||||
const { isLoading, graphData, error, warning } = useFetchQuery({
|
||||
predefinedQuery: validExpr ? expr : [],
|
||||
display: "chart",
|
||||
display: DisplayType.chart,
|
||||
visible,
|
||||
customStep,
|
||||
});
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -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,
|
||||
|
4
app/vmui/packages/vmui/src/types/appType.ts
Normal file
4
app/vmui/packages/vmui/src/types/appType.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export enum AppType {
|
||||
logs = "logs",
|
||||
anomaly = "anomaly",
|
||||
}
|
@ -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
|
||||
|
@ -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 {
|
||||
|
@ -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, string> = {
|
||||
[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})`);
|
||||
};
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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<string, unknown>): void => {
|
||||
if (value) {
|
||||
|
41
app/vmui/packages/vmui/src/utils/uplot/bands.ts
Normal file
41
app/vmui/packages/vmui/src/utils/uplot/bands.ts
Normal file
@ -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)`;
|
||||
}
|
@ -5,3 +5,4 @@ export * from "./hooks";
|
||||
export * from "./instnance";
|
||||
export * from "./scales";
|
||||
export * from "./series";
|
||||
export * from "./bands";
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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),
|
||||
|
Loading…
Reference in New Issue
Block a user