Add beginnings of a PromLens-style tree view

Signed-off-by: Julius Volz <julius.volz@gmail.com>
This commit is contained in:
Julius Volz 2024-09-04 17:42:15 +02:00
parent e5e212dad2
commit c0ba4ee2bb
13 changed files with 684 additions and 9 deletions

View file

@ -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
View 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
}

View file

@ -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",

View file

@ -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={

View file

@ -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}

View file

@ -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();

View 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));
}

View 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;

View 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;

View file

@ -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));

View file

@ -7,7 +7,7 @@
}
.promql-metric-name {
color: light-dark(#000, #fff);
/* color: var(--input-color); */
}
.promql-label-name {

View file

@ -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 }>

View file

@ -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",