From c0ba4ee2bb865e179eee00d7179bbb1d23d49792 Mon Sep 17 00:00:00 2001 From: Julius Volz Date: Wed, 4 Sep 2024 17:42:15 +0200 Subject: [PATCH] Add beginnings of a PromLens-style tree view Signed-off-by: Julius Volz --- web/api/v1/api.go | 12 + web/api/v1/parse_expr.go | 153 ++++++++ web/ui/mantine-ui/package.json | 1 + .../src/pages/query/ExpressionInput.tsx | 13 + .../mantine-ui/src/pages/query/QueryPanel.tsx | 56 ++- .../mantine-ui/src/pages/query/TableTab.tsx | 5 +- .../src/pages/query/TreeNode.module.css | 36 ++ .../mantine-ui/src/pages/query/TreeNode.tsx | 354 ++++++++++++++++++ .../mantine-ui/src/pages/query/TreeView.tsx | 45 +++ .../src/pages/query/urlStateEncoding.ts | 4 + web/ui/mantine-ui/src/promql.css | 2 +- web/ui/mantine-ui/src/state/queryPageSlice.ts | 11 +- web/ui/package-lock.json | 1 + 13 files changed, 684 insertions(+), 9 deletions(-) create mode 100644 web/api/v1/parse_expr.go create mode 100644 web/ui/mantine-ui/src/pages/query/TreeNode.module.css create mode 100644 web/ui/mantine-ui/src/pages/query/TreeNode.tsx create mode 100644 web/ui/mantine-ui/src/pages/query/TreeView.tsx diff --git a/web/api/v1/api.go b/web/api/v1/api.go index d58be211f..0ec8467fa 100644 --- a/web/api/v1/api.go +++ b/web/api/v1/api.go @@ -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 diff --git a/web/api/v1/parse_expr.go b/web/api/v1/parse_expr.go new file mode 100644 index 000000000..ed14b6829 --- /dev/null +++ b/web/api/v1/parse_expr.go @@ -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 +} diff --git a/web/ui/mantine-ui/package.json b/web/ui/mantine-ui/package.json index c4afcdc92..bb7e5534a 100644 --- a/web/ui/mantine-ui/package.json +++ b/web/ui/mantine-ui/package.json @@ -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", diff --git a/web/ui/mantine-ui/src/pages/query/ExpressionInput.tsx b/web/ui/mantine-ui/src/pages/query/ExpressionInput.tsx index fca715dca..75a578f91 100644 --- a/web/ui/mantine-ui/src/pages/query/ExpressionInput.tsx +++ b/web/ui/mantine-ui/src/pages/query/ExpressionInput.tsx @@ -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 = ({ metricNames, executeQuery, removePanel, + treeShown, + setShowTree, }) => { const theme = useComputedColorScheme(); const { queryHistory } = useAppSelector((state) => state.queryPage); @@ -245,6 +250,14 @@ const ExpressionInput: FC = ({ > Format expression + + } + onClick={() => setShowTree(!treeShown)} + > + {treeShown ? "Hide" : "Show"} tree view + = ({ 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 = ({ idx, metricNames }) => { return ( { @@ -79,10 +100,37 @@ const QueryPanel: FC = ({ 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 && ( + + + {Array.from(Array(20), (_, i) => ( + + ))} + + } + > + + + + )} @@ -107,7 +155,7 @@ const QueryPanel: FC = ({ idx, metricNames }) => { - + = ({ idx, metricNames }) => { = ({ panelIdx, retriggerIdx }) => { +const TableTab: FC = ({ panelIdx, retriggerIdx, expr }) => { const [responseTime, setResponseTime] = useState(0); const [limitResults, setLimitResults] = useState(true); - const { expr, visualizer } = useAppSelector( + const { visualizer } = useAppSelector( (state) => state.queryPage.panels[panelIdx] ); const dispatch = useAppDispatch(); diff --git a/web/ui/mantine-ui/src/pages/query/TreeNode.module.css b/web/ui/mantine-ui/src/pages/query/TreeNode.module.css new file mode 100644 index 000000000..da3b8436b --- /dev/null +++ b/web/ui/mantine-ui/src/pages/query/TreeNode.module.css @@ -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)); +} diff --git a/web/ui/mantine-ui/src/pages/query/TreeNode.tsx b/web/ui/mantine-ui/src/pages/query/TreeNode.tsx new file mode 100644 index 000000000..087f58a91 --- /dev/null +++ b/web/ui/mantine-ui/src/pages/query/TreeNode.tsx @@ -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; + reportNodeState?: (state: NodeState) => void; + reverse: boolean; +}> = ({ + node, + selectedNode, + setSelectedNode, + parentRef, + reportNodeState, + reverse, +}) => { + const nodeID = useId(); + const nodeRef = useRef(null); + const [connectorStyle, setConnectorStyle] = useState({ + 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(0); + const [resultStats, setResultStats] = useState<{ + numSeries: number; + labelCardinalities: Record; + labelExamples: Record; + }>({ + numSeries: 0, + labelCardinalities: {}, + labelExamples: {}, + }); + + const children = getNodeChildren(node); + + const [childStates, setChildStates] = useState( + children.map(() => "waiting") + ); + const mergedChildState = useMemo( + () => mergeChildStates(childStates), + [childStates] + ); + + const { data, error, isFetching, refetch } = useAPIQuery({ + 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> = {}; + 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 = {}; + const labelExamples: Record = + {}; + 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 = ( + + {parentRef && ( + // Connector line between this node and its parent. + + )} + {/* The node itself. */} + { + if (selectedNode?.id === nodeID) { + setSelectedNode(null); + } else { + setSelectedNode({ id: nodeID, node: node }); + } + }} + > + {formatNode(node, false, 1)} + + {mergedChildState === "waiting" ? ( + + + + ) : mergedChildState === "running" ? ( + + ) : mergedChildState === "error" ? ( + + Blocked on child query error + + ) : isFetching ? ( + + ) : error ? ( + + + + Error executing query: {error.message} + + + ) : ( + + {resultStats.numSeries} result{resultStats.numSeries !== 1 && "s"} –{" "} + {responseTime}ms + {/* {resultStats.numSeries > 0 && ( + <> + {labelNames.length > 0 ? ( + labelNames.map((v, idx) => ( + + {idx !== 0 && ", "} + {v} + + )) + ) : ( + <>no labels + )} + + )} */} + + )} + + ); + + if (node.type === nodeType.binaryExpr) { + return ( +
+ + { + setChildStates((prev) => { + const newStates = [...prev]; + newStates[0] = state; + return newStates; + }); + }} + /> + + {innerNode} + + { + setChildStates((prev) => { + const newStates = [...prev]; + newStates[1] = state; + return newStates; + }); + }} + /> + +
+ ); + } else { + return ( +
+ {innerNode} + {children.map((child, idx) => ( + + { + setChildStates((prev) => { + const newStates = [...prev]; + newStates[idx] = state; + return newStates; + }); + }} + /> + + ))} +
+ ); + } +}; + +export default TreeNode; diff --git a/web/ui/mantine-ui/src/pages/query/TreeView.tsx b/web/ui/mantine-ui/src/pages/query/TreeView.tsx new file mode 100644 index 000000000..900acaca5 --- /dev/null +++ b/web/ui/mantine-ui/src/pages/query/TreeView.tsx @@ -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({ + path: "/parse_query", + params: { + query: expr, + }, + enabled: expr !== "", + }); + + return ( + + + + ); +}; + +export default TreeView; diff --git a/web/ui/mantine-ui/src/pages/query/urlStateEncoding.ts b/web/ui/mantine-ui/src/pages/query/urlStateEncoding.ts index 5d07bde20..86b85b3c8 100644 --- a/web/ui/mantine-ui/src/pages/query/urlStateEncoding.ts +++ b/web/ui/mantine-ui/src/pages/query/urlStateEncoding.ts @@ -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)); diff --git a/web/ui/mantine-ui/src/promql.css b/web/ui/mantine-ui/src/promql.css index 58dcaf3d3..5337590bb 100644 --- a/web/ui/mantine-ui/src/promql.css +++ b/web/ui/mantine-ui/src/promql.css @@ -7,7 +7,7 @@ } .promql-metric-name { - color: light-dark(#000, #fff); + /* color: var(--input-color); */ } .promql-label-name { diff --git a/web/ui/mantine-ui/src/state/queryPageSlice.ts b/web/ui/mantine-ui/src/state/queryPageSlice.ts index 4f58d549d..253b3ee9c 100644 --- a/web/ui/mantine-ui/src/state/queryPageSlice.ts +++ b/web/ui/mantine-ui/src/state/queryPageSlice.ts @@ -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 }> diff --git a/web/ui/package-lock.json b/web/ui/package-lock.json index 27268dcd2..26cadf8bc 100644 --- a/web/ui/package-lock.json +++ b/web/ui/package-lock.json @@ -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",