mirror of
https://github.com/prometheus/prometheus.git
synced 2024-11-09 23:24:05 -08:00
Add beginnings of a PromLens-style tree view
Signed-off-by: Julius Volz <julius.volz@gmail.com>
This commit is contained in:
parent
e5e212dad2
commit
c0ba4ee2bb
|
@ -366,6 +366,9 @@ func (api *API) Register(r *route.Router) {
|
|||
r.Get("/format_query", wrapAgent(api.formatQuery))
|
||||
r.Post("/format_query", wrapAgent(api.formatQuery))
|
||||
|
||||
r.Get("/parse_query", wrapAgent(api.parseQuery))
|
||||
r.Post("/parse_query", wrapAgent(api.parseQuery))
|
||||
|
||||
r.Get("/labels", wrapAgent(api.labelNames))
|
||||
r.Post("/labels", wrapAgent(api.labelNames))
|
||||
r.Get("/label/:name/values", wrapAgent(api.labelValues))
|
||||
|
@ -485,6 +488,15 @@ func (api *API) formatQuery(r *http.Request) (result apiFuncResult) {
|
|||
return apiFuncResult{expr.Pretty(0), nil, nil, nil}
|
||||
}
|
||||
|
||||
func (api *API) parseQuery(r *http.Request) apiFuncResult {
|
||||
expr, err := parser.ParseExpr(r.FormValue("query"))
|
||||
if err != nil {
|
||||
return invalidParamError(err, "query")
|
||||
}
|
||||
|
||||
return apiFuncResult{data: translateAST(expr), err: nil, warnings: nil, finalizer: nil}
|
||||
}
|
||||
|
||||
func extractQueryOpts(r *http.Request) (promql.QueryOpts, error) {
|
||||
var duration time.Duration
|
||||
|
||||
|
|
153
web/api/v1/parse_expr.go
Normal file
153
web/api/v1/parse_expr.go
Normal file
|
@ -0,0 +1,153 @@
|
|||
// Copyright 2024 The Prometheus Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package v1
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/prometheus/prometheus/model/labels"
|
||||
"github.com/prometheus/prometheus/promql/parser"
|
||||
)
|
||||
|
||||
func getStartOrEnd(startOrEnd parser.ItemType) interface{} {
|
||||
if startOrEnd == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return startOrEnd.String()
|
||||
}
|
||||
|
||||
func translateAST(node parser.Expr) interface{} {
|
||||
if node == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch n := node.(type) {
|
||||
case *parser.AggregateExpr:
|
||||
return map[string]interface{}{
|
||||
"type": "aggregation",
|
||||
"op": n.Op.String(),
|
||||
"expr": translateAST(n.Expr),
|
||||
"param": translateAST(n.Param),
|
||||
"grouping": sanitizeList(n.Grouping),
|
||||
"without": n.Without,
|
||||
}
|
||||
case *parser.BinaryExpr:
|
||||
var matching interface{}
|
||||
if m := n.VectorMatching; m != nil {
|
||||
matching = map[string]interface{}{
|
||||
"card": m.Card.String(),
|
||||
"labels": sanitizeList(m.MatchingLabels),
|
||||
"on": m.On,
|
||||
"include": sanitizeList(m.Include),
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"type": "binaryExpr",
|
||||
"op": n.Op.String(),
|
||||
"lhs": translateAST(n.LHS),
|
||||
"rhs": translateAST(n.RHS),
|
||||
"matching": matching,
|
||||
"bool": n.ReturnBool,
|
||||
}
|
||||
case *parser.Call:
|
||||
args := []interface{}{}
|
||||
for _, arg := range n.Args {
|
||||
args = append(args, translateAST(arg))
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"type": "call",
|
||||
"func": map[string]interface{}{
|
||||
"name": n.Func.Name,
|
||||
"argTypes": n.Func.ArgTypes,
|
||||
"variadic": n.Func.Variadic,
|
||||
"returnType": n.Func.ReturnType,
|
||||
},
|
||||
"args": args,
|
||||
}
|
||||
case *parser.MatrixSelector:
|
||||
vs := n.VectorSelector.(*parser.VectorSelector)
|
||||
return map[string]interface{}{
|
||||
"type": "matrixSelector",
|
||||
"name": vs.Name,
|
||||
"range": n.Range.Milliseconds(),
|
||||
"offset": vs.OriginalOffset.Milliseconds(),
|
||||
"matchers": translateMatchers(vs.LabelMatchers),
|
||||
"timestamp": vs.Timestamp,
|
||||
"startOrEnd": getStartOrEnd(vs.StartOrEnd),
|
||||
}
|
||||
case *parser.SubqueryExpr:
|
||||
return map[string]interface{}{
|
||||
"type": "subquery",
|
||||
"expr": translateAST(n.Expr),
|
||||
"range": n.Range.Milliseconds(),
|
||||
"offset": n.OriginalOffset.Milliseconds(),
|
||||
"step": n.Step.Milliseconds(),
|
||||
"timestamp": n.Timestamp,
|
||||
"startOrEnd": getStartOrEnd(n.StartOrEnd),
|
||||
}
|
||||
case *parser.NumberLiteral:
|
||||
return map[string]string{
|
||||
"type": "numberLiteral",
|
||||
"val": strconv.FormatFloat(n.Val, 'f', -1, 64),
|
||||
}
|
||||
case *parser.ParenExpr:
|
||||
return map[string]interface{}{
|
||||
"type": "parenExpr",
|
||||
"expr": translateAST(n.Expr),
|
||||
}
|
||||
case *parser.StringLiteral:
|
||||
return map[string]interface{}{
|
||||
"type": "stringLiteral",
|
||||
"val": n.Val,
|
||||
}
|
||||
case *parser.UnaryExpr:
|
||||
return map[string]interface{}{
|
||||
"type": "unaryExpr",
|
||||
"op": n.Op.String(),
|
||||
"expr": translateAST(n.Expr),
|
||||
}
|
||||
case *parser.VectorSelector:
|
||||
return map[string]interface{}{
|
||||
"type": "vectorSelector",
|
||||
"name": n.Name,
|
||||
"offset": n.OriginalOffset.Milliseconds(),
|
||||
"matchers": translateMatchers(n.LabelMatchers),
|
||||
"timestamp": n.Timestamp,
|
||||
"startOrEnd": getStartOrEnd(n.StartOrEnd),
|
||||
}
|
||||
}
|
||||
panic("unsupported node type")
|
||||
}
|
||||
|
||||
func sanitizeList(l []string) []string {
|
||||
if l == nil {
|
||||
return []string{}
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func translateMatchers(in []*labels.Matcher) interface{} {
|
||||
out := []map[string]interface{}{}
|
||||
for _, m := range in {
|
||||
out = append(out, map[string]interface{}{
|
||||
"name": m.Name,
|
||||
"value": m.Value,
|
||||
"type": m.Type.String(),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
|
@ -35,6 +35,7 @@
|
|||
"@types/lodash": "^4.17.7",
|
||||
"@types/sanitize-html": "^2.13.0",
|
||||
"@uiw/react-codemirror": "^4.21.22",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.10",
|
||||
"lodash": "^4.17.21",
|
||||
"react": "^18.3.1",
|
||||
|
|
|
@ -58,6 +58,7 @@ import { highlightSelectionMatches } from "@codemirror/search";
|
|||
import { lintKeymap } from "@codemirror/lint";
|
||||
import {
|
||||
IconAlignJustified,
|
||||
IconBinaryTree,
|
||||
IconDotsVertical,
|
||||
IconSearch,
|
||||
IconTerminal,
|
||||
|
@ -118,6 +119,8 @@ interface ExpressionInputProps {
|
|||
initialExpr: string;
|
||||
metricNames: string[];
|
||||
executeQuery: (expr: string) => void;
|
||||
treeShown: boolean;
|
||||
setShowTree: (showTree: boolean) => void;
|
||||
removePanel: () => void;
|
||||
}
|
||||
|
||||
|
@ -126,6 +129,8 @@ const ExpressionInput: FC<ExpressionInputProps> = ({
|
|||
metricNames,
|
||||
executeQuery,
|
||||
removePanel,
|
||||
treeShown,
|
||||
setShowTree,
|
||||
}) => {
|
||||
const theme = useComputedColorScheme();
|
||||
const { queryHistory } = useAppSelector((state) => state.queryPage);
|
||||
|
@ -245,6 +250,14 @@ const ExpressionInput: FC<ExpressionInputProps> = ({
|
|||
>
|
||||
Format expression
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
<IconBinaryTree style={{ width: rem(14), height: rem(14) }} />
|
||||
}
|
||||
onClick={() => setShowTree(!treeShown)}
|
||||
>
|
||||
{treeShown ? "Hide" : "Show"} tree view
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={
|
||||
|
|
|
@ -6,15 +6,18 @@ import {
|
|||
Box,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Button,
|
||||
Skeleton,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconChartAreaFilled,
|
||||
IconChartGridDots,
|
||||
IconChartLine,
|
||||
IconCheckbox,
|
||||
IconGraph,
|
||||
IconSquare,
|
||||
IconTable,
|
||||
} from "@tabler/icons-react";
|
||||
import { FC, useCallback, useState } from "react";
|
||||
import { FC, Suspense, useCallback, useMemo, useState } from "react";
|
||||
import { useAppDispatch, useAppSelector } from "../../state/hooks";
|
||||
import {
|
||||
addQueryToHistory,
|
||||
|
@ -22,6 +25,7 @@ import {
|
|||
GraphResolution,
|
||||
removePanel,
|
||||
setExpr,
|
||||
setShowTree,
|
||||
setVisualizer,
|
||||
} from "../../state/queryPageSlice";
|
||||
import TimeInput from "./TimeInput";
|
||||
|
@ -30,6 +34,10 @@ import ExpressionInput from "./ExpressionInput";
|
|||
import Graph from "./Graph";
|
||||
import ResolutionInput from "./ResolutionInput";
|
||||
import TableTab from "./TableTab";
|
||||
import TreeView from "./TreeView";
|
||||
import ErrorBoundary from "../../components/ErrorBoundary";
|
||||
import ASTNode from "../../promql/ast";
|
||||
import serializeNode from "../../promql/serialize";
|
||||
|
||||
export interface PanelProps {
|
||||
idx: number;
|
||||
|
@ -48,6 +56,17 @@ const QueryPanel: FC<PanelProps> = ({ idx, metricNames }) => {
|
|||
const panel = useAppSelector((state) => state.queryPage.panels[idx]);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [selectedNode, setSelectedNode] = useState<{
|
||||
id: string;
|
||||
node: ASTNode;
|
||||
} | null>(null);
|
||||
|
||||
const expr = useMemo(
|
||||
() =>
|
||||
selectedNode !== null ? serializeNode(selectedNode.node) : panel.expr,
|
||||
[selectedNode, panel.expr]
|
||||
);
|
||||
|
||||
const onSelectRange = useCallback(
|
||||
(start: number, end: number) =>
|
||||
dispatch(
|
||||
|
@ -69,6 +88,8 @@ const QueryPanel: FC<PanelProps> = ({ idx, metricNames }) => {
|
|||
return (
|
||||
<Stack gap="lg">
|
||||
<ExpressionInput
|
||||
// TODO: Maybe just pass the panelIdx and retriggerIdx to the ExpressionInput
|
||||
// so it can manage its own state?
|
||||
initialExpr={panel.expr}
|
||||
metricNames={metricNames}
|
||||
executeQuery={(expr: string) => {
|
||||
|
@ -79,10 +100,37 @@ const QueryPanel: FC<PanelProps> = ({ idx, metricNames }) => {
|
|||
dispatch(addQueryToHistory(expr));
|
||||
}
|
||||
}}
|
||||
treeShown={panel.showTree}
|
||||
setShowTree={(showTree: boolean) => {
|
||||
dispatch(setShowTree({ idx, showTree }));
|
||||
if (!showTree) {
|
||||
setSelectedNode(null);
|
||||
}
|
||||
}}
|
||||
removePanel={() => {
|
||||
dispatch(removePanel(idx));
|
||||
}}
|
||||
/>
|
||||
{panel.expr.trim() !== "" && panel.showTree && (
|
||||
<ErrorBoundary key={retriggerIdx} title="Error showing tree view">
|
||||
<Suspense
|
||||
fallback={
|
||||
<Box mt="lg">
|
||||
{Array.from(Array(20), (_, i) => (
|
||||
<Skeleton key={i} height={30} mb={15} width="100%" />
|
||||
))}
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<TreeView
|
||||
panelIdx={idx}
|
||||
retriggerIdx={retriggerIdx}
|
||||
selectedNode={selectedNode}
|
||||
setSelectedNode={setSelectedNode}
|
||||
/>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
<Tabs
|
||||
value={panel.visualizer.activeTab}
|
||||
onChange={(v) =>
|
||||
|
@ -107,7 +155,7 @@ const QueryPanel: FC<PanelProps> = ({ idx, metricNames }) => {
|
|||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<Tabs.Panel pt="sm" value="table">
|
||||
<TableTab panelIdx={idx} retriggerIdx={retriggerIdx} />
|
||||
<TableTab expr={expr} panelIdx={idx} retriggerIdx={retriggerIdx} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel
|
||||
pt="sm"
|
||||
|
@ -236,7 +284,7 @@ const QueryPanel: FC<PanelProps> = ({ idx, metricNames }) => {
|
|||
</Group>
|
||||
<Space h="lg" />
|
||||
<Graph
|
||||
expr={panel.expr}
|
||||
expr={expr}
|
||||
endTime={panel.visualizer.endTime}
|
||||
range={panel.visualizer.range}
|
||||
resolution={panel.visualizer.resolution}
|
||||
|
|
|
@ -14,13 +14,14 @@ dayjs.extend(timezone);
|
|||
export interface TableTabProps {
|
||||
panelIdx: number;
|
||||
retriggerIdx: number;
|
||||
expr: string;
|
||||
}
|
||||
|
||||
const TableTab: FC<TableTabProps> = ({ panelIdx, retriggerIdx }) => {
|
||||
const TableTab: FC<TableTabProps> = ({ panelIdx, retriggerIdx, expr }) => {
|
||||
const [responseTime, setResponseTime] = useState<number>(0);
|
||||
const [limitResults, setLimitResults] = useState<boolean>(true);
|
||||
|
||||
const { expr, visualizer } = useAppSelector(
|
||||
const { visualizer } = useAppSelector(
|
||||
(state) => state.queryPage.panels[panelIdx]
|
||||
);
|
||||
const dispatch = useAppDispatch();
|
||||
|
|
36
web/ui/mantine-ui/src/pages/query/TreeNode.module.css
Normal file
36
web/ui/mantine-ui/src/pages/query/TreeNode.module.css
Normal file
|
@ -0,0 +1,36 @@
|
|||
.nodeText {
|
||||
cursor: pointer;
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-dark-5)
|
||||
);
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.nodeText.nodeTextSelected,
|
||||
.nodeText.nodeTextSelected:hover {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-3),
|
||||
var(--mantine-color-dark-3)
|
||||
);
|
||||
border: 2px solid
|
||||
light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-2));
|
||||
}
|
||||
|
||||
.nodeText:hover {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-2),
|
||||
var(--mantine-color-dark-4)
|
||||
);
|
||||
}
|
||||
|
||||
.nodeText.nodeTextError {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-red-1),
|
||||
darken(var(--mantine-color-red-5), 70%)
|
||||
);
|
||||
}
|
||||
|
||||
.errorText {
|
||||
color: light-dark(var(--mantine-color-red-9), var(--mantine-color-red-3));
|
||||
}
|
354
web/ui/mantine-ui/src/pages/query/TreeNode.tsx
Normal file
354
web/ui/mantine-ui/src/pages/query/TreeNode.tsx
Normal file
|
@ -0,0 +1,354 @@
|
|||
import {
|
||||
FC,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import ASTNode, { nodeType } from "../../promql/ast";
|
||||
import { getNodeChildren } from "../../promql/utils";
|
||||
import { formatNode } from "../../promql/format";
|
||||
import { Box, CSSProperties, Group, Loader, Text } from "@mantine/core";
|
||||
import { useAPIQuery } from "../../api/api";
|
||||
import {
|
||||
InstantQueryResult,
|
||||
InstantSample,
|
||||
RangeSamples,
|
||||
} from "../../api/responseTypes/query";
|
||||
import serializeNode from "../../promql/serialize";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconPoint,
|
||||
IconPointFilled,
|
||||
} from "@tabler/icons-react";
|
||||
import classes from "./TreeNode.module.css";
|
||||
import clsx from "clsx";
|
||||
import { useId } from "@mantine/hooks";
|
||||
|
||||
const nodeIndent = 20;
|
||||
|
||||
type NodeState = "waiting" | "running" | "error" | "success";
|
||||
|
||||
const mergeChildStates = (states: NodeState[]): NodeState => {
|
||||
if (states.includes("error")) {
|
||||
return "error";
|
||||
}
|
||||
if (states.includes("waiting")) {
|
||||
return "waiting";
|
||||
}
|
||||
if (states.includes("running")) {
|
||||
return "running";
|
||||
}
|
||||
|
||||
return "success";
|
||||
};
|
||||
|
||||
const TreeNode: FC<{
|
||||
node: ASTNode;
|
||||
selectedNode: { id: string; node: ASTNode } | null;
|
||||
setSelectedNode: (Node: { id: string; node: ASTNode } | null) => void;
|
||||
parentRef?: React.RefObject<HTMLDivElement>;
|
||||
reportNodeState?: (state: NodeState) => void;
|
||||
reverse: boolean;
|
||||
}> = ({
|
||||
node,
|
||||
selectedNode,
|
||||
setSelectedNode,
|
||||
parentRef,
|
||||
reportNodeState,
|
||||
reverse,
|
||||
}) => {
|
||||
const nodeID = useId();
|
||||
const nodeRef = useRef<HTMLDivElement>(null);
|
||||
const [connectorStyle, setConnectorStyle] = useState<CSSProperties>({
|
||||
borderColor:
|
||||
"light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3))",
|
||||
borderLeftStyle: "solid",
|
||||
borderLeftWidth: 2,
|
||||
width: nodeIndent - 7,
|
||||
left: -nodeIndent + 7,
|
||||
});
|
||||
const [responseTime, setResponseTime] = useState<number>(0);
|
||||
const [resultStats, setResultStats] = useState<{
|
||||
numSeries: number;
|
||||
labelCardinalities: Record<string, number>;
|
||||
labelExamples: Record<string, { value: string; count: number }[]>;
|
||||
}>({
|
||||
numSeries: 0,
|
||||
labelCardinalities: {},
|
||||
labelExamples: {},
|
||||
});
|
||||
|
||||
const children = getNodeChildren(node);
|
||||
|
||||
const [childStates, setChildStates] = useState<NodeState[]>(
|
||||
children.map(() => "waiting")
|
||||
);
|
||||
const mergedChildState = useMemo(
|
||||
() => mergeChildStates(childStates),
|
||||
[childStates]
|
||||
);
|
||||
|
||||
const { data, error, isFetching, refetch } = useAPIQuery<InstantQueryResult>({
|
||||
key: [useId()],
|
||||
path: "/query",
|
||||
params: {
|
||||
query: serializeNode(node),
|
||||
},
|
||||
recordResponseTime: setResponseTime,
|
||||
enabled: mergedChildState === "success",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (mergedChildState === "error") {
|
||||
reportNodeState && reportNodeState("error");
|
||||
}
|
||||
}, [mergedChildState, reportNodeState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
reportNodeState && reportNodeState("error");
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFetching) {
|
||||
reportNodeState && reportNodeState("running");
|
||||
}
|
||||
}, [isFetching]);
|
||||
|
||||
// Update the size and position of tree connector lines based on the node's and its parent's position.
|
||||
useLayoutEffect(() => {
|
||||
if (parentRef === undefined) {
|
||||
// We're the root node.
|
||||
return;
|
||||
}
|
||||
|
||||
if (parentRef.current === null || nodeRef.current === null) {
|
||||
return;
|
||||
}
|
||||
const parentRect = parentRef.current.getBoundingClientRect();
|
||||
const nodeRect = nodeRef.current.getBoundingClientRect();
|
||||
if (reverse) {
|
||||
setConnectorStyle((prevStyle) => ({
|
||||
...prevStyle,
|
||||
top: "calc(50% - 1px)",
|
||||
bottom: nodeRect.bottom - parentRect.top,
|
||||
borderTopLeftRadius: 3,
|
||||
borderTopStyle: "solid",
|
||||
borderBottomLeftRadius: undefined,
|
||||
}));
|
||||
} else {
|
||||
setConnectorStyle((prevStyle) => ({
|
||||
...prevStyle,
|
||||
top: parentRect.bottom - nodeRect.top,
|
||||
bottom: "calc(50% - 1px)",
|
||||
borderBottomLeftRadius: 3,
|
||||
borderBottomStyle: "solid",
|
||||
borderTopLeftRadius: undefined,
|
||||
}));
|
||||
}
|
||||
}, [parentRef, reverse, nodeRef]);
|
||||
|
||||
// Update the node info state based on the query result.
|
||||
useEffect(() => {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
reportNodeState && reportNodeState("success");
|
||||
|
||||
let resultSeries = 0;
|
||||
const labelValuesByName: Record<string, Record<string, number>> = {};
|
||||
const { resultType, result } = data.data;
|
||||
|
||||
if (resultType === "scalar" || resultType === "string") {
|
||||
resultSeries = 1;
|
||||
} else if (result && result.length > 0) {
|
||||
resultSeries = result.length;
|
||||
result.forEach((s: InstantSample | RangeSamples) => {
|
||||
Object.entries(s.metric).forEach(([ln, lv]) => {
|
||||
// TODO: If we ever want to include __name__ here again, we cannot use the
|
||||
// count_over_time(foo[7d]) optimization since that removes the metric name.
|
||||
if (ln !== "__name__") {
|
||||
if (!labelValuesByName.hasOwnProperty(ln)) {
|
||||
labelValuesByName[ln] = { [lv]: 1 };
|
||||
} else {
|
||||
if (!labelValuesByName[ln].hasOwnProperty(lv)) {
|
||||
labelValuesByName[ln][lv] = 1;
|
||||
} else {
|
||||
labelValuesByName[ln][lv]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const labelCardinalities: Record<string, number> = {};
|
||||
const labelExamples: Record<string, { value: string; count: number }[]> =
|
||||
{};
|
||||
Object.entries(labelValuesByName).forEach(([ln, lvs]) => {
|
||||
labelCardinalities[ln] = Object.keys(lvs).length;
|
||||
// Sort label values by their number of occurrences within this label name.
|
||||
labelExamples[ln] = Object.entries(lvs)
|
||||
.sort(([, aCnt], [, bCnt]) => bCnt - aCnt)
|
||||
.slice(0, 5)
|
||||
.map(([lv, cnt]) => ({ value: lv, count: cnt }));
|
||||
});
|
||||
|
||||
setResultStats({
|
||||
numSeries: resultSeries,
|
||||
labelCardinalities,
|
||||
labelExamples,
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
const innerNode = (
|
||||
<Group
|
||||
w="fit-content"
|
||||
gap="sm"
|
||||
my="sm"
|
||||
wrap="nowrap"
|
||||
pos="relative"
|
||||
align="center"
|
||||
>
|
||||
{parentRef && (
|
||||
// Connector line between this node and its parent.
|
||||
<Box pos="absolute" display="inline-block" style={connectorStyle} />
|
||||
)}
|
||||
{/* The node itself. */}
|
||||
<Box
|
||||
ref={nodeRef}
|
||||
w="fit-content"
|
||||
px={10}
|
||||
py={5}
|
||||
style={{ borderRadius: 4, flexShrink: 0 }}
|
||||
className={clsx(classes.nodeText, {
|
||||
[classes.nodeTextError]: error,
|
||||
[classes.nodeTextSelected]: selectedNode?.id === nodeID,
|
||||
})}
|
||||
onClick={() => {
|
||||
if (selectedNode?.id === nodeID) {
|
||||
setSelectedNode(null);
|
||||
} else {
|
||||
setSelectedNode({ id: nodeID, node: node });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{formatNode(node, false, 1)}
|
||||
</Box>
|
||||
{mergedChildState === "waiting" ? (
|
||||
<Group c="gray">
|
||||
<IconPointFilled size={18} />
|
||||
</Group>
|
||||
) : mergedChildState === "running" ? (
|
||||
<Loader size={14} color="gray" type="dots" />
|
||||
) : mergedChildState === "error" ? (
|
||||
<Group c="orange.7" gap={5} fz="xs" wrap="nowrap">
|
||||
<IconPointFilled size={18} /> Blocked on child query error
|
||||
</Group>
|
||||
) : isFetching ? (
|
||||
<Loader size={14} color="gray" />
|
||||
) : error ? (
|
||||
<Group
|
||||
gap={5}
|
||||
wrap="nowrap"
|
||||
style={{ flexShrink: 0 }}
|
||||
className={classes.errorText}
|
||||
>
|
||||
<IconPointFilled size={18} />
|
||||
<Text fz="xs">
|
||||
<strong>Error executing query:</strong> {error.message}
|
||||
</Text>
|
||||
</Group>
|
||||
) : (
|
||||
<Text c="dimmed" fz="xs">
|
||||
{resultStats.numSeries} result{resultStats.numSeries !== 1 && "s"} –{" "}
|
||||
{responseTime}ms
|
||||
{/* {resultStats.numSeries > 0 && (
|
||||
<>
|
||||
{labelNames.length > 0 ? (
|
||||
labelNames.map((v, idx) => (
|
||||
<React.Fragment key={idx}>
|
||||
{idx !== 0 && ", "}
|
||||
{v}
|
||||
</React.Fragment>
|
||||
))
|
||||
) : (
|
||||
<>no labels</>
|
||||
)}
|
||||
</>
|
||||
)} */}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
|
||||
if (node.type === nodeType.binaryExpr) {
|
||||
return (
|
||||
<div>
|
||||
<Box ml={nodeIndent}>
|
||||
<TreeNode
|
||||
node={children[0]}
|
||||
selectedNode={selectedNode}
|
||||
setSelectedNode={setSelectedNode}
|
||||
parentRef={nodeRef}
|
||||
reverse={true}
|
||||
reportNodeState={(state: NodeState) => {
|
||||
setChildStates((prev) => {
|
||||
const newStates = [...prev];
|
||||
newStates[0] = state;
|
||||
return newStates;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
{innerNode}
|
||||
<Box ml={nodeIndent}>
|
||||
<TreeNode
|
||||
node={children[1]}
|
||||
selectedNode={selectedNode}
|
||||
setSelectedNode={setSelectedNode}
|
||||
parentRef={nodeRef}
|
||||
reverse={false}
|
||||
reportNodeState={(state: NodeState) => {
|
||||
setChildStates((prev) => {
|
||||
const newStates = [...prev];
|
||||
newStates[1] = state;
|
||||
return newStates;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div>
|
||||
{innerNode}
|
||||
{children.map((child, idx) => (
|
||||
<Box ml={nodeIndent} key={idx}>
|
||||
<TreeNode
|
||||
node={child}
|
||||
selectedNode={selectedNode}
|
||||
setSelectedNode={setSelectedNode}
|
||||
parentRef={nodeRef}
|
||||
reverse={false}
|
||||
reportNodeState={(state: NodeState) => {
|
||||
setChildStates((prev) => {
|
||||
const newStates = [...prev];
|
||||
newStates[idx] = state;
|
||||
return newStates;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default TreeNode;
|
45
web/ui/mantine-ui/src/pages/query/TreeView.tsx
Normal file
45
web/ui/mantine-ui/src/pages/query/TreeView.tsx
Normal file
|
@ -0,0 +1,45 @@
|
|||
import { FC, useState } from "react";
|
||||
import { useSuspenseAPIQuery } from "../../api/api";
|
||||
import { useAppSelector } from "../../state/hooks";
|
||||
import ASTNode from "../../promql/ast";
|
||||
import TreeNode from "./TreeNode";
|
||||
import { Box } from "@mantine/core";
|
||||
|
||||
const TreeView: FC<{
|
||||
panelIdx: number;
|
||||
// TODO: Do we need retriggerIdx for the tree view AST parsing? Maybe for children!
|
||||
retriggerIdx: number;
|
||||
selectedNode: {
|
||||
id: string;
|
||||
node: ASTNode;
|
||||
} | null;
|
||||
setSelectedNode: (
|
||||
node: {
|
||||
id: string;
|
||||
node: ASTNode;
|
||||
} | null
|
||||
) => void;
|
||||
}> = ({ panelIdx, selectedNode, setSelectedNode }) => {
|
||||
const { expr } = useAppSelector((state) => state.queryPage.panels[panelIdx]);
|
||||
|
||||
const { data } = useSuspenseAPIQuery<ASTNode>({
|
||||
path: "/parse_query",
|
||||
params: {
|
||||
query: expr,
|
||||
},
|
||||
enabled: expr !== "",
|
||||
});
|
||||
|
||||
return (
|
||||
<Box fz="sm" style={{ overflowX: "auto" }} pl="sm">
|
||||
<TreeNode
|
||||
node={data.data}
|
||||
selectedNode={selectedNode}
|
||||
setSelectedNode={setSelectedNode}
|
||||
reverse={false}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default TreeView;
|
|
@ -35,6 +35,9 @@ export const decodePanelOptionsFromURLParams = (query: string): Panel[] => {
|
|||
decodeSetting("expr", (value) => {
|
||||
panel.expr = value;
|
||||
});
|
||||
decodeSetting("show_tree", (value) => {
|
||||
panel.showTree = value === "1";
|
||||
});
|
||||
decodeSetting("tab", (value) => {
|
||||
panel.visualizer.activeTab = value === "0" ? "graph" : "table";
|
||||
});
|
||||
|
@ -121,6 +124,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");
|
||||
if (p.visualizer.endTime !== null) {
|
||||
addParam(idx, "end_input", formatTime(p.visualizer.endTime));
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
}
|
||||
|
||||
.promql-metric-name {
|
||||
color: light-dark(#000, #fff);
|
||||
/* color: var(--input-color); */
|
||||
}
|
||||
|
||||
.promql-label-name {
|
||||
|
|
|
@ -64,7 +64,7 @@ export type Panel = {
|
|||
// The id is helpful as a stable key for React.
|
||||
id: string;
|
||||
expr: string;
|
||||
exprStale: boolean;
|
||||
showTree: boolean;
|
||||
showMetricsExplorer: boolean;
|
||||
visualizer: Visualizer;
|
||||
};
|
||||
|
@ -77,7 +77,7 @@ interface QueryPageState {
|
|||
export const newDefaultPanel = (): Panel => ({
|
||||
id: randomId(),
|
||||
expr: "",
|
||||
exprStale: false,
|
||||
showTree: false,
|
||||
showMetricsExplorer: false,
|
||||
visualizer: {
|
||||
activeTab: "table",
|
||||
|
@ -130,6 +130,13 @@ export const queryPageSlice = createSlice({
|
|||
...state.queryHistory.filter((q) => q !== query),
|
||||
].slice(0, 50);
|
||||
},
|
||||
setShowTree: (
|
||||
state,
|
||||
{ payload }: PayloadAction<{ idx: number; showTree: boolean }>
|
||||
) => {
|
||||
state.panels[payload.idx].showTree = payload.showTree;
|
||||
updateURL(state.panels);
|
||||
},
|
||||
setVisualizer: (
|
||||
state,
|
||||
{ payload }: PayloadAction<{ idx: number; visualizer: Visualizer }>
|
||||
|
|
1
web/ui/package-lock.json
generated
1
web/ui/package-lock.json
generated
|
@ -48,6 +48,7 @@
|
|||
"@types/lodash": "^4.17.7",
|
||||
"@types/sanitize-html": "^2.13.0",
|
||||
"@uiw/react-codemirror": "^4.21.22",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.10",
|
||||
"lodash": "^4.17.21",
|
||||
"react": "^18.3.1",
|
||||
|
|
Loading…
Reference in a new issue