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.Get("/format_query", wrapAgent(api.formatQuery))
|
||||||
r.Post("/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.Get("/labels", wrapAgent(api.labelNames))
|
||||||
r.Post("/labels", wrapAgent(api.labelNames))
|
r.Post("/labels", wrapAgent(api.labelNames))
|
||||||
r.Get("/label/:name/values", wrapAgent(api.labelValues))
|
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}
|
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) {
|
func extractQueryOpts(r *http.Request) (promql.QueryOpts, error) {
|
||||||
var duration time.Duration
|
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/lodash": "^4.17.7",
|
||||||
"@types/sanitize-html": "^2.13.0",
|
"@types/sanitize-html": "^2.13.0",
|
||||||
"@uiw/react-codemirror": "^4.21.22",
|
"@uiw/react-codemirror": "^4.21.22",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
|
|
@ -58,6 +58,7 @@ import { highlightSelectionMatches } from "@codemirror/search";
|
||||||
import { lintKeymap } from "@codemirror/lint";
|
import { lintKeymap } from "@codemirror/lint";
|
||||||
import {
|
import {
|
||||||
IconAlignJustified,
|
IconAlignJustified,
|
||||||
|
IconBinaryTree,
|
||||||
IconDotsVertical,
|
IconDotsVertical,
|
||||||
IconSearch,
|
IconSearch,
|
||||||
IconTerminal,
|
IconTerminal,
|
||||||
|
@ -118,6 +119,8 @@ interface ExpressionInputProps {
|
||||||
initialExpr: string;
|
initialExpr: string;
|
||||||
metricNames: string[];
|
metricNames: string[];
|
||||||
executeQuery: (expr: string) => void;
|
executeQuery: (expr: string) => void;
|
||||||
|
treeShown: boolean;
|
||||||
|
setShowTree: (showTree: boolean) => void;
|
||||||
removePanel: () => void;
|
removePanel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -126,6 +129,8 @@ const ExpressionInput: FC<ExpressionInputProps> = ({
|
||||||
metricNames,
|
metricNames,
|
||||||
executeQuery,
|
executeQuery,
|
||||||
removePanel,
|
removePanel,
|
||||||
|
treeShown,
|
||||||
|
setShowTree,
|
||||||
}) => {
|
}) => {
|
||||||
const theme = useComputedColorScheme();
|
const theme = useComputedColorScheme();
|
||||||
const { queryHistory } = useAppSelector((state) => state.queryPage);
|
const { queryHistory } = useAppSelector((state) => state.queryPage);
|
||||||
|
@ -245,6 +250,14 @@ const ExpressionInput: FC<ExpressionInputProps> = ({
|
||||||
>
|
>
|
||||||
Format expression
|
Format expression
|
||||||
</Menu.Item>
|
</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
|
<Menu.Item
|
||||||
color="red"
|
color="red"
|
||||||
leftSection={
|
leftSection={
|
||||||
|
|
|
@ -6,15 +6,18 @@ import {
|
||||||
Box,
|
Box,
|
||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
Stack,
|
Stack,
|
||||||
|
Button,
|
||||||
|
Skeleton,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
IconChartAreaFilled,
|
IconChartAreaFilled,
|
||||||
IconChartGridDots,
|
|
||||||
IconChartLine,
|
IconChartLine,
|
||||||
|
IconCheckbox,
|
||||||
IconGraph,
|
IconGraph,
|
||||||
|
IconSquare,
|
||||||
IconTable,
|
IconTable,
|
||||||
} from "@tabler/icons-react";
|
} 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 { useAppDispatch, useAppSelector } from "../../state/hooks";
|
||||||
import {
|
import {
|
||||||
addQueryToHistory,
|
addQueryToHistory,
|
||||||
|
@ -22,6 +25,7 @@ import {
|
||||||
GraphResolution,
|
GraphResolution,
|
||||||
removePanel,
|
removePanel,
|
||||||
setExpr,
|
setExpr,
|
||||||
|
setShowTree,
|
||||||
setVisualizer,
|
setVisualizer,
|
||||||
} from "../../state/queryPageSlice";
|
} from "../../state/queryPageSlice";
|
||||||
import TimeInput from "./TimeInput";
|
import TimeInput from "./TimeInput";
|
||||||
|
@ -30,6 +34,10 @@ import ExpressionInput from "./ExpressionInput";
|
||||||
import Graph from "./Graph";
|
import Graph from "./Graph";
|
||||||
import ResolutionInput from "./ResolutionInput";
|
import ResolutionInput from "./ResolutionInput";
|
||||||
import TableTab from "./TableTab";
|
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 {
|
export interface PanelProps {
|
||||||
idx: number;
|
idx: number;
|
||||||
|
@ -48,6 +56,17 @@ const QueryPanel: FC<PanelProps> = ({ idx, metricNames }) => {
|
||||||
const panel = useAppSelector((state) => state.queryPage.panels[idx]);
|
const panel = useAppSelector((state) => state.queryPage.panels[idx]);
|
||||||
const dispatch = useAppDispatch();
|
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(
|
const onSelectRange = useCallback(
|
||||||
(start: number, end: number) =>
|
(start: number, end: number) =>
|
||||||
dispatch(
|
dispatch(
|
||||||
|
@ -69,6 +88,8 @@ const QueryPanel: FC<PanelProps> = ({ idx, metricNames }) => {
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<ExpressionInput
|
<ExpressionInput
|
||||||
|
// TODO: Maybe just pass the panelIdx and retriggerIdx to the ExpressionInput
|
||||||
|
// so it can manage its own state?
|
||||||
initialExpr={panel.expr}
|
initialExpr={panel.expr}
|
||||||
metricNames={metricNames}
|
metricNames={metricNames}
|
||||||
executeQuery={(expr: string) => {
|
executeQuery={(expr: string) => {
|
||||||
|
@ -79,10 +100,37 @@ const QueryPanel: FC<PanelProps> = ({ idx, metricNames }) => {
|
||||||
dispatch(addQueryToHistory(expr));
|
dispatch(addQueryToHistory(expr));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
treeShown={panel.showTree}
|
||||||
|
setShowTree={(showTree: boolean) => {
|
||||||
|
dispatch(setShowTree({ idx, showTree }));
|
||||||
|
if (!showTree) {
|
||||||
|
setSelectedNode(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
removePanel={() => {
|
removePanel={() => {
|
||||||
dispatch(removePanel(idx));
|
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
|
<Tabs
|
||||||
value={panel.visualizer.activeTab}
|
value={panel.visualizer.activeTab}
|
||||||
onChange={(v) =>
|
onChange={(v) =>
|
||||||
|
@ -107,7 +155,7 @@ const QueryPanel: FC<PanelProps> = ({ idx, metricNames }) => {
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
<Tabs.Panel pt="sm" value="table">
|
<Tabs.Panel pt="sm" value="table">
|
||||||
<TableTab panelIdx={idx} retriggerIdx={retriggerIdx} />
|
<TableTab expr={expr} panelIdx={idx} retriggerIdx={retriggerIdx} />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
<Tabs.Panel
|
<Tabs.Panel
|
||||||
pt="sm"
|
pt="sm"
|
||||||
|
@ -236,7 +284,7 @@ const QueryPanel: FC<PanelProps> = ({ idx, metricNames }) => {
|
||||||
</Group>
|
</Group>
|
||||||
<Space h="lg" />
|
<Space h="lg" />
|
||||||
<Graph
|
<Graph
|
||||||
expr={panel.expr}
|
expr={expr}
|
||||||
endTime={panel.visualizer.endTime}
|
endTime={panel.visualizer.endTime}
|
||||||
range={panel.visualizer.range}
|
range={panel.visualizer.range}
|
||||||
resolution={panel.visualizer.resolution}
|
resolution={panel.visualizer.resolution}
|
||||||
|
|
|
@ -14,13 +14,14 @@ dayjs.extend(timezone);
|
||||||
export interface TableTabProps {
|
export interface TableTabProps {
|
||||||
panelIdx: number;
|
panelIdx: number;
|
||||||
retriggerIdx: 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 [responseTime, setResponseTime] = useState<number>(0);
|
||||||
const [limitResults, setLimitResults] = useState<boolean>(true);
|
const [limitResults, setLimitResults] = useState<boolean>(true);
|
||||||
|
|
||||||
const { expr, visualizer } = useAppSelector(
|
const { visualizer } = useAppSelector(
|
||||||
(state) => state.queryPage.panels[panelIdx]
|
(state) => state.queryPage.panels[panelIdx]
|
||||||
);
|
);
|
||||||
const dispatch = useAppDispatch();
|
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) => {
|
decodeSetting("expr", (value) => {
|
||||||
panel.expr = value;
|
panel.expr = value;
|
||||||
});
|
});
|
||||||
|
decodeSetting("show_tree", (value) => {
|
||||||
|
panel.showTree = value === "1";
|
||||||
|
});
|
||||||
decodeSetting("tab", (value) => {
|
decodeSetting("tab", (value) => {
|
||||||
panel.visualizer.activeTab = value === "0" ? "graph" : "table";
|
panel.visualizer.activeTab = value === "0" ? "graph" : "table";
|
||||||
});
|
});
|
||||||
|
@ -121,6 +124,7 @@ export const encodePanelOptionsToURLParams = (
|
||||||
|
|
||||||
panels.forEach((p, idx) => {
|
panels.forEach((p, idx) => {
|
||||||
addParam(idx, "expr", p.expr);
|
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 === "graph" ? "0" : "1");
|
||||||
if (p.visualizer.endTime !== null) {
|
if (p.visualizer.endTime !== null) {
|
||||||
addParam(idx, "end_input", formatTime(p.visualizer.endTime));
|
addParam(idx, "end_input", formatTime(p.visualizer.endTime));
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.promql-metric-name {
|
.promql-metric-name {
|
||||||
color: light-dark(#000, #fff);
|
/* color: var(--input-color); */
|
||||||
}
|
}
|
||||||
|
|
||||||
.promql-label-name {
|
.promql-label-name {
|
||||||
|
|
|
@ -64,7 +64,7 @@ export type Panel = {
|
||||||
// The id is helpful as a stable key for React.
|
// The id is helpful as a stable key for React.
|
||||||
id: string;
|
id: string;
|
||||||
expr: string;
|
expr: string;
|
||||||
exprStale: boolean;
|
showTree: boolean;
|
||||||
showMetricsExplorer: boolean;
|
showMetricsExplorer: boolean;
|
||||||
visualizer: Visualizer;
|
visualizer: Visualizer;
|
||||||
};
|
};
|
||||||
|
@ -77,7 +77,7 @@ interface QueryPageState {
|
||||||
export const newDefaultPanel = (): Panel => ({
|
export const newDefaultPanel = (): Panel => ({
|
||||||
id: randomId(),
|
id: randomId(),
|
||||||
expr: "",
|
expr: "",
|
||||||
exprStale: false,
|
showTree: false,
|
||||||
showMetricsExplorer: false,
|
showMetricsExplorer: false,
|
||||||
visualizer: {
|
visualizer: {
|
||||||
activeTab: "table",
|
activeTab: "table",
|
||||||
|
@ -130,6 +130,13 @@ export const queryPageSlice = createSlice({
|
||||||
...state.queryHistory.filter((q) => q !== query),
|
...state.queryHistory.filter((q) => q !== query),
|
||||||
].slice(0, 50);
|
].slice(0, 50);
|
||||||
},
|
},
|
||||||
|
setShowTree: (
|
||||||
|
state,
|
||||||
|
{ payload }: PayloadAction<{ idx: number; showTree: boolean }>
|
||||||
|
) => {
|
||||||
|
state.panels[payload.idx].showTree = payload.showTree;
|
||||||
|
updateURL(state.panels);
|
||||||
|
},
|
||||||
setVisualizer: (
|
setVisualizer: (
|
||||||
state,
|
state,
|
||||||
{ payload }: PayloadAction<{ idx: number; visualizer: Visualizer }>
|
{ 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/lodash": "^4.17.7",
|
||||||
"@types/sanitize-html": "^2.13.0",
|
"@types/sanitize-html": "^2.13.0",
|
||||||
"@uiw/react-codemirror": "^4.21.22",
|
"@uiw/react-codemirror": "^4.21.22",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
|
Loading…
Reference in a new issue