Complete building tree view and implement "Explain" tab

Signed-off-by: Julius Volz <julius.volz@gmail.com>
This commit is contained in:
Julius Volz 2024-09-06 20:38:10 +02:00
parent b75a12b52f
commit 5fd860f806
13 changed files with 1558 additions and 9 deletions

View file

@ -0,0 +1,109 @@
import { FC } from "react";
import ASTNode, { Aggregation, aggregationType } from "../../../promql/ast";
import { labelNameList } from "../../../promql/format";
import { parsePrometheusFloat } from "../../../lib/formatFloatValue";
import { Card, Text } from "@mantine/core";
const describeAggregationType = (
aggrType: aggregationType,
param: ASTNode | null
) => {
switch (aggrType) {
case "sum":
return "sums over the sample values of the input series";
case "min":
return "takes the minimum of the sample values of the input series";
case "max":
return "takes the maximum of the sample values of the input series";
case "avg":
return "calculates the average of the sample values of the input series";
case "stddev":
return "calculates the population standard deviation of the sample values of the input series";
case "stdvar":
return "calculates the population standard variation of the sample values of the input series";
case "count":
return "counts the number of input series";
case "group":
return "groups the input series by the supplied grouping labels, while setting the sample value to 1";
case "count_values":
if (param === null) {
throw new Error(
"encountered count_values() node without label parameter"
);
}
if (param.type !== "stringLiteral") {
throw new Error(
"encountered count_values() node without string literal label parameter"
);
}
return (
<>
outputs one time series for each unique sample value in the input
series (each counting the number of occurrences of that value and
indicating the original value in the {labelNameList([param.val])}{" "}
label)
</>
);
case "bottomk":
return "returns the bottom K series by value";
case "topk":
return "returns the top K series by value";
case "quantile":
if (param === null) {
throw new Error(
"encountered quantile() node without quantile parameter"
);
}
if (param.type === "numberLiteral") {
return `calculates the ${param.val}th quantile (${
parsePrometheusFloat(param.val) * 100
}th percentile) over the sample values of the input series`;
}
return "calculates a quantile over the sample values of the input series";
case "limitk":
return "limits the output to K series";
case "limit_ratio":
return "limits the output to a ratio of the input series";
default:
throw new Error(`invalid aggregation type ${aggrType}`);
}
};
const describeAggregationGrouping = (grouping: string[], without: boolean) => {
if (without) {
return (
<>aggregating away the [{labelNameList(grouping)}] label dimensions</>
);
}
if (grouping.length === 1) {
return <>grouped by their {labelNameList(grouping)} label dimension</>;
}
if (grouping.length > 1) {
return <>grouped by their [{labelNameList(grouping)}] label dimensions</>;
}
return "aggregating away any label dimensions";
};
interface AggregationExplainViewProps {
node: Aggregation;
}
const AggregationExplainView: FC<AggregationExplainViewProps> = ({ node }) => {
return (
<Card withBorder>
<Text fz="lg" fw={600} mb="md">
Aggregation
</Text>
<Text fz="sm">
This node {describeAggregationType(node.op, node.param)},{" "}
{describeAggregationGrouping(node.grouping, node.without)}.
</Text>
</Card>
);
};
export default AggregationExplainView;

View file

@ -0,0 +1,106 @@
import { FC } from "react";
import { BinaryExpr } from "../../../../promql/ast";
import serializeNode from "../../../../promql/serialize";
import VectorScalarBinaryExprExplainView from "./VectorScalar";
import VectorVectorBinaryExprExplainView from "./VectorVector";
import ScalarScalarBinaryExprExplainView from "./ScalarScalar";
import { nodeValueType } from "../../../../promql/utils";
import { useSuspenseAPIQuery } from "../../../../api/api";
import { InstantQueryResult } from "../../../../api/responseTypes/query";
import { Card, Text } from "@mantine/core";
interface BinaryExprExplainViewProps {
node: BinaryExpr;
}
const BinaryExprExplainView: FC<BinaryExprExplainViewProps> = ({ node }) => {
const { data: lhs } = useSuspenseAPIQuery<InstantQueryResult>({
path: `/query`,
params: {
query: serializeNode(node.lhs),
},
});
const { data: rhs } = useSuspenseAPIQuery<InstantQueryResult>({
path: `/query`,
params: {
query: serializeNode(node.rhs),
},
});
if (
lhs.data.resultType !== nodeValueType(node.lhs) ||
rhs.data.resultType !== nodeValueType(node.rhs)
) {
// This can happen for a brief transitionary render when "node" has changed, but "lhs" and "rhs"
// haven't switched back to loading yet (leading to a crash in e.g. the vector-vector explain view).
return null;
}
// Scalar-scalar binops.
if (lhs.data.resultType === "scalar" && rhs.data.resultType === "scalar") {
return (
<Card withBorder>
<Text fz="lg" fw={600} mb="md">
Scalar-to-scalar binary operation
</Text>
<ScalarScalarBinaryExprExplainView
node={node}
lhs={lhs.data.result}
rhs={rhs.data.result}
/>
</Card>
);
}
// Vector-scalar binops.
if (lhs.data.resultType === "scalar" && rhs.data.resultType === "vector") {
return (
<Card withBorder>
<Text fz="lg" fw={600} mb="md">
Scalar-to-vector binary operation
</Text>
<VectorScalarBinaryExprExplainView
node={node}
vector={rhs.data.result}
scalar={lhs.data.result}
scalarLeft={true}
/>
</Card>
);
}
if (lhs.data.resultType === "vector" && rhs.data.resultType === "scalar") {
return (
<Card withBorder>
<Text fz="lg" fw={600} mb="md">
Vector-to-scalar binary operation
</Text>
<VectorScalarBinaryExprExplainView
node={node}
scalar={rhs.data.result}
vector={lhs.data.result}
scalarLeft={false}
/>
</Card>
);
}
// Vector-vector binops.
if (lhs.data.resultType === "vector" && rhs.data.resultType === "vector") {
return (
<Card withBorder>
<Text fz="lg" fw={600} mb="md">
Vector-to-vector binary operation
</Text>
<VectorVectorBinaryExprExplainView
node={node}
lhs={lhs.data.result}
rhs={rhs.data.result}
/>
</Card>
);
}
throw new Error("invalid binary operator argument types");
};
export default BinaryExprExplainView;

View file

@ -0,0 +1,54 @@
import { FC } from "react";
import { BinaryExpr } from "../../../../promql/ast";
import { scalarBinOp } from "../../../../promql/binOp";
import { Table } from "@mantine/core";
import { SampleValue } from "../../../../api/responseTypes/query";
import {
formatPrometheusFloat,
parsePrometheusFloat,
} from "../../../../lib/formatFloatValue";
interface ScalarScalarBinaryExprExplainViewProps {
node: BinaryExpr;
lhs: SampleValue;
rhs: SampleValue;
}
const ScalarScalarBinaryExprExplainView: FC<
ScalarScalarBinaryExprExplainViewProps
> = ({ node, lhs, rhs }) => {
const [lhsVal, rhsVal] = [
parsePrometheusFloat(lhs[1]),
parsePrometheusFloat(rhs[1]),
];
return (
<Table withColumnBorders withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>Left value</Table.Th>
<Table.Th>Operator</Table.Th>
<Table.Th>Right value</Table.Th>
<Table.Th></Table.Th>
<Table.Th>Result</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
<Table.Tr>
<Table.Td className="number-cell">{lhs[1]}</Table.Td>
<Table.Td className="op-cell">
{node.op}
{node.bool && " bool"}
</Table.Td>
<Table.Td className="number-cell">{rhs[1]}</Table.Td>
<Table.Td className="op-cell">=</Table.Td>
<Table.Td className="number-cell">
{formatPrometheusFloat(scalarBinOp(node.op, lhsVal, rhsVal))}
</Table.Td>
</Table.Tr>
</Table.Tbody>
</Table>
);
};
export default ScalarScalarBinaryExprExplainView;

View file

@ -0,0 +1,104 @@
import { FC } from "react";
import { BinaryExpr } from "../../../../promql/ast";
// import SeriesName from '../../../../utils/SeriesName';
import { isComparisonOperator } from "../../../../promql/utils";
import { vectorElemBinop } from "../../../../promql/binOp";
import {
InstantSample,
SampleValue,
} from "../../../../api/responseTypes/query";
import { Alert, Table, Text } from "@mantine/core";
import {
formatPrometheusFloat,
parsePrometheusFloat,
} from "../../../../lib/formatFloatValue";
import SeriesName from "../../SeriesName";
interface VectorScalarBinaryExprExplainViewProps {
node: BinaryExpr;
scalar: SampleValue;
vector: InstantSample[];
scalarLeft: boolean;
}
const VectorScalarBinaryExprExplainView: FC<
VectorScalarBinaryExprExplainViewProps
> = ({ node, scalar, vector, scalarLeft }) => {
if (vector.length === 0) {
return (
<Alert>
One side of the binary operation produces 0 results, no matching
information shown.
</Alert>
);
}
return (
<Table withTableBorder withRowBorders withColumnBorders fz="xs">
<Table.Thead>
<Table.Tr>
{!scalarLeft && <Table.Th>Left labels</Table.Th>}
<Table.Th>Left value</Table.Th>
<Table.Th>Operator</Table.Th>
{scalarLeft && <Table.Th>Right labels</Table.Th>}
<Table.Th>Right value</Table.Th>
<Table.Th></Table.Th>
<Table.Th>Result</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{vector.map((sample: InstantSample, idx) => {
if (!sample.value) {
// TODO: Handle native histograms or show a better error message.
throw new Error("Native histograms are not supported yet");
}
const vecVal = parsePrometheusFloat(sample.value[1]);
const scalVal = parsePrometheusFloat(scalar[1]);
let { value, keep } = scalarLeft
? vectorElemBinop(node.op, scalVal, vecVal)
: vectorElemBinop(node.op, vecVal, scalVal);
if (isComparisonOperator(node.op) && scalarLeft) {
value = vecVal;
}
if (node.bool) {
value = Number(keep);
keep = true;
}
const scalarCell = <Table.Td ta="right">{scalar[1]}</Table.Td>;
const vectorCells = (
<>
<Table.Td>
<SeriesName labels={sample.metric} format={true} />
</Table.Td>
<Table.Td ta="right">{sample.value[1]}</Table.Td>
</>
);
return (
<Table.Tr key={idx}>
{scalarLeft ? scalarCell : vectorCells}
<Table.Td ta="center">
{node.op}
{node.bool && " bool"}
</Table.Td>
{scalarLeft ? vectorCells : scalarCell}
<Table.Td ta="center">=</Table.Td>
<Table.Td ta="right">
{keep ? (
formatPrometheusFloat(value)
) : (
<Text c="dimmed">dropped</Text>
)}
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
);
};
export default VectorScalarBinaryExprExplainView;

View file

@ -0,0 +1,686 @@
import React, { FC, useState } from "react";
import { BinaryExpr, vectorMatchCardinality } from "../../../../promql/ast";
import { InstantSample, Metric } from "../../../../api/responseTypes/query";
import { isComparisonOperator, isSetOperator } from "../../../../promql/utils";
import {
VectorMatchError,
BinOpMatchGroup,
MatchErrorType,
computeVectorVectorBinOp,
filteredSampleValue,
} from "../../../../promql/binOp";
import { formatNode, labelNameList } from "../../../../promql/format";
import {
Alert,
Anchor,
Box,
Button,
Group,
List,
Switch,
Table,
Text,
} from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
import { IconAlertTriangle } from "@tabler/icons-react";
import SeriesName from "../../SeriesName";
// We use this color pool for two purposes:
//
// 1. To distinguish different match groups from each other.
// 2. To distinguish multiple series within one match group from each other.
const colorPool = [
"#1f77b4",
"#ff7f0e",
"#2ca02c",
"#d62728",
"#9467bd",
"#8c564b",
"#e377c2",
"#7f7f7f",
"#bcbd22",
"#17becf",
"#393b79",
"#637939",
"#8c6d31",
"#843c39",
"#d6616b",
"#7b4173",
"#ce6dbd",
"#9c9ede",
"#c5b0d5",
"#c49c94",
"#f7b6d2",
"#c7c7c7",
"#dbdb8d",
"#9edae5",
"#393b79",
"#637939",
"#8c6d31",
"#843c39",
"#d6616b",
"#7b4173",
"#ce6dbd",
"#9c9ede",
"#c5b0d5",
"#c49c94",
"#f7b6d2",
"#c7c7c7",
"#dbdb8d",
"#9edae5",
"#17becf",
"#393b79",
"#637939",
"#8c6d31",
"#843c39",
"#d6616b",
"#7b4173",
"#ce6dbd",
"#9c9ede",
"#c5b0d5",
"#c49c94",
"#f7b6d2",
];
const rhsColorOffset = colorPool.length / 2 + 3;
const colorForIndex = (idx: number, offset?: number) =>
`${colorPool[(idx + (offset || 0)) % colorPool.length]}80`;
const seriesSwatch = (color: string) => (
<Box
display="inline-block"
w={12}
h={12}
bg={color}
style={{
borderRadius: 2,
flexShrink: 0,
}}
/>
);
interface VectorVectorBinaryExprExplainViewProps {
node: BinaryExpr;
lhs: InstantSample[];
rhs: InstantSample[];
}
const noMatchLabels = (
metric: Metric,
on: boolean,
labels: string[]
): Metric => {
const result: Metric = {};
for (const name in metric) {
if (!(labels.includes(name) === on && (on || name !== "__name__"))) {
result[name] = metric[name];
}
}
return result;
};
const explanationText = (node: BinaryExpr): React.ReactNode => {
const matching = node.matching!;
const [oneSide, manySide] =
matching.card === vectorMatchCardinality.oneToMany
? ["left", "right"]
: ["right", "left"];
return (
<>
<Text size="sm">
{isComparisonOperator(node.op) ? (
<>
This node filters the series from the left-hand side based on the
result of a "
<span className="promql-code promql-operator">{node.op}</span>"
comparison with matching series from the right-hand side.
</>
) : (
<>
This node calculates the result of applying the "
<span className="promql-code promql-operator">{node.op}</span>"
operator between the sample values of matching series from two sets
of time series.
</>
)}
</Text>
<List my="md" fz="sm" withPadding>
{(matching.labels.length > 0 || matching.on) &&
(matching.on ? (
<List.Item>
<span className="promql-code promql-keyword">on</span>(
{labelNameList(matching.labels)}):{" "}
{matching.labels.length > 0 ? (
<>
series on both sides are matched on the labels{" "}
{labelNameList(matching.labels)}
</>
) : (
<>
all series from one side are matched to all series on the
other side.
</>
)}
</List.Item>
) : (
<List.Item>
<span className="promql-code promql-keyword">ignoring</span>(
{labelNameList(matching.labels)}): series on both sides are
matched on all of their labels, except{" "}
{labelNameList(matching.labels)}.
</List.Item>
))}
{matching.card === vectorMatchCardinality.oneToOne ? (
<List.Item>
One-to-one match. Each series from the left-hand side is allowed to
match with at most one series on the right-hand side, and vice
versa.
</List.Item>
) : (
<List.Item>
<span className="promql-code promql-keyword">
group_{manySide}({labelNameList(matching.include)})
</span>
: {matching.card} match. Each series from the {oneSide}-hand side is
allowed to match with multiple series from the {manySide}-hand side.
{matching.include.length !== 0 && (
<>
{" "}
Any {labelNameList(matching.include)} labels found on the{" "}
{oneSide}-hand side are propagated into the result, in addition
to the match group's labels.
</>
)}
</List.Item>
)}
{node.bool && (
<List.Item>
<span className="promql-code promql-keyword">bool</span>: Instead of
filtering series based on the outcome of the comparison for matched
series, keep all series, but return the comparison outcome as a
boolean <span className="promql-code promql-number">0</span> or{" "}
<span className="promql-code promql-number">1</span> sample value.
</List.Item>
)}
</List>
</>
);
};
const explainError = (
binOp: BinaryExpr,
_mg: BinOpMatchGroup,
err: VectorMatchError
) => {
const fixes = (
<>
<Text size="sm">
<strong>Possible fixes:</strong>
</Text>
<List withPadding my="md" fz="sm">
{err.type === MatchErrorType.multipleMatchesForOneToOneMatching && (
<List.Item>
<Text size="sm">
<strong>
Allow {err.dupeSide === "left" ? "many-to-one" : "one-to-many"}{" "}
matching
</strong>
: If you want to allow{" "}
{err.dupeSide === "left" ? "many-to-one" : "one-to-many"}{" "}
matching, you need to explicitly request it by adding a{" "}
<span className="promql-code promql-keyword">
group_{err.dupeSide}()
</span>{" "}
modifier to the operator:
</Text>
<Text size="sm" ta="center" my="md">
{formatNode(
{
...binOp,
matching: {
...(binOp.matching
? binOp.matching
: { labels: [], on: false, include: [] }),
card:
err.dupeSide === "left"
? vectorMatchCardinality.manyToOne
: vectorMatchCardinality.oneToMany,
},
},
true,
1
)}
</Text>
</List.Item>
)}
<List.Item>
<strong>Update your matching parameters:</strong> Consider including
more differentiating labels in your matching modifiers (via{" "}
<span className="promql-code promql-keyword">on()</span> /{" "}
<span className="promql-code promql-keyword">ignoring()</span>) to
split multiple series into distinct match groups.
</List.Item>
<List.Item>
<strong>Aggregate the input:</strong> Consider aggregating away the
extra labels that create multiple series per group before applying the
binary operation.
</List.Item>
</List>
</>
);
switch (err.type) {
case MatchErrorType.multipleMatchesForOneToOneMatching:
return (
<>
<Text size="sm">
Binary operators only allow <strong>one-to-one</strong> matching by
default, but we found{" "}
<strong>multiple series on the {err.dupeSide} side</strong> for this
match group.
</Text>
{fixes}
</>
);
case MatchErrorType.multipleMatchesOnBothSides:
return (
<>
<Text size="sm">
We found <strong>multiple series on both sides</strong> for this
match group. Since <strong>many-to-many matching</strong> is not
supported, you need to ensure that at least one of the sides only
yields a single series.
</Text>
{fixes}
</>
);
case MatchErrorType.multipleMatchesOnOneSide: {
const [oneSide, manySide] =
binOp.matching!.card === vectorMatchCardinality.oneToMany
? ["left", "right"]
: ["right", "left"];
return (
<>
<Text size="sm">
You requested{" "}
<strong>
{oneSide === "right" ? "many-to-one" : "one-to-many"} matching
</strong>{" "}
via{" "}
<span className="promql-code promql-keyword">
group_{manySide}()
</span>
, but we also found{" "}
<strong>multiple series on the {oneSide} side</strong> of the match
group. Make sure that the {oneSide} side only contains a single
series.
</Text>
{fixes}
</>
);
}
default:
throw new Error("unknown match error");
}
};
const VectorVectorBinaryExprExplainView: FC<
VectorVectorBinaryExprExplainViewProps
> = ({ node, lhs, rhs }) => {
// TODO: Don't use Mantine's local storage as a one-off here.
// const [allowLineBreaks, setAllowLineBreaks] = useLocalStorage<boolean>({
// key: "queryPage.explain.binaryOperators.breakLongLines",
// defaultValue: true,
// });
const [showSampleValues, setShowSampleValues] = useLocalStorage<boolean>({
key: "queryPage.explain.binaryOperators.showSampleValues",
defaultValue: false,
});
const [maxGroups, setMaxGroups] = useState<number | undefined>(100);
const [maxSeriesPerGroup, setMaxSeriesPerGroup] = useState<
number | undefined
>(100);
const { matching } = node;
if (matching === null) {
// The parent should make sure to only pass in vector-vector binops that have their "matching" field filled out.
throw new Error("missing matching parameters in vector-to-vector binop");
}
const { groups: matchGroups, numGroups } = computeVectorVectorBinOp(
node.op,
matching,
node.bool,
lhs,
rhs,
{
maxGroups: maxGroups,
maxSeriesPerGroup: maxSeriesPerGroup,
}
);
const errCount = Object.values(matchGroups).filter((mg) => mg.error).length;
return (
<>
<Text size="sm">{explanationText(node)}</Text>
{!isSetOperator(node.op) && (
<>
<Group my="lg" justify="flex-end" gap="xl">
{/* <Switch
label="Break long lines"
checked={allowLineBreaks}
onChange={(event) =>
setAllowLineBreaks(event.currentTarget.checked)
}
/> */}
<Switch
label="Show sample values"
checked={showSampleValues}
onChange={(event) =>
setShowSampleValues(event.currentTarget.checked)
}
/>
</Group>
{numGroups > Object.keys(matchGroups).length && (
<Alert
color="yellow"
mb="md"
icon={<IconAlertTriangle size={14} />}
>
Too many match groups to display, only showing{" "}
{Object.keys(matchGroups).length} out of {numGroups} groups.
<Anchor onClick={() => setMaxGroups(undefined)}>
Show all groups
</Anchor>
</Alert>
)}
{errCount > 0 && (
<Alert
color="yellow"
mb="md"
icon={<IconAlertTriangle size={14} />}
>
Found matching issues in {errCount} match group
{errCount > 1 ? "s" : ""}. See below for per-group error details.
</Alert>
)}
<Table fz="xs" withRowBorders={false}>
<Table.Tbody>
{Object.values(matchGroups).map((mg, mgIdx) => {
const {
groupLabels,
lhs,
lhsCount,
rhs,
rhsCount,
result,
error,
} = mg;
const noLHSMatches = lhs.length === 0;
const noRHSMatches = rhs.length === 0;
const groupColor = colorPool[mgIdx % colorPool.length];
const noMatchesColor = "#e0e0e0";
const lhsGroupColor = noLHSMatches
? noMatchesColor
: groupColor;
const rhsGroupColor = noRHSMatches
? noMatchesColor
: groupColor;
const resultGroupColor =
noLHSMatches || noRHSMatches ? noMatchesColor : groupColor;
const matchGroupTitleRow = (color: string) => (
<Table.Tr ta="center">
<Table.Td
colSpan={2}
style={{ backgroundColor: `${color}25` }}
>
<SeriesName labels={groupLabels} format={true} />
</Table.Td>
</Table.Tr>
);
const matchGroupTable = (
series: InstantSample[],
seriesCount: number,
color: string,
colorOffset?: number
) => (
<Box
style={{ borderRadius: 3, border: `2px solid ${color}` }}
>
<Table fz="xs" withRowBorders={false}>
<Table.Tbody>
{series.length === 0 ? (
<Table.Tr>
<Table.Td
bg="gray.0"
ta="center"
c="gray.6"
py="md"
fw="bold"
>
no matching series
</Table.Td>
</Table.Tr>
) : (
<>
{matchGroupTitleRow(color)}
{series.map((s, sIdx) => {
if (s.value === undefined) {
// TODO: Figure out how to handle native histograms.
throw new Error(
"Native histograms are not supported yet"
);
}
return (
<Table.Tr key={sIdx}>
<Table.Td>
<Group wrap="nowrap" gap={7} align="center">
{seriesSwatch(
colorForIndex(sIdx, colorOffset)
)}
<SeriesName
labels={noMatchLabels(
s.metric,
matching.on,
matching.labels
)}
format={true}
/>
</Group>
</Table.Td>
{showSampleValues && (
<Table.Td>{s.value[1]}</Table.Td>
)}
</Table.Tr>
);
})}
</>
)}
{seriesCount > series.length && (
<Table.Tr>
<Table.Td
bg="gray.0"
ta="center"
c="gray.6"
py="md"
fw="bold"
>
{seriesCount - series.length} more series omitted
<Button
size="sm"
variant="link"
onClick={() => setMaxSeriesPerGroup(undefined)}
>
show all
</Button>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</Box>
);
const lhsTable = matchGroupTable(lhs, lhsCount, lhsGroupColor);
const rhsTable = matchGroupTable(
rhs,
rhsCount,
rhsGroupColor,
rhsColorOffset
);
const resultTable = (
<Box
style={{
borderRadius: 3,
border: `2px solid ${resultGroupColor}`,
}}
>
<Table fz="xs" withRowBorders={false}>
<Table.Tbody>
{noLHSMatches || noRHSMatches ? (
<Table.Tr>
<Table.Td
bg="gray.0"
ta="center"
c="gray.6"
py="md"
fw="bold"
>
dropped
</Table.Td>
</Table.Tr>
) : error !== null ? (
<Table.Tr>
<Table.Td
bg="gray.0"
ta="center"
c="gray.6"
py="md"
fw="bold"
>
error, result omitted
</Table.Td>
</Table.Tr>
) : (
<>
{result.map(({ sample, manySideIdx }, resIdx) => {
if (sample.value === undefined) {
// TODO: Figure out how to handle native histograms.
throw new Error(
"Native histograms are not supported yet"
);
}
const filtered =
sample.value[1] === filteredSampleValue;
const [lIdx, rIdx] =
matching.card ===
vectorMatchCardinality.oneToMany
? [0, manySideIdx]
: [manySideIdx, 0];
return (
<Table.Tr key={resIdx}>
<Table.Td
style={{ opacity: filtered ? 0.5 : 1 }}
title={
filtered
? "Series has been filtered by comparison operator"
: undefined
}
>
<Group wrap="nowrap" gap="xs">
<Group wrap="nowrap" gap={0}>
{seriesSwatch(colorForIndex(lIdx))}
<span style={{ color: "#aaa" }}></span>
{seriesSwatch(
colorForIndex(rIdx, rhsColorOffset)
)}
</Group>
<SeriesName
labels={sample.metric}
format={true}
/>
</Group>
</Table.Td>
{showSampleValues && (
<Table.Td className="number-cell">
{filtered ? (
<span style={{ color: "grey" }}>
filtered
</span>
) : (
<span>{sample.value[1]}</span>
)}
</Table.Td>
)}
</Table.Tr>
);
})}
</>
)}
</Table.Tbody>
</Table>
</Box>
);
return (
<React.Fragment key={mgIdx}>
{mgIdx !== 0 && <tr style={{ height: 30 }}></tr>}
<Table.Tr>
<Table.Td colSpan={5}>
{error && (
<Alert
color="red"
mb="md"
title="Error in match group below"
icon={<IconAlertTriangle size={14} />}
>
{explainError(node, mg, error)}
</Alert>
)}
</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Td valign="middle" p={0}>
{lhsTable}
</Table.Td>
<Table.Td ta="center">
{node.op}
{node.bool && " bool"}
</Table.Td>
<Table.Td valign="middle" p={0}>
{rhsTable}
</Table.Td>
<Table.Td ta="center">=</Table.Td>
<Table.Td valign="middle" p={0}>
{resultTable}
</Table.Td>
</Table.Tr>
</React.Fragment>
);
})}
</Table.Tbody>
</Table>
</>
)}
</>
);
};
export default VectorVectorBinaryExprExplainView;

View file

@ -0,0 +1,8 @@
.funcDoc code {
background-color: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-dark-5)
);
padding: 0.05em 0.2em;
border-radius: 0.2em;
}

View file

@ -0,0 +1,198 @@
import { FC } from "react";
import { Alert, Text, Anchor, Card, Divider } from "@mantine/core";
import ASTNode, { nodeType } from "../../../promql/ast";
// import AggregationExplainView from "./Aggregation";
// import BinaryExprExplainView from "./BinaryExpr/BinaryExpr";
// import SelectorExplainView from "./Selector";
import funcDocs from "../../../promql/functionDocs";
import { escapeString } from "../../../promql/utils";
import { formatPrometheusDuration } from "../../../lib/formatTime";
import classes from "./ExplainView.module.css";
import SelectorExplainView from "./Selector";
import AggregationExplainView from "./Aggregation";
import BinaryExprExplainView from "./BinaryExpr/BinaryExpr";
interface ExplainViewProps {
node: ASTNode | null;
treeShown: boolean;
setShowTree: () => void;
}
const ExplainView: FC<ExplainViewProps> = ({
node,
treeShown,
setShowTree,
}) => {
if (node === null) {
return (
<Alert>
<>
To use the Explain view,{" "}
{!treeShown && (
<>
<Anchor fz="unset" onClick={setShowTree}>
enable the query tree view
</Anchor>{" "}
(also available via the expression input dropdown) and then
</>
)}{" "}
select a node in the tree above.
</>
</Alert>
);
}
switch (node.type) {
case nodeType.aggregation:
return <AggregationExplainView node={node} />;
case nodeType.binaryExpr:
return <BinaryExprExplainView node={node} />;
case nodeType.call:
return (
<Card withBorder>
<Text fz="lg" fw={600} mb="md">
Function call
</Text>
<Text fz="sm">
This node calls the{" "}
<Anchor
fz="inherit"
href={`https://prometheus.io/docs/prometheus/latest/querying/functions/#${node.func.name}`}
target="_blank"
>
<span className="promql-code promql-keyword">
{node.func.name}()
</span>
</Anchor>{" "}
function{node.args.length > 0 ? " on the provided inputs" : ""}.
</Text>
<Divider my="md" />
{/* TODO: Some docs, like x_over_time, have relative links pointing back to the Prometheus docs,
make sure to modify those links in the docs extraction so they work from the explain view */}
<Text fz="sm" className={classes.funcDoc}>
{funcDocs[node.func.name]}
</Text>
</Card>
);
case nodeType.matrixSelector:
return <SelectorExplainView node={node} />;
case nodeType.subquery:
return (
<Card withBorder>
<Text fz="lg" fw={600} mb="md">
Subquery
</Text>
<Text fz="sm">
This node evaluates the passed expression as a subquery over the
last{" "}
<span className="promql-code promql-duration">
{formatPrometheusDuration(node.range)}
</span>{" "}
at a query resolution{" "}
{node.step > 0 ? (
<>
of{" "}
<span className="promql-code promql-duration">
{formatPrometheusDuration(node.step)}
</span>
</>
) : (
"equal to the default rule evaluation interval"
)}
{node.timestamp !== null ? (
<>
, evaluated relative to an absolute evaluation timestamp of{" "}
<span className="promql-number">
{(node.timestamp / 1000).toFixed(3)}
</span>
</>
) : node.startOrEnd !== null ? (
<>
, evaluated relative to the {node.startOrEnd} of the query range
</>
) : (
<></>
)}
{node.offset === 0 ? (
<></>
) : node.offset > 0 ? (
<>
, time-shifted{" "}
<span className="promql-code promql-duration">
{formatPrometheusDuration(node.offset)}
</span>{" "}
into the past
</>
) : (
<>
, time-shifted{" "}
<span className="promql-code promql-duration">
{formatPrometheusDuration(-node.offset)}
</span>{" "}
into the future
</>
)}
.
</Text>
</Card>
);
case nodeType.numberLiteral:
return (
<Card withBorder>
<Text fz="lg" fw={600} mb="md">
Number literal
</Text>
<Text fz="sm">
A scalar number literal with the value{" "}
<span className="promql-code promql-number">{node.val}</span>.
</Text>
</Card>
);
case nodeType.parenExpr:
return (
<Card withBorder>
<Text fz="lg" fw={600} mb="md">
Parentheses
</Text>
<Text fz="sm">
Parentheses that contain a sub-expression to be evaluated.
</Text>
</Card>
);
case nodeType.stringLiteral:
return (
<Card withBorder>
<Text fz="lg" fw={600} mb="md">
String literal
</Text>
<Text fz="sm">
A string literal with the value{" "}
<span className="promql-code promql-string">
"{escapeString(node.val)}"
</span>
.
</Text>
</Card>
);
case nodeType.unaryExpr:
return (
<Card withBorder>
<Text fz="lg" fw={600} mb="md">
Unary expression
</Text>
<Text fz="sm">
A unary expression that{" "}
{node.op === "+"
? "does not affect the expression it is applied to"
: "changes the sign of the expression it is applied to"}
.
</Text>
</Card>
);
case nodeType.vectorSelector:
return <SelectorExplainView node={node} />;
default:
throw new Error("invalid node type");
}
};
export default ExplainView;

View file

@ -0,0 +1,230 @@
import { FC, ReactNode } from "react";
import {
VectorSelector,
MatrixSelector,
nodeType,
LabelMatcher,
matchType,
} from "../../../promql/ast";
import { escapeString } from "../../../promql/utils";
import { useSuspenseAPIQuery } from "../../../api/api";
import { Card, Text, Divider, List } from "@mantine/core";
import { MetadataResult } from "../../../api/responseTypes/metadata";
import { formatPrometheusDuration } from "../../../lib/formatTime";
interface SelectorExplainViewProps {
node: VectorSelector | MatrixSelector;
}
const matchingCriteriaList = (
name: string,
matchers: LabelMatcher[]
): ReactNode => {
return (
<List fz="sm" my="md" withPadding>
{name.length > 0 && (
<List.Item>
The metric name is{" "}
<span className="promql-code promql-metric-name">{name}</span>.
</List.Item>
)}
{matchers
.filter((m) => !(m.name === "__name__"))
.map((m) => {
switch (m.type) {
case matchType.equal:
return (
<List.Item>
<span className="promql-code promql-label-name">
{m.name}
</span>
<span className="promql-code promql-operator">{m.type}</span>
<span className="promql-code promql-string">
"{escapeString(m.value)}"
</span>
: The label{" "}
<span className="promql-code promql-label-name">
{m.name}
</span>{" "}
is exactly{" "}
<span className="promql-code promql-string">
"{escapeString(m.value)}"
</span>
.
</List.Item>
);
case matchType.notEqual:
return (
<List.Item>
<span className="promql-code promql-label-name">
{m.name}
</span>
<span className="promql-code promql-operator">{m.type}</span>
<span className="promql-code promql-string">
"{escapeString(m.value)}"
</span>
: The label{" "}
<span className="promql-code promql-label-name">
{m.name}
</span>{" "}
is not{" "}
<span className="promql-code promql-string">
"{escapeString(m.value)}"
</span>
.
</List.Item>
);
case matchType.matchRegexp:
return (
<List.Item>
<span className="promql-code promql-label-name">
{m.name}
</span>
<span className="promql-code promql-operator">{m.type}</span>
<span className="promql-code promql-string">
"{escapeString(m.value)}"
</span>
: The label{" "}
<span className="promql-code promql-label-name">
{m.name}
</span>{" "}
matches the regular expression{" "}
<span className="promql-code promql-string">
"{escapeString(m.value)}"
</span>
.
</List.Item>
);
case matchType.matchNotRegexp:
return (
<List.Item>
<span className="promql-code promql-label-name">
{m.name}
</span>
<span className="promql-code promql-operator">{m.type}</span>
<span className="promql-code promql-string">
"{escapeString(m.value)}"
</span>
: The label{" "}
<span className="promql-code promql-label-name">
{m.name}
</span>{" "}
does not match the regular expression{" "}
<span className="promql-code promql-string">
"{escapeString(m.value)}"
</span>
.
</List.Item>
);
default:
throw new Error("invalid matcher type");
}
})}
</List>
);
};
const SelectorExplainView: FC<SelectorExplainViewProps> = ({ node }) => {
const baseMetricName = node.name.replace(/(_count|_sum|_bucket)$/, "");
const { data: metricMeta } = useSuspenseAPIQuery<MetadataResult>({
path: `/metadata`,
params: {
metric: baseMetricName,
},
});
return (
<Card withBorder>
<Text fz="lg" fw={600} mb="md">
{node.type === nodeType.vectorSelector ? "Instant" : "Range"} vector
selector
</Text>
<Text fz="sm">
{metricMeta.data === undefined ||
metricMeta.data[baseMetricName] === undefined ||
metricMeta.data[baseMetricName].length < 1 ? (
<>No metric metadata found.</>
) : (
<>
<strong>Metric help</strong>:{" "}
{metricMeta.data[baseMetricName][0].help}
<br />
<strong>Metric type</strong>:{" "}
{metricMeta.data[baseMetricName][0].type}
</>
)}
</Text>
<Divider my="md" />
<Text fz="sm">
{node.type === nodeType.vectorSelector ? (
<>
This node selects the latest (non-stale) sample value within the
last <span className="promql-code promql-duration">5m</span>
</>
) : (
<>
This node selects{" "}
<span className="promql-code promql-duration">
{formatPrometheusDuration(node.range)}
</span>{" "}
of data going backward from the evaluation timestamp
</>
)}
{node.timestamp !== null ? (
<>
, evaluated relative to an absolute evaluation timestamp of{" "}
<span className="promql-number">
{(node.timestamp / 1000).toFixed(3)}
</span>
</>
) : node.startOrEnd !== null ? (
<>, evaluated relative to the {node.startOrEnd} of the query range</>
) : (
<></>
)}
{node.offset === 0 ? (
<></>
) : node.offset > 0 ? (
<>
, time-shifted{" "}
<span className="promql-code promql-duration">
{formatPrometheusDuration(node.offset)}
</span>{" "}
into the past,
</>
) : (
<>
, time-shifted{" "}
<span className="promql-code promql-duration">
{formatPrometheusDuration(-node.offset)}
</span>{" "}
into the future,
</>
)}{" "}
for any series that match all of the following criteria:
</Text>
{matchingCriteriaList(node.name, node.matchers)}
<Text fz="sm">
If a series has no values in the last{" "}
<span className="promql-code promql-duration">
{node.type === nodeType.vectorSelector
? "5m"
: formatPrometheusDuration(node.range)}
</span>
{node.offset > 0 && (
<>
{" "}
(relative to the time-shifted instant{" "}
<span className="promql-code promql-duration">
{formatPrometheusDuration(node.offset)}
</span>{" "}
in the past)
</>
)}
, the series will not be returned.
</Text>
</Card>
);
};
export default SelectorExplainView;

View file

@ -14,6 +14,7 @@ import {
IconChartLine,
IconCheckbox,
IconGraph,
IconInfoCircle,
IconSquare,
IconTable,
} from "@tabler/icons-react";
@ -38,6 +39,7 @@ import TreeView from "./TreeView";
import ErrorBoundary from "../../components/ErrorBoundary";
import ASTNode from "../../promql/ast";
import serializeNode from "../../promql/serialize";
import ExplainView from "./ExplainViews/ExplainView";
export interface PanelProps {
idx: number;
@ -153,6 +155,12 @@ const QueryPanel: FC<PanelProps> = ({ idx, metricNames }) => {
<Tabs.Tab value="graph" leftSection={<IconGraph style={iconStyle} />}>
Graph
</Tabs.Tab>
<Tabs.Tab
value="explain"
leftSection={<IconInfoCircle style={iconStyle} />}
>
Explain
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel pt="sm" value="table">
<TableTab expr={expr} panelIdx={idx} retriggerIdx={retriggerIdx} />
@ -300,6 +308,30 @@ const QueryPanel: FC<PanelProps> = ({ idx, metricNames }) => {
onSelectRange={onSelectRange}
/>
</Tabs.Panel>
<Tabs.Panel pt="sm" value="explain">
<ErrorBoundary
key={selectedNode?.id}
title="Error showing explain view"
>
<Suspense
fallback={
<Box mt="lg">
{Array.from(Array(20), (_, i) => (
<Skeleton key={i} height={30} mb={15} width="100%" />
))}
</Box>
}
>
<ExplainView
node={selectedNode?.node ?? null}
treeShown={panel.showTree}
setShowTree={() => {
dispatch(setShowTree({ idx, showTree: true }));
}}
/>
</Suspense>
</ErrorBoundary>
</Tabs.Panel>
</Tabs>
</Stack>
);

View file

@ -51,14 +51,14 @@ const SeriesName: FC<SeriesNameProps> = ({ labels, format }) => {
}
return (
<div>
<span>
<span className={classes.metricName}>
{labels ? labels.__name__ : ""}
</span>
{"{"}
{labelNodes}
{"}"}
</div>
</span>
);
};

View file

@ -88,6 +88,13 @@ const TreeNode: FC<{
sortedLabelCards: [],
});
// Deselect node when node is unmounted.
useEffect(() => {
return () => {
setSelectedNode(null);
};
}, [setSelectedNode]);
const children = getNodeChildren(node);
const [childStates, setChildStates] = useState<NodeState[]>(
@ -236,12 +243,12 @@ const TreeNode: FC<{
// Connector line between this node and its parent.
<Box pos="absolute" display="inline-block" style={connectorStyle} />
)}
{/* The node itself. */}
{/* The node (visible box) itself. */}
<Box
ref={nodeRef}
w="fit-content"
px={10}
py={5}
py={4}
style={{ borderRadius: 4, flexShrink: 0 }}
className={clsx(classes.nodeText, {
[classes.nodeTextError]: error,

View file

@ -3,7 +3,7 @@ import { useSuspenseAPIQuery } from "../../api/api";
import { useAppSelector } from "../../state/hooks";
import ASTNode from "../../promql/ast";
import TreeNode from "./TreeNode";
import { Box } from "@mantine/core";
import { Card } from "@mantine/core";
const TreeView: FC<{
panelIdx: number;
@ -31,14 +31,14 @@ const TreeView: FC<{
});
return (
<Box fz="sm" style={{ overflowX: "auto" }} pl="sm">
<Card withBorder fz="sm" style={{ overflowX: "auto" }} pl="sm">
<TreeNode
node={data.data}
selectedNode={selectedNode}
setSelectedNode={setSelectedNode}
reverse={false}
/>
</Box>
</Card>
);
};

View file

@ -39,7 +39,22 @@ export const decodePanelOptionsFromURLParams = (query: string): Panel[] => {
panel.showTree = value === "1";
});
decodeSetting("tab", (value) => {
panel.visualizer.activeTab = value === "0" ? "graph" : "table";
// Numeric values are deprecated (from the old UI), but we still support decoding them.
switch (value) {
case "0":
case "graph":
panel.visualizer.activeTab = "graph";
break;
case "1":
case "table":
panel.visualizer.activeTab = "table";
break;
case "explain":
panel.visualizer.activeTab = "explain";
break;
default:
console.log("Unknown tab", value);
}
});
decodeSetting("display_mode", (value) => {
panel.visualizer.displayMode = value as GraphDisplayMode;
@ -125,7 +140,7 @@ export const encodePanelOptionsToURLParams = (
panels.forEach((p, idx) => {
addParam(idx, "expr", p.expr);
addParam(idx, "show_tree", p.showTree ? "1" : "0");
addParam(idx, "tab", p.visualizer.activeTab === "graph" ? "0" : "1");
addParam(idx, "tab", p.visualizer.activeTab);
if (p.visualizer.endTime !== null) {
addParam(idx, "end_input", formatTime(p.visualizer.endTime));
addParam(idx, "moment_input", formatTime(p.visualizer.endTime));