vmui: add vmanomaly explorer (#5401)

This commit is contained in:
Yury Molodov 2023-12-19 17:20:54 +01:00 committed by GitHub
parent df012f1553
commit a35e52114b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
54 changed files with 1452 additions and 343 deletions

View file

@ -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}

View file

@ -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";
}
}
)
)

View file

@ -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'",

View 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;

View file

@ -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;

View file

@ -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;
}
}
}

View file

@ -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]);

View file

@ -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}

View file

@ -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 }) => {

View file

@ -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>
);
};

View file

@ -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;

View file

@ -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

View file

@ -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

View file

@ -126,4 +126,18 @@
max-height: calc(($vh * 100) - 70px);
}
}
&_disabled {
* {
cursor: not-allowed;
}
.vm-select-input {
&-content {
input {
color: $color-text-disabled;
}
}
}
}
}

View file

@ -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>
);
};

View file

@ -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,
];

View file

@ -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;

View file

@ -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);

View file

@ -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;

View file

@ -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;

View file

@ -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

View file

@ -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);

View file

@ -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>;

View file

@ -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";

View file

@ -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;
}
}
}

View file

@ -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;
};

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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 = () => {

View file

@ -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;

View file

@ -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>
);

View file

@ -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;
}
}

View file

@ -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;

View file

@ -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;

View file

@ -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;
}
}

View file

@ -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,
};
};

View file

@ -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, []);
};

View file

@ -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,
});

View file

@ -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);

View file

@ -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
}
};

View file

@ -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,

View file

@ -0,0 +1,4 @@
export enum AppType {
logs = "logs",
anomaly = "anomaly",
}

View file

@ -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

View file

@ -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 {

View file

@ -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})`);
};

View file

@ -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;
};

View file

@ -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) {

View 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)`;
}

View file

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

View file

@ -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;
};

View file

@ -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),