mirror of
https://github.com/VictoriaMetrics/VictoriaMetrics.git
synced 2024-11-21 14:44:00 +00:00
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:
parent
22497c2c98
commit
f06f55edb6
22 changed files with 308 additions and 474 deletions
|
@ -1,3 +1,3 @@
|
|||
## 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)
|
||||
|
|
|
@ -7,10 +7,11 @@ import "./style.scss";
|
|||
interface LegendProps {
|
||||
labels: LegendItemType[];
|
||||
query: string[];
|
||||
isAnomalyView?: boolean;
|
||||
onChange: (item: LegendItemType, metaKey: boolean) => void;
|
||||
}
|
||||
|
||||
const Legend: FC<LegendProps> = ({ labels, query, onChange }) => {
|
||||
const Legend: FC<LegendProps> = ({ labels, query, isAnomalyView, onChange }) => {
|
||||
const groups = useMemo(() => {
|
||||
return Array.from(new Set(labels.map(l => l.group)));
|
||||
}, [labels]);
|
||||
|
@ -39,6 +40,7 @@ const Legend: FC<LegendProps> = ({ labels, query, onChange }) => {
|
|||
<LegendItem
|
||||
key={legendItem.label}
|
||||
legend={legendItem}
|
||||
isAnomalyView={isAnomalyView}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -11,9 +11,10 @@ interface LegendItemProps {
|
|||
legend: LegendItemType;
|
||||
onChange?: (item: LegendItemType, metaKey: boolean) => void;
|
||||
isHeatmap?: boolean;
|
||||
isAnomalyView?: boolean;
|
||||
}
|
||||
|
||||
const LegendItem: FC<LegendItemProps> = ({ legend, onChange, isHeatmap }) => {
|
||||
const LegendItem: FC<LegendItemProps> = ({ legend, onChange, isHeatmap, isAnomalyView }) => {
|
||||
const copyToClipboard = useCopyToClipboard();
|
||||
|
||||
const freeFormFields = useMemo(() => {
|
||||
|
@ -47,7 +48,7 @@ const LegendItem: FC<LegendItemProps> = ({ legend, onChange, isHeatmap }) => {
|
|||
})}
|
||||
onClick={createHandlerClick(legend)}
|
||||
>
|
||||
{!isHeatmap && (
|
||||
{!isAnomalyView && !isHeatmap && (
|
||||
<div
|
||||
className="vm-legend-item__marker"
|
||||
style={{ backgroundColor: legend.color }}
|
||||
|
|
|
@ -9,8 +9,8 @@ type Props = {
|
|||
|
||||
const titles: Partial<Record<ForecastType, string>> = {
|
||||
[ForecastType.yhat]: "yhat",
|
||||
[ForecastType.yhatLower]: "yhat_lower/_upper",
|
||||
[ForecastType.yhatUpper]: "yhat_lower/_upper",
|
||||
[ForecastType.yhatLower]: "yhat_upper - yhat_lower",
|
||||
[ForecastType.yhatUpper]: "yhat_upper - yhat_lower",
|
||||
[ForecastType.anomaly]: "anomalies",
|
||||
[ForecastType.training]: "training data",
|
||||
[ForecastType.actual]: "y"
|
||||
|
@ -42,9 +42,6 @@ const LegendAnomaly: FC<Props> = ({ series }) => {
|
|||
}));
|
||||
}, [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 */}
|
||||
|
|
|
@ -40,7 +40,7 @@ export interface LineChartProps {
|
|||
setPeriod: ({ from, to }: { from: Date, to: Date }) => void;
|
||||
layoutSize: ElementSize;
|
||||
height?: number;
|
||||
anomalyView?: boolean;
|
||||
isAnomalyView?: boolean;
|
||||
spanGaps?: boolean;
|
||||
}
|
||||
|
||||
|
@ -54,7 +54,7 @@ const LineChart: FC<LineChartProps> = ({
|
|||
setPeriod,
|
||||
layoutSize,
|
||||
height,
|
||||
anomalyView,
|
||||
isAnomalyView,
|
||||
spanGaps = false
|
||||
}) => {
|
||||
const { isDarkTheme } = useAppState();
|
||||
|
@ -73,7 +73,7 @@ const LineChart: FC<LineChartProps> = ({
|
|||
seriesFocus,
|
||||
setCursor,
|
||||
resetTooltips
|
||||
} = useLineTooltip({ u: uPlotInst, metrics, series, unit, anomalyView });
|
||||
} = useLineTooltip({ u: uPlotInst, metrics, series, unit, isAnomalyView });
|
||||
|
||||
const options: uPlotOptions = {
|
||||
...getDefaultOptions({ width: layoutSize.width, height }),
|
||||
|
|
|
@ -12,8 +12,11 @@ import useBoolean from "../../../hooks/useBoolean";
|
|||
import useEventListener from "../../../hooks/useEventListener";
|
||||
import Tooltip from "../../Main/Tooltip/Tooltip";
|
||||
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 queryDispatch = useQueryDispatch();
|
||||
|
||||
|
@ -54,31 +57,35 @@ const AdditionalSettingsControls: FC<{isMobile?: boolean}> = ({ isMobile }) => {
|
|||
"vm-additional-settings_mobile": isMobile
|
||||
})}
|
||||
>
|
||||
<Tooltip title={<>Quick tip: {AUTOCOMPLETE_QUICK_KEY}</>}>
|
||||
<Switch
|
||||
label={"Autocomplete"}
|
||||
value={autocomplete}
|
||||
onChange={onChangeAutocomplete}
|
||||
fullWidth={isMobile}
|
||||
/>
|
||||
</Tooltip>
|
||||
{!hideButtons?.autocomplete && (
|
||||
<Tooltip title={<>Quick tip: {AUTOCOMPLETE_QUICK_KEY}</>}>
|
||||
<Switch
|
||||
label={"Autocomplete"}
|
||||
value={autocomplete}
|
||||
onChange={onChangeAutocomplete}
|
||||
fullWidth={isMobile}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Switch
|
||||
label={"Disable cache"}
|
||||
value={nocache}
|
||||
onChange={onChangeCache}
|
||||
fullWidth={isMobile}
|
||||
/>
|
||||
<Switch
|
||||
label={"Trace query"}
|
||||
value={isTracingEnabled}
|
||||
onChange={onChangeQueryTracing}
|
||||
fullWidth={isMobile}
|
||||
/>
|
||||
{!hideButtons?.traceQuery && (
|
||||
<Switch
|
||||
label={"Trace query"}
|
||||
value={isTracingEnabled}
|
||||
onChange={onChangeQueryTracing}
|
||||
fullWidth={isMobile}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AdditionalSettings: FC = () => {
|
||||
const AdditionalSettings: FC<Props> = (props) => {
|
||||
const { isMobile } = useDeviceDetect();
|
||||
const targetRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
@ -106,13 +113,16 @@ const AdditionalSettings: FC = () => {
|
|||
onClose={handleCloseList}
|
||||
title={"Query settings"}
|
||||
>
|
||||
<AdditionalSettingsControls isMobile={isMobile}/>
|
||||
<AdditionalSettingsControls
|
||||
isMobile={isMobile}
|
||||
{...props}
|
||||
/>
|
||||
</Popper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <AdditionalSettingsControls/>;
|
||||
return <AdditionalSettingsControls {...props}/>;
|
||||
};
|
||||
|
||||
export default AdditionalSettings;
|
||||
|
|
|
@ -25,6 +25,7 @@ import useDeviceDetect from "../../../hooks/useDeviceDetect";
|
|||
import useElementSize from "../../../hooks/useElementSize";
|
||||
import { ChartTooltipProps } from "../../Chart/ChartTooltip/ChartTooltip";
|
||||
import LegendAnomaly from "../../Chart/Line/LegendAnomaly/LegendAnomaly";
|
||||
import { groupByMultipleKeys } from "../../../utils/array";
|
||||
|
||||
export interface GraphViewProps {
|
||||
data?: MetricResult[];
|
||||
|
@ -40,7 +41,7 @@ export interface GraphViewProps {
|
|||
fullWidth?: boolean;
|
||||
height?: number;
|
||||
isHistogram?: boolean;
|
||||
anomalyView?: boolean;
|
||||
isAnomalyView?: boolean;
|
||||
spanGaps?: boolean;
|
||||
}
|
||||
|
||||
|
@ -58,7 +59,7 @@ const GraphView: FC<GraphViewProps> = ({
|
|||
fullWidth = true,
|
||||
height,
|
||||
isHistogram,
|
||||
anomalyView,
|
||||
isAnomalyView,
|
||||
spanGaps
|
||||
}) => {
|
||||
const { isMobile } = useDeviceDetect();
|
||||
|
@ -74,8 +75,8 @@ const GraphView: FC<GraphViewProps> = ({
|
|||
const [legendValue, setLegendValue] = useState<ChartTooltipProps | null>(null);
|
||||
|
||||
const getSeriesItem = useMemo(() => {
|
||||
return getSeriesItemContext(data, hideSeries, alias, anomalyView);
|
||||
}, [data, hideSeries, alias, anomalyView]);
|
||||
return getSeriesItemContext(data, hideSeries, alias, isAnomalyView);
|
||||
}, [data, hideSeries, alias, isAnomalyView]);
|
||||
|
||||
const setLimitsYaxis = (values: { [key: string]: number[] }) => {
|
||||
const limits = getLimitsYAxis(values, !isHistogram);
|
||||
|
@ -83,7 +84,7 @@ const GraphView: FC<GraphViewProps> = ({
|
|||
};
|
||||
|
||||
const onChangeLegend = (legend: LegendItemType, metaKey: boolean) => {
|
||||
setHideSeries(getHideSeries({ hideSeries, legend, metaKey, series }));
|
||||
setHideSeries(getHideSeries({ hideSeries, legend, metaKey, series, isAnomalyView }));
|
||||
};
|
||||
|
||||
const prepareHistogramData = (data: (number | null)[][]) => {
|
||||
|
@ -108,6 +109,20 @@ const GraphView: FC<GraphViewProps> = ({
|
|||
return [null, [xs, ys, counts]];
|
||||
};
|
||||
|
||||
const prepareAnomalyLegend = (legend: LegendItemType[]): LegendItemType[] => {
|
||||
if (!isAnomalyView) return legend;
|
||||
|
||||
// For vmanomaly: Only select the first series per group (due to API specs) and clear __name__ in freeFormFields.
|
||||
const grouped = groupByMultipleKeys(legend, ["group", "label"]);
|
||||
return grouped.map((group) => {
|
||||
const firstEl = group.values[0];
|
||||
return {
|
||||
...firstEl,
|
||||
freeFormFields: { ...firstEl.freeFormFields, __name__: "" }
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const tempTimes: number[] = [];
|
||||
const tempValues: { [key: string]: number[] } = {};
|
||||
|
@ -153,14 +168,18 @@ const GraphView: FC<GraphViewProps> = ({
|
|||
const range = getMinMaxBuffer(getMinFromArray(resultAsNumber), getMaxFromArray(resultAsNumber));
|
||||
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);
|
||||
setLimitsYaxis(tempValues);
|
||||
const result = isHistogram ? prepareHistogramData(timeDataSeries) : timeDataSeries;
|
||||
setDataChart(result as uPlotData);
|
||||
setSeries(tempSeries);
|
||||
setLegend(tempLegend);
|
||||
const legend = prepareAnomalyLegend(tempLegend);
|
||||
setLegend(legend);
|
||||
if (isAnomalyView) {
|
||||
setHideSeries(legend.map(s => s.label || "").slice(1));
|
||||
}
|
||||
}, [data, timezone, isHistogram]);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -172,7 +191,7 @@ const GraphView: FC<GraphViewProps> = ({
|
|||
tempLegend.push(getLegendItem(seriesItem, d.group));
|
||||
});
|
||||
setSeries(tempSeries);
|
||||
setLegend(tempLegend);
|
||||
setLegend(prepareAnomalyLegend(tempLegend));
|
||||
}, [hideSeries]);
|
||||
|
||||
const [containerRef, containerSize] = useElementSize();
|
||||
|
@ -197,7 +216,7 @@ const GraphView: FC<GraphViewProps> = ({
|
|||
setPeriod={setPeriod}
|
||||
layoutSize={containerSize}
|
||||
height={height}
|
||||
anomalyView={anomalyView}
|
||||
isAnomalyView={isAnomalyView}
|
||||
spanGaps={spanGaps}
|
||||
/>
|
||||
)}
|
||||
|
@ -213,10 +232,12 @@ const GraphView: FC<GraphViewProps> = ({
|
|||
onChangeLegend={setLegendValue}
|
||||
/>
|
||||
)}
|
||||
{!isHistogram && !anomalyView && showLegend && (
|
||||
{isAnomalyView && showLegend && (<LegendAnomaly series={series as SeriesItem[]}/>)}
|
||||
{!isHistogram && showLegend && (
|
||||
<Legend
|
||||
labels={legend}
|
||||
query={query}
|
||||
isAnomalyView={isAnomalyView}
|
||||
onChange={onChangeLegend}
|
||||
/>
|
||||
)}
|
||||
|
@ -228,11 +249,6 @@ const GraphView: FC<GraphViewProps> = ({
|
|||
legendValue={legendValue}
|
||||
/>
|
||||
)}
|
||||
{anomalyView && showLegend && (
|
||||
<LegendAnomaly
|
||||
series={series as SeriesItem[]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -62,10 +62,6 @@ export const anomalyNavigation: NavigationItem[] = [
|
|||
{
|
||||
label: routerOptions[router.anomaly].title,
|
||||
value: router.home,
|
||||
},
|
||||
{
|
||||
label: routerOptions[router.home].title,
|
||||
value: router.query,
|
||||
}
|
||||
];
|
||||
|
||||
|
|
|
@ -14,10 +14,10 @@ interface LineTooltipHook {
|
|||
metrics: MetricResult[];
|
||||
series: uPlotSeries[];
|
||||
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 [tooltipIdx, setTooltipIdx] = useState({ seriesIdx: -1, dataIdx: -1 });
|
||||
const [stickyTooltips, setStickyToolTips] = useState<ChartTooltipProps[]>([]);
|
||||
|
@ -61,14 +61,14 @@ const useLineTooltip = ({ u, metrics, series, unit, anomalyView }: LineTooltipHo
|
|||
point,
|
||||
u: u,
|
||||
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) : "-"],
|
||||
value: formatPrettyNumber(value, min, max),
|
||||
info: getMetricName(metricItem),
|
||||
statsFormatted: seriesItem?.statsFormatted,
|
||||
marker: `${seriesItem?.stroke}`,
|
||||
};
|
||||
}, [u, tooltipIdx, metrics, series, unit, anomalyView]);
|
||||
}, [u, tooltipIdx, metrics, series, unit, isAnomalyView]);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (!showTooltip) return;
|
||||
|
|
|
@ -12,7 +12,8 @@ import { useTimeState } from "../state/time/TimeStateContext";
|
|||
import { useCustomPanelState } from "../state/customPanel/CustomPanelStateContext";
|
||||
import { isHistogramData } from "../utils/metric";
|
||||
import { useGraphState } from "../state/graph/GraphStateContext";
|
||||
import { getStepFromDuration } from "../utils/time";
|
||||
import { getSecondsFromDuration, getStepFromDuration } from "../utils/time";
|
||||
import { AppType } from "../types/appType";
|
||||
|
||||
interface FetchQueryParams {
|
||||
predefinedQuery?: string[]
|
||||
|
@ -47,13 +48,15 @@ interface FetchDataParams {
|
|||
hideQuery?: number[]
|
||||
}
|
||||
|
||||
const isAnomalyUI = AppType.anomaly === process.env.REACT_APP_TYPE;
|
||||
|
||||
export const useFetchQuery = ({
|
||||
predefinedQuery,
|
||||
visible,
|
||||
display,
|
||||
customStep,
|
||||
hideQuery,
|
||||
showAllSeries
|
||||
showAllSeries,
|
||||
}: FetchQueryParams): FetchQueryReturn => {
|
||||
const { query } = useQueryState();
|
||||
const { period } = useTimeState();
|
||||
|
@ -124,7 +127,7 @@ export const useFetchQuery = ({
|
|||
tempTraces.push(trace);
|
||||
}
|
||||
|
||||
isHistogramResult = isDisplayChart && isHistogramData(resp.data.result);
|
||||
isHistogramResult = !isAnomalyUI && isDisplayChart && isHistogramData(resp.data.result);
|
||||
seriesLimit = isHistogramResult ? Infinity : defaultLimit;
|
||||
const freeTempSize = seriesLimit - tempData.length;
|
||||
resp.data.result.slice(0, freeTempSize).forEach((d: MetricBase) => {
|
||||
|
@ -172,7 +175,7 @@ export const useFetchQuery = ({
|
|||
setQueryErrors(expr.map(() => ErrorTypes.validQuery));
|
||||
} else if (isValidHttpUrl(serverUrl)) {
|
||||
const updatedPeriod = { ...period };
|
||||
updatedPeriod.step = customStep;
|
||||
updatedPeriod.step = isAnomalyUI ? `${getSecondsFromDuration(customStep)*1000}ms` : customStep;
|
||||
return expr.map(q => displayChart
|
||||
? getQueryRangeUrl(serverUrl, q, updatedPeriod, nocache, isTracingEnabled)
|
||||
: getQueryUrl(serverUrl, q, updatedPeriod, nocache, isTracingEnabled));
|
||||
|
|
|
@ -14,10 +14,10 @@ type Props = {
|
|||
isHistogram: boolean;
|
||||
graphData: MetricResult[];
|
||||
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 { customStep, yaxis, spanGaps } = useGraphState();
|
||||
|
@ -68,7 +68,7 @@ const GraphTab: FC<Props> = ({ isHistogram, graphData, controlsRef, anomalyView
|
|||
setPeriod={setPeriod}
|
||||
height={isMobile ? window.innerHeight * 0.5 : 500}
|
||||
isHistogram={isHistogram}
|
||||
anomalyView={anomalyView}
|
||||
isAnomalyView={isAnomalyView}
|
||||
spanGaps={spanGaps}
|
||||
/>
|
||||
</>
|
||||
|
|
|
@ -30,8 +30,14 @@ export interface QueryConfiguratorProps {
|
|||
setQueryErrors: StateUpdater<string[]>;
|
||||
setHideError: StateUpdater<boolean>;
|
||||
stats: QueryStats[];
|
||||
onHideQuery: (queries: number[]) => void
|
||||
onRunQuery: () => void
|
||||
onHideQuery?: (queries: number[]) => void
|
||||
onRunQuery: () => void;
|
||||
hideButtons?: {
|
||||
addQuery?: boolean;
|
||||
prettify?: boolean;
|
||||
autocomplete?: boolean;
|
||||
traceQuery?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
const QueryConfigurator: FC<QueryConfiguratorProps> = ({
|
||||
|
@ -40,7 +46,8 @@ const QueryConfigurator: FC<QueryConfiguratorProps> = ({
|
|||
setHideError,
|
||||
stats,
|
||||
onHideQuery,
|
||||
onRunQuery
|
||||
onRunQuery,
|
||||
hideButtons
|
||||
}) => {
|
||||
|
||||
const { isMobile } = useDeviceDetect();
|
||||
|
@ -159,7 +166,7 @@ const QueryConfigurator: FC<QueryConfiguratorProps> = ({
|
|||
}, [stateQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
onHideQuery(hideQuery);
|
||||
onHideQuery && onHideQuery(hideQuery);
|
||||
}, [hideQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -188,40 +195,43 @@ const QueryConfigurator: FC<QueryConfiguratorProps> = ({
|
|||
>
|
||||
<QueryEditor
|
||||
value={stateQuery[i]}
|
||||
autocomplete={autocomplete || autocompleteQuick}
|
||||
autocomplete={!hideButtons?.autocomplete && (autocomplete || autocompleteQuick)}
|
||||
error={queryErrors[i]}
|
||||
stats={stats[i]}
|
||||
onArrowUp={createHandlerArrow(-1, i)}
|
||||
onArrowDown={createHandlerArrow(1, i)}
|
||||
onEnter={handleRunQuery}
|
||||
onChange={createHandlerChangeQuery(i)}
|
||||
label={`Query ${i + 1}`}
|
||||
label={`Query ${stateQuery.length > 1 ? i + 1 : ""}`}
|
||||
disabled={hideQuery.includes(i)}
|
||||
/>
|
||||
<Tooltip title={hideQuery.includes(i) ? "Enable query" : "Disable query"}>
|
||||
<div className="vm-query-configurator-list-row__button">
|
||||
<Button
|
||||
variant={"text"}
|
||||
color={"gray"}
|
||||
startIcon={hideQuery.includes(i) ? <VisibilityOffIcon/> : <VisibilityIcon/>}
|
||||
onClick={createHandlerHideQuery(i)}
|
||||
ariaLabel="visibility query"
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
{onHideQuery && (
|
||||
<Tooltip title={hideQuery.includes(i) ? "Enable query" : "Disable query"}>
|
||||
<div className="vm-query-configurator-list-row__button">
|
||||
<Button
|
||||
variant={"text"}
|
||||
color={"gray"}
|
||||
startIcon={hideQuery.includes(i) ? <VisibilityOffIcon/> : <VisibilityIcon/>}
|
||||
onClick={createHandlerHideQuery(i)}
|
||||
ariaLabel="visibility query"
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip title={"Prettify query"}>
|
||||
<div className="vm-query-configurator-list-row__button">
|
||||
<Button
|
||||
variant={"text"}
|
||||
color={"gray"}
|
||||
startIcon={<Prettify/>}
|
||||
onClick={async () => await handlePrettifyQuery(i)}
|
||||
className="prettify"
|
||||
ariaLabel="prettify the query"
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
{!hideButtons?.prettify && (
|
||||
<Tooltip title={"Prettify query"}>
|
||||
<div className="vm-query-configurator-list-row__button">
|
||||
<Button
|
||||
variant={"text"}
|
||||
color={"gray"}
|
||||
startIcon={<Prettify/>}
|
||||
onClick={async () => await handlePrettifyQuery(i)}
|
||||
className="prettify"
|
||||
ariaLabel="prettify the query"
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>)}
|
||||
|
||||
{stateQuery.length > 1 && (
|
||||
<Tooltip title="Remove Query">
|
||||
|
@ -240,10 +250,10 @@ const QueryConfigurator: FC<QueryConfiguratorProps> = ({
|
|||
))}
|
||||
</div>
|
||||
<div className="vm-query-configurator-settings">
|
||||
<AdditionalSettings/>
|
||||
<AdditionalSettings hideButtons={hideButtons}/>
|
||||
<div className="vm-query-configurator-settings__buttons">
|
||||
<QueryHistory handleSelectQuery={handleSelectHistory}/>
|
||||
{stateQuery.length < MAX_QUERY_FIELDS && (
|
||||
{!hideButtons?.addQuery && stateQuery.length < MAX_QUERY_FIELDS && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleAddQuery}
|
||||
|
|
|
@ -33,7 +33,7 @@
|
|||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
justify-content: space-between;
|
||||
font-size: $font-size-small;
|
||||
margin: -$padding-medium 0-$padding-medium $padding-medium;
|
||||
padding: 0 $padding-medium;
|
||||
|
|
|
@ -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 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 ExploreAnomalyHeader from "./ExploreAnomalyHeader/ExploreAnomalyHeader";
|
||||
import Alert from "../../components/Main/Alert/Alert";
|
||||
import { extractFields, isForecast } from "../../utils/uplot";
|
||||
import { useQueryState } from "../../state/query/QueryStateContext";
|
||||
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 Spinner from "../../components/Main/Spinner/Spinner";
|
||||
import Alert from "../../components/Main/Alert/Alert";
|
||||
import WarningLimitSeries from "../CustomPanel/WarningLimitSeries/WarningLimitSeries";
|
||||
import GraphTab from "../CustomPanel/CustomPanelTabs/GraphTab";
|
||||
import { extractFields, isForecast } from "../../utils/uplot";
|
||||
import { MetricResult } from "../../api/types";
|
||||
import { promValueToNumber } from "../../utils/metric";
|
||||
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.
|
||||
const ANOMALY_SCORE_THRESHOLD = 1;
|
||||
|
||||
const ExploreAnomaly: FC = () => {
|
||||
useSetQueryParams();
|
||||
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 { query } = useQueryState();
|
||||
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,
|
||||
customStep,
|
||||
showAllSeries: true,
|
||||
hideQuery,
|
||||
showAllSeries
|
||||
});
|
||||
|
||||
const data = useMemo(() => {
|
||||
if (!graphData) return;
|
||||
if (!graphData) return [];
|
||||
const detectedData = graphData.map(d => ({ ...isForecast(d.metric), ...d }));
|
||||
const realData = detectedData.filter(d => d.value === null);
|
||||
const anomalyScoreData = detectedData.filter(d => d.isAnomalyScore);
|
||||
const realData = detectedData.filter(d => d.value === ForecastType.actual);
|
||||
const anomalyScoreData = detectedData.filter(d => d.value === ForecastType.anomaly);
|
||||
const anomalyData: MetricResult[] = realData.map((d) => {
|
||||
const id = extractFields(d.metric);
|
||||
const anomalyScoreDataByLabels = anomalyScoreData.find(du => extractFields(du.metric) === id);
|
||||
|
||||
return {
|
||||
group: queries.length + 1,
|
||||
group: 1,
|
||||
metric: { ...d.metric, __name__: ForecastType.anomaly },
|
||||
values: d.values.filter(([t]) => {
|
||||
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]);
|
||||
|
||||
const onChangeFilter = (expr: Record<string, string>) => {
|
||||
const { __name__ = "", ...labelValue } = expr;
|
||||
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 handleRunQuery = () => {
|
||||
setHideError(false);
|
||||
};
|
||||
|
||||
const handleChangePopstate = () => window.location.reload();
|
||||
useEventListener("popstate", handleChangePopstate);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames({
|
||||
|
@ -87,14 +81,23 @@ const ExploreAnomaly: FC = () => {
|
|||
"vm-custom-panel_mobile": isMobile,
|
||||
})}
|
||||
>
|
||||
<ExploreAnomalyHeader
|
||||
queries={queries}
|
||||
series={series}
|
||||
onChange={onChangeFilter}
|
||||
<QueryConfigurator
|
||||
queryErrors={!hideError ? queryErrors : []}
|
||||
setQueryErrors={setQueryErrors}
|
||||
setHideError={setHideError}
|
||||
stats={queryStats}
|
||||
onRunQuery={handleRunQuery}
|
||||
hideButtons={{ addQuery: true, prettify: true, autocomplete: true, traceQuery: true }}
|
||||
/>
|
||||
{(isGraphDataLoading || isAnomalySeriesLoading) && <Spinner />}
|
||||
{(error || errorSeries) && <Alert variant="error">{error || errorSeries}</Alert>}
|
||||
{!error && !errorSeries && queryErrors?.[0] && <Alert variant="error">{queryErrors[0]}</Alert>}
|
||||
{isLoading && <Spinner/>}
|
||||
{(!hideError && error) && <Alert variant="error">{error}</Alert>}
|
||||
{warning && (
|
||||
<WarningLimitSeries
|
||||
warning={warning}
|
||||
query={query}
|
||||
onChange={setShowAllSeries}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={classNames({
|
||||
"vm-custom-panel-body": true,
|
||||
|
@ -112,9 +115,9 @@ const ExploreAnomaly: FC = () => {
|
|||
{data && (
|
||||
<GraphTab
|
||||
graphData={data}
|
||||
isHistogram={isHistogram}
|
||||
isHistogram={false}
|
||||
controlsRef={controlsRef}
|
||||
anomalyView={true}
|
||||
isAnomalyView={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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, []);
|
||||
};
|
|
@ -29,7 +29,8 @@ export interface HideSeriesArgs {
|
|||
hideSeries: string[],
|
||||
legend: LegendItemType,
|
||||
metaKey: boolean,
|
||||
series: Series[]
|
||||
series: Series[],
|
||||
isAnomalyView?: boolean,
|
||||
}
|
||||
|
||||
export type MinMax = { min: number, max: number }
|
||||
|
|
|
@ -8,9 +8,16 @@ 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 anomalyURL = window.location.href.replace(/(?:graph|vmui)\/.*/, "");
|
||||
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;
|
||||
|
||||
switch (REACT_APP_TYPE) {
|
||||
case AppType.logs:
|
||||
return logsURL;
|
||||
case AppType.anomaly:
|
||||
return serverURL || storageURL || anomalyURL;
|
||||
default:
|
||||
return tenantId ? replaceTenantId(url, tenantId) : url;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -66,7 +66,7 @@ export const isSupportedDuration = (str: string): Partial<Record<UnitTypeShort,
|
|||
|
||||
export const getSecondsFromDuration = (dur: string) => {
|
||||
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 durObject = durItems.reduce((prev, curr) => {
|
||||
|
|
|
@ -14,25 +14,26 @@ export const extractFields = (metric: MetricBase["metric"]): string => {
|
|||
.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 forecastRegex = new RegExp(`(${Object.values(ForecastType).join("|")})$`);
|
||||
const match = metricName.match(forecastRegex);
|
||||
const value = match && match[0] as ForecastType;
|
||||
const isY = /(?:^|[^a-zA-Z0-9_])y(?:$|[^a-zA-Z0-9_])/.test(metricName);
|
||||
return {
|
||||
value,
|
||||
isUpper: value === ForecastType.yhatUpper,
|
||||
isLower: value === ForecastType.yhatLower,
|
||||
isYhat: value === ForecastType.yhat,
|
||||
isAnomaly: value === ForecastType.anomaly,
|
||||
isAnomalyScore: value === ForecastType.anomalyScore,
|
||||
value: isY ? ForecastType.actual : value,
|
||||
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 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++) {
|
||||
const label = getNameForMetric(data[i], alias[data[i].group - 1]);
|
||||
|
@ -40,77 +41,45 @@ export const getSeriesItemContext = (data: MetricResult[], hideSeries: string[],
|
|||
}
|
||||
|
||||
return (d: MetricResult, i: number): SeriesItem => {
|
||||
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;
|
||||
}
|
||||
const metricInfo = isAnomalyUI ? isForecast(data[i].metric) : null;
|
||||
const label = isAnomalyUI ? metricInfo?.group || "" : getNameForMetric(d, alias[d.group - 1]);
|
||||
|
||||
return {
|
||||
label,
|
||||
dash,
|
||||
width,
|
||||
stroke,
|
||||
points,
|
||||
dash: getDashSeries(metricInfo),
|
||||
width: getWidthSeries(metricInfo),
|
||||
stroke: getStrokeSeries({ metricInfo, label, isAnomalyUI, colorState }),
|
||||
points: getPointsSeries(metricInfo),
|
||||
spanGaps: false,
|
||||
forecast: forecast.value,
|
||||
forecastGroup: forecast.group,
|
||||
forecast: metricInfo?.value,
|
||||
forecastGroup: metricInfo?.group,
|
||||
freeFormFields: d.metric,
|
||||
show: !includesHideSeries(label, hideSeries),
|
||||
scale: "1",
|
||||
statsFormatted: {
|
||||
min: formatPrettyNumber(min, min, max),
|
||||
max: formatPrettyNumber(max, min, max),
|
||||
median: formatPrettyNumber(median, min, max),
|
||||
last: formatPrettyNumber(last, min, max),
|
||||
},
|
||||
median: median,
|
||||
...getSeriesStatistics(d),
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const getSeriesStatistics = (d: MetricResult) => {
|
||||
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),
|
||||
};
|
||||
return {
|
||||
median,
|
||||
statsFormatted: {
|
||||
min: formatPrettyNumber(min, min, max),
|
||||
max: formatPrettyNumber(max, min, max),
|
||||
median: formatPrettyNumber(median, min, max),
|
||||
last: formatPrettyNumber(last, min, max),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const getLegendItem = (s: SeriesItem, group: number): LegendItemType => ({
|
||||
group,
|
||||
label: s.label || "",
|
||||
|
@ -121,10 +90,16 @@ export const getLegendItem = (s: SeriesItem, group: number): LegendItemType => (
|
|||
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 include = includesHideSeries(label, hideSeries);
|
||||
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) {
|
||||
return include ? hideSeries.filter(l => l !== label) : [...hideSeries, label];
|
||||
} else if (hideSeries.length) {
|
||||
|
@ -172,3 +147,71 @@ export const addSeries = (u: uPlot, series: uPlotSeries[], spanGaps = false) =>
|
|||
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);
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue