import { FC, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react"; import ASTNode, { nodeType } from "../../promql/ast"; import { escapeString, getNodeChildren } from "../../promql/utils"; import { formatNode } from "../../promql/format"; import { Box, Code, CSSProperties, Group, List, Loader, Text, Tooltip, } from "@mantine/core"; import { useAPIQuery } from "../../api/api"; import { InstantQueryResult, InstantSample, RangeSamples, } from "../../api/responseTypes/query"; import serializeNode from "../../promql/serialize"; import { IconPointFilled } from "@tabler/icons-react"; import classes from "./TreeNode.module.css"; import clsx from "clsx"; import { useId } from "@mantine/hooks"; import { functionSignatures } from "../../promql/functionSignatures"; const nodeIndent = 20; const maxLabelNames = 10; const maxLabelValues = 10; 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?: (childIdx: number, state: NodeState) => void; reverse: boolean; // The index of this node in its parent's children. childIdx: number; }> = ({ node, selectedNode, setSelectedNode, parentRef, reportNodeState, reverse, childIdx, }) => { 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; labelExamples: Record; sortedLabelCards: [string, number][]; }>({ numSeries: 0, labelExamples: {}, sortedLabelCards: [], }); // Select the node when it is mounted and it is the root of the tree. useEffect(() => { if (parentRef === undefined) { setSelectedNode({ id: nodeID, node: node }); } }, [parentRef, setSelectedNode, nodeID, node]); // Deselect node when node is unmounted. useEffect(() => { return () => { setSelectedNode(null); }; }, [setSelectedNode]); const children = getNodeChildren(node); const [childStates, setChildStates] = useState( children.map(() => "waiting") ); const mergedChildState = useMemo( () => mergeChildStates(childStates), [childStates] ); // Optimize range vector selector fetches to give us the info we're looking for // more cheaply. E.g. 'foo[7w]' can be expensive to fully fetch, but wrapping it // in 'last_over_time(foo[7w])' is cheaper and also gives us all the info we // need (number of series and labels). let queryNode = node; if (queryNode.type === nodeType.matrixSelector) { queryNode = { type: nodeType.call, func: functionSignatures["last_over_time"], args: [node], }; } const { data, error, isFetching } = useAPIQuery({ key: [useId()], path: "/query", params: { query: serializeNode(queryNode), }, recordResponseTime: setResponseTime, enabled: mergedChildState === "success", }); useEffect(() => { if (mergedChildState === "error") { reportNodeState && reportNodeState(childIdx, "error"); } }, [mergedChildState, reportNodeState, childIdx]); useEffect(() => { if (error) { reportNodeState && reportNodeState(childIdx, "error"); } }, [error, reportNodeState, childIdx]); useEffect(() => { if (isFetching) { reportNodeState && reportNodeState(childIdx, "running"); } }, [isFetching, reportNodeState, childIdx]); const childReportNodeState = useCallback( (childIdx: number, state: NodeState) => { setChildStates((prev) => { const newStates = [...prev]; newStates[childIdx] = state; return newStates; }); }, [setChildStates] ); // 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, setConnectorStyle]); // Update the node info state based on the query result. useEffect(() => { if (!data) { return; } reportNodeState && reportNodeState(childIdx, "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 // last_over_time(foo[7d]) optimization since that removes the metric name. if (ln !== "__name__") { if (!labelValuesByName[ln]) { labelValuesByName[ln] = {}; } labelValuesByName[ln][lv] = (labelValuesByName[ln][lv] || 0) + 1; } }); }); } 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, maxLabelValues) .map(([lv, cnt]) => ({ value: lv, count: cnt })); }); setResultStats({ numSeries: resultSeries, sortedLabelCards: Object.entries(labelCardinalities).sort( (a, b) => b[1] - a[1] ), labelExamples, }); }, [data, reportNodeState, childIdx]); const innerNode = ( {parentRef && ( // Connector line between this node and its parent. )} {/* The node (visible box) 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.sortedLabelCards.length > 0 && ( <>  –   )} {resultStats.sortedLabelCards .slice(0, maxLabelNames) .map(([ln, cnt]) => ( {resultStats.labelExamples[ln].map( ({ value, count }) => ( {escapeString(value)} {" "} ({count} x) ) )} {cnt > maxLabelValues &&
  • ...
  • }
    } > {ln} : {cnt} ))} {resultStats.sortedLabelCards.length > maxLabelNames ? ( ...{resultStats.sortedLabelCards.length - maxLabelNames} more... ) : null}
    )} ); if (node.type === nodeType.binaryExpr) { return (
    {innerNode}
    ); } return (
    {innerNode} {children.map((child, idx) => ( ))}
    ); }; export default TreeNode;