From 84a8f6578bff7943c73f895b58db217b861358aa Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 8 Oct 2026 06:33:53 +0000 Subject: [PATCH] feat(telemetry): highlight matching actions when hovering the graph Graph nodes are actions, and one action can run many times, so this hover is the action name scoped to its application. Telemetry rows and insight lines for that action use the same highlight as a direct row hover. Co-authored-by: mengxiaopeng <602012854@qq.com> --- .../ui/src/components/routes/app/AppView.tsx | 30 +++- .../src/components/routes/app/GraphView.tsx | 64 +++++++- .../components/routes/app/InsightsView.tsx | 19 ++- .../components/routes/app/StateMachine.tsx | 4 + .../ui/src/components/routes/app/StepList.tsx | 49 ++++-- .../routes/app/hoverHighlight.test.ts | 152 ++++++++++++++++++ .../components/routes/app/hoverHighlight.ts | 56 +++++++ 7 files changed, 347 insertions(+), 27 deletions(-) create mode 100644 telemetry/ui/src/components/routes/app/hoverHighlight.test.ts create mode 100644 telemetry/ui/src/components/routes/app/hoverHighlight.ts diff --git a/telemetry/ui/src/components/routes/app/AppView.tsx b/telemetry/ui/src/components/routes/app/AppView.tsx index 692d6802e..191d2b474 100644 --- a/telemetry/ui/src/components/routes/app/AppView.tsx +++ b/telemetry/ui/src/components/routes/app/AppView.tsx @@ -33,6 +33,7 @@ import { AppStateView } from './StateMachine'; import { createContext, useEffect, useState } from 'react'; import { Status, useLocationParams } from '../../../utils'; import { GraphView } from './GraphView'; +import type { HoveredAction } from './hoverHighlight'; import { useSearchParams } from 'react-router-dom'; export const REFRESH_INTERVAL = 500; @@ -117,6 +118,9 @@ export type HighlightState = { currentSelectedIndex?: SequenceLocation; setCurrentHoverIndex: (index: SequenceLocation | undefined) => void; currentHoverIndex?: SequenceLocation; + // Graph hover. Separate from currentHoverIndex because one action maps to many sequences. + setCurrentHoverAction: (action: HoveredAction | undefined) => void; + currentHoverAction?: HoveredAction; currentEditingAnnotationContext?: AnnotationEditingContext; setCurrentEditingAnnotationContext: ( annotationContext: AnnotationEditingContext | undefined @@ -145,6 +149,8 @@ export const AppContext = createContext({ currentSelectedIndex: undefined, setCurrentHoverIndex: () => {}, currentHoverIndex: undefined, + setCurrentHoverAction: () => {}, + currentHoverAction: undefined, currentEditingAnnotationContext: undefined, setCurrentEditingAnnotationContext: () => {}, createAnnotation: () => { @@ -222,6 +228,20 @@ export const AppView = (props: { const { projectId } = props; // const [currentActionIndex, setCurrentActionIndex] = useState(undefined); const [hoverIndex, setHoverIndex] = useState(undefined); + const [hoveredAction, setHoveredAction] = useState(undefined); + // The two hover sources are exclusive: a row hover is one sequence, a graph hover is an action. + const setCurrentHoverIndex = (index: SequenceLocation | undefined) => { + setHoverIndex(index); + if (index !== undefined) { + setHoveredAction(undefined); + } + }; + const setCurrentHoverAction = (action: HoveredAction | undefined) => { + setHoveredAction(action); + if (action !== undefined) { + setHoverIndex(undefined); + } + }; const [autoRefresh, setAutoRefresh] = useState(props.defaultAutoRefresh || false); const shouldQuery = projectId !== undefined && appID !== undefined; const [minimizedTable, setMinimizedTable] = useState(false); @@ -471,8 +491,10 @@ export const AppView = (props: { setInspectViewOpen(loc !== undefined); }, currentSelectedIndex: currentSequenceLocation, - setCurrentHoverIndex: setHoverIndex, + setCurrentHoverIndex: setCurrentHoverIndex, currentHoverIndex: hoverIndex, + setCurrentHoverAction: setCurrentHoverAction, + currentHoverAction: hoveredAction, currentEditingAnnotationContext: currentEditingAnnotationContext, setCurrentEditingAnnotationContext: setCurrentEditingAnnotationContext, // TODO -- handle span ID @@ -544,6 +566,8 @@ export const AppView = (props: { // highlightedActions={previousActions} highlightedActions={undefined} hoverAction={hoverAction} + appId={appID} + partitionKey={partitionKey} /> )} @@ -558,6 +582,10 @@ export const AppView = (props: { highlightedActions={undefined} hoverAction={hoverAction} currentActionLocation={currentSequenceLocation} + graphAppId={currentFocusStepsData !== undefined ? currentFocusAppID : appID} + graphPartitionKey={ + currentFocusStepsData !== undefined ? currentFocusPartitionKey : partitionKey + } displayGraphAsTab={displayGraphAsTabs} // in this case we want the graph as a tab setMinimized={(min: boolean) => setInspectViewOpen(!min)} isMinimized={!inspectViewOpen} diff --git a/telemetry/ui/src/components/routes/app/GraphView.tsx b/telemetry/ui/src/components/routes/app/GraphView.tsx index b9ab05aed..d559a9abf 100644 --- a/telemetry/ui/src/components/routes/app/GraphView.tsx +++ b/telemetry/ui/src/components/routes/app/GraphView.tsx @@ -20,7 +20,7 @@ import { ActionModel, ApplicationModel, Step } from '../../../api'; import dagre from 'dagre'; -import React, { createContext, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import React, { createContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import ReactFlow, { BaseEdge, Controls, @@ -35,7 +35,7 @@ import ReactFlow, { } from 'reactflow'; import 'reactflow/dist/style.css'; -import { backgroundColorsForIndex } from './AppView'; +import { AppContext, backgroundColorsForIndex } from './AppView'; import { getActionStatus } from '../../../utils'; import { getSmartEdge } from '@tisoap/react-flow-smart-edge'; @@ -79,10 +79,14 @@ type EdgeType = { }; const ActionNode = (props: { data: NodeData }) => { + const { setCurrentHoverAction } = React.useContext(AppContext); const { highlightedActions: previousActions, hoverAction, - currentAction + currentAction, + hoveredActionName, + appId, + partitionKey } = React.useContext(NodeStateProvider); const highlightedActions = [currentAction, ...(previousActions || [])].reverse(); const name = props.data.label; @@ -98,7 +102,8 @@ const ActionNode = (props: { data: NodeData }) => { : shouldHighlight ? 'bg-gray-100' : ''; - const opacity = hoverAction?.step_start_log.action === name ? 'opacity-50' : ''; + const opacity = + hoverAction?.step_start_log.action === name || hoveredActionName === name ? 'opacity-50' : ''; const additionalClasses = isCurrentAction ? 'border-dwlightblue/50 text-white border-2' : shouldHighlight @@ -109,6 +114,14 @@ const ActionNode = (props: { data: NodeData }) => {
+ setCurrentHoverAction({ + actionName: name, + appId, + partitionKey + }) + } + onMouseLeave={() => setCurrentHoverAction(undefined)} > {name}
@@ -305,13 +318,19 @@ const edgeTypes = { type NodeState = { highlightedActions: Step[] | undefined; // one for each highlighted action, in order from most recent to least recent - hoverAction: Step | undefined; // the action currently being hovered over + hoverAction: Step | undefined; // the action currently being hovered over in the telemetry list currentAction: Step | undefined; // the action currently being viewed + hoveredActionName: string | undefined; // graph-node hover, already scoped to this application + appId: string; + partitionKey: string | null; }; const NodeStateProvider = createContext({ highlightedActions: undefined, hoverAction: undefined, - currentAction: undefined + currentAction: undefined, + hoveredActionName: undefined, + appId: '', + partitionKey: null }); export const _Graph = (props: { @@ -319,6 +338,8 @@ export const _Graph = (props: { currentAction: Step | undefined; previousActions: Step[] | undefined; hoverAction: Step | undefined; + appId: string; + partitionKey: string | null; }) => { const [showInputs, setShowInputs] = useState(true); @@ -326,6 +347,19 @@ export const _Graph = (props: { const [edges, setEdges] = useState([]); const { fitView } = useReactFlow(); + const { currentHoverAction, setCurrentHoverAction } = React.useContext(AppContext); + const hoveredActionName = + currentHoverAction?.appId === props.appId && + currentHoverAction.partitionKey === props.partitionKey + ? currentHoverAction.actionName + : undefined; + const setCurrentHoverActionRef = useRef(setCurrentHoverAction); + setCurrentHoverActionRef.current = setCurrentHoverAction; + useEffect(() => { + return () => { + setCurrentHoverActionRef.current(undefined); + }; + }, []); // Keyed on the rendered structure rather than object identity: refetches and focus // switches that don't change the graph must not trigger a full relayout. @@ -357,9 +391,19 @@ export const _Graph = (props: { () => ({ highlightedActions: props.previousActions, hoverAction: props.hoverAction, - currentAction: props.currentAction + currentAction: props.currentAction, + hoveredActionName, + appId: props.appId, + partitionKey: props.partitionKey }), - [props.previousActions, props.hoverAction, props.currentAction] + [ + props.previousActions, + props.hoverAction, + props.currentAction, + hoveredActionName, + props.appId, + props.partitionKey + ] ); return ( @@ -392,6 +436,8 @@ export const GraphView = (props: { currentAction: Step | undefined; highlightedActions: Step[] | undefined; hoverAction: Step | undefined; + appId: string; + partitionKey: string | null; }) => { const parentRef = useRef(null); const childRef = useRef(null); @@ -405,6 +451,8 @@ export const GraphView = (props: { currentAction={props.currentAction} previousActions={props.highlightedActions} hoverAction={props.hoverAction} + appId={props.appId} + partitionKey={props.partitionKey} /> diff --git a/telemetry/ui/src/components/routes/app/InsightsView.tsx b/telemetry/ui/src/components/routes/app/InsightsView.tsx index 48da7ba27..f32bc0f12 100644 --- a/telemetry/ui/src/components/routes/app/InsightsView.tsx +++ b/telemetry/ui/src/components/routes/app/InsightsView.tsx @@ -22,6 +22,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '. import React, { useContext, useState } from 'react'; import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/outline'; import { AppContext } from './AppView'; +import { isTelemetryRowHovered } from './hoverHighlight'; import { Chip } from '../../common/chip'; import { modelCosts } from '../../common/modelCost'; @@ -370,8 +371,13 @@ const InsightSubTable = (props: { return acc; }, new Map()); - const { currentSelectedIndex, setCurrentSelectedIndex, currentHoverIndex, setCurrentHoverIndex } = - useContext(AppContext); + const { + currentSelectedIndex, + setCurrentSelectedIndex, + currentHoverIndex, + currentHoverAction, + setCurrentHoverIndex + } = useContext(AppContext); return ( <> @@ -408,7 +414,14 @@ const InsightSubTable = (props: { const span = spansBySpanID.get(attribute.span_id || ''); const step = stepsByStepID.get(span?.begin_entry.action_sequence_id || 0); const insightCasted = props.insight as InsightWithIndividualValues; - const isHovered = currentHoverIndex === span?.begin_entry.action_sequence_id; + const isHovered = isTelemetryRowHovered({ + currentHoverIndex, + currentHoverAction, + sequenceId: span?.begin_entry.action_sequence_id, + actionName: span ? step?.step_start_log.action : undefined, + appId: props.appID, + partitionKey: props.partitionKey + }); const isCurrentSelected = currentSelectedIndex === span?.begin_entry.action_sequence_id; return ( void; isMinimized: boolean; @@ -108,6 +110,8 @@ export const AppStateView = (props: { currentAction={currentStep} highlightedActions={props.highlightedActions} hoverAction={props.hoverAction} + appId={props.graphAppId} + partitionKey={props.graphPartitionKey} /> )} {tab === 'insights' && ( diff --git a/telemetry/ui/src/components/routes/app/StepList.tsx b/telemetry/ui/src/components/routes/app/StepList.tsx index bd97d2c28..05f8c5c33 100644 --- a/telemetry/ui/src/components/routes/app/StepList.tsx +++ b/telemetry/ui/src/components/routes/app/StepList.tsx @@ -38,6 +38,7 @@ import { REFRESH_INTERVAL, SequenceLocation } from './AppView'; +import { isTelemetryRowHovered } from './hoverHighlight'; import { Status, getActionStatus } from '../../../utils'; import { Chip } from '../../common/chip'; import { useContext, useEffect, useRef, useState } from 'react'; @@ -214,15 +215,20 @@ const ActionTableRow = (props: { setCurrentEditingAnnotationContext, setTab, currentHoverIndex, + currentHoverAction, setCurrentHoverIndex, currentSelectedIndex, setCurrentSelectedIndex } = useContext(AppContext); - const isHovered = - currentHoverIndex?.sequenceId === sequenceID && - currentHoverIndex?.appId === props.appID && - currentHoverIndex?.partitionKey === props.partitionKey; + const isHovered = isTelemetryRowHovered({ + currentHoverIndex, + currentHoverAction, + sequenceId: sequenceID, + actionName: props.step.step_start_log.action, + appId: props.appID, + partitionKey: props.partitionKey + }); // const spanCount = props.step.spans.length; const childCount = props.links.length; const shouldBeHighlighted = @@ -478,13 +484,22 @@ const LinkSubTable = (props: { latestTimeSeen: Date; depth: number; }) => { - const { currentHoverIndex, setCurrentHoverIndex, currentSelectedIndex, setCurrentSelectedIndex } = - useContext(AppContext); + const { + currentHoverIndex, + currentHoverAction, + setCurrentHoverIndex, + currentSelectedIndex, + setCurrentSelectedIndex + } = useContext(AppContext); const sequenceID = props.step.step_start_log.sequence_id; - const isHovered = - currentHoverIndex?.appId === props.appID && - currentHoverIndex?.partitionKey === props.partitionKey && - currentHoverIndex?.sequenceId === sequenceID; + const isHovered = isTelemetryRowHovered({ + currentHoverIndex, + currentHoverAction, + sequenceId: sequenceID, + actionName: props.step.step_start_log.action, + appId: props.appID, + partitionKey: props.partitionKey + }); const shouldBeHighlighted = currentSelectedIndex !== undefined && currentSelectedIndex.appId === props.appID && @@ -781,7 +796,7 @@ const StepSubTable = (props: { displayAnnotations: boolean; depth: number; }) => { - const { currentHoverIndex, currentSelectedIndex } = useContext(AppContext); + const { currentHoverIndex, currentHoverAction, currentSelectedIndex } = useContext(AppContext); const attributesBySpanID = props.attributes.reduce((acc, attr) => { const existing = acc.get(attr.span_id) || []; existing.push(attr); @@ -796,10 +811,14 @@ const StepSubTable = (props: { // return acc; // }, new Map>()); const sequenceID = props.step.step_start_log.sequence_id; - const isHovered = - currentHoverIndex?.sequenceId === sequenceID && - currentHoverIndex?.appId === props.appID && - currentHoverIndex?.partitionKey === props.partitionKey; + const isHovered = isTelemetryRowHovered({ + currentHoverIndex, + currentHoverAction, + sequenceId: sequenceID, + actionName: props.step.step_start_log.action, + appId: props.appID, + partitionKey: props.partitionKey + }); // const spanCount = props.step.spans.length; const shouldBeHighlighted = currentSelectedIndex !== undefined && diff --git a/telemetry/ui/src/components/routes/app/hoverHighlight.test.ts b/telemetry/ui/src/components/routes/app/hoverHighlight.test.ts new file mode 100644 index 000000000..1bafd4bbe --- /dev/null +++ b/telemetry/ui/src/components/routes/app/hoverHighlight.test.ts @@ -0,0 +1,152 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +import { expect, test } from 'vitest'; +import { isTelemetryRowHovered } from './hoverHighlight'; + +const appId = 'app-1'; +const partitionKey = 'partition-1'; + +const sequenceHover = { + appId, + partitionKey, + sequenceId: 3 +}; + +const actionHover = { + actionName: 'respond', + appId, + partitionKey +}; + +const row = { + sequenceId: 7, + actionName: 'respond', + appId, + partitionKey +}; + +test('sequence hover highlights only that sequence in the same application', () => { + expect( + isTelemetryRowHovered({ + currentHoverIndex: sequenceHover, + currentHoverAction: undefined, + ...row, + sequenceId: 3 + }) + ).toBe(true); + expect( + isTelemetryRowHovered({ + currentHoverIndex: sequenceHover, + currentHoverAction: undefined, + ...row, + sequenceId: 4 + }) + ).toBe(false); + expect( + isTelemetryRowHovered({ + currentHoverIndex: sequenceHover, + currentHoverAction: undefined, + ...row, + sequenceId: 3, + appId: 'other-app' + }) + ).toBe(false); +}); + +test('graph action hover highlights every run of that action', () => { + expect( + isTelemetryRowHovered({ + currentHoverIndex: undefined, + currentHoverAction: actionHover, + ...row, + sequenceId: 1 + }) + ).toBe(true); + expect( + isTelemetryRowHovered({ + currentHoverIndex: undefined, + currentHoverAction: actionHover, + ...row, + sequenceId: 9 + }) + ).toBe(true); + expect( + isTelemetryRowHovered({ + currentHoverIndex: undefined, + currentHoverAction: actionHover, + ...row, + actionName: 'retrieve' + }) + ).toBe(false); +}); + +test('graph action hover does not cross applications or partitions', () => { + expect( + isTelemetryRowHovered({ + currentHoverIndex: undefined, + currentHoverAction: actionHover, + ...row, + appId: 'other-app' + }) + ).toBe(false); + expect( + isTelemetryRowHovered({ + currentHoverIndex: undefined, + currentHoverAction: actionHover, + ...row, + partitionKey: null + }) + ).toBe(false); + expect( + isTelemetryRowHovered({ + currentHoverIndex: undefined, + currentHoverAction: { ...actionHover, partitionKey: null }, + ...row, + partitionKey: null + }) + ).toBe(true); +}); + +test('either hover source is enough, and neither leaves the row idle', () => { + expect( + isTelemetryRowHovered({ + currentHoverIndex: sequenceHover, + currentHoverAction: { ...actionHover, actionName: 'other' }, + ...row, + sequenceId: 3 + }) + ).toBe(true); + expect( + isTelemetryRowHovered({ + currentHoverIndex: undefined, + currentHoverAction: undefined, + ...row + }) + ).toBe(false); + expect( + isTelemetryRowHovered({ + currentHoverIndex: undefined, + currentHoverAction: actionHover, + ...row, + sequenceId: undefined, + actionName: undefined + }) + ).toBe(false); +}); diff --git a/telemetry/ui/src/components/routes/app/hoverHighlight.ts b/telemetry/ui/src/components/routes/app/hoverHighlight.ts new file mode 100644 index 000000000..0b76d5dda --- /dev/null +++ b/telemetry/ui/src/components/routes/app/hoverHighlight.ts @@ -0,0 +1,56 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +import type { SequenceLocation } from './AppView'; + +/** + * Graph nodes are actions. An action can run many times, so a graph hover + * cannot be stored as one sequence id. It is the action name, scoped to the + * application that owns the graph. + */ +export type HoveredAction = { + actionName: string; + appId: string; + partitionKey: string | null; +}; + +/** + * A telemetry row is hovered when the pointer is on that sequence, or when + * the pointer is on the graph node for the same action in the same application. + */ +export const isTelemetryRowHovered = (args: { + currentHoverIndex: SequenceLocation | undefined; + currentHoverAction: HoveredAction | undefined; + sequenceId: number | undefined; + actionName: string | undefined; + appId: string; + partitionKey: string | null; +}): boolean => { + const sequenceMatches = + args.sequenceId !== undefined && + args.currentHoverIndex?.sequenceId === args.sequenceId && + args.currentHoverIndex.appId === args.appId && + args.currentHoverIndex.partitionKey === args.partitionKey; + const actionMatches = + args.actionName !== undefined && + args.currentHoverAction?.actionName === args.actionName && + args.currentHoverAction.appId === args.appId && + args.currentHoverAction.partitionKey === args.partitionKey; + return sequenceMatches || actionMatches; +};