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; +};