mirror of
https://github.com/prometheus/prometheus.git
synced 2024-12-24 21:24:05 -08:00
A lot of work around uPlot and resolution picking
Signed-off-by: Julius Volz <julius.volz@gmail.com>
This commit is contained in:
parent
1c91b82206
commit
8ef66c41a0
|
@ -14,5 +14,7 @@ module.exports = {
|
|||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
'no-unused-vars': ['error', { 'argsIgnorePattern': '^_' }],
|
||||
'@typescript-eslint/no-unused-vars': ['error', { 'argsIgnorePattern': '^_' }],
|
||||
},
|
||||
}
|
||||
|
|
|
@ -13,7 +13,7 @@ import {
|
|||
PromQLExtension,
|
||||
newCompleteStrategy,
|
||||
} from "@prometheus-io/codemirror-promql";
|
||||
import { FC, useEffect, useState } from "react";
|
||||
import { FC, useEffect, useMemo, useState } from "react";
|
||||
import CodeMirror, {
|
||||
EditorState,
|
||||
EditorView,
|
||||
|
@ -107,10 +107,6 @@ export class HistoryCompleteStrategy implements CompleteStrategy {
|
|||
}
|
||||
}
|
||||
|
||||
// This is just a placeholder until query history is implemented, so disable the linter warning.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const queryHistory = [] as string[];
|
||||
|
||||
interface ExpressionInputProps {
|
||||
initialExpr: string;
|
||||
metricNames: string[];
|
||||
|
@ -163,13 +159,15 @@ const ExpressionInput: FC<ExpressionInputProps> = ({
|
|||
if (formatResult) {
|
||||
setExpr(formatResult.data);
|
||||
notifications.show({
|
||||
color: "green",
|
||||
title: "Expression formatted",
|
||||
message: "Expression formatted successfully!",
|
||||
});
|
||||
}
|
||||
}, [formatResult, formatError]);
|
||||
|
||||
// This is just a placeholder until query history is implemented, so disable the linter warning.
|
||||
const queryHistory = useMemo<string[]>(() => [], []);
|
||||
|
||||
// (Re)initialize editor based on settings / setting changes.
|
||||
useEffect(() => {
|
||||
// Build the dynamic part of the config.
|
||||
|
|
|
@ -1,29 +1,24 @@
|
|||
import { FC, useEffect, useId, useLayoutEffect, useState } from "react";
|
||||
import { FC, useEffect, useId, useState } from "react";
|
||||
import { Alert, Skeleton, Box, LoadingOverlay } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconInfoCircle } from "@tabler/icons-react";
|
||||
import {
|
||||
InstantQueryResult,
|
||||
RangeSamples,
|
||||
} from "../../api/responseTypes/query";
|
||||
import { InstantQueryResult } from "../../api/responseTypes/query";
|
||||
import { useAPIQuery } from "../../api/api";
|
||||
import classes from "./Graph.module.css";
|
||||
import { GraphDisplayMode } from "../../state/queryPageSlice";
|
||||
import { formatSeries } from "../../lib/formatSeries";
|
||||
import uPlot, { Series } from "uplot";
|
||||
import UplotReact from "uplot-react";
|
||||
import {
|
||||
GraphDisplayMode,
|
||||
GraphResolution,
|
||||
getEffectiveResolution,
|
||||
} from "../../state/queryPageSlice";
|
||||
import "uplot/dist/uPlot.min.css";
|
||||
import "./uplot.css";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
import { formatTimestamp } from "../../lib/formatTime";
|
||||
import { computePosition, shift, flip, offset, Axis } from "@floating-ui/dom";
|
||||
import { colorPool } from "./ColorPool";
|
||||
import UPlotChart, { UPlotChartProps, UPlotChartRange } from "./UPlotChart";
|
||||
import UPlotChart, { UPlotChartRange } from "./UPlotChart";
|
||||
|
||||
export interface GraphProps {
|
||||
expr: string;
|
||||
endTime: number | null;
|
||||
range: number;
|
||||
resolution: number | null;
|
||||
resolution: GraphResolution;
|
||||
showExemplars: boolean;
|
||||
displayMode: GraphDisplayMode;
|
||||
retriggerIdx: number;
|
||||
|
@ -45,8 +40,7 @@ const Graph: FC<GraphProps> = ({
|
|||
|
||||
const effectiveEndTime = (endTime !== null ? endTime : Date.now()) / 1000;
|
||||
const startTime = effectiveEndTime - range / 1000;
|
||||
const effectiveResolution =
|
||||
resolution || Math.max(Math.floor(range / 250000), 1);
|
||||
const effectiveResolution = getEffectiveResolution(resolution, range) / 1000;
|
||||
|
||||
const { data, error, isFetching, isLoading, refetch } =
|
||||
useAPIQuery<InstantQueryResult>({
|
||||
|
@ -62,7 +56,7 @@ const Graph: FC<GraphProps> = ({
|
|||
});
|
||||
|
||||
// Keep the displayed chart range separate from the actual query range, so that
|
||||
// the chart will keep displaying the old range while a new query for a different range
|
||||
// the chart will keep displaying the old range while a query for a new range
|
||||
// is still in progress.
|
||||
const [displayedChartRange, setDisplayedChartRange] =
|
||||
useState<UPlotChartRange>({
|
||||
|
@ -77,6 +71,8 @@ const Graph: FC<GraphProps> = ({
|
|||
endTime: effectiveEndTime,
|
||||
resolution: effectiveResolution,
|
||||
});
|
||||
// We actually want to update the displayed range only once the new data is there.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
@ -4,10 +4,10 @@ import {
|
|||
Center,
|
||||
Space,
|
||||
Box,
|
||||
Input,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Select,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconChartAreaFilled,
|
||||
|
@ -20,6 +20,8 @@ import { FC, useState } from "react";
|
|||
import { useAppDispatch, useAppSelector } from "../../state/hooks";
|
||||
import {
|
||||
GraphDisplayMode,
|
||||
GraphResolution,
|
||||
getEffectiveResolution,
|
||||
removePanel,
|
||||
setExpr,
|
||||
setVisualizer,
|
||||
|
@ -29,6 +31,10 @@ import TimeInput from "./TimeInput";
|
|||
import RangeInput from "./RangeInput";
|
||||
import ExpressionInput from "./ExpressionInput";
|
||||
import Graph from "./Graph";
|
||||
import {
|
||||
formatPrometheusDuration,
|
||||
parsePrometheusDuration,
|
||||
} from "../../lib/formatTime";
|
||||
|
||||
export interface PanelProps {
|
||||
idx: number;
|
||||
|
@ -45,8 +51,40 @@ const QueryPanel: FC<PanelProps> = ({ idx, metricNames }) => {
|
|||
const [retriggerIdx, setRetriggerIdx] = useState<number>(0);
|
||||
|
||||
const panel = useAppSelector((state) => state.queryPage.panels[idx]);
|
||||
const resolution = panel.visualizer.resolution;
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [customResolutionInput, setCustomResolutionInput] = useState<string>(
|
||||
formatPrometheusDuration(
|
||||
getEffectiveResolution(resolution, panel.visualizer.range)
|
||||
)
|
||||
);
|
||||
|
||||
const setResolution = (res: GraphResolution) => {
|
||||
dispatch(
|
||||
setVisualizer({
|
||||
idx,
|
||||
visualizer: {
|
||||
...panel.visualizer,
|
||||
resolution: res,
|
||||
},
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const onChangeCustomResolutionInput = (resText: string): void => {
|
||||
const newResolution = parsePrometheusDuration(resText);
|
||||
if (newResolution === null) {
|
||||
setCustomResolutionInput(
|
||||
formatPrometheusDuration(
|
||||
getEffectiveResolution(resolution, panel.visualizer.range)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
setResolution({ type: "custom", value: newResolution });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<ExpressionInput
|
||||
|
@ -124,52 +162,99 @@ const QueryPanel: FC<PanelProps> = ({ idx, metricNames }) => {
|
|||
/>
|
||||
|
||||
<Select
|
||||
title="Resolution"
|
||||
placeholder="Resolution"
|
||||
maxDropdownHeight={500}
|
||||
data={[
|
||||
{
|
||||
group: "Automatic resolution",
|
||||
items: [
|
||||
{ label: "Low", value: "low" },
|
||||
{ label: "Medium", value: "medium" },
|
||||
{ label: "High", value: "high" },
|
||||
{ label: "Low res.", value: "low" },
|
||||
{ label: "Medium res.", value: "medium" },
|
||||
{ label: "High res.", value: "high" },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "Fixed resolution",
|
||||
items: [
|
||||
{ label: "10s", value: "10" },
|
||||
{ label: "30s", value: "30" },
|
||||
{ label: "1m", value: "60" },
|
||||
{ label: "5m", value: "300" },
|
||||
{ label: "15m", value: "900" },
|
||||
{ label: "1h", value: "3600" },
|
||||
{ label: "10s", value: "10000" },
|
||||
{ label: "30s", value: "30000" },
|
||||
{ label: "1m", value: "60000" },
|
||||
{ label: "5m", value: "300000" },
|
||||
{ label: "15m", value: "900000" },
|
||||
{ label: "1h", value: "3600000" },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "Custom resolution",
|
||||
items: [{ label: "Enter value", value: "custom" }],
|
||||
items: [{ label: "Enter value...", value: "custom" }],
|
||||
},
|
||||
]}
|
||||
w={160}
|
||||
// value={value ? value.value : null}
|
||||
value={
|
||||
resolution.type === "auto"
|
||||
? resolution.density
|
||||
: resolution.type === "fixed"
|
||||
? resolution.value.toString()
|
||||
: "custom"
|
||||
}
|
||||
onChange={(_value, option) => {
|
||||
dispatch(
|
||||
setVisualizer({
|
||||
idx,
|
||||
visualizer: {
|
||||
...panel.visualizer,
|
||||
resolution: option
|
||||
? option.value
|
||||
? parseInt(option.value)
|
||||
: null
|
||||
: null,
|
||||
},
|
||||
})
|
||||
);
|
||||
if (["low", "medium", "high"].includes(option.value)) {
|
||||
setResolution({
|
||||
type: "auto",
|
||||
density: option.value as "low" | "medium" | "high",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (option.value === "custom") {
|
||||
// Start the custom resolution at the current effective resolution.
|
||||
const effectiveResolution = getEffectiveResolution(
|
||||
resolution,
|
||||
panel.visualizer.range
|
||||
);
|
||||
setResolution({
|
||||
type: "custom",
|
||||
value: effectiveResolution,
|
||||
});
|
||||
setCustomResolutionInput(
|
||||
formatPrometheusDuration(effectiveResolution)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const value = parseInt(option.value);
|
||||
if (!isNaN(value)) {
|
||||
setResolution({
|
||||
type: "fixed",
|
||||
value,
|
||||
});
|
||||
} else {
|
||||
throw new Error("Invalid resolution value");
|
||||
}
|
||||
}}
|
||||
clearable
|
||||
/>
|
||||
|
||||
{resolution.type === "custom" && (
|
||||
<TextInput
|
||||
placeholder="Resolution"
|
||||
value={customResolutionInput}
|
||||
onChange={(event) =>
|
||||
setCustomResolutionInput(event.currentTarget.value)
|
||||
}
|
||||
onBlur={() =>
|
||||
onChangeCustomResolutionInput(customResolutionInput)
|
||||
}
|
||||
onKeyDown={(event) =>
|
||||
event.key === "Enter" &&
|
||||
onChangeCustomResolutionInput(customResolutionInput)
|
||||
}
|
||||
aria-label="Range"
|
||||
style={{
|
||||
width: `calc(44px + ${customResolutionInput.length + 3}ch)`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<SegmentedControl
|
||||
|
|
|
@ -79,6 +79,7 @@ const RangeInput: FC<RangeInputProps> = ({ range, onChangeRange }) => {
|
|||
return (
|
||||
<Group gap={5}>
|
||||
<Input
|
||||
title="Range"
|
||||
value={rangeInput}
|
||||
onChange={(event) => setRangeInput(event.currentTarget.value)}
|
||||
onBlur={() => onChangeRangeInput(rangeInput)}
|
||||
|
|
|
@ -26,6 +26,7 @@ const TimeInput: FC<TimeInputProps> = ({
|
|||
<Group gap={5}>
|
||||
<DatesProvider settings={{ timezone: useLocalTime ? undefined : "UTC" }}>
|
||||
<DateTimePicker
|
||||
title="End time"
|
||||
w={230}
|
||||
valueFormat="YYYY-MM-DD HH:mm:ss"
|
||||
withSeconds
|
||||
|
|
|
@ -1,11 +1,5 @@
|
|||
import { FC, useEffect, useId, useState } from "react";
|
||||
import { Alert, Skeleton, Box, LoadingOverlay } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconInfoCircle } from "@tabler/icons-react";
|
||||
import {
|
||||
InstantQueryResult,
|
||||
RangeSamples,
|
||||
} from "../../api/responseTypes/query";
|
||||
import { useAPIQuery } from "../../api/api";
|
||||
import { FC, useEffect, useState } from "react";
|
||||
import { RangeSamples } from "../../api/responseTypes/query";
|
||||
import classes from "./Graph.module.css";
|
||||
import { GraphDisplayMode } from "../../state/queryPageSlice";
|
||||
import { formatSeries } from "../../lib/formatSeries";
|
||||
|
@ -13,9 +7,8 @@ import uPlot, { Series } from "uplot";
|
|||
import UplotReact from "uplot-react";
|
||||
import "uplot/dist/uPlot.min.css";
|
||||
import "./uplot.css";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
import { formatTimestamp } from "../../lib/formatTime";
|
||||
import { computePosition, shift, flip, offset, Axis } from "@floating-ui/dom";
|
||||
import { computePosition, shift, flip, offset } from "@floating-ui/dom";
|
||||
import { colorPool } from "./ColorPool";
|
||||
|
||||
const formatYAxisTickValue = (y: number | null): string => {
|
||||
|
@ -132,7 +125,7 @@ const tooltipPlugin = () => {
|
|||
},
|
||||
// When a series is selected by hovering close to it, store the
|
||||
// index of the selected series.
|
||||
setSeries: (self: uPlot, seriesIdx: number | null, opts: Series) => {
|
||||
setSeries: (_u: uPlot, seriesIdx: number | null, _opts: Series) => {
|
||||
selectedSeriesIdx = seriesIdx;
|
||||
},
|
||||
// When the cursor is moved, update the tooltip with the current
|
||||
|
@ -155,8 +148,12 @@ const tooltipPlugin = () => {
|
|||
const ts = u.data[0][idx];
|
||||
const value = u.data[selectedSeriesIdx][idx];
|
||||
const series = u.series[selectedSeriesIdx];
|
||||
// @ts-expect-error - uPlot doesn't have a field for labels, but we just attach some anyway.
|
||||
const labels = series.labels;
|
||||
const color = series.stroke();
|
||||
if (typeof series.stroke !== "function") {
|
||||
throw new Error("series.stroke is not a function");
|
||||
}
|
||||
const color = series.stroke(u, selectedSeriesIdx);
|
||||
|
||||
const x = left + boundingLeft;
|
||||
const y = top + boundingTop;
|
||||
|
@ -205,29 +202,31 @@ const tooltipPlugin = () => {
|
|||
// A helper function to automatically create enough space for the Y axis
|
||||
// ticket labels depending on their length.
|
||||
const autoPadLeft = (
|
||||
self: uPlot,
|
||||
u: uPlot,
|
||||
values: string[],
|
||||
axisIdx: number,
|
||||
cycleNum: number
|
||||
) => {
|
||||
const axis = self.axes[axisIdx];
|
||||
const axis = u.axes[axisIdx];
|
||||
|
||||
// bail out, force convergence
|
||||
if (cycleNum > 1) {
|
||||
// @ts-expect-error - got this from a uPlot demo example, not sure if it's correct.
|
||||
return axis._size;
|
||||
}
|
||||
|
||||
let axisSize = axis.ticks.size + axis.gap;
|
||||
let axisSize = axis.ticks!.size! + axis.gap!;
|
||||
|
||||
// find longest value
|
||||
// Find longest tick text.
|
||||
const longestVal = (values ?? []).reduce(
|
||||
(acc, val) => (val.length > acc.length ? val : acc),
|
||||
""
|
||||
);
|
||||
|
||||
if (longestVal != "") {
|
||||
self.ctx.font = axis.font[0];
|
||||
axisSize += self.ctx.measureText(longestVal).width / devicePixelRatio;
|
||||
console.log("axis.font", axis.font![0]);
|
||||
u.ctx.font = axis.font![0];
|
||||
axisSize += u.ctx.measureText(longestVal).width / devicePixelRatio;
|
||||
}
|
||||
|
||||
return Math.ceil(axisSize);
|
||||
|
@ -236,7 +235,7 @@ const autoPadLeft = (
|
|||
const getOptions = (
|
||||
width: number,
|
||||
result: RangeSamples[],
|
||||
onSelectRange: (start: number, end: number) => void
|
||||
onSelectRange: (_start: number, _end: number) => void
|
||||
): uPlot.Options => ({
|
||||
width: width - 30,
|
||||
height: 550,
|
||||
|
@ -258,7 +257,7 @@ const getOptions = (
|
|||
live: false,
|
||||
markers: {
|
||||
fill: (
|
||||
self: uPlot,
|
||||
_u: uPlot,
|
||||
seriesIdx: number
|
||||
): CSSStyleDeclaration["borderColor"] => {
|
||||
return colorPool[seriesIdx % colorPool.length];
|
||||
|
@ -285,7 +284,7 @@ const getOptions = (
|
|||
},
|
||||
// Y axis (sample value).
|
||||
{
|
||||
values: (u: uPlot, splits: number[]) => splits.map(formatYAxisTickValue),
|
||||
values: (_u: uPlot, splits: number[]) => splits.map(formatYAxisTickValue),
|
||||
border: {
|
||||
show: true,
|
||||
stroke: "#333",
|
||||
|
@ -298,6 +297,7 @@ const getOptions = (
|
|||
},
|
||||
],
|
||||
series: [
|
||||
{},
|
||||
...result.map((r, idx) => ({
|
||||
label: formatSeries(r.metric),
|
||||
width: 2,
|
||||
|
@ -323,44 +323,43 @@ export const normalizeData = (
|
|||
endTime: number,
|
||||
resolution: number
|
||||
): uPlot.AlignedData => {
|
||||
const timeData: (number | null)[][] = [];
|
||||
timeData[0] = [];
|
||||
const timeData: number[] = [];
|
||||
for (let t = startTime; t <= endTime; t += resolution) {
|
||||
timeData[0].push(t);
|
||||
timeData.push(t);
|
||||
}
|
||||
|
||||
return timeData.concat(
|
||||
inputData.map(({ values, histograms }) => {
|
||||
// Insert nulls for all missing steps.
|
||||
const data: (number | null)[] = [];
|
||||
let valuePos = 0;
|
||||
let histogramPos = 0;
|
||||
const values = inputData.map(({ values, histograms }) => {
|
||||
// Insert nulls for all missing steps.
|
||||
const data: (number | null)[] = [];
|
||||
let valuePos = 0;
|
||||
let histogramPos = 0;
|
||||
|
||||
for (let t = startTime; t <= endTime; t += resolution) {
|
||||
// Allow for floating point inaccuracy.
|
||||
const currentValue = values && values[valuePos];
|
||||
const currentHistogram = histograms && histograms[histogramPos];
|
||||
if (
|
||||
currentValue &&
|
||||
values.length > valuePos &&
|
||||
currentValue[0] < t + resolution / 100
|
||||
) {
|
||||
data.push(parseValue(currentValue[1]));
|
||||
valuePos++;
|
||||
} else if (
|
||||
currentHistogram &&
|
||||
histograms.length > histogramPos &&
|
||||
currentHistogram[0] < t + resolution / 100
|
||||
) {
|
||||
data.push(parseValue(currentHistogram[1].sum));
|
||||
histogramPos++;
|
||||
} else {
|
||||
data.push(null);
|
||||
}
|
||||
for (let t = startTime; t <= endTime; t += resolution) {
|
||||
// Allow for floating point inaccuracy.
|
||||
const currentValue = values && values[valuePos];
|
||||
const currentHistogram = histograms && histograms[histogramPos];
|
||||
if (
|
||||
currentValue &&
|
||||
values.length > valuePos &&
|
||||
currentValue[0] < t + resolution / 100
|
||||
) {
|
||||
data.push(parseValue(currentValue[1]));
|
||||
valuePos++;
|
||||
} else if (
|
||||
currentHistogram &&
|
||||
histograms.length > histogramPos &&
|
||||
currentHistogram[0] < t + resolution / 100
|
||||
) {
|
||||
data.push(parseValue(currentHistogram[1].sum));
|
||||
histogramPos++;
|
||||
} else {
|
||||
data.push(null);
|
||||
}
|
||||
return data;
|
||||
})
|
||||
);
|
||||
}
|
||||
return data;
|
||||
});
|
||||
|
||||
return [timeData, ...values];
|
||||
};
|
||||
|
||||
const parseValue = (value: string): null | number => {
|
||||
|
@ -388,7 +387,6 @@ export interface UPlotChartProps {
|
|||
const UPlotChart: FC<UPlotChartProps> = ({
|
||||
data,
|
||||
range: { startTime, endTime, resolution },
|
||||
displayMode,
|
||||
width,
|
||||
onSelectRange,
|
||||
}) => {
|
||||
|
|
|
@ -7,14 +7,49 @@ export enum GraphDisplayMode {
|
|||
Heatmap = "heatmap",
|
||||
}
|
||||
|
||||
export type GraphResolution =
|
||||
| {
|
||||
type: "auto";
|
||||
density: "low" | "medium" | "high";
|
||||
}
|
||||
| {
|
||||
type: "fixed";
|
||||
value: number; // Resolution step in milliseconds.
|
||||
}
|
||||
| {
|
||||
type: "custom";
|
||||
value: number; // Resolution step in milliseconds.
|
||||
};
|
||||
|
||||
export const getEffectiveResolution = (
|
||||
resolution: GraphResolution,
|
||||
range: number
|
||||
) => {
|
||||
switch (resolution.type) {
|
||||
case "auto": {
|
||||
const factor =
|
||||
resolution.density === "high"
|
||||
? 750
|
||||
: resolution.density === "medium"
|
||||
? 250
|
||||
: 100;
|
||||
return Math.max(Math.floor(range / factor), 1);
|
||||
}
|
||||
case "fixed":
|
||||
return resolution.value; // TODO: Scope this to a list?
|
||||
case "custom":
|
||||
return resolution.value;
|
||||
}
|
||||
};
|
||||
|
||||
// NOTE: This is not represented as a discriminated union type
|
||||
// because we want to preserve and partially share settings while
|
||||
// switching between display modes.
|
||||
export interface Visualizer {
|
||||
activeTab: "table" | "graph" | "explain";
|
||||
endTime: number | null; // Timestamp in milliseconds.
|
||||
range: number; // Range in seconds.
|
||||
resolution: number | null; // Resolution step in seconds.
|
||||
range: number; // Range in milliseconds.
|
||||
resolution: GraphResolution;
|
||||
displayMode: GraphDisplayMode;
|
||||
showExemplars: boolean;
|
||||
}
|
||||
|
@ -41,7 +76,7 @@ const newDefaultPanel = (): Panel => ({
|
|||
activeTab: "table",
|
||||
endTime: null,
|
||||
range: 3600 * 1000,
|
||||
resolution: null,
|
||||
resolution: { type: "auto", density: "medium" },
|
||||
displayMode: GraphDisplayMode.Lines,
|
||||
showExemplars: false,
|
||||
},
|
||||
|
|
Loading…
Reference in a new issue