diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/ConnectionLines.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/ConnectionLines.tsx new file mode 100644 index 00000000000..9525af48ed2 --- /dev/null +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/ConnectionLines.tsx @@ -0,0 +1,177 @@ +import { css } from '@emotion/css'; +import { useLayoutEffect, useRef, useState } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { useStyles2 } from '@grafana/ui'; + +interface Connection { + from: string; + to: string; +} + +interface ConnectionLinesProps { + connections: Connection[]; +} + +export function ConnectionLines({ connections }: ConnectionLinesProps) { + const styles = useStyles2(getStyles); + const [positions, setPositions] = useState>(new Map()); + const containerRef = useRef(null); + + // Update positions when cards change + useLayoutEffect(() => { + const updatePositions = () => { + const newPositions = new Map(); + + // Find all cards by their data-card-id attribute + const cards = document.querySelectorAll('[data-card-id]'); + + cards.forEach((element) => { + const cardId = element.getAttribute('data-card-id'); + if (cardId) { + const rect = element.getBoundingClientRect(); + newPositions.set(cardId, rect); + } + }); + + setPositions(newPositions); + }; + + // Delay to ensure cards are rendered + const timeoutId = setTimeout(updatePositions, 100); + + // Update on resize and scroll + window.addEventListener('resize', updatePositions); + + const container = containerRef.current?.parentElement; + if (container) { + // Watch for scroll events + const scrollContainer = container.querySelector('[data-scrollcontainer]'); + scrollContainer?.addEventListener('scroll', updatePositions); + + // Watch for DOM changes + const observer = new MutationObserver(() => { + setTimeout(updatePositions, 50); + }); + observer.observe(container, { childList: true, subtree: true }); + + // Watch for container resize (splitter changes) + const resizeObserver = new ResizeObserver(updatePositions); + resizeObserver.observe(container); + + return () => { + clearTimeout(timeoutId); + window.removeEventListener('resize', updatePositions); + scrollContainer?.removeEventListener('scroll', updatePositions); + observer.disconnect(); + resizeObserver.disconnect(); + }; + } + + return () => { + clearTimeout(timeoutId); + window.removeEventListener('resize', updatePositions); + }; + }, [connections]); + + const containerRect = containerRef.current?.parentElement?.getBoundingClientRect(); + + if (!containerRect || connections.length === 0) { + return ; + } + + // Group connections by expression (each "to" card gets its own lane) + const lanesByExpression = new Map>(); + + connections.forEach((conn) => { + if (!lanesByExpression.has(conn.to)) { + lanesByExpression.set(conn.to, new Set()); + } + const lane = lanesByExpression.get(conn.to)!; + lane.add(conn.from); // Add the referenced query + lane.add(conn.to); // Add the expression itself + }); + + // Convert to array of lanes + const activeLanes = Array.from(lanesByExpression.values()); + + // Find the rightmost edge of all cards + let maxCardRight = 0; + positions.forEach((rect) => { + const cardRight = rect.right - containerRect.left; + if (cardRight > maxCardRight) { + maxCardRight = cardRight; + } + }); + + const laneSpacing = 16; // Spacing between lanes (must match QueryTransformList.tsx) + const baseOffset = 24; // Offset from rightmost card (must match QueryTransformList.tsx) + + return ( + + {activeLanes.map((lane, laneIndex) => { + // Calculate Y positions for cards in this lane + const laneYPositions: number[] = []; + lane.forEach((cardId) => { + const cardRect = positions.get(cardId); + if (cardRect) { + laneYPositions.push(cardRect.top + cardRect.height / 2 - containerRect.top); + } + }); + + if (laneYPositions.length === 0) { + return null; + } + + // Calculate swimlane X position for this lane (start from rightmost card edge) + const swimlaneX = maxCardRight + baseOffset + laneIndex * laneSpacing; + + // Find min/max Y to draw the line only between connected points + const minY = Math.min(...laneYPositions); + const maxY = Math.max(...laneYPositions); + + return ( + + {/* Vertical swimlane line */} + + + {/* Connection points for cards in this lane */} + {Array.from(lane).map((cardId) => { + const cardRect = positions.get(cardId); + + if (!cardRect) { + return null; + } + + const pointY = cardRect.top + cardRect.height / 2 - containerRect.top; + + return ; + })} + + ); + })} + + ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + svg: css({ + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + pointerEvents: 'none', + zIndex: 10, + overflow: 'visible', + }), + swimlane: css({ + stroke: theme.colors.text.maxContrast, + strokeWidth: 2, + opacity: 0.3, + }), + point: css({ + fill: theme.colors.text.maxContrast, + opacity: 0.8, + }), +}); diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/QueryTransformCard.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/QueryTransformCard.tsx index 7c4297f9827..73d3f538997 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/QueryTransformCard.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/QueryTransformCard.tsx @@ -68,6 +68,7 @@ export const QueryTransformCard = memo( onClick={onClick} onKeyDown={handleKeyDown} data-testid={`${type}-card-${index}`} + data-card-id={'refId' in item ? item.refId : `transform-${index}`} > {/* Header with type and action icons */}
{ return { card: css({ + position: 'relative', cursor: 'pointer', border: `1px solid ${theme.colors.border.weak}`, borderRadius: theme.shape.radius.default, diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/QueryTransformList.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/QueryTransformList.tsx index e7509b45d90..41e1844d7cb 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/QueryTransformList.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/QueryTransformList.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { memo } from 'react'; +import { memo, useMemo } from 'react'; import { DataTransformerConfig, GrafanaTheme2 } from '@grafana/data'; import { SceneDataQuery } from '@grafana/scenes'; @@ -7,6 +7,7 @@ import { ScrollContainer, Stack, useStyles2 } from '@grafana/ui'; import { ExpressionQueryType } from 'app/features/expressions/types'; import { AddDataItemMenu } from './AddDataItemMenu'; +import { ConnectionLines } from './ConnectionLines'; import { QueryTransformCard } from './QueryTransformCard'; export interface QueryTransformItem { @@ -50,6 +51,41 @@ export const QueryTransformList = memo( }: QueryTransformListProps) => { const styles = useStyles2(getStyles); + // Detect connections between items + const connections = useMemo(() => { + const conns: Array<{ from: string; to: string }> = []; + + items.forEach((item) => { + if (item.type === 'expression' && 'expression' in item.data && 'refId' in item.data) { + const expr = item.data; + + if ('expression' in expr && typeof expr.expression === 'string' && 'type' in expr) { + const expressionType = expr.type; + const expressionString = expr.expression; + + if (expressionType === 'math') { + // Math expressions: parse $A, $B, etc. + const matches = expressionString.matchAll(/\$(\w+)/g); + for (const match of matches) { + const refId = match[1]; + conns.push({ from: refId, to: expr.refId }); + } + } else if (expressionType === 'reduce' || expressionType === 'resample' || expressionType === 'threshold') { + // Reduce/Resample/Threshold: expression field is a single refId + if (expressionString) { + conns.push({ from: expressionString, to: expr.refId }); + } + } + // TODO: Handle 'sql' and 'classic_conditions' types if needed + } + } + }); + + console.log('Detected connections:', conns); + + return conns; + }, [items]); + const getHandlers = (item: QueryTransformItem) => { switch (item.type) { case 'query': @@ -75,10 +111,22 @@ export const QueryTransformList = memo( } }; + // Calculate number of lanes for dynamic padding + const lanesByExpression = useMemo(() => { + const lanes = new Map>(); + connections.forEach((conn) => { + if (!lanes.has(conn.to)) { + lanes.set(conn.to, new Set()); + } + }); + return lanes.size; + }, [connections]); + return (
-
- + + +
0 ? styles.contentWithConnections(lanesByExpression) : styles.content}> {items.map((item) => ( - -
+
+
); } @@ -108,19 +156,31 @@ export const QueryTransformList = memo( QueryTransformList.displayName = 'QueryTransformList'; const getStyles = (theme: GrafanaTheme2) => { + const laneSpacing = 16; // Must match ConnectionLines.tsx + const baseOffset = 24; // Must match ConnectionLines.tsx + const extraPadding = 8; // Extra breathing room beyond the last lane + return { container: css({ + position: 'relative', display: 'flex', flexDirection: 'column', height: '100%', width: '100%', overflow: 'hidden', }), - scrollContainer: css({ + content: css({ + position: 'relative', padding: theme.spacing(2), - flex: 1, - minHeight: 0, - overflow: 'auto', + zIndex: 1, }), + contentWithConnections: (numLanes: number) => + css({ + position: 'relative', + padding: theme.spacing(2), + // Calculate exact space needed: base offset + (lanes * spacing) + extra padding + paddingRight: baseOffset + numLanes * laneSpacing + extraPadding, + zIndex: 1, + }), }; };