vmui/vmanomaly: integrate vmanomaly query_server (#6017)

* vmui: fix parsing of fractional values

* vmui/vmanomaly: update display logic to align with vmanomaly /query_range API

* vmui/vmanomaly: rename flag anomalyView to isAnomalyView
This commit is contained in:
Yury Molodov 2024-04-15 09:25:52 +02:00 committed by GitHub
parent 22497c2c98
commit f06f55edb6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 308 additions and 474 deletions

View file

@ -1,3 +1,3 @@
## Predefined dashboards ## Predefined dashboards
See [this docs](https://github.com/VictoriaMetrics/VictoriaMetrics/tree/master/app/vmui#predefined-dashboards) See [this doc](https://github.com/VictoriaMetrics/VictoriaMetrics/tree/master/app/vmui#predefined-dashboards)

View file

@ -7,10 +7,11 @@ import "./style.scss";
interface LegendProps { interface LegendProps {
labels: LegendItemType[]; labels: LegendItemType[];
query: string[]; query: string[];
isAnomalyView?: boolean;
onChange: (item: LegendItemType, metaKey: boolean) => void; onChange: (item: LegendItemType, metaKey: boolean) => void;
} }
const Legend: FC<LegendProps> = ({ labels, query, onChange }) => { const Legend: FC<LegendProps> = ({ labels, query, isAnomalyView, onChange }) => {
const groups = useMemo(() => { const groups = useMemo(() => {
return Array.from(new Set(labels.map(l => l.group))); return Array.from(new Set(labels.map(l => l.group)));
}, [labels]); }, [labels]);
@ -39,6 +40,7 @@ const Legend: FC<LegendProps> = ({ labels, query, onChange }) => {
<LegendItem <LegendItem
key={legendItem.label} key={legendItem.label}
legend={legendItem} legend={legendItem}
isAnomalyView={isAnomalyView}
onChange={onChange} onChange={onChange}
/> />
)} )}

View file

@ -11,9 +11,10 @@ interface LegendItemProps {
legend: LegendItemType; legend: LegendItemType;
onChange?: (item: LegendItemType, metaKey: boolean) => void; onChange?: (item: LegendItemType, metaKey: boolean) => void;
isHeatmap?: boolean; isHeatmap?: boolean;
isAnomalyView?: boolean;
} }
const LegendItem: FC<LegendItemProps> = ({ legend, onChange, isHeatmap }) => { const LegendItem: FC<LegendItemProps> = ({ legend, onChange, isHeatmap, isAnomalyView }) => {
const copyToClipboard = useCopyToClipboard(); const copyToClipboard = useCopyToClipboard();
const freeFormFields = useMemo(() => { const freeFormFields = useMemo(() => {
@ -47,7 +48,7 @@ const LegendItem: FC<LegendItemProps> = ({ legend, onChange, isHeatmap }) => {
})} })}
onClick={createHandlerClick(legend)} onClick={createHandlerClick(legend)}
> >
{!isHeatmap && ( {!isAnomalyView && !isHeatmap && (
<div <div
className="vm-legend-item__marker" className="vm-legend-item__marker"
style={{ backgroundColor: legend.color }} style={{ backgroundColor: legend.color }}

View file

@ -9,8 +9,8 @@ type Props = {
const titles: Partial<Record<ForecastType, string>> = { const titles: Partial<Record<ForecastType, string>> = {
[ForecastType.yhat]: "yhat", [ForecastType.yhat]: "yhat",
[ForecastType.yhatLower]: "yhat_lower/_upper", [ForecastType.yhatLower]: "yhat_upper - yhat_lower",
[ForecastType.yhatUpper]: "yhat_lower/_upper", [ForecastType.yhatUpper]: "yhat_upper - yhat_lower",
[ForecastType.anomaly]: "anomalies", [ForecastType.anomaly]: "anomalies",
[ForecastType.training]: "training data", [ForecastType.training]: "training data",
[ForecastType.actual]: "y" [ForecastType.actual]: "y"
@ -42,9 +42,6 @@ const LegendAnomaly: FC<Props> = ({ series }) => {
})); }));
}, [series]); }, [series]);
const container = document.getElementById("legendAnomaly");
if (!container) return null;
return <> return <>
<div className="vm-legend-anomaly"> <div className="vm-legend-anomaly">
{/* TODO: remove .filter() after the correct training data has been added */} {/* TODO: remove .filter() after the correct training data has been added */}

View file

@ -40,7 +40,7 @@ export interface LineChartProps {
setPeriod: ({ from, to }: { from: Date, to: Date }) => void; setPeriod: ({ from, to }: { from: Date, to: Date }) => void;
layoutSize: ElementSize; layoutSize: ElementSize;
height?: number; height?: number;
anomalyView?: boolean; isAnomalyView?: boolean;
spanGaps?: boolean; spanGaps?: boolean;
} }
@ -54,7 +54,7 @@ const LineChart: FC<LineChartProps> = ({
setPeriod, setPeriod,
layoutSize, layoutSize,
height, height,
anomalyView, isAnomalyView,
spanGaps = false spanGaps = false
}) => { }) => {
const { isDarkTheme } = useAppState(); const { isDarkTheme } = useAppState();
@ -73,7 +73,7 @@ const LineChart: FC<LineChartProps> = ({
seriesFocus, seriesFocus,
setCursor, setCursor,
resetTooltips resetTooltips
} = useLineTooltip({ u: uPlotInst, metrics, series, unit, anomalyView }); } = useLineTooltip({ u: uPlotInst, metrics, series, unit, isAnomalyView });
const options: uPlotOptions = { const options: uPlotOptions = {
...getDefaultOptions({ width: layoutSize.width, height }), ...getDefaultOptions({ width: layoutSize.width, height }),

View file

@ -12,8 +12,11 @@ import useBoolean from "../../../hooks/useBoolean";
import useEventListener from "../../../hooks/useEventListener"; import useEventListener from "../../../hooks/useEventListener";
import Tooltip from "../../Main/Tooltip/Tooltip"; import Tooltip from "../../Main/Tooltip/Tooltip";
import { AUTOCOMPLETE_QUICK_KEY } from "../../Main/ShortcutKeys/constants/keyList"; import { AUTOCOMPLETE_QUICK_KEY } from "../../Main/ShortcutKeys/constants/keyList";
import { QueryConfiguratorProps } from "../../../pages/CustomPanel/QueryConfigurator/QueryConfigurator";
const AdditionalSettingsControls: FC<{isMobile?: boolean}> = ({ isMobile }) => { type Props = Pick<QueryConfiguratorProps, "hideButtons">;
const AdditionalSettingsControls: FC<Props & {isMobile?: boolean}> = ({ isMobile, hideButtons }) => {
const { autocomplete } = useQueryState(); const { autocomplete } = useQueryState();
const queryDispatch = useQueryDispatch(); const queryDispatch = useQueryDispatch();
@ -54,6 +57,7 @@ const AdditionalSettingsControls: FC<{isMobile?: boolean}> = ({ isMobile }) => {
"vm-additional-settings_mobile": isMobile "vm-additional-settings_mobile": isMobile
})} })}
> >
{!hideButtons?.autocomplete && (
<Tooltip title={<>Quick tip: {AUTOCOMPLETE_QUICK_KEY}</>}> <Tooltip title={<>Quick tip: {AUTOCOMPLETE_QUICK_KEY}</>}>
<Switch <Switch
label={"Autocomplete"} label={"Autocomplete"}
@ -62,23 +66,26 @@ const AdditionalSettingsControls: FC<{isMobile?: boolean}> = ({ isMobile }) => {
fullWidth={isMobile} fullWidth={isMobile}
/> />
</Tooltip> </Tooltip>
)}
<Switch <Switch
label={"Disable cache"} label={"Disable cache"}
value={nocache} value={nocache}
onChange={onChangeCache} onChange={onChangeCache}
fullWidth={isMobile} fullWidth={isMobile}
/> />
{!hideButtons?.traceQuery && (
<Switch <Switch
label={"Trace query"} label={"Trace query"}
value={isTracingEnabled} value={isTracingEnabled}
onChange={onChangeQueryTracing} onChange={onChangeQueryTracing}
fullWidth={isMobile} fullWidth={isMobile}
/> />
)}
</div> </div>
); );
}; };
const AdditionalSettings: FC = () => { const AdditionalSettings: FC<Props> = (props) => {
const { isMobile } = useDeviceDetect(); const { isMobile } = useDeviceDetect();
const targetRef = useRef<HTMLDivElement>(null); const targetRef = useRef<HTMLDivElement>(null);
@ -106,13 +113,16 @@ const AdditionalSettings: FC = () => {
onClose={handleCloseList} onClose={handleCloseList}
title={"Query settings"} title={"Query settings"}
> >
<AdditionalSettingsControls isMobile={isMobile}/> <AdditionalSettingsControls
isMobile={isMobile}
{...props}
/>
</Popper> </Popper>
</> </>
); );
} }
return <AdditionalSettingsControls/>; return <AdditionalSettingsControls {...props}/>;
}; };
export default AdditionalSettings; export default AdditionalSettings;

View file

@ -25,6 +25,7 @@ import useDeviceDetect from "../../../hooks/useDeviceDetect";
import useElementSize from "../../../hooks/useElementSize"; import useElementSize from "../../../hooks/useElementSize";
import { ChartTooltipProps } from "../../Chart/ChartTooltip/ChartTooltip"; import { ChartTooltipProps } from "../../Chart/ChartTooltip/ChartTooltip";
import LegendAnomaly from "../../Chart/Line/LegendAnomaly/LegendAnomaly"; import LegendAnomaly from "../../Chart/Line/LegendAnomaly/LegendAnomaly";
import { groupByMultipleKeys } from "../../../utils/array";
export interface GraphViewProps { export interface GraphViewProps {
data?: MetricResult[]; data?: MetricResult[];
@ -40,7 +41,7 @@ export interface GraphViewProps {
fullWidth?: boolean; fullWidth?: boolean;
height?: number; height?: number;
isHistogram?: boolean; isHistogram?: boolean;
anomalyView?: boolean; isAnomalyView?: boolean;
spanGaps?: boolean; spanGaps?: boolean;
} }
@ -58,7 +59,7 @@ const GraphView: FC<GraphViewProps> = ({
fullWidth = true, fullWidth = true,
height, height,
isHistogram, isHistogram,
anomalyView, isAnomalyView,
spanGaps spanGaps
}) => { }) => {
const { isMobile } = useDeviceDetect(); const { isMobile } = useDeviceDetect();
@ -74,8 +75,8 @@ const GraphView: FC<GraphViewProps> = ({
const [legendValue, setLegendValue] = useState<ChartTooltipProps | null>(null); const [legendValue, setLegendValue] = useState<ChartTooltipProps | null>(null);
const getSeriesItem = useMemo(() => { const getSeriesItem = useMemo(() => {
return getSeriesItemContext(data, hideSeries, alias, anomalyView); return getSeriesItemContext(data, hideSeries, alias, isAnomalyView);
}, [data, hideSeries, alias, anomalyView]); }, [data, hideSeries, alias, isAnomalyView]);
const setLimitsYaxis = (values: { [key: string]: number[] }) => { const setLimitsYaxis = (values: { [key: string]: number[] }) => {
const limits = getLimitsYAxis(values, !isHistogram); const limits = getLimitsYAxis(values, !isHistogram);
@ -83,7 +84,7 @@ const GraphView: FC<GraphViewProps> = ({
}; };
const onChangeLegend = (legend: LegendItemType, metaKey: boolean) => { const onChangeLegend = (legend: LegendItemType, metaKey: boolean) => {
setHideSeries(getHideSeries({ hideSeries, legend, metaKey, series })); setHideSeries(getHideSeries({ hideSeries, legend, metaKey, series, isAnomalyView }));
}; };
const prepareHistogramData = (data: (number | null)[][]) => { const prepareHistogramData = (data: (number | null)[][]) => {
@ -108,6 +109,20 @@ const GraphView: FC<GraphViewProps> = ({
return [null, [xs, ys, counts]]; return [null, [xs, ys, counts]];
}; };
const prepareAnomalyLegend = (legend: LegendItemType[]): LegendItemType[] => {
if (!isAnomalyView) return legend;
// For vmanomaly: Only select the first series per group (due to API specs) and clear __name__ in freeFormFields.
const grouped = groupByMultipleKeys(legend, ["group", "label"]);
return grouped.map((group) => {
const firstEl = group.values[0];
return {
...firstEl,
freeFormFields: { ...firstEl.freeFormFields, __name__: "" }
};
});
};
useEffect(() => { useEffect(() => {
const tempTimes: number[] = []; const tempTimes: number[] = [];
const tempValues: { [key: string]: number[] } = {}; const tempValues: { [key: string]: number[] } = {};
@ -153,14 +168,18 @@ const GraphView: FC<GraphViewProps> = ({
const range = getMinMaxBuffer(getMinFromArray(resultAsNumber), getMaxFromArray(resultAsNumber)); const range = getMinMaxBuffer(getMinFromArray(resultAsNumber), getMaxFromArray(resultAsNumber));
const rangeStep = Math.abs(range[1] - range[0]); const rangeStep = Math.abs(range[1] - range[0]);
return (avg > rangeStep * 1e10) && !anomalyView ? results.map(() => avg) : results; return (avg > rangeStep * 1e10) && !isAnomalyView ? results.map(() => avg) : results;
}); });
timeDataSeries.unshift(timeSeries); timeDataSeries.unshift(timeSeries);
setLimitsYaxis(tempValues); setLimitsYaxis(tempValues);
const result = isHistogram ? prepareHistogramData(timeDataSeries) : timeDataSeries; const result = isHistogram ? prepareHistogramData(timeDataSeries) : timeDataSeries;
setDataChart(result as uPlotData); setDataChart(result as uPlotData);
setSeries(tempSeries); setSeries(tempSeries);
setLegend(tempLegend); const legend = prepareAnomalyLegend(tempLegend);
setLegend(legend);
if (isAnomalyView) {
setHideSeries(legend.map(s => s.label || "").slice(1));
}
}, [data, timezone, isHistogram]); }, [data, timezone, isHistogram]);
useEffect(() => { useEffect(() => {
@ -172,7 +191,7 @@ const GraphView: FC<GraphViewProps> = ({
tempLegend.push(getLegendItem(seriesItem, d.group)); tempLegend.push(getLegendItem(seriesItem, d.group));
}); });
setSeries(tempSeries); setSeries(tempSeries);
setLegend(tempLegend); setLegend(prepareAnomalyLegend(tempLegend));
}, [hideSeries]); }, [hideSeries]);
const [containerRef, containerSize] = useElementSize(); const [containerRef, containerSize] = useElementSize();
@ -197,7 +216,7 @@ const GraphView: FC<GraphViewProps> = ({
setPeriod={setPeriod} setPeriod={setPeriod}
layoutSize={containerSize} layoutSize={containerSize}
height={height} height={height}
anomalyView={anomalyView} isAnomalyView={isAnomalyView}
spanGaps={spanGaps} spanGaps={spanGaps}
/> />
)} )}
@ -213,10 +232,12 @@ const GraphView: FC<GraphViewProps> = ({
onChangeLegend={setLegendValue} onChangeLegend={setLegendValue}
/> />
)} )}
{!isHistogram && !anomalyView && showLegend && ( {isAnomalyView && showLegend && (<LegendAnomaly series={series as SeriesItem[]}/>)}
{!isHistogram && showLegend && (
<Legend <Legend
labels={legend} labels={legend}
query={query} query={query}
isAnomalyView={isAnomalyView}
onChange={onChangeLegend} onChange={onChangeLegend}
/> />
)} )}
@ -228,11 +249,6 @@ const GraphView: FC<GraphViewProps> = ({
legendValue={legendValue} legendValue={legendValue}
/> />
)} )}
{anomalyView && showLegend && (
<LegendAnomaly
series={series as SeriesItem[]}
/>
)}
</div> </div>
); );
}; };

View file

@ -62,10 +62,6 @@ export const anomalyNavigation: NavigationItem[] = [
{ {
label: routerOptions[router.anomaly].title, label: routerOptions[router.anomaly].title,
value: router.home, value: router.home,
},
{
label: routerOptions[router.home].title,
value: router.query,
} }
]; ];

View file

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

View file

@ -12,7 +12,8 @@ import { useTimeState } from "../state/time/TimeStateContext";
import { useCustomPanelState } from "../state/customPanel/CustomPanelStateContext"; import { useCustomPanelState } from "../state/customPanel/CustomPanelStateContext";
import { isHistogramData } from "../utils/metric"; import { isHistogramData } from "../utils/metric";
import { useGraphState } from "../state/graph/GraphStateContext"; import { useGraphState } from "../state/graph/GraphStateContext";
import { getStepFromDuration } from "../utils/time"; import { getSecondsFromDuration, getStepFromDuration } from "../utils/time";
import { AppType } from "../types/appType";
interface FetchQueryParams { interface FetchQueryParams {
predefinedQuery?: string[] predefinedQuery?: string[]
@ -47,13 +48,15 @@ interface FetchDataParams {
hideQuery?: number[] hideQuery?: number[]
} }
const isAnomalyUI = AppType.anomaly === process.env.REACT_APP_TYPE;
export const useFetchQuery = ({ export const useFetchQuery = ({
predefinedQuery, predefinedQuery,
visible, visible,
display, display,
customStep, customStep,
hideQuery, hideQuery,
showAllSeries showAllSeries,
}: FetchQueryParams): FetchQueryReturn => { }: FetchQueryParams): FetchQueryReturn => {
const { query } = useQueryState(); const { query } = useQueryState();
const { period } = useTimeState(); const { period } = useTimeState();
@ -124,7 +127,7 @@ export const useFetchQuery = ({
tempTraces.push(trace); tempTraces.push(trace);
} }
isHistogramResult = isDisplayChart && isHistogramData(resp.data.result); isHistogramResult = !isAnomalyUI && isDisplayChart && isHistogramData(resp.data.result);
seriesLimit = isHistogramResult ? Infinity : defaultLimit; seriesLimit = isHistogramResult ? Infinity : defaultLimit;
const freeTempSize = seriesLimit - tempData.length; const freeTempSize = seriesLimit - tempData.length;
resp.data.result.slice(0, freeTempSize).forEach((d: MetricBase) => { resp.data.result.slice(0, freeTempSize).forEach((d: MetricBase) => {
@ -172,7 +175,7 @@ export const useFetchQuery = ({
setQueryErrors(expr.map(() => ErrorTypes.validQuery)); setQueryErrors(expr.map(() => ErrorTypes.validQuery));
} else if (isValidHttpUrl(serverUrl)) { } else if (isValidHttpUrl(serverUrl)) {
const updatedPeriod = { ...period }; const updatedPeriod = { ...period };
updatedPeriod.step = customStep; updatedPeriod.step = isAnomalyUI ? `${getSecondsFromDuration(customStep)*1000}ms` : customStep;
return expr.map(q => displayChart return expr.map(q => displayChart
? getQueryRangeUrl(serverUrl, q, updatedPeriod, nocache, isTracingEnabled) ? getQueryRangeUrl(serverUrl, q, updatedPeriod, nocache, isTracingEnabled)
: getQueryUrl(serverUrl, q, updatedPeriod, nocache, isTracingEnabled)); : getQueryUrl(serverUrl, q, updatedPeriod, nocache, isTracingEnabled));

View file

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

View file

@ -30,8 +30,14 @@ export interface QueryConfiguratorProps {
setQueryErrors: StateUpdater<string[]>; setQueryErrors: StateUpdater<string[]>;
setHideError: StateUpdater<boolean>; setHideError: StateUpdater<boolean>;
stats: QueryStats[]; stats: QueryStats[];
onHideQuery: (queries: number[]) => void onHideQuery?: (queries: number[]) => void
onRunQuery: () => void onRunQuery: () => void;
hideButtons?: {
addQuery?: boolean;
prettify?: boolean;
autocomplete?: boolean;
traceQuery?: boolean;
}
} }
const QueryConfigurator: FC<QueryConfiguratorProps> = ({ const QueryConfigurator: FC<QueryConfiguratorProps> = ({
@ -40,7 +46,8 @@ const QueryConfigurator: FC<QueryConfiguratorProps> = ({
setHideError, setHideError,
stats, stats,
onHideQuery, onHideQuery,
onRunQuery onRunQuery,
hideButtons
}) => { }) => {
const { isMobile } = useDeviceDetect(); const { isMobile } = useDeviceDetect();
@ -159,7 +166,7 @@ const QueryConfigurator: FC<QueryConfiguratorProps> = ({
}, [stateQuery]); }, [stateQuery]);
useEffect(() => { useEffect(() => {
onHideQuery(hideQuery); onHideQuery && onHideQuery(hideQuery);
}, [hideQuery]); }, [hideQuery]);
useEffect(() => { useEffect(() => {
@ -188,16 +195,17 @@ const QueryConfigurator: FC<QueryConfiguratorProps> = ({
> >
<QueryEditor <QueryEditor
value={stateQuery[i]} value={stateQuery[i]}
autocomplete={autocomplete || autocompleteQuick} autocomplete={!hideButtons?.autocomplete && (autocomplete || autocompleteQuick)}
error={queryErrors[i]} error={queryErrors[i]}
stats={stats[i]} stats={stats[i]}
onArrowUp={createHandlerArrow(-1, i)} onArrowUp={createHandlerArrow(-1, i)}
onArrowDown={createHandlerArrow(1, i)} onArrowDown={createHandlerArrow(1, i)}
onEnter={handleRunQuery} onEnter={handleRunQuery}
onChange={createHandlerChangeQuery(i)} onChange={createHandlerChangeQuery(i)}
label={`Query ${i + 1}`} label={`Query ${stateQuery.length > 1 ? i + 1 : ""}`}
disabled={hideQuery.includes(i)} disabled={hideQuery.includes(i)}
/> />
{onHideQuery && (
<Tooltip title={hideQuery.includes(i) ? "Enable query" : "Disable query"}> <Tooltip title={hideQuery.includes(i) ? "Enable query" : "Disable query"}>
<div className="vm-query-configurator-list-row__button"> <div className="vm-query-configurator-list-row__button">
<Button <Button
@ -209,7 +217,9 @@ const QueryConfigurator: FC<QueryConfiguratorProps> = ({
/> />
</div> </div>
</Tooltip> </Tooltip>
)}
{!hideButtons?.prettify && (
<Tooltip title={"Prettify query"}> <Tooltip title={"Prettify query"}>
<div className="vm-query-configurator-list-row__button"> <div className="vm-query-configurator-list-row__button">
<Button <Button
@ -221,7 +231,7 @@ const QueryConfigurator: FC<QueryConfiguratorProps> = ({
ariaLabel="prettify the query" ariaLabel="prettify the query"
/> />
</div> </div>
</Tooltip> </Tooltip>)}
{stateQuery.length > 1 && ( {stateQuery.length > 1 && (
<Tooltip title="Remove Query"> <Tooltip title="Remove Query">
@ -240,10 +250,10 @@ const QueryConfigurator: FC<QueryConfiguratorProps> = ({
))} ))}
</div> </div>
<div className="vm-query-configurator-settings"> <div className="vm-query-configurator-settings">
<AdditionalSettings/> <AdditionalSettings hideButtons={hideButtons}/>
<div className="vm-query-configurator-settings__buttons"> <div className="vm-query-configurator-settings__buttons">
<QueryHistory handleSelectQuery={handleSelectHistory}/> <QueryHistory handleSelectQuery={handleSelectHistory}/>
{stateQuery.length < MAX_QUERY_FIELDS && ( {!hideButtons?.addQuery && stateQuery.length < MAX_QUERY_FIELDS && (
<Button <Button
variant="outlined" variant="outlined"
onClick={handleAddQuery} onClick={handleAddQuery}

View file

@ -33,7 +33,7 @@
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-start; justify-content: space-between;
font-size: $font-size-small; font-size: $font-size-small;
margin: -$padding-medium 0-$padding-medium $padding-medium; margin: -$padding-medium 0-$padding-medium $padding-medium;
padding: 0 $padding-medium; padding: 0 $padding-medium;

View file

@ -1,60 +1,63 @@
import React, { FC, useMemo, useRef } from "preact/compat"; import React, { FC, useMemo, useRef, useState } from "preact/compat";
import classNames from "classnames"; import classNames from "classnames";
import useDeviceDetect from "../../hooks/useDeviceDetect"; import useDeviceDetect from "../../hooks/useDeviceDetect";
import useEventListener from "../../hooks/useEventListener"; import { ForecastType } from "../../types";
import { useSetQueryParams } from "../CustomPanel/hooks/useSetQueryParams";
import QueryConfigurator from "../CustomPanel/QueryConfigurator/QueryConfigurator";
import "../CustomPanel/style.scss"; import "../CustomPanel/style.scss";
import ExploreAnomalyHeader from "./ExploreAnomalyHeader/ExploreAnomalyHeader"; import { useQueryState } from "../../state/query/QueryStateContext";
import Alert from "../../components/Main/Alert/Alert";
import { extractFields, isForecast } from "../../utils/uplot";
import { useFetchQuery } from "../../hooks/useFetchQuery"; 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 { useGraphState } from "../../state/graph/GraphStateContext";
import Spinner from "../../components/Main/Spinner/Spinner";
import Alert from "../../components/Main/Alert/Alert";
import WarningLimitSeries from "../CustomPanel/WarningLimitSeries/WarningLimitSeries";
import GraphTab from "../CustomPanel/CustomPanelTabs/GraphTab";
import { extractFields, isForecast } from "../../utils/uplot";
import { MetricResult } from "../../api/types"; import { MetricResult } from "../../api/types";
import { promValueToNumber } from "../../utils/metric"; 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 anomalySeries = [
ForecastType.yhat,
ForecastType.yhatUpper,
ForecastType.yhatLower,
ForecastType.anomalyScore
];
// Hardcoded to 1.0 for now; consider adding a UI slider for threshold adjustment in the future. // Hardcoded to 1.0 for now; consider adding a UI slider for threshold adjustment in the future.
const ANOMALY_SCORE_THRESHOLD = 1; const ANOMALY_SCORE_THRESHOLD = 1;
const ExploreAnomaly: FC = () => { const ExploreAnomaly: FC = () => {
useSetQueryParams();
const { isMobile } = useDeviceDetect(); const { isMobile } = useDeviceDetect();
const queryDispatch = useQueryDispatch(); const { query } = useQueryState();
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 { customStep } = useGraphState();
const { graphData, error, queryErrors, isHistogram, isLoading: isGraphDataLoading } = useFetchQuery({ const controlsRef = useRef<HTMLDivElement>(null);
const [hideQuery] = useState<number[]>([]);
const [hideError, setHideError] = useState(!query[0]);
const [showAllSeries, setShowAllSeries] = useState(false);
const {
isLoading,
graphData,
error,
queryErrors,
setQueryErrors,
queryStats,
warning,
} = useFetchQuery({
visible: true, visible: true,
customStep, customStep,
showAllSeries: true, hideQuery,
showAllSeries
}); });
const data = useMemo(() => { const data = useMemo(() => {
if (!graphData) return; if (!graphData) return [];
const detectedData = graphData.map(d => ({ ...isForecast(d.metric), ...d })); const detectedData = graphData.map(d => ({ ...isForecast(d.metric), ...d }));
const realData = detectedData.filter(d => d.value === null); const realData = detectedData.filter(d => d.value === ForecastType.actual);
const anomalyScoreData = detectedData.filter(d => d.isAnomalyScore); const anomalyScoreData = detectedData.filter(d => d.value === ForecastType.anomaly);
const anomalyData: MetricResult[] = realData.map((d) => { const anomalyData: MetricResult[] = realData.map((d) => {
const id = extractFields(d.metric); const id = extractFields(d.metric);
const anomalyScoreDataByLabels = anomalyScoreData.find(du => extractFields(du.metric) === id); const anomalyScoreDataByLabels = anomalyScoreData.find(du => extractFields(du.metric) === id);
return { return {
group: queries.length + 1, group: 1,
metric: { ...d.metric, __name__: ForecastType.anomaly }, metric: { ...d.metric, __name__: ForecastType.anomaly },
values: d.values.filter(([t]) => { values: d.values.filter(([t]) => {
if (!anomalyScoreDataByLabels) return false; if (!anomalyScoreDataByLabels) return false;
@ -63,23 +66,14 @@ const ExploreAnomaly: FC = () => {
}) })
}; };
}); });
return graphData.filter(d => d.group !== anomalyScoreData[0]?.group).concat(anomalyData); const filterData = detectedData.filter(d => (d.value !== ForecastType.anomaly) && d.value) as MetricResult[];
return filterData.concat(anomalyData);
}, [graphData]); }, [graphData]);
const onChangeFilter = (expr: Record<string, string>) => { const handleRunQuery = () => {
const { __name__ = "", ...labelValue } = expr; setHideError(false);
let prefix = __name__.replace(/y|_y/, "");
if (prefix) prefix += "_";
const metrics = [__name__, ...anomalySeries];
const filters = Object.entries(labelValue).map(([key, value]) => `${key}="${value}"`).join(",");
const queries = metrics.map((m, i) => `${i ? prefix : ""}${m}{${filters}}`);
queryDispatch({ type: "SET_QUERY", payload: queries });
timeDispatch({ type: "RUN_QUERY" });
}; };
const handleChangePopstate = () => window.location.reload();
useEventListener("popstate", handleChangePopstate);
return ( return (
<div <div
className={classNames({ className={classNames({
@ -87,14 +81,23 @@ const ExploreAnomaly: FC = () => {
"vm-custom-panel_mobile": isMobile, "vm-custom-panel_mobile": isMobile,
})} })}
> >
<ExploreAnomalyHeader <QueryConfigurator
queries={queries} queryErrors={!hideError ? queryErrors : []}
series={series} setQueryErrors={setQueryErrors}
onChange={onChangeFilter} setHideError={setHideError}
stats={queryStats}
onRunQuery={handleRunQuery}
hideButtons={{ addQuery: true, prettify: true, autocomplete: true, traceQuery: true }}
/> />
{(isGraphDataLoading || isAnomalySeriesLoading) && <Spinner />} {isLoading && <Spinner/>}
{(error || errorSeries) && <Alert variant="error">{error || errorSeries}</Alert>} {(!hideError && error) && <Alert variant="error">{error}</Alert>}
{!error && !errorSeries && queryErrors?.[0] && <Alert variant="error">{queryErrors[0]}</Alert>} {warning && (
<WarningLimitSeries
warning={warning}
query={query}
onChange={setShowAllSeries}
/>
)}
<div <div
className={classNames({ className={classNames({
"vm-custom-panel-body": true, "vm-custom-panel-body": true,
@ -112,9 +115,9 @@ const ExploreAnomaly: FC = () => {
{data && ( {data && (
<GraphTab <GraphTab
graphData={data} graphData={data}
isHistogram={isHistogram} isHistogram={false}
controlsRef={controlsRef} controlsRef={controlsRef}
anomalyView={true} isAnomalyView={true}
/> />
)} )}
</div> </div>

View file

@ -1,112 +0,0 @@
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

@ -1,37 +0,0 @@
@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

@ -1,75 +0,0 @@
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";
import { useTimeState } from "../../../state/time/TimeStateContext";
import dayjs from "dayjs";
// TODO: Change the method of retrieving aliases from the configuration after the API has been added
const seriesQuery = `{
for!="",
__name__!~".*yhat.*|.*trend.*|.*anomaly_score.*|.*daily.*|.*additive_terms.*|.*multiplicative_terms.*|.*weekly.*"
}`;
export const useFetchAnomalySeries = () => {
const { serverUrl } = useAppState();
const { period: { start, end } } = useTimeState();
const [series, setSeries] = useState<Record<string, MetricBase["metric"][]>>();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<ErrorTypes | string>();
// TODO add cached metrics by date
const fetchUrl = useMemo(() => {
const startDay = dayjs(start * 1000).startOf("day").valueOf() / 1000;
const endDay = dayjs(end * 1000).endOf("day").valueOf() / 1000;
const params = new URLSearchParams({
"match[]": seriesQuery,
start: `${startDay}`,
end: `${endDay}`
});
return `${serverUrl}/api/v1/series?${params}`;
}, [serverUrl, start, end]);
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

@ -1,31 +0,0 @@
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

@ -29,7 +29,8 @@ export interface HideSeriesArgs {
hideSeries: string[], hideSeries: string[],
legend: LegendItemType, legend: LegendItemType,
metaKey: boolean, metaKey: boolean,
series: Series[] series: Series[],
isAnomalyView?: boolean,
} }
export type MinMax = { min: number, max: number } export type MinMax = { min: number, max: number }

View file

@ -8,9 +8,16 @@ export const getDefaultServer = (tenantId?: string): string => {
const { serverURL } = getAppModeParams(); const { serverURL } = getAppModeParams();
const storageURL = getFromStorage("SERVER_URL") as string; const storageURL = getFromStorage("SERVER_URL") as string;
const logsURL = window.location.href.replace(/\/(select\/)?(vmui)\/.*/, ""); const logsURL = window.location.href.replace(/\/(select\/)?(vmui)\/.*/, "");
const anomalyURL = window.location.href.replace(/(?:graph|vmui)\/.*/, "");
const defaultURL = window.location.href.replace(/\/(?:prometheus\/)?(?:graph|vmui)\/.*/, "/prometheus"); const defaultURL = window.location.href.replace(/\/(?:prometheus\/)?(?:graph|vmui)\/.*/, "/prometheus");
const url = serverURL || storageURL || defaultURL; const url = serverURL || storageURL || defaultURL;
if (REACT_APP_TYPE === AppType.logs) return logsURL;
if (tenantId) return replaceTenantId(url, tenantId); switch (REACT_APP_TYPE) {
return url; case AppType.logs:
return logsURL;
case AppType.anomaly:
return serverURL || storageURL || anomalyURL;
default:
return tenantId ? replaceTenantId(url, tenantId) : url;
}
}; };

View file

@ -66,7 +66,7 @@ export const isSupportedDuration = (str: string): Partial<Record<UnitTypeShort,
export const getSecondsFromDuration = (dur: string) => { export const getSecondsFromDuration = (dur: string) => {
const shortSupportedDur = supportedDurations.map(d => d.short).join("|"); const shortSupportedDur = supportedDurations.map(d => d.short).join("|");
const regexp = new RegExp(`\\d+[${shortSupportedDur}]+`, "g"); const regexp = new RegExp(`\\d+(\\.\\d+)?[${shortSupportedDur}]+`, "g");
const durItems = dur.match(regexp) || []; const durItems = dur.match(regexp) || [];
const durObject = durItems.reduce((prev, curr) => { const durObject = durItems.reduce((prev, curr) => {

View file

@ -14,25 +14,26 @@ export const extractFields = (metric: MetricBase["metric"]): string => {
.map(([key, value]) => `${key}: ${value}`).join(","); .map(([key, value]) => `${key}: ${value}`).join(",");
}; };
export const isForecast = (metric: MetricBase["metric"]) => { type ForecastMetricInfo = {
value: ForecastType | null;
group: string;
}
export const isForecast = (metric: MetricBase["metric"]): ForecastMetricInfo => {
const metricName = metric?.__name__ || ""; const metricName = metric?.__name__ || "";
const forecastRegex = new RegExp(`(${Object.values(ForecastType).join("|")})$`); const forecastRegex = new RegExp(`(${Object.values(ForecastType).join("|")})$`);
const match = metricName.match(forecastRegex); const match = metricName.match(forecastRegex);
const value = match && match[0] as ForecastType; const value = match && match[0] as ForecastType;
const isY = /(?:^|[^a-zA-Z0-9_])y(?:$|[^a-zA-Z0-9_])/.test(metricName);
return { return {
value, value: isY ? ForecastType.actual : value,
isUpper: value === ForecastType.yhatUpper,
isLower: value === ForecastType.yhatLower,
isYhat: value === ForecastType.yhat,
isAnomaly: value === ForecastType.anomaly,
isAnomalyScore: value === ForecastType.anomalyScore,
group: extractFields(metric) group: extractFields(metric)
}; };
}; };
export const getSeriesItemContext = (data: MetricResult[], hideSeries: string[], alias: string[], isAnomaly?: boolean) => { export const getSeriesItemContext = (data: MetricResult[], hideSeries: string[], alias: string[], isAnomalyUI?: boolean) => {
const colorState: {[key: string]: string} = {}; const colorState: {[key: string]: string} = {};
const maxColors = isAnomaly ? 0 : Math.min(data.length, baseContrastColors.length); const maxColors = isAnomalyUI ? 0 : Math.min(data.length, baseContrastColors.length);
for (let i = 0; i < maxColors; i++) { for (let i = 0; i < maxColors; i++) {
const label = getNameForMetric(data[i], alias[data[i].group - 1]); const label = getNameForMetric(data[i], alias[data[i].group - 1]);
@ -40,9 +41,27 @@ export const getSeriesItemContext = (data: MetricResult[], hideSeries: string[],
} }
return (d: MetricResult, i: number): SeriesItem => { return (d: MetricResult, i: number): SeriesItem => {
const forecast = isForecast(data[i].metric); const metricInfo = isAnomalyUI ? isForecast(data[i].metric) : null;
const label = isAnomaly ? forecast.group : getNameForMetric(d, alias[d.group - 1]); const label = isAnomalyUI ? metricInfo?.group || "" : getNameForMetric(d, alias[d.group - 1]);
return {
label,
dash: getDashSeries(metricInfo),
width: getWidthSeries(metricInfo),
stroke: getStrokeSeries({ metricInfo, label, isAnomalyUI, colorState }),
points: getPointsSeries(metricInfo),
spanGaps: false,
forecast: metricInfo?.value,
forecastGroup: metricInfo?.group,
freeFormFields: d.metric,
show: !includesHideSeries(label, hideSeries),
scale: "1",
...getSeriesStatistics(d),
};
};
};
const getSeriesStatistics = (d: MetricResult) => {
const values = d.values.map(v => promValueToNumber(v[1])); const values = d.values.map(v => promValueToNumber(v[1]));
const { min, max, median, last } = { const { min, max, median, last } = {
min: getMinFromArray(values), min: getMinFromArray(values),
@ -50,64 +69,14 @@ export const getSeriesItemContext = (data: MetricResult[], hideSeries: string[],
median: getMedianFromArray(values), median: getMedianFromArray(values),
last: getLastFromArray(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 { return {
label, median,
dash,
width,
stroke,
points,
spanGaps: false,
forecast: forecast.value,
forecastGroup: forecast.group,
freeFormFields: d.metric,
show: !includesHideSeries(label, hideSeries),
scale: "1",
statsFormatted: { statsFormatted: {
min: formatPrettyNumber(min, min, max), min: formatPrettyNumber(min, min, max),
max: formatPrettyNumber(max, min, max), max: formatPrettyNumber(max, min, max),
median: formatPrettyNumber(median, min, max), median: formatPrettyNumber(median, min, max),
last: formatPrettyNumber(last, min, max), last: formatPrettyNumber(last, min, max),
}, },
median: median,
};
}; };
}; };
@ -121,10 +90,16 @@ export const getLegendItem = (s: SeriesItem, group: number): LegendItemType => (
median: s.median, median: s.median,
}); });
export const getHideSeries = ({ hideSeries, legend, metaKey, series }: HideSeriesArgs): string[] => { export const getHideSeries = ({ hideSeries, legend, metaKey, series, isAnomalyView }: HideSeriesArgs): string[] => {
const { label } = legend; const { label } = legend;
const include = includesHideSeries(label, hideSeries); const include = includesHideSeries(label, hideSeries);
const labels = series.map(s => s.label || ""); const labels = series.map(s => s.label || "");
// if anomalyView is true, always return all series except the one specified by `label`
if (isAnomalyView) {
return labels.filter(l => l !== label);
}
if (metaKey) { if (metaKey) {
return include ? hideSeries.filter(l => l !== label) : [...hideSeries, label]; return include ? hideSeries.filter(l => l !== label) : [...hideSeries, label];
} else if (hideSeries.length) { } else if (hideSeries.length) {
@ -172,3 +147,71 @@ export const addSeries = (u: uPlot, series: uPlotSeries[], spanGaps = false) =>
u.addSeries(s); u.addSeries(s);
}); });
}; };
// Helpers
const getDashSeries = (metricInfo: ForecastMetricInfo | null): number[] => {
const isLower = metricInfo?.value === ForecastType.yhatLower;
const isUpper = metricInfo?.value === ForecastType.yhatUpper;
const isYhat = metricInfo?.value === ForecastType.yhat;
if (isLower || isUpper) {
return [10, 5];
} else if (isYhat) {
return [10, 2];
}
return [];
};
const getWidthSeries = (metricInfo: ForecastMetricInfo | null): number => {
const isLower = metricInfo?.value === ForecastType.yhatLower;
const isUpper = metricInfo?.value === ForecastType.yhatUpper;
const isYhat = metricInfo?.value === ForecastType.yhat;
const isAnomalyMetric = metricInfo?.value === ForecastType.anomaly;
if (isUpper || isLower) {
return 0.7;
} else if (isYhat) {
return 1;
} else if (isAnomalyMetric) {
return 0;
}
return 1.4;
};
const getPointsSeries = (metricInfo: ForecastMetricInfo | null): uPlotSeries.Points => {
const isAnomalyMetric = metricInfo?.value === ForecastType.anomaly;
if (isAnomalyMetric) {
return { size: 8, width: 4, space: 0 };
}
return { size: 4.2, width: 1.4 };
};
type GetStrokeSeriesArgs = {
metricInfo: ForecastMetricInfo | null,
label: string,
colorState: {[p: string]: string},
isAnomalyUI?: boolean
}
const getStrokeSeries = ({ metricInfo, label, isAnomalyUI, colorState }: GetStrokeSeriesArgs): uPlotSeries.Stroke => {
const stroke: uPlotSeries.Stroke = colorState[label] || getColorFromString(label);
const isAnomalyMetric = metricInfo?.value === ForecastType.anomaly;
if (isAnomalyUI && isAnomalyMetric) {
return anomalyColors[ForecastType.anomaly];
} else if (isAnomalyUI && !isAnomalyMetric && !metricInfo?.value) {
// TODO add stroke for training data
// const hzGrad: [number, string][] = [
// [time, anomalyColors[ForecastType.actual]],
// [time, anomalyColors[ForecastType.training]],
// [time, anomalyColors[ForecastType.actual]],
// ];
// stroke = scaleGradient("x", 0, hzGrad, true);
return anomalyColors[ForecastType.actual];
} else if (metricInfo?.value) {
return metricInfo?.value ? anomalyColors[metricInfo?.value] : stroke;
}
return colorState[label] || getColorFromString(label);
};