mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 08:09:27 +00:00
Trace Graph (Tree View and Graph View) and Flame Graph had no zoom buttons, so zooming needed a mouse wheel or trackpad gesture. They now share the zoom in / zoom out / fit control and zoom logic that Service Graph already had (useChartZoom + ChartZoomControls). Flame Graph zooms by narrowing the visible time range and pans by dragging or by horizontal / shift wheel; selecting a span outside the window brings it into view, and a new trace resets the zoom. Tree View gave every copy of a repeated service the same latency and error state, built from all of that service's spans. Each node now takes its numbers from the spans on its own ancestor path, and the hover tooltip resolves the node by its position in the tree the chart draws, so it stays right while the service search filters the tree. Service Catalog selected infer_service_system and infer_service_type whenever infer_service_name existed, so streams without them got a 400, and the failure was shown as "No services found" or as the previous stream's rows. Absent columns are now selected as NULL, and a failed query clears the rows and shows the load-error state with the server message and Retry. With Auto Run off, a header org switch still searched: the remounted page could read the previous org's URL, whose stream= counted as a shared link, and the Rate and Duration panels queried because searchApplied survived the reset. During an org switch a URL that still names the org being left is no longer treated as a shared link, and the reset clears searchApplied. A shared link for another org still searches.
513 lines
17 KiB
TypeScript
513 lines
17 KiB
TypeScript
// Copyright 2026 OpenObserve Inc.
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Affero General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
import type { I18nText } from "@/types/i18n";
|
|
|
|
import { getServiceColorHex } from "@/utils/traces/traceColors";
|
|
import { CHART_ZOOM_MAX, CHART_ZOOM_MIN } from "@/composables/traces/useChartZoom";
|
|
|
|
/**
|
|
* Tree node interface for tree visualization with coordinate support
|
|
*/
|
|
export interface TreeNode {
|
|
id: string;
|
|
name: string;
|
|
label?: I18nText;
|
|
value: number; // Primary metric (requests/duration)
|
|
errorRate?: number;
|
|
children?: TreeNode[];
|
|
metadata?: Record<string, any>; // Context-specific data
|
|
x?: number; // Node position for coordinate-based hit detection
|
|
y?: number; // Node position for coordinate-based hit detection
|
|
}
|
|
|
|
/**
|
|
* Configuration for tree visualization rendering
|
|
*/
|
|
export interface TreeVisualizationConfig {
|
|
layoutType: "horizontal" | "vertical";
|
|
isDarkMode: boolean;
|
|
nodeSize: "fixed" | "dynamic";
|
|
symbolSize?: number;
|
|
}
|
|
|
|
/**
|
|
* Data interface for tree visualization
|
|
*/
|
|
export interface TreeVisualizationData {
|
|
treeData: TreeNode[];
|
|
getNodeLabel: (node: TreeNode) => string;
|
|
getNodeTooltip: (node: TreeNode) => string;
|
|
getNodeErrorRate: (node: TreeNode) => number;
|
|
getNodeServiceColor?: (node: TreeNode) => string;
|
|
}
|
|
|
|
/**
|
|
* Create shared tree visualization engine
|
|
* Provides consistent ECharts tree configuration across ServiceGraph and TraceDetails
|
|
*/
|
|
export function createTreeVisualizationEngine() {
|
|
/**
|
|
* Generate ECharts tree options with shared styling and behavior
|
|
* Reuses the proven patterns from ServiceGraph.vue tree view
|
|
*/
|
|
const generateEChartsOptions = (
|
|
data: TreeVisualizationData,
|
|
config: TreeVisualizationConfig,
|
|
): any => {
|
|
// Generate ECharts tree configuration from provided tree data
|
|
const { treeData, getNodeLabel, getNodeErrorRate } = data;
|
|
const { layoutType, isDarkMode, nodeSize, symbolSize = 30 } = config;
|
|
|
|
/**
|
|
* Create a simple circle SVG data URL with a colored border.
|
|
* The border is baked into the SVG so it's always visible — ECharts tree
|
|
* series doesn't reliably render itemStyle.borderColor on default symbols.
|
|
* Pattern matches ServiceGraph's getServiceIconSvg in convertTraceData.ts.
|
|
*/
|
|
const getCircleSvg = (fillColor: string, strokeColor: string, strokeWidth: number): string => {
|
|
const svg =
|
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 56 56">` +
|
|
`<circle cx="28" cy="28" r="22" fill="${fillColor}" stroke="${strokeColor}" stroke-width="${strokeWidth}"/>` +
|
|
`</svg>`;
|
|
return `image://data:image/svg+xml;base64,${btoa(encodeURIComponent(svg).replace(/%([0-9A-F]{2})/g, (_, p1) => String.fromCharCode(parseInt(p1, 16))))}`;
|
|
};
|
|
|
|
// Convert TreeNode format to ECharts tree format
|
|
const convertToEChartsFormat = (nodes: TreeNode[], parentPath: number[] = []): any[] => {
|
|
return nodes.map((node, index) => {
|
|
const treePath = [...parentPath, index];
|
|
const errorCount = getNodeErrorRate(node);
|
|
const serviceColor =
|
|
data.getNodeServiceColor?.(node) ||
|
|
getServiceColorHex(node.name, isDarkMode ? "dark" : "light");
|
|
const borderColor =
|
|
errorCount > 0
|
|
? isDarkMode
|
|
? "#ef4444"
|
|
: "#dc2626" // Red for error spans
|
|
: serviceColor;
|
|
const edgeColor = isDarkMode ? "#4a5568" : "#b0b7c3";
|
|
const fillColor = isDarkMode ? "#1a1f2e" : "#ffffff";
|
|
|
|
return {
|
|
name: node.name,
|
|
value: node.value,
|
|
// Repeated services share name and id, so the tooltip resolves the node by position.
|
|
treePath,
|
|
symbol: getCircleSvg(fillColor, borderColor, 4),
|
|
symbolSize:
|
|
nodeSize === "dynamic"
|
|
? Math.max(20, Math.min(50, Math.log10((node.value || 0) + 1) * 8))
|
|
: symbolSize,
|
|
lineStyle: {
|
|
color: edgeColor,
|
|
width: 2,
|
|
},
|
|
itemStyle: {
|
|
color: isDarkMode ? "#1a1f2e" : "#ffffff",
|
|
borderColor: borderColor,
|
|
borderWidth: 4, // Service-color border always visible
|
|
borderType: "solid",
|
|
shadowBlur: 10,
|
|
shadowColor: isDarkMode ? "rgba(0, 0, 0, 0.3)" : "rgba(0, 0, 0, 0.1)",
|
|
shadowOffsetX: 0,
|
|
shadowOffsetY: 0,
|
|
opacity: 1, // Ensure full opacity
|
|
},
|
|
emphasis: {
|
|
scale: true,
|
|
scaleSize: 1.15,
|
|
itemStyle: {
|
|
borderColor: borderColor, // Maintain service/error color on hover
|
|
borderWidth: 5, // Slightly thicker on hover
|
|
shadowBlur: 20,
|
|
shadowColor: isDarkMode ? "rgba(0, 0, 0, 0.5)" : "rgba(0, 0, 0, 0.3)",
|
|
},
|
|
label: {
|
|
show: true,
|
|
fontSize: 12,
|
|
fontWeight: "bold",
|
|
},
|
|
},
|
|
select: {
|
|
itemStyle: {
|
|
borderColor: borderColor, // Keep service/error color
|
|
borderWidth: 6, // Thicker border when selected
|
|
borderType: "solid",
|
|
shadowBlur: 45,
|
|
shadowColor: "rgba(59, 130, 246, 0.9)", // Prominent blue glow for selection
|
|
shadowOffsetX: 0,
|
|
shadowOffsetY: 0,
|
|
opacity: 1,
|
|
},
|
|
label: {
|
|
show: true,
|
|
fontSize: 12,
|
|
fontWeight: "bold",
|
|
},
|
|
},
|
|
label: {
|
|
show: true,
|
|
position: layoutType === "vertical" ? "bottom" : "right",
|
|
distance: 6,
|
|
formatter: () => getNodeLabel(node),
|
|
rich: {
|
|
name: {
|
|
fontSize: 12,
|
|
fontWeight: "600",
|
|
color: isDarkMode ? "#e4e7eb" : "#1f2937",
|
|
lineHeight: 16,
|
|
},
|
|
requests: {
|
|
fontSize: 10,
|
|
fontWeight: "normal",
|
|
color: isDarkMode ? "#9ca3af" : "#6b7280",
|
|
lineHeight: 14,
|
|
},
|
|
duration: {
|
|
fontSize: 10,
|
|
fontWeight: "normal",
|
|
color: isDarkMode ? "#9ca3af" : "#6b7280",
|
|
lineHeight: 14,
|
|
},
|
|
},
|
|
},
|
|
children: node.children ? convertToEChartsFormat(node.children, treePath) : undefined,
|
|
};
|
|
});
|
|
};
|
|
|
|
// Handle multiple root nodes - create virtual root if needed
|
|
const echartsData =
|
|
treeData.length > 1
|
|
? [
|
|
{
|
|
name: "",
|
|
symbolSize: 1,
|
|
itemStyle: { opacity: 0 },
|
|
label: { show: false },
|
|
lineStyle: { opacity: 0 },
|
|
children: convertToEChartsFormat(treeData),
|
|
},
|
|
]
|
|
: convertToEChartsFormat(treeData);
|
|
|
|
// Generate ECharts options with consistent configuration
|
|
const chartOptions = {
|
|
backgroundColor: "transparent",
|
|
tooltip: {
|
|
show: false, // Custom tooltips handled by parent components
|
|
},
|
|
series: [
|
|
{
|
|
type: "tree",
|
|
data: echartsData,
|
|
layout: "orthogonal",
|
|
orient: layoutType === "vertical" ? "TB" : "LR",
|
|
// Consistent spacing configuration from ServiceGraph
|
|
left: layoutType === "vertical" ? "2%" : "3%",
|
|
right: layoutType === "vertical" ? "2%" : "20%",
|
|
top: layoutType === "vertical" ? "8%" : "2%",
|
|
bottom: layoutType === "vertical" ? "8%" : "2%",
|
|
initialTreeDepth: -1,
|
|
symbolSize: symbolSize,
|
|
roam: true, // Enable panning and zooming
|
|
// Same bounds as the zoom buttons, so wheel and buttons agree.
|
|
scaleLimit: { min: CHART_ZOOM_MIN, max: CHART_ZOOM_MAX },
|
|
selectedMode: "single", // Enable single node selection
|
|
label: {
|
|
position: layoutType === "vertical" ? "bottom" : "right",
|
|
distance: 6,
|
|
fontSize: 12,
|
|
rotate: 0,
|
|
},
|
|
leaves: {
|
|
label: {
|
|
position: layoutType === "vertical" ? "bottom" : "right",
|
|
distance: 6,
|
|
fontSize: 12,
|
|
rotate: 0,
|
|
},
|
|
},
|
|
emphasis: {
|
|
focus: "relative", // Dims nodes not connected to hovered node
|
|
},
|
|
blur: {
|
|
itemStyle: { opacity: 0.15 },
|
|
label: { opacity: 0.15 },
|
|
lineStyle: { opacity: 0.08 },
|
|
},
|
|
expandAndCollapse: false,
|
|
animationDuration: 550,
|
|
animationDurationUpdate: 750,
|
|
},
|
|
],
|
|
};
|
|
|
|
// Return the complete ECharts configuration
|
|
|
|
return chartOptions;
|
|
};
|
|
|
|
/**
|
|
* Get health-based color using the same thresholds as ServiceGraph
|
|
* Reuses exact color logic from convertServiceGraphToTree
|
|
*/
|
|
const getHealthColor = (errorRate: number, isDarkMode: boolean): string => {
|
|
// Use more contrasting colors for better visibility
|
|
if (errorRate > 10) return isDarkMode ? "#ef4444" : "#dc2626"; // Red — critical
|
|
if (errorRate > 5) return isDarkMode ? "#f97316" : "#ea580c"; // Orange — warning
|
|
if (errorRate > 1) return isDarkMode ? "#fbbf24" : "#d97706"; // Yellow — degraded
|
|
|
|
// Healthy nodes - use more contrasting green
|
|
return isDarkMode ? "#10b981" : "#16a34a"; // Green — healthy (more contrasting)
|
|
};
|
|
|
|
/**
|
|
* Find chart node at screen coordinates using ECharts hit detection
|
|
*/
|
|
const findNodeAtPoint = (
|
|
chart: any,
|
|
point: [number, number],
|
|
treeData: TreeNode[],
|
|
): TreeNode | null => {
|
|
try {
|
|
// Use ECharts' built-in hit detection for accurate node detection
|
|
const zr = chart.getZr();
|
|
if (!zr || !zr.handler) {
|
|
return null;
|
|
}
|
|
|
|
// Get the element at the mouse position
|
|
const hoveredElements = zr.handler.findHover(point[0], point[1]);
|
|
|
|
if (!hoveredElements || !hoveredElements.target) return null;
|
|
|
|
// Check if we hit a tree node symbol
|
|
const target = hoveredElements.target;
|
|
const dataIndex = target.dataIndex;
|
|
|
|
if (dataIndex === undefined || dataIndex === null) return null;
|
|
|
|
// Find the corresponding tree node using the data index
|
|
const foundNode = findNodeByIndex(treeData, dataIndex);
|
|
return foundNode;
|
|
} catch (error) {
|
|
console.warn("Error in findNodeAtPoint:", error);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const findNodeByPath = (nodes: TreeNode[], treePath: number[]): TreeNode | null => {
|
|
let level: TreeNode[] | undefined = nodes;
|
|
let node: TreeNode | null = null;
|
|
for (const index of treePath) {
|
|
node = level?.[index] ?? null;
|
|
if (!node) return null;
|
|
level = node.children;
|
|
}
|
|
return node;
|
|
};
|
|
|
|
/**
|
|
* Helper to find tree node by ECharts data index (fallback method)
|
|
* Traverses the tree data to find the node at the given index
|
|
*/
|
|
const findNodeByIndex = (nodes: TreeNode[], targetIndex: number): TreeNode | null => {
|
|
let currentIndex = 0;
|
|
|
|
const traverse = (nodeList: TreeNode[]): TreeNode | null => {
|
|
for (const node of nodeList) {
|
|
if (currentIndex === targetIndex) {
|
|
return node;
|
|
}
|
|
currentIndex++;
|
|
|
|
if (node.children && node.children.length > 0) {
|
|
const found = traverse(node.children);
|
|
if (found) return found;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
return traverse(nodes);
|
|
};
|
|
|
|
/**
|
|
* Setup custom DOM tooltips for trace graph nodes
|
|
* Follows exact Service Graph tooltip patterns for consistency
|
|
*/
|
|
const setupTraceNodeTooltips = (
|
|
chart: any,
|
|
data: TreeVisualizationData,
|
|
isDarkMode: boolean,
|
|
): (() => void) => {
|
|
const chartDom = chart.getDom();
|
|
|
|
// Create tooltip element with Service Graph styling
|
|
const tooltipEl = document.createElement("div");
|
|
|
|
// Exact styling from Service Graph implementation
|
|
/* eslint-disable local/no-hardcoded-px -- optical effects: backdrop blur radii and a 1-device-pixel border, which must not scale with text */
|
|
tooltipEl.style.cssText = `
|
|
position: absolute; pointer-events: none; z-index: 9999;
|
|
background: ${isDarkMode ? "rgba(22, 22, 26, 0.90)" : "rgba(255, 255, 255, 0.88)"};
|
|
backdrop-filter: blur(24px) saturate(180%);
|
|
-webkit-backdrop-filter: blur(24px) saturate(180%);
|
|
border: 1px solid ${isDarkMode ? "rgba(255,255,255,0.07)" : "rgba(0,0,0,0.06)"};
|
|
border-radius: 0.5rem; padding: 0.5625rem 0.8125rem; font-size: 0.75rem; line-height: 1.5;
|
|
font-family: var(--font-sans);
|
|
letter-spacing: 0.01em; white-space: nowrap; display: none;
|
|
color: ${isDarkMode ? "rgba(255,255,255,0.88)" : "rgba(0,0,0,0.82)"};
|
|
`;
|
|
/* eslint-enable local/no-hardcoded-px */
|
|
|
|
if (!chartDom.style.position || chartDom.style.position === "static") {
|
|
chartDom.style.position = "relative";
|
|
}
|
|
chartDom.appendChild(tooltipEl);
|
|
|
|
let hideTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let activeNode: TreeNode | null = null;
|
|
|
|
// Positioning logic from Service Graph with proper dimension measurement
|
|
const positionTooltip = (mouseX: number, mouseY: number) => {
|
|
const cw = chartDom.clientWidth;
|
|
const ch = chartDom.clientHeight;
|
|
let left = mouseX + 15;
|
|
let top = mouseY + 15;
|
|
|
|
// Make visible but positioned off-screen to measure dimensions
|
|
tooltipEl.style.visibility = "hidden";
|
|
tooltipEl.style.display = "block";
|
|
tooltipEl.style.left = "0";
|
|
tooltipEl.style.top = "0";
|
|
|
|
// Now we can get accurate dimensions
|
|
const tooltipWidth = tooltipEl.offsetWidth;
|
|
const tooltipHeight = tooltipEl.offsetHeight;
|
|
|
|
// Adjust position if tooltip would go off-screen
|
|
if (left + tooltipWidth > cw) {
|
|
left = mouseX - tooltipWidth - 10;
|
|
}
|
|
if (top + tooltipHeight > ch) {
|
|
top = mouseY - tooltipHeight - 10;
|
|
}
|
|
|
|
// Ensure tooltip doesn't go negative
|
|
left = Math.max(5, left);
|
|
top = Math.max(5, top);
|
|
|
|
// Position and make visible
|
|
tooltipEl.style.left = left + "px";
|
|
tooltipEl.style.top = top + "px";
|
|
tooltipEl.style.visibility = "visible";
|
|
};
|
|
|
|
const showNodeTooltip = (mouseX: number, mouseY: number, node: TreeNode) => {
|
|
const tooltipContent = data.getNodeTooltip(node);
|
|
|
|
tooltipEl.innerHTML = tooltipContent;
|
|
positionTooltip(mouseX, mouseY);
|
|
};
|
|
|
|
const hideTooltip = () => {
|
|
tooltipEl.style.display = "none";
|
|
activeNode = null;
|
|
};
|
|
|
|
// ECharts event handling - use built-in hover events directly on chart elements
|
|
const onNodeMouseOver = (params: any) => {
|
|
if (!params?.data?.treePath) return;
|
|
|
|
const hoveredNode = findNodeByPath(data.treeData, params.data.treePath);
|
|
if (hoveredNode && hoveredNode !== activeNode) {
|
|
// Clear any existing timer to prevent race conditions
|
|
if (hideTimer) {
|
|
clearTimeout(hideTimer);
|
|
hideTimer = null;
|
|
}
|
|
|
|
activeNode = hoveredNode;
|
|
|
|
// Use event pixel coordinates if available, otherwise use chart center
|
|
const mouseX = params.event?.offsetX || chartDom.clientWidth / 2;
|
|
const mouseY = params.event?.offsetY || chartDom.clientHeight / 2;
|
|
|
|
showNodeTooltip(mouseX, mouseY, hoveredNode);
|
|
}
|
|
};
|
|
|
|
const onNodeMouseOut = () => {
|
|
if (activeNode) {
|
|
// Clear existing timer before setting new one
|
|
if (hideTimer) {
|
|
clearTimeout(hideTimer);
|
|
}
|
|
hideTimer = setTimeout(() => {
|
|
hideTimer = null;
|
|
hideTooltip();
|
|
}, 150);
|
|
}
|
|
};
|
|
|
|
const onChartMouseLeave = () => {
|
|
if (hideTimer) {
|
|
clearTimeout(hideTimer);
|
|
hideTimer = null;
|
|
}
|
|
hideTooltip();
|
|
};
|
|
|
|
// Register ECharts events directly on chart elements
|
|
chart.on("mouseover", onNodeMouseOver);
|
|
chart.on("mouseout", onNodeMouseOut);
|
|
chart.on("globalout", onChartMouseLeave);
|
|
|
|
// Cleanup function
|
|
return () => {
|
|
if (hideTimer) clearTimeout(hideTimer);
|
|
chart.off("mouseover", onNodeMouseOver);
|
|
chart.off("mouseout", onNodeMouseOut);
|
|
chart.off("globalout", onChartMouseLeave);
|
|
tooltipEl.remove();
|
|
};
|
|
};
|
|
|
|
return {
|
|
generateEChartsOptions,
|
|
setupTraceNodeTooltips,
|
|
findNodeAtPoint, // Export for testing
|
|
findNodeByIndex, // Export helper for testing
|
|
getHealthColor,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Placeholder tree-options API for ServiceGraph.vue — not yet implemented.
|
|
*/
|
|
export const createLegacyTreeOptions = (
|
|
graphData: { nodes: any[]; edges: any[] },
|
|
_layoutType: string = "horizontal",
|
|
_isDarkMode: boolean = true,
|
|
) => {
|
|
throw new Error(
|
|
"Legacy tree options not yet implemented - use convertServiceGraphToTree for now",
|
|
);
|
|
};
|