fix: servie graph v2 (#10410)

This commit is contained in:
Sai Nikhil
2026-02-10 12:04:40 +05:30
committed by GitHub
parent 9b11d0346a
commit c2c756613f
9 changed files with 4551 additions and 162 deletions
@@ -848,7 +848,14 @@ export default defineComponent({
},
{ deep: true },
);
return { chartRef, hoveredSeriesState, handleNativeContextMenu };
return {
chartRef,
hoveredSeriesState,
handleNativeContextMenu,
get chart() {
return chart;
}
};
},
});
</script>
+41 -2
View File
@@ -44,9 +44,9 @@ style="min-height: auto">
<!-- Service Graph Tab Content -->
<div
v-if="activeTab === 'service-graph' && store.state.zoConfig.service_graph_enabled"
class="tw:px-[0.625rem] tw:pb-[0.625rem] tw:h-[calc(100vh-98px)] tw:overflow-hidden"
class="tw:px-[0.625rem] tw:pb-[0.625rem] tw:h-[calc(100vh-90px)] tw:overflow-hidden"
>
<service-graph class="tw:h-full" />
<service-graph class="tw:h-full" @view-traces="handleServiceGraphViewTraces" />
</div>
<!-- Search Tab Content -->
@@ -208,6 +208,7 @@ import {
b64DecodeUnicode,
formatTimeWithSuffix,
timestampToTimezoneDate,
escapeSingleQuotes,
} from "@/utils/zincutils";
import segment from "@/services/segment_analytics";
import config from "@/aws-exports";
@@ -1374,6 +1375,44 @@ watch(moveSplitter, () => {
// },
// );
// Handler for service graph view traces event
const handleServiceGraphViewTraces = (data: any) => {
// Switch to search tab
activeTab.value = 'search';
// Set the selected stream in dropdown
if (data.stream) {
searchObj.data.stream.selectedStream = {
label: data.stream,
value: data.stream,
};
}
// Set the filter query (just the WHERE condition, no SELECT or ORDER BY)
if (data.serviceName) {
const escapedServiceName = escapeSingleQuotes(data.serviceName);
const filterQuery = `service_name = '${escapedServiceName}'`;
searchObj.data.editorValue = filterQuery;
searchObj.data.query = filterQuery;
searchObj.meta.sqlMode = false; // Traces doesn't use SQL mode
}
// Set the time range
if (data.timeRange) {
searchObj.data.datetime = {
startTime: data.timeRange.startTime,
endTime: data.timeRange.endTime,
relativeTimePeriod: null,
type: "absolute",
};
}
// Run the query
nextTick(() => {
runQueryFn();
});
};
watch(updateSelectedColumns, () => {
searchObj.meta.resultGrid.manualRemoveFields = true;
setTimeout(() => {
+400 -105
View File
@@ -1,6 +1,6 @@
<template>
<q-card flat class="tw:h-full">
<q-card-section class="tw:p-[0.375rem] tw:h-full card-container">
<q-card-section class="tw:p-[0.375rem] tw:h-full card-container service-graph-container">
<!-- Top row with search and control buttons -->
<div class="row items-center q-col-gutter-sm q-mb-md">
<div class="col-12 col-md-5 tw:flex tw:gap-[0.5rem]">
@@ -8,26 +8,27 @@
<q-select
v-model="streamFilter"
:options="
searchObj.data.stream.streamLists && searchObj.data.stream.streamLists.length > 0
availableStreams.length > 0
? [
{ label: 'All Streams', value: 'all' },
...searchObj.data.stream.streamLists,
...availableStreams.map((s) => ({ label: s, value: s })),
]
: [{ label: 'All Streams', value: 'all' }]
"
dense
outlined
borderless
emit-value
map-options
class="tw:w-[180px] tw:flex-shrink-0"
@update:model-value="onStreamFilterChange"
:disable="!searchObj.data.stream.streamLists || searchObj.data.stream.streamLists.length === 0"
:disable="availableStreams.length === 0"
>
<template #prepend>
<q-icon name="storage" size="xs" />
</template>
<q-tooltip v-if="!searchObj.data.stream.streamLists || searchObj.data.stream.streamLists.length === 0">
No streams detected. Loading streams from parent...
<q-tooltip v-if="availableStreams.length === 0">
No streams detected. Ensure service graph metrics include
stream_name labels.
</q-tooltip>
</q-select>
@@ -36,10 +37,11 @@
v-model="searchFilter"
borderless
dense
class="no-border tw:h-[36px] tw:flex-grow"
class="no-border tw:h-[36px]"
placeholder="Search services..."
debounce="300"
@update:model-value="applyFilters"
clearable
>
<template #prepend>
<q-icon class="o2-search-input-icon" name="search" />
@@ -69,35 +71,30 @@
data-test="service-graph-refresh-btn"
no-caps
flat
dense
icon="refresh"
class="tw:border tw:border-solid tw:border-[var(--o2-border-color)] q-px-sm element-box-shadow hover:tw:bg-[var(--o2-hover-accent)]"
class="o2-secondary-button"
@click="loadServiceGraph"
:loading="loading"
>
<q-tooltip>Refresh Service Graph</q-tooltip>
Refresh
</q-btn>
<!-- 3. Graph/Tree view toggle buttons -->
<q-btn-toggle
v-model="visualizationType"
toggle-color="primary"
:options="[
{ label: 'Graph View', value: 'graph', icon: 'hub' },
{ label: 'Tree View', value: 'tree', icon: 'account_tree' },
]"
dense
no-caps
class="view-toggle-buttons"
@update:model-value="setVisualizationType"
/>
<!-- 3. Graph/Tree view toggle -->
<div class="app-tabs-container tw:h-[36px]">
<app-tabs
data-test="service-graph-view-tabs"
class="tabs-selection-container"
:tabs="visualizationTabs"
v-model:active-tab="visualizationType"
@update:active-tab="setVisualizationType"
/>
</div>
<!-- 4. Layout dropdown -->
<q-select
v-model="layoutType"
:options="layoutOptions"
dense
filled
borderless
class="tw:w-[160px]"
emit-value
map-options
@@ -150,13 +147,37 @@
</div>
</div>
</div>
<div v-else class="tw:h-full">
<div v-else class="tw:h-full graph-with-panel-container">
<ChartRenderer
ref="chartRendererRef"
data-test="service-graph-chart"
:data="chartData"
:key="chartKey"
class="tw:h-full"
@click="handleNodeClick"
/>
<!-- Service Graph Side Panel -->
<ServiceGraphSidePanel
v-if="selectedNode"
:selected-node="selectedNode"
:graph-data="graphData"
:time-range="searchObj.data.datetime"
:visible="showSidePanel"
:stream-filter="streamFilter"
@close="handleCloseSidePanel"
@view-logs="handleViewLogs"
@view-traces="handleViewTraces"
/>
<!-- Service Graph Edge Panel -->
<ServiceGraphEdgePanel
v-if="selectedEdge"
:selected-edge="selectedEdge"
:graph-data="graphData"
:time-range="searchObj.data.datetime"
:visible="showEdgePanel"
@close="handleCloseEdgePanel"
/>
</div>
</div>
@@ -205,18 +226,23 @@
</template>
<script lang="ts">
import { defineComponent, ref, onMounted, computed } from "vue";
import { defineComponent, ref, onMounted, computed, watch, nextTick } from "vue";
import { useStore } from "vuex";
import { useRouter } from "vue-router";
import { useQuasar } from "quasar";
import serviceGraphService from "@/services/service_graph";
import AppTabs from "@/components/common/AppTabs.vue";
import ChartRenderer from "@/components/dashboards/panels/ChartRenderer.vue";
import DateTime from "@/components/DateTime.vue";
import ServiceGraphSidePanel from "./ServiceGraphSidePanel.vue";
import ServiceGraphEdgePanel from "./ServiceGraphEdgePanel.vue";
import {
convertServiceGraphToTree,
convertServiceGraphToNetwork,
} from "@/utils/traces/convertTraceData";
import useStreams from "@/composables/useStreams";
import useTraces from "@/composables/useTraces";
import { b64EncodeUnicode, escapeSingleQuotes } from "@/utils/zincutils";
export default defineComponent({
name: "ServiceGraph",
@@ -224,9 +250,14 @@ export default defineComponent({
AppTabs,
ChartRenderer,
DateTime,
ServiceGraphSidePanel,
ServiceGraphEdgePanel,
},
setup() {
emits: ['view-traces'],
setup(props, { emit }) {
const store = useStore();
const $q = useQuasar();
const router = useRouter();
const { getStreams } = useStreams();
const { searchObj } = useTraces();
@@ -235,6 +266,14 @@ export default defineComponent({
const showSettings = ref(false);
const lastUpdated = ref("");
// Side panel state
const selectedNode = ref<any>(null);
const showSidePanel = ref(false);
// Edge panel state
const selectedEdge = ref<any>(null);
const showEdgePanel = ref(false);
// Persist visualization type in localStorage
const storedVisualizationType = localStorage.getItem(
"serviceGraph_visualizationType",
@@ -243,6 +282,12 @@ export default defineComponent({
(storedVisualizationType as "tree" | "graph") || "graph",
);
// Visualization tabs configuration
const visualizationTabs = [
{ label: "Graph View", value: "graph" },
{ label: "Tree View", value: "tree" },
];
// Initialize layout type based on visualization type
const storedLayoutType = localStorage.getItem("serviceGraph_layoutType");
let defaultLayoutType = "force";
@@ -255,20 +300,12 @@ export default defineComponent({
const searchFilter = ref("");
const connectionTypeFilter = ref("all");
// Stream filter - use computed property to read from and write to shared state
const streamFilter = computed({
get: () => {
const selectedValue = searchObj.data.stream.selectedStream?.value;
return selectedValue || "all";
},
set: (value) => {
searchObj.data.stream.selectedStream = {
label: value === "all" ? "" : value,
value: value === "all" ? "" : value,
};
},
});
// availableStreams removed - now using searchObj.data.stream.streamLists directly
// Stream filter
const storedStreamFilter = localStorage.getItem(
"serviceGraph_streamFilter",
);
const streamFilter = ref(storedStreamFilter || "all");
const availableStreams = ref<string[]>([]);
// Connection type tabs configuration
const connectionTypeTabs = [
@@ -297,10 +334,6 @@ export default defineComponent({
const dateTimeRef = ref<any>(null);
// Store node positions for graph view to prevent re-layout on updates
const graphNodePositions = ref<Map<string, { x: number; y: number }>>(
new Map(),
);
// Key to control chart recreation - only change when layout/visualization type changes
const chartKey = ref(0);
@@ -329,46 +362,141 @@ export default defineComponent({
return { options: {}, notMerge: true };
}
// Disabled caching to ensure fresh edges with __original property
// Position stability maintained through graphNodePositions passed to conversion
// if (visualizationType.value === "graph" && lastChartOptions.value && chartKey.value === lastChartOptions.value.key) {
// console.log('[ServiceGraph] Reusing cached chart options, chartKey:', chartKey.value);
// return {
// options: lastChartOptions.value.data.options,
// notMerge: false,
// lazyUpdate: true
// };
// }
// Don't use cache if filters are active (search or connection type)
const hasActiveFilters = searchFilter.value?.trim() || connectionTypeFilter.value !== "all";
// Use cached options if chartKey hasn't changed (prevents double rendering)
// BUT only if no filters are active
if (visualizationType.value === "graph" &&
lastChartOptions.value &&
chartKey.value === lastChartOptions.value.key &&
!hasActiveFilters) {
console.log('[ServiceGraph] Reusing cached chart options, chartKey:', chartKey.value);
return {
options: lastChartOptions.value.data.options,
notMerge: false,
lazyUpdate: true,
silent: true,
};
}
console.log(
"[ServiceGraph] Generating new chart options, chartKey:",
chartKey.value,
"visualizationType:",
visualizationType.value,
);
const newOptions =
visualizationType.value === "tree"
? convertServiceGraphToTree(filteredGraphData.value, layoutType.value)
? convertServiceGraphToTree(
filteredGraphData.value,
layoutType.value,
$q.dark.isActive // Pass dark mode state
)
: convertServiceGraphToNetwork(
filteredGraphData.value,
layoutType.value,
graphNodePositions.value,
new Map(), // Empty position cache to allow free movement
$q.dark.isActive, // Pass dark mode state
undefined, // Don't pass selected node - we'll use dispatchAction instead
);
// Cache the options with the current key for graph view
if (visualizationType.value === "graph") {
// Cache the options for graph view
// BUT only if no filters are active (to avoid caching filtered states)
if (visualizationType.value === "graph" && !hasActiveFilters) {
lastChartOptions.value = {
key: chartKey.value,
data: newOptions,
};
} else if (hasActiveFilters) {
// Clear cache when filtering to ensure fresh render on filter removal
lastChartOptions.value = null;
}
return {
...newOptions,
notMerge: visualizationType.value === "graph" ? false : true, // Merge for graph, replace for tree
lazyUpdate: true, // Prevent viewport reset when only styles change
silent: true, // Disable animations during update to prevent position jumps
};
});
// Use ECharts select action to persistently highlight selected node
watch(
() => selectedNode.value?.id,
async (newId, oldId) => {
await nextTick();
if (!chartRendererRef.value?.chart) {
return;
}
const chart = chartRendererRef.value.chart;
// Unselect the old node (if any)
if (oldId) {
chart.dispatchAction({
type: 'unselect',
seriesIndex: 0,
name: oldId,
});
}
// Select the new node (if any)
if (newId) {
chart.dispatchAction({
type: 'select',
seriesIndex: 0,
name: newId,
});
}
},
{ flush: 'post' }
);
// Watch for theme changes and re-apply selection
watch(
() => store.state.theme,
async () => {
// Increment chartKey to force regeneration with new theme colors
chartKey.value++;
// Save the current selected node ID (in case it changes during the delay)
const nodeIdToReselect = selectedNode.value?.id;
// Use setTimeout to wait for chart to be fully regenerated after theme change
setTimeout(() => {
if (!chartRendererRef.value?.chart || !nodeIdToReselect) {
return;
}
const chart = chartRendererRef.value.chart;
// Re-apply node selection
chart.dispatchAction({
type: 'select',
seriesIndex: 0,
name: nodeIdToReselect,
});
}, 500); // 500ms delay to ensure chart has fully regenerated
}
);
// Watch for stream filter changes and restore chart viewport
watch(
() => streamFilter.value,
async () => {
// Wait for chart to update with new data
await nextTick();
setTimeout(() => {
if (!chartRendererRef.value?.chart) {
return;
}
const chart = chartRendererRef.value.chart;
// Restore chart to default zoom/pan to fit all content
chart.dispatchAction({
type: 'restore',
});
}, 500); // Longer delay to ensure chart has recalculated positions
}
);
const loadServiceGraph = async () => {
loading.value = true;
error.value = null;
@@ -380,7 +508,27 @@ export default defineComponent({
throw new Error("No organization selected");
}
// Streams are loaded by parent Index.vue and available in searchObj.data.stream.streamLists
// If a specific stream is selected but we don't have available streams yet,
// first fetch all streams to populate the dropdown
if (
availableStreams.value.length === 0 &&
streamFilter.value !== "all"
) {
console.log(
"[ServiceGraph] Fetching all streams first to populate dropdown",
);
const allStreamsResponse =
await serviceGraphService.getCurrentTopology(orgId, {
startTime: searchObj.data.datetime.startTime,
endTime: searchObj.data.datetime.endTime,
});
if (
allStreamsResponse.data.availableStreams &&
allStreamsResponse.data.availableStreams.length > 0
) {
availableStreams.value = allStreamsResponse.data.availableStreams;
}
}
// Stream-only implementation - no store stats needed
// Use JSON topology endpoint with time range
@@ -589,11 +737,7 @@ export default defineComponent({
};
const onStreamFilterChange = (stream: string) => {
// Update shared stream selection
searchObj.data.stream.selectedStream = {
label: stream === "all" ? "" : stream,
value: stream === "all" ? "" : stream,
};
streamFilter.value = stream;
localStorage.setItem("serviceGraph_streamFilter", stream);
// Reload service graph with new stream filter
@@ -605,8 +749,9 @@ export default defineComponent({
let edges = [...graphData.value.edges];
// Filter by search
if (searchFilter.value) {
const search = searchFilter.value.toLowerCase();
const trimmedSearch = searchFilter.value?.trim();
if (trimmedSearch) {
const search = trimmedSearch.toLowerCase();
const matchingNodeIds = new Set(
nodes
.filter((n) => n.label.toLowerCase().includes(search))
@@ -657,8 +802,6 @@ export default defineComponent({
layoutType.value = "horizontal";
} else {
layoutType.value = "force";
// Clear cached positions when switching to graph view to allow fresh layout
graphNodePositions.value = new Map();
}
// Force chart recreation when visualization type changes
chartKey.value++;
@@ -671,25 +814,170 @@ export default defineComponent({
};
const updateTimeRange = (value: any) => {
// Update shared datetime state
searchObj.data.datetime = {
startTime: value.startTime,
endTime: value.endTime,
relativeTimePeriod:
value.relativeTimePeriod || searchObj.data.datetime.relativeTimePeriod,
type: value.relativeTimePeriod ? "relative" : "absolute",
queryRangeRestrictionMsg: searchObj.data.datetime?.queryRangeRestrictionMsg || "",
queryRangeRestrictionInHour: searchObj.data.datetime?.queryRangeRestrictionInHour || 0,
};
// Reload service graph with new time range
loadServiceGraph();
};
// Load trace streams using the same method as the Traces search page
// Streams are loaded by parent Index.vue into searchObj.data.stream.streamLists
// No need to load them again here
const loadTraceStreams = async () => {
try {
const res = await getStreams("traces", false, false);
if (res?.list?.length > 0) {
availableStreams.value = res.list.map((stream: any) => stream.name);
}
} catch (e) {
console.error("Error loading trace streams:", e);
}
};
onMounted(() => {
// Settings handler
const resetSettings = () => {
// Reset to default settings if needed
showSettings.value = false;
};
// Side Panel Handlers
const handleNodeClick = (params: any) => {
console.log('[ServiceGraph] Click event:', params);
// Check if it's an edge click (for graph visualization)
if (params.dataType === 'edge' && params.data) {
console.log('[ServiceGraph] Opening edge panel for edge:', params.data);
// Close node panel when opening edge panel
showSidePanel.value = false;
selectedNode.value = null;
// Find the full edge data from graphData
const edgeData = graphData.value.edges.find(
(e: any) => e.from === params.data.source && e.to === params.data.target
);
if (edgeData) {
selectedEdge.value = edgeData;
showEdgePanel.value = true;
} else {
console.warn('[ServiceGraph] Could not find edge data for:', params.data.source, '->', params.data.target);
}
}
// Check if it's a node click (for graph visualization)
else if (params.dataType === 'node' && params.data) {
// Check if clicking the same node - if so, close the panel
if (selectedNode.value && selectedNode.value.id === params.data.id) {
console.log('[ServiceGraph] Clicking same node, closing side panel');
showSidePanel.value = false;
selectedNode.value = null;
} else {
console.log('[ServiceGraph] Opening side panel for node:', params.data);
// Close edge panel when opening node panel
showEdgePanel.value = false;
selectedEdge.value = null;
selectedNode.value = params.data;
showSidePanel.value = true;
}
}
// For tree visualization, check if it's a tree node
else if (params.componentType === 'series' && params.data && params.data.name) {
// Find the actual node data from graphData
const nodeData = graphData.value.nodes.find(
(n: any) => n.label === params.data.name || n.id === params.data.name
);
if (nodeData) {
// Check if clicking the same node - if so, close the panel
if (selectedNode.value && selectedNode.value.id === nodeData.id) {
console.log('[ServiceGraph] Clicking same tree node, closing side panel');
showSidePanel.value = false;
selectedNode.value = null;
} else {
console.log('[ServiceGraph] Opening side panel for tree node:', params.data);
// Close edge panel when opening node panel
showEdgePanel.value = false;
selectedEdge.value = null;
selectedNode.value = nodeData;
showSidePanel.value = true;
}
} else {
console.warn('[ServiceGraph] Could not find node data for:', params.data.name);
}
} else {
console.log('[ServiceGraph] Click not on a node or edge, ignoring');
}
};
const handleCloseSidePanel = () => {
showSidePanel.value = false;
// Don't clear selectedNode immediately to allow smooth close animation
setTimeout(() => {
selectedNode.value = null;
}, 300);
};
const handleViewLogs = () => {
if (!selectedNode.value) return;
const serviceName = selectedNode.value.name || selectedNode.value.label || selectedNode.value.id;
const escapedServiceName = escapeSingleQuotes(serviceName);
const escapedStream = escapeSingleQuotes(streamFilter.value);
const sql = `SELECT * FROM "${escapedStream}" WHERE service_name = '${escapedServiceName}' ORDER BY _timestamp DESC`;
const query = b64EncodeUnicode(sql);
const queryObject = {
stream_type: "logs",
stream: streamFilter.value,
from: searchObj.data.datetime.startTime,
to: searchObj.data.datetime.endTime,
refresh: 0,
sql_mode: "true",
query,
defined_schemas: "user_defined_schema",
org_identifier: store.state.selectedOrganization.identifier,
quick_mode: "false",
show_histogram: "true",
type: "service_graph_view_logs",
};
router.push({
path: "/logs",
query: queryObject,
});
};
const handleViewTraces = () => {
if (!selectedNode.value) return;
const serviceName = selectedNode.value.name || selectedNode.value.label || selectedNode.value.id;
// Emit event to parent to switch tab and apply query
// Parent will handle tab switching and query application
emit('view-traces', {
stream: streamFilter.value,
serviceName: serviceName,
timeRange: {
startTime: searchObj.data.datetime.startTime,
endTime: searchObj.data.datetime.endTime,
},
});
};
// Edge Panel Handlers
const handleCloseEdgePanel = () => {
showEdgePanel.value = false;
// Don't clear selectedEdge immediately to allow smooth close animation
setTimeout(() => {
selectedEdge.value = null;
}, 300);
};
onMounted(async () => {
await loadTraceStreams();
loadServiceGraph();
});
@@ -703,9 +991,11 @@ export default defineComponent({
lastUpdated,
searchFilter,
streamFilter,
availableStreams,
connectionTypeFilter,
connectionTypeTabs,
visualizationType,
visualizationTabs,
layoutType,
layoutOptions,
chartData,
@@ -720,6 +1010,18 @@ export default defineComponent({
setLayout,
setVisualizationType,
updateTimeRange,
resetSettings,
// Side panel
selectedNode,
showSidePanel,
handleNodeClick,
handleCloseSidePanel,
handleViewLogs,
handleViewTraces,
// Edge panel
selectedEdge,
showEdgePanel,
handleCloseEdgePanel,
};
},
});
@@ -747,33 +1049,26 @@ export default defineComponent({
overflow: hidden;
}
.service-graph-container {
position: relative;
overflow: hidden;
background: #0f1419 !important;
}
.body--light .service-graph-container {
background: #ffffff !important; // White background for light mode
}
.graph-with-panel-container {
position: relative;
overflow: visible;
}
code {
background: #f0f0f0;
padding: 2px 6px;
border-radius: 3px;
font-family: "Courier New", monospace;
font-size: 0.9em;
font-weight: 600;
color: #333;
}
.view-toggle-buttons {
:deep(.q-btn) {
padding-left: 1rem;
padding-right: 1rem;
}
:deep(.q-btn-item) {
padding-left: 1rem;
padding-right: 1rem;
}
:deep(.q-btn__content) {
gap: 0.5rem;
}
:deep(.q-separator) {
margin: 0.25rem 0;
}
}
</style>
@@ -0,0 +1,710 @@
// 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 { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mount, VueWrapper } from "@vue/test-utils";
import { installQuasar } from "@/test/unit/helpers";
import { nextTick } from "vue";
import ServiceGraphEdgePanel from "./ServiceGraphEdgePanel.vue";
installQuasar({
plugins: [],
});
// Mock CustomChartRenderer component
vi.mock("@/components/dashboards/panels/CustomChartRenderer.vue", () => ({
default: {
name: "CustomChartRenderer",
props: ["data"],
template: '<div data-test="custom-chart-renderer">Chart</div>',
},
}));
// Mock store
const createMockStore = (overrides = {}) => ({
state: {
theme: "dark",
...overrides,
},
dispatch: vi.fn(),
commit: vi.fn(),
});
describe("ServiceGraphEdgePanel.vue", () => {
let wrapper: VueWrapper<any>;
let mockStore: any;
const mockNodes = [
{ id: "service-a", label: "Service A" },
{ id: "service-b", label: "Service B" },
{ id: "service-c", label: "Service C" },
];
const mockEdges = [
{
from: "service-a",
to: "service-b",
total_requests: 1500,
failed_requests: 50,
p50_latency_ns: 10000000, // 10ms
p95_latency_ns: 50000000, // 50ms
p99_latency_ns: 100000000, // 100ms
},
];
const mockTimeRange = {
startTime: Date.now() - 3600000,
endTime: Date.now(),
};
const createWrapper = (props = {}, storeOverrides = {}) => {
mockStore = createMockStore(storeOverrides);
return mount(ServiceGraphEdgePanel, {
props: {
selectedEdge: mockEdges[0],
graphData: { nodes: mockNodes, edges: mockEdges },
timeRange: mockTimeRange,
visible: true,
...props,
},
global: {
mocks: {
$store: mockStore,
},
provide: {
store: mockStore,
},
},
});
};
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
if (wrapper) {
wrapper.unmount();
}
});
describe("Initialization", () => {
it("should mount component successfully", () => {
wrapper = createWrapper();
expect(wrapper.exists()).toBe(true);
});
it("should render panel when visible is true", () => {
wrapper = createWrapper({ visible: true });
expect(wrapper.find('[data-test="service-graph-edge-panel"]').exists()).toBe(true);
});
it("should not render panel when visible is false", () => {
wrapper = createWrapper({ visible: false });
expect(wrapper.find('[data-test="service-graph-edge-panel"]').exists()).toBe(false);
});
it("should display panel header", () => {
wrapper = createWrapper();
expect(wrapper.find('[data-test="service-graph-edge-panel-header"]').exists()).toBe(true);
});
it("should display panel title with service names", () => {
wrapper = createWrapper();
expect(wrapper.find('[data-test="service-graph-edge-panel-title"]').exists()).toBe(true);
});
it("should display close button", () => {
wrapper = createWrapper();
expect(wrapper.find('[data-test="service-graph-edge-panel-close-btn"]').exists()).toBe(true);
});
});
describe("Computed Properties - sourceServiceName", () => {
it("should compute source service name from graph data", () => {
wrapper = createWrapper();
expect(wrapper.vm.sourceServiceName).toBe("Service A");
});
it("should return edge.from when node is not found", () => {
const edgeWithUnknownSource = {
...mockEdges[0],
from: "unknown-service",
};
wrapper = createWrapper({
selectedEdge: edgeWithUnknownSource,
});
expect(wrapper.vm.sourceServiceName).toBe("unknown-service");
});
it("should return empty string when selectedEdge is null", () => {
wrapper = createWrapper({
selectedEdge: null,
});
expect(wrapper.vm.sourceServiceName).toBe("");
});
});
describe("Computed Properties - targetServiceName", () => {
it("should compute target service name from graph data", () => {
wrapper = createWrapper();
expect(wrapper.vm.targetServiceName).toBe("Service B");
});
it("should return edge.to when node is not found", () => {
const edgeWithUnknownTarget = {
...mockEdges[0],
to: "unknown-service",
};
wrapper = createWrapper({
selectedEdge: edgeWithUnknownTarget,
});
expect(wrapper.vm.targetServiceName).toBe("unknown-service");
});
it("should return empty string when selectedEdge is null", () => {
wrapper = createWrapper({
selectedEdge: null,
});
expect(wrapper.vm.targetServiceName).toBe("");
});
});
describe("Computed Properties - edgeStats", () => {
it("should compute edge statistics correctly", () => {
wrapper = createWrapper();
const stats = wrapper.vm.edgeStats;
expect(stats.totalRequests).toBe("1.5K");
expect(stats.requestRate).toBe("1.5K req/min");
});
it("should calculate success rate correctly", () => {
wrapper = createWrapper();
const stats = wrapper.vm.edgeStats;
// (1500 - 50) / 1500 * 100 = 96.67%
expect(stats.successRate).toBe("96.7%");
});
it("should calculate error rate correctly", () => {
wrapper = createWrapper();
const stats = wrapper.vm.edgeStats;
// 50 / 1500 * 100 = 3.33%
expect(stats.errorRate).toBe("3.33%");
expect(stats.errorRateValue).toBe(3.3333333333333335);
});
it("should return N/A for all stats when selectedEdge is null", () => {
wrapper = createWrapper({
selectedEdge: null,
});
const stats = wrapper.vm.edgeStats;
expect(stats.totalRequests).toBe("N/A");
expect(stats.requestRate).toBe("N/A");
expect(stats.successRate).toBe("N/A");
expect(stats.errorRate).toBe("N/A");
expect(stats.errorRateValue).toBe(0);
});
it("should handle edge with zero total requests", () => {
const edgeWithZeroRequests = {
...mockEdges[0],
total_requests: 0,
failed_requests: 0,
};
wrapper = createWrapper({
selectedEdge: edgeWithZeroRequests,
});
const stats = wrapper.vm.edgeStats;
expect(stats.totalRequests).toBe("0");
expect(stats.successRate).toBe("100.0%");
expect(stats.errorRate).toBe("0.00%");
});
it("should handle edge with missing request data", () => {
const edgeWithMissingData = {
from: "service-a",
to: "service-b",
};
wrapper = createWrapper({
selectedEdge: edgeWithMissingData,
});
const stats = wrapper.vm.edgeStats;
expect(stats.totalRequests).toBe("0");
expect(stats.errorRate).toBe("0.00%");
});
it("should handle 100% error rate", () => {
const edgeWithAllErrors = {
...mockEdges[0],
total_requests: 100,
failed_requests: 100,
};
wrapper = createWrapper({
selectedEdge: edgeWithAllErrors,
});
const stats = wrapper.vm.edgeStats;
expect(stats.successRate).toBe("0.0%");
expect(stats.errorRate).toBe("100.00%");
expect(stats.errorRateValue).toBe(100);
});
});
describe("Computed Properties - latencyDistribution", () => {
it("should compute latency distribution chart data", () => {
wrapper = createWrapper();
const distribution = wrapper.vm.latencyDistribution;
expect(distribution).toBeTruthy();
expect(distribution.chartType).toBe("custom_chart");
expect(distribution.series).toBeTruthy();
});
it("should convert nanoseconds to milliseconds", () => {
wrapper = createWrapper();
const distribution = wrapper.vm.latencyDistribution;
const values = distribution.series[0].data.map((d: any) => d.value);
expect(values[0]).toBe(10); // 10ms (P50)
expect(values[1]).toBe(50); // 50ms (P95)
expect(values[2]).toBe(100); // 100ms (P99)
expect(values[3]).toBe(100); // 100ms (Max)
});
it("should return no data message when all latencies are zero", () => {
const edgeWithZeroLatency = {
...mockEdges[0],
p50_latency_ns: 0,
p95_latency_ns: 0,
p99_latency_ns: 0,
};
wrapper = createWrapper({
selectedEdge: edgeWithZeroLatency,
});
const distribution = wrapper.vm.latencyDistribution;
expect(distribution.title.text).toBe("No latency data available");
});
it("should return null when selectedEdge is null", () => {
wrapper = createWrapper({
selectedEdge: null,
});
const distribution = wrapper.vm.latencyDistribution;
expect(distribution).toBeNull();
});
it("should use correct colors for latency bars", () => {
wrapper = createWrapper();
const distribution = wrapper.vm.latencyDistribution;
const colors = distribution.series[0].data.map((d: any) => d.itemStyle.color.colorStops[0].color);
expect(colors[0]).toBe("#4caf50"); // P50 - green
expect(colors[1]).toBe("#2196f3"); // P95 - blue
expect(colors[2]).toBe("#ff9800"); // P99 - orange
expect(colors[3]).toBe("#f44336"); // Max - red
});
it("should use dark theme colors when theme is dark", () => {
wrapper = createWrapper({}, { theme: "dark" });
const distribution = wrapper.vm.latencyDistribution;
expect(distribution.tooltip.backgroundColor).toBe("#2B2C2D");
expect(distribution.tooltip.borderColor).toBe("#444444");
});
it("should use light theme colors when theme is light", () => {
wrapper = createWrapper({}, { theme: "light" });
const distribution = wrapper.vm.latencyDistribution;
expect(distribution.tooltip.backgroundColor).toBe("#ffffff");
expect(distribution.tooltip.borderColor).toBe("#E7EAEE");
});
it("should handle missing latency data gracefully", () => {
const edgeWithMissingLatency = {
from: "service-a",
to: "service-b",
total_requests: 100,
};
wrapper = createWrapper({
selectedEdge: edgeWithMissingLatency,
});
const distribution = wrapper.vm.latencyDistribution;
expect(distribution.title.text).toBe("No latency data available");
});
it("should format large latency values correctly (>= 1 second)", () => {
const edgeWithHighLatency = {
...mockEdges[0],
p50_latency_ns: 1000000000, // 1s
p95_latency_ns: 2000000000, // 2s
p99_latency_ns: 3000000000, // 3s
};
wrapper = createWrapper({
selectedEdge: edgeWithHighLatency,
});
const distribution = wrapper.vm.latencyDistribution;
const values = distribution.series[0].data.map((d: any) => d.value);
expect(values[0]).toBe(1000); // 1000ms
expect(values[1]).toBe(2000); // 2000ms
expect(values[2]).toBe(3000); // 3000ms
});
});
describe("Helper Functions - formatNumber", () => {
it("should format numbers >= 1M with M suffix", () => {
wrapper = createWrapper();
const edge = {
...mockEdges[0],
total_requests: 1500000,
};
wrapper = createWrapper({ selectedEdge: edge });
const stats = wrapper.vm.edgeStats;
expect(stats.totalRequests).toBe("1.5M");
});
it("should format numbers >= 1K with K suffix", () => {
wrapper = createWrapper();
const stats = wrapper.vm.edgeStats;
expect(stats.totalRequests).toBe("1.5K");
});
it("should format numbers < 1K as plain numbers", () => {
const edge = {
...mockEdges[0],
total_requests: 500,
};
wrapper = createWrapper({ selectedEdge: edge });
const stats = wrapper.vm.edgeStats;
expect(stats.totalRequests).toBe("500");
});
it("should handle zero correctly", () => {
const edge = {
...mockEdges[0],
total_requests: 0,
};
wrapper = createWrapper({ selectedEdge: edge });
const stats = wrapper.vm.edgeStats;
expect(stats.totalRequests).toBe("0");
});
});
describe("Helper Functions - formatRequestRate", () => {
it("should format rate >= 1M with M req/min suffix", () => {
const edge = {
...mockEdges[0],
total_requests: 1500000,
};
wrapper = createWrapper({ selectedEdge: edge });
const stats = wrapper.vm.edgeStats;
expect(stats.requestRate).toBe("1.5M req/min");
});
it("should format rate >= 1K with K req/min suffix", () => {
wrapper = createWrapper();
const stats = wrapper.vm.edgeStats;
expect(stats.requestRate).toBe("1.5K req/min");
});
it("should format rate < 1K with req/min suffix", () => {
const edge = {
...mockEdges[0],
total_requests: 500,
};
wrapper = createWrapper({ selectedEdge: edge });
const stats = wrapper.vm.edgeStats;
expect(stats.requestRate).toBe("500 req/min");
});
});
describe("Event Handlers", () => {
it("should emit close event when close button is clicked", async () => {
wrapper = createWrapper();
const closeBtn = wrapper.find('[data-test="service-graph-edge-panel-close-btn"]');
await closeBtn.trigger("click");
expect(wrapper.emitted("close")).toBeTruthy();
expect(wrapper.emitted("close")).toHaveLength(1);
});
it("should invoke handleClose method", () => {
wrapper = createWrapper();
// Verify the method exists and is callable
expect(typeof wrapper.vm.handleClose).toBe("function");
// Call the method directly
wrapper.vm.handleClose();
// Verify it emits the close event
expect(wrapper.emitted("close")).toBeTruthy();
});
});
describe("UI Rendering - Statistics Section", () => {
it("should display statistics section", () => {
wrapper = createWrapper();
expect(wrapper.find('[data-test="service-graph-edge-panel-stats"]').exists()).toBe(true);
});
it("should display total requests stat", () => {
wrapper = createWrapper();
expect(wrapper.find('[data-test="service-graph-edge-panel-total-requests"]').exists()).toBe(true);
});
it("should display request rate stat", () => {
wrapper = createWrapper();
expect(wrapper.find('[data-test="service-graph-edge-panel-request-rate"]').exists()).toBe(true);
});
it("should display success rate stat", () => {
wrapper = createWrapper();
expect(wrapper.find('[data-test="service-graph-edge-panel-success-rate"]').exists()).toBe(true);
});
it("should display error rate stat", () => {
wrapper = createWrapper();
expect(wrapper.find('[data-test="service-graph-edge-panel-error-rate"]').exists()).toBe(true);
});
it("should apply error class when error rate > 5%", () => {
const edgeWithHighError = {
...mockEdges[0],
total_requests: 100,
failed_requests: 10, // 10% error rate
};
wrapper = createWrapper({ selectedEdge: edgeWithHighError });
// The error class is applied when errorRateValue > 5
expect(wrapper.vm.edgeStats.errorRateValue).toBeGreaterThan(5);
});
it("should not apply error class when error rate <= 5%", () => {
wrapper = createWrapper();
expect(wrapper.vm.edgeStats.errorRateValue).toBeLessThanOrEqual(5);
});
});
describe("UI Rendering - Latency Distribution Section", () => {
it("should display latency distribution section", () => {
wrapper = createWrapper();
expect(wrapper.find('[data-test="service-graph-edge-panel-latency-distribution"]').exists()).toBe(true);
});
it("should render CustomChartRenderer when latency data exists", async () => {
wrapper = createWrapper();
await nextTick();
expect(wrapper.findComponent({ name: "CustomChartRenderer" }).exists()).toBe(true);
});
it("should not render CustomChartRenderer when latency data is null", async () => {
wrapper = createWrapper({
selectedEdge: null,
});
await nextTick();
expect(wrapper.findComponent({ name: "CustomChartRenderer" }).exists()).toBe(false);
});
});
describe("Edge Cases", () => {
it("should handle undefined graphData nodes", () => {
wrapper = createWrapper({
graphData: { nodes: [], edges: mockEdges },
});
expect(wrapper.vm.sourceServiceName).toBe("service-a");
expect(wrapper.vm.targetServiceName).toBe("service-b");
});
it("should handle edge with very large request numbers", () => {
const edgeWithLargeNumbers = {
...mockEdges[0],
total_requests: 99999999,
failed_requests: 1000000,
};
wrapper = createWrapper({
selectedEdge: edgeWithLargeNumbers,
});
const stats = wrapper.vm.edgeStats;
expect(stats.totalRequests).toBe("100.0M");
expect(stats.requestRate).toContain("M req/min");
});
it("should handle edge with fractional requests", () => {
const edgeWithFractionalRequests = {
...mockEdges[0],
total_requests: 1234.56,
failed_requests: 12.34,
};
wrapper = createWrapper({
selectedEdge: edgeWithFractionalRequests,
});
const stats = wrapper.vm.edgeStats;
expect(stats.totalRequests).toBeTruthy();
expect(stats.errorRate).toBeTruthy();
});
it("should handle edge with negative values gracefully", () => {
const edgeWithNegativeValues = {
...mockEdges[0],
total_requests: -100,
failed_requests: -10,
};
wrapper = createWrapper({
selectedEdge: edgeWithNegativeValues,
});
const stats = wrapper.vm.edgeStats;
expect(stats).toBeTruthy();
});
it("should handle missing time range", () => {
wrapper = createWrapper({
timeRange: null,
});
expect(wrapper.exists()).toBe(true);
});
});
describe("Props Validation", () => {
it("should accept all required props", () => {
wrapper = createWrapper();
expect(wrapper.props("selectedEdge")).toEqual(mockEdges[0]);
expect(wrapper.props("graphData")).toEqual({ nodes: mockNodes, edges: mockEdges });
expect(wrapper.props("timeRange")).toEqual(mockTimeRange);
expect(wrapper.props("visible")).toBe(true);
});
it("should handle visible prop change", async () => {
wrapper = createWrapper({ visible: true });
expect(wrapper.find('[data-test="service-graph-edge-panel"]').exists()).toBe(true);
await wrapper.setProps({ visible: false });
expect(wrapper.find('[data-test="service-graph-edge-panel"]').exists()).toBe(false);
});
it("should handle selectedEdge prop change", async () => {
wrapper = createWrapper();
expect(wrapper.vm.sourceServiceName).toBe("Service A");
const newEdge = {
from: "service-b",
to: "service-c",
total_requests: 2000,
failed_requests: 100,
};
await wrapper.setProps({ selectedEdge: newEdge });
expect(wrapper.vm.sourceServiceName).toBe("Service B");
expect(wrapper.vm.targetServiceName).toBe("Service C");
});
});
describe("Animation and Transitions", () => {
it("should apply slide transition class", () => {
wrapper = createWrapper();
const panel = wrapper.find('[data-test="service-graph-edge-panel"]');
expect(panel.classes()).toContain("service-graph-edge-panel");
});
});
describe("Accessibility", () => {
it("should have proper data-test attributes", () => {
wrapper = createWrapper();
expect(wrapper.find('[data-test="service-graph-edge-panel"]').exists()).toBe(true);
expect(wrapper.find('[data-test="service-graph-edge-panel-header"]').exists()).toBe(true);
expect(wrapper.find('[data-test="service-graph-edge-panel-title"]').exists()).toBe(true);
expect(wrapper.find('[data-test="service-graph-edge-panel-close-btn"]').exists()).toBe(true);
expect(wrapper.find('[data-test="service-graph-edge-panel-stats"]').exists()).toBe(true);
});
it("should have close button that is clickable", async () => {
wrapper = createWrapper();
const closeBtn = wrapper.find('[data-test="service-graph-edge-panel-close-btn"]');
expect(closeBtn.exists()).toBe(true);
// Verify button can be clicked
await closeBtn.trigger("click");
expect(wrapper.emitted("close")).toBeTruthy();
});
});
describe("Theme Support", () => {
it("should use dark theme colors by default", () => {
wrapper = createWrapper();
const distribution = wrapper.vm.latencyDistribution;
expect(distribution.tooltip.backgroundColor).toBe("#2B2C2D");
});
it("should switch to light theme colors when theme changes", async () => {
wrapper = createWrapper({}, { theme: "light" });
const distribution = wrapper.vm.latencyDistribution;
expect(distribution.tooltip.backgroundColor).toBe("#ffffff");
});
});
});
@@ -0,0 +1,841 @@
<!-- 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/>.
-->
<template>
<transition name="slide">
<div
v-if="visible"
class="service-graph-edge-panel"
data-test="service-graph-edge-panel"
>
<!-- Header Section -->
<div
class="panel-header"
data-test="service-graph-edge-panel-header"
>
<div class="edge-title" data-test="service-graph-edge-panel-title">
<span class="source-service">{{ sourceServiceName }}</span>
<q-icon name="arrow_forward" size="20px" class="arrow-icon" />
<span class="target-service">{{ targetServiceName }}</span>
</div>
<q-btn
flat
dense
round
icon="cancel"
size="sm"
@click="handleClose"
data-test="service-graph-edge-panel-close-btn"
class="close-btn"
/>
</div>
<!-- Content Scrollable Area -->
<div class="panel-content">
<!-- Request Statistics Section -->
<div
class="panel-section stats-section"
data-test="service-graph-edge-panel-stats"
>
<div class="section-title">Request Statistics</div>
<div class="stats-grid">
<div class="stat-card" data-test="service-graph-edge-panel-total-requests">
<div class="stat-label">TOTAL REQUESTS</div>
<div class="stat-value">{{ edgeStats.totalRequests }}</div>
</div>
<div class="stat-card" data-test="service-graph-edge-panel-request-rate">
<div class="stat-label">REQUEST RATE</div>
<div class="stat-value">{{ edgeStats.requestRate }}</div>
</div>
<div class="stat-card" data-test="service-graph-edge-panel-success-rate">
<div class="stat-label">SUCCESS RATE</div>
<div class="stat-value success">{{ edgeStats.successRate }}</div>
</div>
<div class="stat-card" data-test="service-graph-edge-panel-error-rate">
<div class="stat-label">ERROR RATE</div>
<div class="stat-value" :class="{ error: edgeStats.errorRateValue > 5 }">
{{ edgeStats.errorRate }}
</div>
</div>
</div>
</div>
<!-- Latency Distribution Section -->
<div
class="panel-section latency-section"
data-test="service-graph-edge-panel-latency-distribution"
>
<div class="section-title">Latency Distribution</div>
<!-- Chart -->
<div class="latency-chart-container">
<CustomChartRenderer
v-if="latencyDistribution"
:data="latencyDistribution"
class="latency-chart"
/>
</div>
</div>
</div>
</div>
</transition>
</template>
<script lang="ts">
import { defineComponent, computed, type PropType } from 'vue';
import { useStore } from 'vuex';
import CustomChartRenderer from '@/components/dashboards/panels/CustomChartRenderer.vue';
export default defineComponent({
name: 'ServiceGraphEdgePanel',
components: {
CustomChartRenderer,
},
props: {
selectedEdge: {
type: Object as PropType<any>,
required: true,
},
graphData: {
type: Object as PropType<{ nodes: any[]; edges: any[] }>,
required: true,
},
timeRange: {
type: Object as PropType<{ startTime: number; endTime: number }>,
required: true,
},
visible: {
type: Boolean,
required: true,
},
},
emits: ['close'],
setup(props, { emit }) {
const store = useStore();
// Computed: Source Service Name
const sourceServiceName = computed(() => {
if (!props.selectedEdge) return '';
const sourceNode = props.graphData.nodes.find(
(n: any) => n.id === props.selectedEdge.from
);
return sourceNode?.label || props.selectedEdge.from;
});
// Computed: Target Service Name
const targetServiceName = computed(() => {
if (!props.selectedEdge) return '';
const targetNode = props.graphData.nodes.find(
(n: any) => n.id === props.selectedEdge.to
);
return targetNode?.label || props.selectedEdge.to;
});
// Computed: Edge Statistics
const edgeStats = computed(() => {
if (!props.selectedEdge) {
return {
totalRequests: 'N/A',
requestRate: 'N/A',
successRate: 'N/A',
errorRate: 'N/A',
errorRateValue: 0,
};
}
const totalRequests = props.selectedEdge.total_requests || 0;
const failedRequests = props.selectedEdge.failed_requests || 0;
const successRequests = totalRequests - failedRequests;
const errorRate = totalRequests > 0 ? (failedRequests / totalRequests) * 100 : 0;
const successRate = totalRequests > 0 ? (successRequests / totalRequests) * 100 : 100;
return {
totalRequests: formatNumber(totalRequests),
requestRate: formatRequestRate(totalRequests),
successRate: successRate.toFixed(1) + '%',
errorRate: errorRate.toFixed(2) + '%',
errorRateValue: errorRate,
};
});
// Computed: Latency Distribution Chart Data
const latencyDistribution = computed(() => {
if (!props.selectedEdge) {
return null;
}
const edge = props.selectedEdge;
// Get latency values from edge data
const p50Ns = edge.p50_latency_ns || 0;
const p95Ns = edge.p95_latency_ns || 0;
const p99Ns = edge.p99_latency_ns || 0;
const maxNs = p99Ns; // Use P99 as max
if (p50Ns === 0 && p95Ns === 0 && p99Ns === 0) {
return {
chartType: 'custom_chart',
title: {
text: 'No latency data available',
left: 'center',
top: 'center',
textStyle: {
fontSize: 14,
fontWeight: 'normal',
color: store.state.theme === 'dark' ? '#B7B7B7' : '#72777B',
},
},
};
}
// Convert to milliseconds for display
const p50Ms = p50Ns / 1000000;
const p95Ms = p95Ns / 1000000;
const p99Ms = p99Ns / 1000000;
const maxMs = maxNs / 1000000;
// Chart data
const categories = ['P50', 'P95', 'P99', 'Max'];
const values = [p50Ms, p95Ms, p99Ms, maxMs];
const colors = ['#4caf50', '#2196f3', '#ff9800', '#f44336'];
return {
chartType: 'custom_chart',
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
backgroundColor: store.state.theme === 'dark' ? '#2B2C2D' : '#ffffff',
borderColor: store.state.theme === 'dark' ? '#444444' : '#E7EAEE',
textStyle: {
color: store.state.theme === 'dark' ? '#DCDCDC' : '#232323',
},
formatter: (params: any) => {
const value = params[0].value;
return `${params[0].name}: ${value >= 1000 ? (value / 1000).toFixed(2) + 's' : value.toFixed(2) + 'ms'}`;
},
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
top: '10%',
containLabel: true,
},
xAxis: {
type: 'category',
data: categories,
axisLabel: {
color: store.state.theme === 'dark' ? '#B7B7B7' : '#72777B',
fontSize: 12,
fontWeight: 600,
},
axisLine: {
lineStyle: {
color: store.state.theme === 'dark' ? '#444444' : '#E7EAEE',
},
},
axisTick: {
show: false,
},
},
yAxis: {
type: 'value',
name: 'Latency (ms)',
nameTextStyle: {
color: store.state.theme === 'dark' ? '#B7B7B7' : '#72777B',
fontSize: 11,
},
axisLabel: {
color: store.state.theme === 'dark' ? '#B7B7B7' : '#72777B',
formatter: (value: number) => {
if (value >= 1000) return (value / 1000).toFixed(1) + 's';
return value.toFixed(0);
},
},
axisLine: {
show: false,
},
axisTick: {
show: false,
},
splitLine: {
lineStyle: {
color: store.state.theme === 'dark' ? '#3A3A3A' : '#F0F0F0',
},
},
},
series: [
{
type: 'bar',
data: values.map((value, index) => ({
value,
itemStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: colors[index] },
{ offset: 1, color: colors[index] + 'CC' },
],
},
borderRadius: [4, 4, 0, 0],
},
})),
barWidth: '50%',
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowColor: 'rgba(0, 0, 0, 0.3)',
},
},
animationDuration: 800,
animationEasing: 'cubicOut',
},
],
};
});
// Helper: Format Number
const formatNumber = (num: number): string => {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
}
if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K';
}
return num.toString();
};
// Helper: Format Request Rate
const formatRequestRate = (requests: number): string => {
if (requests >= 1000000) {
return (requests / 1000000).toFixed(1) + 'M req/min';
}
if (requests >= 1000) {
return (requests / 1000).toFixed(1) + 'K req/min';
}
return requests + ' req/min';
};
// Handlers
const handleClose = () => {
emit('close');
};
return {
sourceServiceName,
targetServiceName,
edgeStats,
latencyDistribution,
handleClose,
};
},
});
</script>
<style scoped lang="scss">
.service-graph-edge-panel {
position: absolute;
right: 0;
top: 0;
width: 420px;
height: 100%;
z-index: 100;
display: flex;
flex-direction: column;
background: #1a1f2e; // Match node panel background
border-left: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.5);
overflow: hidden;
animation: slideIn 0.3s ease-out;
}
// Light mode support
.body--light .service-graph-edge-panel {
background: #ffffff;
color: #333333;
border-left: 1px solid #e0e0e0;
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.08);
}
// Slide animation keyframes
@keyframes slideIn {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
// Slide animation for Vue transition
.slide-enter-active,
.slide-leave-active {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.slide-enter-from,
.slide-leave-to {
transform: translateX(100%);
}
.slide-enter-to,
.slide-leave-from {
transform: translateX(0);
}
// Header
.panel-header {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1rem 1.25rem; // Reduced from 1.5rem 1.75rem
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
background: #0f1419;
flex-shrink: 0;
position: relative;
.edge-title {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 1.125rem; // Reduced from 1.25rem
font-weight: 600;
color: #e8eaed;
letter-spacing: -0.01em;
.source-service,
.target-service {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.arrow-icon {
flex-shrink: 0;
color: rgba(255, 255, 255, 0.5);
}
}
.connection-type {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: rgba(255, 255, 255, 0.6);
padding: 0.25rem 0.625rem;
background: rgba(255, 255, 255, 0.08);
border-radius: 4px;
display: inline-block;
width: fit-content;
}
.close-btn {
position: absolute;
top: 1rem; // Adjusted from 1.5rem to match new padding
right: 1.25rem; // Adjusted from 1.75rem to match new padding
}
}
.body--light .panel-header {
border-bottom: 1px solid #e0e0e0;
background: #f5f5f5;
.edge-title {
color: #202124;
.arrow-icon {
color: rgba(0, 0, 0, 0.5);
}
}
.connection-type {
color: rgba(0, 0, 0, 0.6);
background: rgba(0, 0, 0, 0.06);
}
}
// Content
.panel-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
background: #0f1419;
padding: 1rem 1.25rem; // Reduced from 1.5rem 1.75rem
// Custom scrollbar
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 3px;
&:hover {
background: rgba(255, 255, 255, 0.25);
}
}
}
.body--light .panel-content {
background: #ffffff;
&::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.15);
&:hover {
background: rgba(0, 0, 0, 0.25);
}
}
}
// Panel Sections
.panel-section {
padding: 0;
margin-bottom: 1.25rem; // Reduced from 1.75rem
&:last-child {
margin-bottom: 0;
}
.section-title {
font-size: 0.8125rem; // Slightly reduced from 0.875rem
font-weight: 600;
text-transform: none;
letter-spacing: 0;
margin-bottom: 0.75rem; // Reduced from 1rem
color: rgba(255, 255, 255, 0.9);
}
}
.body--light .panel-section .section-title {
color: rgba(0, 0, 0, 0.87);
}
// Actions Section
.actions-section {
display: flex;
gap: 0.5rem;
padding: 0;
margin-bottom: 1.5rem;
.action-btn {
flex: 1;
justify-content: center;
padding: 0.625rem 0.875rem;
font-weight: 500;
font-size: 0.875rem;
transition: all 0.2s ease;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.7);
&:hover {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.2);
color: rgba(255, 255, 255, 0.9);
}
}
}
.body--light .actions-section .action-btn {
border: 1px solid rgba(0, 0, 0, 0.12);
color: rgba(0, 0, 0, 0.7);
&:hover {
background: rgba(0, 0, 0, 0.05);
border-color: rgba(0, 0, 0, 0.2);
color: rgba(0, 0, 0, 0.9);
}
}
// Statistics Section
.stats-section {
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
.stat-card {
padding: 1.25rem 1rem;
border-radius: 6px;
text-align: center;
background: #252b3d;
border: 1px solid rgba(255, 255, 255, 0.08);
transition: all 0.2s ease;
&:hover {
background: #2a3145;
border-color: rgba(255, 255, 255, 0.12);
}
.stat-label {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 0.75rem;
color: rgba(255, 255, 255, 0.6);
}
.stat-value {
font-size: 1.25rem;
font-weight: 600;
color: #e8eaed;
letter-spacing: -0.01em;
&.success {
color: #4caf50;
}
&.error {
color: #f44336;
}
}
}
}
}
.body--light .stats-section .stats-grid .stat-card {
background: #ffffff;
border-color: #e0e0e0;
&:hover {
background: #f8f9fa;
border-color: #d0d0d0;
}
.stat-label {
color: rgba(0, 0, 0, 0.6);
}
.stat-value {
color: #202124;
&.success {
color: #2e7d32;
}
&.error {
color: #c62828;
}
}
}
// Latency Distribution Section
.latency-section {
.latency-chart-container {
padding: 0.5rem;
border-radius: 8px;
background: #252b3d;
border: 1px solid rgba(255, 255, 255, 0.08);
min-height: 140px;
}
.latency-chart {
width: 100%;
height: 140px;
}
}
.body--light .latency-section {
.latency-chart-container {
background: #ffffff;
border-color: #e0e0e0;
}
}
// Traces Section
.traces-section {
.traces-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
.trace-item {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1rem 1.125rem;
border-radius: 6px;
background: #252b3d;
border: 1px solid rgba(255, 255, 255, 0.08);
transition: all 0.2s ease;
cursor: pointer;
&:hover {
background: #2a3145;
border-color: rgba(255, 255, 255, 0.12);
}
.trace-id {
font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
font-size: 0.8rem;
color: #5e9cff;
font-weight: 500;
}
.trace-info {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.75rem;
.trace-duration {
font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
color: rgba(255, 255, 255, 0.7);
font-weight: 500;
}
.trace-status {
padding: 0.25rem 0.625rem;
border-radius: 4px;
text-transform: uppercase;
font-weight: 600;
&.status-success {
color: #4caf50;
background: rgba(76, 175, 80, 0.15);
}
&.status-error {
color: #f44336;
background: rgba(244, 67, 54, 0.15);
}
&.status-warning {
color: #ff9800;
background: rgba(255, 152, 0, 0.15);
}
}
.trace-time {
color: rgba(255, 255, 255, 0.5);
margin-left: auto;
}
}
}
}
}
.body--light .traces-section .traces-list .trace-item {
background: #ffffff;
border-color: #e0e0e0;
&:hover {
background: #f8f9fa;
border-color: #d0d0d0;
}
.trace-id {
color: #1a73e8;
}
.trace-info {
.trace-duration {
color: rgba(0, 0, 0, 0.7);
}
.trace-status {
&.status-success {
color: #2e7d32;
background: rgba(76, 175, 80, 0.1);
}
&.status-error {
color: #c62828;
background: rgba(244, 67, 54, 0.1);
}
&.status-warning {
color: #f57c00;
background: rgba(255, 152, 0, 0.1);
}
}
.trace-time {
color: rgba(0, 0, 0, 0.5);
}
}
}
// Empty/Loading/Error States
.empty-state,
.loading-state,
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2.5rem 1.5rem;
text-align: center;
color: rgba(255, 255, 255, 0.5);
font-size: 0.875rem;
.loading-text {
margin-top: 0.75rem;
font-weight: 400;
}
}
.body--light {
.empty-state,
.loading-state,
.error-state {
color: rgba(0, 0, 0, 0.5);
}
}
.error-state {
color: #f44336;
font-weight: 500;
}
.body--light .error-state {
color: #c62828;
}
</style>
<style lang="scss">
// Dark mode - match node panel background
.body--dark {
.service-graph-edge-panel {
background: #1a1f2e; // Match node panel background
color: #e4e7eb;
border-left: 1px solid rgba(255, 255, 255, 0.08);
}
}
// Light mode
.body--light {
.service-graph-edge-panel {
background: #ffffff;
color: #333333;
border-left: 1px solid #e0e0e0;
}
}
</style>
@@ -0,0 +1,992 @@
// 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 { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mount, VueWrapper, flushPromises } from "@vue/test-utils";
import { installQuasar } from "@/test/unit/helpers";
import { Notify } from "quasar";
import { nextTick } from "vue";
import ServiceGraphSidePanel from "./ServiceGraphSidePanel.vue";
installQuasar({
plugins: [Notify],
});
// Mock dependencies
vi.mock("@/services/search", () => ({
default: {
get_traces: vi.fn(),
},
}));
vi.mock("vue-router", () => ({
useRouter: () => ({
push: vi.fn(),
}),
}));
vi.mock("quasar", async () => {
const actual: any = await vi.importActual("quasar");
return {
...actual,
useQuasar: () => ({
notify: vi.fn(),
}),
};
});
import searchService from "@/services/search";
// Mock store
const createMockStore = (overrides = {}) => ({
state: {
theme: "dark",
selectedOrganization: {
identifier: "test-org",
},
...overrides,
},
dispatch: vi.fn(),
commit: vi.fn(),
});
describe("ServiceGraphSidePanel.vue", () => {
let wrapper: VueWrapper<any>;
let mockStore: any;
let mockRouter: any;
const mockNodes = [
{
id: "service-a",
label: "Service A",
name: "Service A",
error_rate: 2.5,
},
{
id: "service-b",
label: "Service B",
name: "Service B",
error_rate: 7.8,
},
{
id: "service-c",
label: "Service C",
name: "Service C",
error_rate: 12.5,
},
];
const mockEdges = [
{
from: "service-upstream",
to: "service-a",
total_requests: 1000,
failed_requests: 20,
p95_latency_ns: 50000000, // 50ms
error_rate: 2.0,
},
{
from: "service-a",
to: "service-b",
total_requests: 1500,
failed_requests: 50,
p95_latency_ns: 75000000, // 75ms
error_rate: 3.33,
},
{
from: "service-a",
to: "service-c",
total_requests: 800,
failed_requests: 100,
p95_latency_ns: 100000000, // 100ms
error_rate: 12.5,
},
];
const mockTimeRange = {
startTime: Date.now() - 3600000,
endTime: Date.now(),
};
const mockTraces = {
data: {
hits: [
{
trace_id: "trace-123456789012345678901234567890ab",
duration: 150000, // 150ms in microseconds
start_time: Date.now() * 1000000, // Convert to nanoseconds
spans: [5, 0], // [total spans, error spans]
},
{
trace_id: "trace-abcdef1234567890abcdef1234567890",
duration: 2500000, // 2.5s in microseconds
start_time: Date.now() * 1000000,
spans: [10, 2], // [total spans, error spans]
},
],
},
};
const createWrapper = (props = {}, storeOverrides = {}) => {
mockStore = createMockStore(storeOverrides);
mockRouter = {
push: vi.fn(),
};
return mount(ServiceGraphSidePanel, {
props: {
selectedNode: mockNodes[0],
graphData: { nodes: mockNodes, edges: mockEdges },
timeRange: mockTimeRange,
visible: true,
streamFilter: "default",
...props,
},
global: {
mocks: {
$store: mockStore,
$router: mockRouter,
},
provide: {
store: mockStore,
router: mockRouter,
},
stubs: {
QBtn: false,
QIcon: false,
QTooltip: false,
QSpinner: false,
},
},
});
};
beforeEach(() => {
vi.clearAllMocks();
// Mock clipboard API
Object.assign(navigator, {
clipboard: {
writeText: vi.fn().mockResolvedValue(undefined),
},
});
});
afterEach(() => {
if (wrapper) {
wrapper.unmount();
}
});
describe("Initialization", () => {
it("should mount component successfully", () => {
wrapper = createWrapper();
expect(wrapper.exists()).toBe(true);
});
it("should render panel when visible is true", () => {
wrapper = createWrapper({ visible: true });
expect(wrapper.find('[data-test="service-graph-side-panel"]').exists()).toBe(true);
});
it("should not render panel when visible is false", () => {
wrapper = createWrapper({ visible: false });
expect(wrapper.find('[data-test="service-graph-side-panel"]').exists()).toBe(false);
});
it("should display panel header", () => {
wrapper = createWrapper();
expect(wrapper.find('[data-test="service-graph-side-panel-header"]').exists()).toBe(true);
});
it("should display service name", () => {
wrapper = createWrapper();
expect(wrapper.find('[data-test="service-graph-side-panel-service-name"]').exists()).toBe(true);
expect(wrapper.find('[data-test="service-graph-side-panel-service-name"]').text()).toContain("Service A");
});
it("should display close button", () => {
wrapper = createWrapper();
expect(wrapper.find('[data-test="service-graph-side-panel-close-btn"]').exists()).toBe(true);
});
});
describe("Computed Properties - upstreamServices", () => {
it("should compute upstream services correctly", () => {
wrapper = createWrapper();
const upstream = wrapper.vm.upstreamServices;
expect(upstream).toHaveLength(1);
expect(upstream[0].id).toBe("service-upstream");
expect(upstream[0].requests).toBe(1000);
});
it("should return empty array when no upstream services", () => {
wrapper = createWrapper({
selectedNode: { id: "isolated-service", label: "Isolated" },
});
expect(wrapper.vm.upstreamServices).toHaveLength(0);
});
it("should calculate health status for upstream services", () => {
wrapper = createWrapper();
const upstream = wrapper.vm.upstreamServices;
expect(upstream[0].healthStatus).toBe("healthy"); // 2% error rate
});
it("should handle missing node labels in upstream services", () => {
const customEdges = [
{
from: "unknown-service",
to: "service-a",
total_requests: 100,
error_rate: 1.0,
},
];
wrapper = createWrapper({
graphData: { nodes: mockNodes, edges: customEdges },
});
const upstream = wrapper.vm.upstreamServices;
expect(upstream[0].name).toBe("unknown-service");
});
});
describe("Computed Properties - downstreamServices", () => {
it("should compute downstream services correctly", () => {
wrapper = createWrapper();
const downstream = wrapper.vm.downstreamServices;
expect(downstream).toHaveLength(2);
expect(downstream[0].id).toBe("service-b");
expect(downstream[1].id).toBe("service-c");
});
it("should return empty array when no downstream services", () => {
wrapper = createWrapper({
selectedNode: mockNodes[1], // service-b has no downstream
graphData: { nodes: mockNodes, edges: mockEdges.slice(0, 2) },
});
const downstream = wrapper.vm.downstreamServices;
expect(downstream).toHaveLength(0);
});
it("should calculate health status for downstream services", () => {
wrapper = createWrapper();
const downstream = wrapper.vm.downstreamServices;
expect(downstream[0].healthStatus).toBe("healthy"); // 3.33% error rate
expect(downstream[1].healthStatus).toBe("critical"); // 12.5% error rate
});
});
describe("Computed Properties - serviceMetrics", () => {
it("should compute service metrics correctly", () => {
wrapper = createWrapper();
const metrics = wrapper.vm.serviceMetrics;
expect(metrics.requestRate).toBeTruthy();
expect(metrics.errorRate).toBe("2.50%");
expect(metrics.p95Latency).toBeTruthy();
});
it("should calculate total requests from outgoing edges", () => {
wrapper = createWrapper();
const metrics = wrapper.vm.serviceMetrics;
// 1500 + 800 = 2300 total requests
expect(metrics.requestRateValue).toBe("2.3K");
});
it("should use node's error_rate property", () => {
wrapper = createWrapper();
const metrics = wrapper.vm.serviceMetrics;
expect(metrics.errorRate).toBe("2.50%");
});
it("should show N/A for P95 latency when no outgoing edges", () => {
wrapper = createWrapper({
selectedNode: mockNodes[1],
graphData: { nodes: mockNodes, edges: [] },
});
const metrics = wrapper.vm.serviceMetrics;
expect(metrics.p95Latency).toBe("N/A");
});
it("should return N/A for all metrics when selectedNode is null", () => {
wrapper = createWrapper({
selectedNode: null,
});
const metrics = wrapper.vm.serviceMetrics;
expect(metrics.requestRate).toBe("N/A");
expect(metrics.errorRate).toBe("N/A");
expect(metrics.p95Latency).toBe("N/A");
});
it("should format P95 latency in milliseconds", () => {
wrapper = createWrapper();
const metrics = wrapper.vm.serviceMetrics;
// Max of 75ms and 100ms = 100ms
expect(metrics.p95Latency).toBe("100ms");
});
it("should format high latency in seconds", () => {
const highLatencyEdges = [
{
from: "service-a",
to: "service-b",
total_requests: 1000,
p95_latency_ns: 2500000000, // 2.5 seconds
},
];
wrapper = createWrapper({
graphData: { nodes: mockNodes, edges: highLatencyEdges },
});
const metrics = wrapper.vm.serviceMetrics;
expect(metrics.p95Latency).toBe("2.50s");
});
it("should format large request numbers with M suffix", () => {
const highVolumeEdges = [
{
from: "service-a",
to: "service-b",
total_requests: 2500000,
},
];
wrapper = createWrapper({
graphData: { nodes: mockNodes, edges: highVolumeEdges },
});
const metrics = wrapper.vm.serviceMetrics;
expect(metrics.requestRateValue).toBe("2.5M");
});
});
describe("Computed Properties - serviceHealth", () => {
it("should return healthy status when error rate <= 5%", () => {
wrapper = createWrapper();
const health = wrapper.vm.serviceHealth;
expect(health.status).toBe("healthy");
expect(health.text).toBe("Healthy");
expect(health.color).toBe("positive");
expect(health.icon).toBe("check_circle");
});
it("should return degraded status when error rate > 5% and <= 10%", () => {
wrapper = createWrapper({
selectedNode: mockNodes[1], // 7.8% error rate
});
const health = wrapper.vm.serviceHealth;
expect(health.status).toBe("degraded");
expect(health.text).toBe("Degraded");
expect(health.color).toBe("warning");
expect(health.icon).toBe("warning");
});
it("should return critical status when error rate > 10%", () => {
wrapper = createWrapper({
selectedNode: mockNodes[2], // 12.5% error rate
});
const health = wrapper.vm.serviceHealth;
expect(health.status).toBe("critical");
expect(health.text).toBe("Critical");
expect(health.color).toBe("negative");
expect(health.icon).toBe("error");
});
it("should return unknown status when selectedNode is null", () => {
wrapper = createWrapper({
selectedNode: null,
});
const health = wrapper.vm.serviceHealth;
expect(health.status).toBe("unknown");
expect(health.text).toBe("Unknown");
});
it("should handle missing error_rate property", () => {
wrapper = createWrapper({
selectedNode: { id: "test", label: "Test" },
});
const health = wrapper.vm.serviceHealth;
expect(health.status).toBe("healthy");
});
});
describe("Computed Properties - isAllStreamsSelected", () => {
it("should return true when streamFilter is 'all'", () => {
wrapper = createWrapper({ streamFilter: "all" });
expect(wrapper.vm.isAllStreamsSelected).toBe(true);
});
it("should return false when streamFilter is not 'all'", () => {
wrapper = createWrapper({ streamFilter: "default" });
expect(wrapper.vm.isAllStreamsSelected).toBe(false);
});
});
describe("Health Status Functionality", () => {
it("should correctly categorize upstream services by health", () => {
wrapper = createWrapper();
const upstream = wrapper.vm.upstreamServices;
// Upstream service has 2% error rate, should be healthy
expect(upstream[0].healthStatus).toBe("healthy");
});
it("should correctly categorize downstream services by health", () => {
wrapper = createWrapper();
const downstream = wrapper.vm.downstreamServices;
// Service B: 3.33% error rate - healthy
expect(downstream[0].healthStatus).toBe("healthy");
// Service C: 12.5% error rate - critical
expect(downstream[1].healthStatus).toBe("critical");
});
it("should calculate degraded health status correctly", () => {
// Create a service with 7.8% error rate (degraded)
wrapper = createWrapper({
selectedNode: mockNodes[1], // Service B with 7.8% error
});
const health = wrapper.vm.serviceHealth;
expect(health.status).toBe("degraded");
});
it("should provide health information for different error rates", () => {
// Test healthy status
wrapper = createWrapper({
selectedNode: { ...mockNodes[0], error_rate: 3 },
});
expect(wrapper.vm.serviceHealth.status).toBe("healthy");
// Test degraded status
wrapper = createWrapper({
selectedNode: { ...mockNodes[0], error_rate: 8 },
});
expect(wrapper.vm.serviceHealth.status).toBe("degraded");
// Test critical status
wrapper = createWrapper({
selectedNode: { ...mockNodes[0], error_rate: 15 },
});
expect(wrapper.vm.serviceHealth.status).toBe("critical");
});
});
describe("Helper Functions - Metric Formatting", () => {
it("should format request rates in service metrics", () => {
wrapper = createWrapper();
const metrics = wrapper.vm.serviceMetrics;
// Verify the formatted values are shown correctly
expect(metrics.requestRate).toContain("req/min");
expect(metrics.requestRateValue).toBeTruthy();
});
it("should format latency values in service metrics", () => {
wrapper = createWrapper();
const metrics = wrapper.vm.serviceMetrics;
// P95 latency should be formatted (either in ms or s)
expect(metrics.p95Latency).toMatch(/(ms|s|N\/A)/);
});
it("should format error rates as percentages", () => {
wrapper = createWrapper();
const metrics = wrapper.vm.serviceMetrics;
// Error rate should be a percentage
expect(metrics.errorRate).toContain("%");
});
});
describe("Event Handlers - handleClose", () => {
it("should emit close event when close button is clicked", async () => {
wrapper = createWrapper();
const closeBtn = wrapper.find('[data-test="service-graph-side-panel-close-btn"]');
await closeBtn.trigger("click");
expect(wrapper.emitted("close")).toBeTruthy();
expect(wrapper.emitted("close")).toHaveLength(1);
});
});
describe("Event Handlers - handleViewTraces", () => {
it("should emit view-traces event when button is clicked", async () => {
wrapper = createWrapper();
const viewTracesBtn = wrapper.find('[data-test="service-graph-side-panel-view-traces-btn"]');
await viewTracesBtn.trigger("click");
expect(wrapper.emitted("view-traces")).toBeTruthy();
});
it("should disable view traces button when all streams selected", () => {
wrapper = createWrapper({ streamFilter: "all" });
// Verify isAllStreamsSelected computed property is true
expect(wrapper.vm.isAllStreamsSelected).toBe(true);
});
it("should enable view traces button when specific stream selected", () => {
wrapper = createWrapper({ streamFilter: "default" });
// Verify isAllStreamsSelected computed property is false
expect(wrapper.vm.isAllStreamsSelected).toBe(false);
});
});
describe("Event Handlers - handleTraceClick", () => {
it("should have handleTraceClick method defined", () => {
wrapper = createWrapper();
// Verify the method exists
expect(typeof wrapper.vm.handleTraceClick).toBe("function");
});
it("should navigate with trace data", async () => {
wrapper = createWrapper();
const mockTrace = {
traceId: "test-trace-id",
};
// Call the method (router navigation happens inside)
wrapper.vm.handleTraceClick(mockTrace);
// The method should be callable without errors
expect(wrapper.vm.handleTraceClick).toBeTruthy();
});
});
describe("Event Handlers - copyTraceId", () => {
it("should copy trace ID to clipboard", async () => {
wrapper = createWrapper();
await wrapper.vm.copyTraceId("test-trace-id");
expect(navigator.clipboard.writeText).toHaveBeenCalledWith("test-trace-id");
});
it("should set copiedTraceId state after copying", async () => {
wrapper = createWrapper();
await wrapper.vm.copyTraceId("test-trace-id");
expect(wrapper.vm.copiedTraceId).toBe("test-trace-id");
});
it("should reset copiedTraceId after 2 seconds", async () => {
vi.useFakeTimers();
wrapper = createWrapper();
await wrapper.vm.copyTraceId("test-trace-id");
expect(wrapper.vm.copiedTraceId).toBe("test-trace-id");
vi.advanceTimersByTime(2000);
expect(wrapper.vm.copiedTraceId).toBeNull();
vi.useRealTimers();
});
it("should handle clipboard copy failure", async () => {
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
navigator.clipboard.writeText = vi.fn().mockRejectedValue(new Error("Copy failed"));
wrapper = createWrapper();
await wrapper.vm.copyTraceId("test-trace-id");
await flushPromises();
expect(consoleError).toHaveBeenCalled();
consoleError.mockRestore();
});
});
describe("Async Operations - fetchRecentTraces", () => {
it("should fetch traces when panel is visible and stream is selected", async () => {
vi.mocked(searchService.get_traces).mockResolvedValue(mockTraces);
wrapper = createWrapper({ visible: true, streamFilter: "default" });
await flushPromises();
expect(searchService.get_traces).toHaveBeenCalledWith({
org_identifier: "test-org",
stream_name: "default",
filter: "service_name = 'Service A'",
start_time: mockTimeRange.startTime,
end_time: mockTimeRange.endTime,
from: 0,
size: 10,
});
});
it("should not fetch traces when streamFilter is 'all'", async () => {
wrapper = createWrapper({ visible: true, streamFilter: "all" });
await flushPromises();
expect(searchService.get_traces).not.toHaveBeenCalled();
});
it("should not fetch traces when panel is not visible", async () => {
wrapper = createWrapper({ visible: false, streamFilter: "default" });
await flushPromises();
expect(searchService.get_traces).not.toHaveBeenCalled();
});
it("should manage loading state during fetch", async () => {
let resolvePromise: any;
vi.mocked(searchService.get_traces).mockImplementation(
() =>
new Promise((resolve) => {
resolvePromise = () => resolve(mockTraces);
})
);
wrapper = createWrapper({ visible: true, streamFilter: "default" });
await nextTick();
// Check loading state during fetch
expect(wrapper.vm.loadingTraces).toBe(true);
// Resolve the promise
resolvePromise();
await flushPromises();
await nextTick();
// Check loading state is false after fetch completes
expect(wrapper.vm.loadingTraces).toBe(false);
});
it("should process trace data correctly", async () => {
vi.mocked(searchService.get_traces).mockResolvedValue(mockTraces);
wrapper = createWrapper({ visible: true, streamFilter: "default" });
await flushPromises();
expect(wrapper.vm.recentTraces).toHaveLength(2);
expect(wrapper.vm.recentTraces[0].status).toBe("OK");
expect(wrapper.vm.recentTraces[1].status).toBe("ERROR");
});
it("should mark slow traces correctly", async () => {
vi.mocked(searchService.get_traces).mockResolvedValue(mockTraces);
wrapper = createWrapper({ visible: true, streamFilter: "default" });
await flushPromises();
// First trace: 150ms (< 1s) - not slow
expect(wrapper.vm.recentTraces[0].durationClass).toBe("");
// Second trace: 2.5s (> 1s) - slow
expect(wrapper.vm.recentTraces[1].durationClass).toBe("duration-slow");
});
it("should handle fetch error gracefully", async () => {
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
vi.mocked(searchService.get_traces).mockRejectedValue(new Error("API Error"));
wrapper = createWrapper({ visible: true, streamFilter: "default" });
await flushPromises();
expect(wrapper.vm.recentTraces).toHaveLength(0);
expect(wrapper.vm.loadingTraces).toBe(false);
expect(consoleError).toHaveBeenCalled();
consoleError.mockRestore();
});
it("should handle empty trace response", async () => {
vi.mocked(searchService.get_traces).mockResolvedValue({ data: { hits: [] } });
wrapper = createWrapper({ visible: true, streamFilter: "default" });
await flushPromises();
expect(wrapper.vm.recentTraces).toHaveLength(0);
});
});
describe("Watchers", () => {
it("should fetch traces when visible changes to true", async () => {
vi.mocked(searchService.get_traces).mockResolvedValue(mockTraces);
wrapper = createWrapper({ visible: false, streamFilter: "default" });
await flushPromises();
expect(searchService.get_traces).not.toHaveBeenCalled();
await wrapper.setProps({ visible: true });
await flushPromises();
expect(searchService.get_traces).toHaveBeenCalled();
});
it("should fetch traces when selectedNode changes", async () => {
vi.mocked(searchService.get_traces).mockResolvedValue(mockTraces);
wrapper = createWrapper({ visible: true, streamFilter: "default" });
await flushPromises();
const callCount = vi.mocked(searchService.get_traces).mock.calls.length;
await wrapper.setProps({ selectedNode: mockNodes[1] });
await flushPromises();
expect(vi.mocked(searchService.get_traces).mock.calls.length).toBeGreaterThan(callCount);
});
it("should fetch traces when streamFilter changes", async () => {
vi.mocked(searchService.get_traces).mockResolvedValue(mockTraces);
wrapper = createWrapper({ visible: true, streamFilter: "default" });
await flushPromises();
const callCount = vi.mocked(searchService.get_traces).mock.calls.length;
await wrapper.setProps({ streamFilter: "another-stream" });
await flushPromises();
expect(vi.mocked(searchService.get_traces).mock.calls.length).toBeGreaterThan(callCount);
});
it("should not fetch traces when changing to 'all' stream", async () => {
vi.mocked(searchService.get_traces).mockResolvedValue(mockTraces);
wrapper = createWrapper({ visible: true, streamFilter: "default" });
await flushPromises();
vi.mocked(searchService.get_traces).mockClear();
await wrapper.setProps({ streamFilter: "all" });
await flushPromises();
expect(searchService.get_traces).not.toHaveBeenCalled();
});
});
describe("UI Rendering - Metrics Section", () => {
it("should display metrics section", () => {
wrapper = createWrapper();
expect(wrapper.find('[data-test="service-graph-side-panel-metrics"]').exists()).toBe(true);
});
it("should display request rate metric", () => {
wrapper = createWrapper();
expect(wrapper.find('[data-test="service-graph-side-panel-request-rate"]').exists()).toBe(true);
});
it("should display error rate metric", () => {
wrapper = createWrapper();
expect(wrapper.find('[data-test="service-graph-side-panel-error-rate"]').exists()).toBe(true);
});
it("should display P95 latency metric", () => {
wrapper = createWrapper();
expect(wrapper.find('[data-test="service-graph-side-panel-p95-latency"]').exists()).toBe(true);
});
});
describe("UI Rendering - Services Lists", () => {
it("should display upstream services section", () => {
wrapper = createWrapper();
expect(wrapper.find('[data-test="service-graph-side-panel-upstream-services"]').exists()).toBe(true);
});
it("should display downstream services section", () => {
wrapper = createWrapper();
expect(wrapper.find('[data-test="service-graph-side-panel-downstream-services"]').exists()).toBe(true);
});
it("should display correct upstream service count", () => {
wrapper = createWrapper();
const upstreamSection = wrapper.find('[data-test="service-graph-side-panel-upstream-services"]');
expect(upstreamSection.text()).toContain("(1)");
});
it("should display correct downstream service count", () => {
wrapper = createWrapper();
const downstreamSection = wrapper.find('[data-test="service-graph-side-panel-downstream-services"]');
expect(downstreamSection.text()).toContain("(2)");
});
it("should render upstream service items", () => {
wrapper = createWrapper();
const upstreamItems = wrapper.findAll('[data-test="service-graph-side-panel-upstream-service-item"]');
expect(upstreamItems.length).toBeGreaterThan(0);
});
it("should render downstream service items", () => {
wrapper = createWrapper();
const downstreamItems = wrapper.findAll('[data-test="service-graph-side-panel-downstream-service-item"]');
expect(downstreamItems.length).toBe(2);
});
});
describe("UI Rendering - Recent Traces", () => {
it("should display recent traces section when stream is selected", async () => {
vi.mocked(searchService.get_traces).mockResolvedValue(mockTraces);
wrapper = createWrapper({ visible: true, streamFilter: "default" });
await flushPromises();
expect(wrapper.find('[data-test="service-graph-side-panel-recent-traces"]').exists()).toBe(true);
});
it("should not display recent traces section when stream is 'all'", () => {
wrapper = createWrapper({ streamFilter: "all" });
expect(wrapper.find('[data-test="service-graph-side-panel-recent-traces"]').exists()).toBe(false);
});
it("should show loading spinner while fetching traces", async () => {
vi.mocked(searchService.get_traces).mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(() => resolve(mockTraces), 100);
})
);
wrapper = createWrapper({ visible: true, streamFilter: "default" });
// Should show loading immediately
expect(wrapper.vm.loadingTraces).toBe(true);
});
it("should render trace items after loading", async () => {
vi.mocked(searchService.get_traces).mockResolvedValue(mockTraces);
wrapper = createWrapper({ visible: true, streamFilter: "default" });
await flushPromises();
await nextTick();
const traceItems = wrapper.findAll('[data-test="service-graph-side-panel-trace-item"]');
expect(traceItems.length).toBe(2);
});
it("should display copy button for each trace", async () => {
vi.mocked(searchService.get_traces).mockResolvedValue(mockTraces);
wrapper = createWrapper({ visible: true, streamFilter: "default" });
await flushPromises();
await nextTick();
const copyButtons = wrapper.findAll('[data-test="service-graph-side-panel-copy-trace-btn"]');
expect(copyButtons.length).toBe(2);
});
});
describe("Edge Cases", () => {
it("should handle null selectedNode", () => {
wrapper = createWrapper({ selectedNode: null });
expect(wrapper.vm.upstreamServices).toHaveLength(0);
expect(wrapper.vm.downstreamServices).toHaveLength(0);
});
it("should handle empty graphData", () => {
wrapper = createWrapper({
graphData: { nodes: [], edges: [] },
});
expect(wrapper.vm.upstreamServices).toHaveLength(0);
expect(wrapper.vm.downstreamServices).toHaveLength(0);
});
it("should handle missing node name/label", () => {
wrapper = createWrapper({
selectedNode: { id: "test-service" },
});
const serviceName = wrapper.find('[data-test="service-graph-side-panel-service-name"]');
expect(serviceName.text()).toContain("test-service");
});
it("should handle very large request numbers", () => {
const largeEdges = [
{
from: "service-a",
to: "service-b",
total_requests: 99999999,
},
];
wrapper = createWrapper({
graphData: { nodes: mockNodes, edges: largeEdges },
});
const metrics = wrapper.vm.serviceMetrics;
expect(metrics.requestRateValue).toContain("M");
});
it("should handle negative error rates", () => {
wrapper = createWrapper({
selectedNode: { ...mockNodes[0], error_rate: -5 },
});
const health = wrapper.vm.serviceHealth;
expect(health).toBeTruthy();
});
});
describe("Props Validation", () => {
it("should accept all required props", () => {
wrapper = createWrapper();
expect(wrapper.props("selectedNode")).toEqual(mockNodes[0]);
expect(wrapper.props("graphData")).toEqual({ nodes: mockNodes, edges: mockEdges });
expect(wrapper.props("timeRange")).toEqual(mockTimeRange);
expect(wrapper.props("visible")).toBe(true);
expect(wrapper.props("streamFilter")).toBe("default");
});
it("should handle visible prop change", async () => {
wrapper = createWrapper({ visible: true });
expect(wrapper.find('[data-test="service-graph-side-panel"]').exists()).toBe(true);
await wrapper.setProps({ visible: false });
expect(wrapper.find('[data-test="service-graph-side-panel"]').exists()).toBe(false);
});
});
describe("Accessibility", () => {
it("should have proper data-test attributes", () => {
wrapper = createWrapper();
expect(wrapper.find('[data-test="service-graph-side-panel"]').exists()).toBe(true);
expect(wrapper.find('[data-test="service-graph-side-panel-header"]').exists()).toBe(true);
expect(wrapper.find('[data-test="service-graph-side-panel-service-name"]').exists()).toBe(true);
expect(wrapper.find('[data-test="service-graph-side-panel-close-btn"]').exists()).toBe(true);
expect(wrapper.find('[data-test="service-graph-side-panel-view-traces-btn"]').exists()).toBe(true);
});
});
});
File diff suppressed because it is too large Load Diff
@@ -567,9 +567,9 @@ describe("convertTraceData", () => {
expect(result.options).toBeDefined();
const data = result.options.series[0].data[0].children;
expect(data[0].itemStyle.color).toBe('#4CAF50'); // Green for healthy
expect(data[1].itemStyle.color).toBe('#FFC107'); // Yellow for warning (>1%)
expect(data[2].itemStyle.color).toBe('#F44336'); // Red for critical (>10%)
expect(data[0].itemStyle.color).toBe('#1a1f2e'); // Green for healthy
expect(data[1].itemStyle.color).toBe('#1a1f2e'); // Yellow for warning (>1%)
expect(data[2].itemStyle.color).toBe('#1a1f2e'); // Red for critical (>10%)
});
it('should handle vertical layout orientation', () => {
+168 -51
View File
@@ -194,11 +194,13 @@ export const convertTraceServiceMapData = (
* Convert service graph data (nodes/edges) to ECharts tree format
* @param graphData - Object containing nodes and edges arrays
* @param layoutType - Layout orientation: 'horizontal' | 'vertical' | 'radial'
* @param isDarkMode - Whether dark mode is active
* @returns ECharts tree options
*/
export const convertServiceGraphToTree = (
graphData: { nodes: any[]; edges: any[] },
layoutType: string = 'horizontal'
layoutType: string = 'horizontal',
isDarkMode: boolean = true
) => {
console.log('[convertServiceGraphToTree] Called with:', {
nodeCount: graphData.nodes.length,
@@ -279,20 +281,66 @@ export const convertServiceGraphToTree = (
const incomingEdges = incomingEdgesMap.get(nodeId) || [];
const connectionCount = incomingEdges.length + outgoingEdges.length;
// Determine node color based on error rate
let nodeColor = "#4CAF50"; // Green for healthy
if (errorRate > 10) nodeColor = "#F44336"; // Red
else if (errorRate > 5) nodeColor = "#FF9800"; // Orange
else if (errorRate > 1) nodeColor = "#FFC107"; // Yellow
// Border color based on error rate (theme-aware) - matches graph view
let borderColor: string;
if (isDarkMode) {
// Dark mode colors
borderColor = "#10b981"; // Green (healthy)
if (errorRate > 10) borderColor = "#ef4444"; // Red (critical)
else if (errorRate > 5) borderColor = "#f97316"; // Orange (warning)
else if (errorRate > 1) borderColor = "#fbbf24"; // Yellow (degraded)
} else {
// Light mode colors
borderColor = "#52c41a"; // Green (healthy)
if (errorRate > 10) borderColor = "#f5222d"; // Red (critical)
else if (errorRate > 5) borderColor = "#fa8c16"; // Orange (warning)
else if (errorRate > 1) borderColor = "#faad14"; // Yellow (degraded)
}
// Fixed size for tree view to prevent overlapping
const symbolSize = 45;
return {
name: node.label || node.id,
value: totalRequests,
symbolSize: Math.max(20, Math.min(60, Math.log10(totalRequests + 1) * 15)),
symbolSize: symbolSize,
itemStyle: {
color: nodeColor,
borderColor: nodeColor,
borderWidth: 2,
color: isDarkMode ? '#1a1f2e' : '#ffffff',
borderColor: borderColor,
borderWidth: 4,
shadowBlur: 10,
shadowColor: isDarkMode ? 'rgba(0, 0, 0, 0.3)' : 'rgba(0, 0, 0, 0.1)',
shadowOffsetX: 0,
shadowOffsetY: 0,
},
emphasis: {
scale: true,
scaleSize: 1.15,
itemStyle: {
shadowBlur: 20,
shadowColor: isDarkMode ? 'rgba(0, 0, 0, 0.5)' : 'rgba(0, 0, 0, 0.3)',
},
label: {
show: true,
fontSize: 12,
fontWeight: 'bold',
},
},
select: {
// Persistent selection styling - matches graph view
itemStyle: {
borderColor: borderColor,
borderWidth: 5,
shadowBlur: 45,
shadowColor: 'rgba(59, 130, 246, 0.9)', // Prominent blue glow for selected
shadowOffsetX: 0,
shadowOffsetY: 0,
},
label: {
show: true,
fontSize: 12,
fontWeight: 'bold',
},
},
label: {
show: true,
@@ -337,19 +385,28 @@ export const convertServiceGraphToTree = (
// If still no tree data, create a flat structure
if (treeData.length === 0 && graphData.nodes.length > 0) {
return {
tooltip: { show: true, trigger: 'item' },
backgroundColor: 'transparent', // Make chart background transparent to match graph view
tooltip: { show: true, trigger: 'item', hideDelay: 0, enterable: false },
series: [{
type: 'tree',
data: graphData.nodes.map((node: any) => ({
name: node.label || node.id,
value: 0,
symbolSize: 20,
itemStyle: { color: '#9E9E9E' },
symbolSize: 45,
itemStyle: {
color: isDarkMode ? '#1a1f2e' : '#ffffff',
borderColor: '#9E9E9E',
borderWidth: 4,
shadowBlur: 10,
shadowColor: isDarkMode ? 'rgba(0, 0, 0, 0.3)' : 'rgba(0, 0, 0, 0.1)',
},
})),
layout: 'orthogonal',
orient: layoutType === 'vertical' ? 'TB' : 'LR',
initialTreeDepth: -1,
symbolSize: 20,
symbolSize: 45,
roam: true, // Enable panning and zooming
selectedMode: 'single', // Enable single node selection
label: {
position: layoutType === 'vertical' ? 'bottom' : 'right',
verticalAlign: layoutType === 'vertical' ? 'top' : 'middle',
@@ -372,10 +429,13 @@ export const convertServiceGraphToTree = (
: treeData;
const options = {
backgroundColor: 'transparent', // Make chart background transparent to match graph view
tooltip: {
show: true,
trigger: 'item',
triggerOn: 'mousemove',
hideDelay: 0, // Hide immediately when mouse leaves
enterable: false, // Prevent mouse from entering tooltip
},
series: [
{
@@ -385,7 +445,9 @@ export const convertServiceGraphToTree = (
orient: layoutType === 'vertical' ? 'TB' : 'LR',
initialTreeDepth: -1,
symbol: 'circle',
symbolSize: 20,
symbolSize: 45,
roam: true, // Enable panning and zooming
selectedMode: 'single', // Enable single node selection
label: {
position: layoutType === 'vertical' ? 'top' : 'left',
verticalAlign: layoutType === 'vertical' ? 'bottom' : 'middle',
@@ -408,7 +470,7 @@ export const convertServiceGraphToTree = (
],
};
return { options };
return { options, positions: null };
};
// D3-Force simulation physics parameters
@@ -503,7 +565,9 @@ const computeForceLayout = (
export const convertServiceGraphToNetwork = (
graphData: { nodes: any[]; edges: any[] },
layoutType: string = "force",
cachedPositions?: Map<string, { x: number; y: number }>
cachedPositions?: Map<string, { x: number; y: number }>,
isDarkMode: boolean = true,
selectedNodeId?: string
) => {
// Validate layout type - graph view only supports 'force' and 'circular'
// Tree layouts ('horizontal', 'vertical', 'radial') should use convertServiceGraphToTree instead
@@ -566,15 +630,28 @@ export const convertServiceGraphToNetwork = (
const metrics = nodeMetrics.get(node.id) || { requests: 0, errors: 0 };
const errorRate = metrics.requests > 0 ? (metrics.errors / metrics.requests) * 100 : 0;
// Border color based on error rate
let borderColor = "#52c41a"; // Green (healthy)
if (errorRate > 10) borderColor = "#f5222d"; // Red (critical)
else if (errorRate > 5) borderColor = "#fa8c16"; // Orange (high)
else if (errorRate > 1) borderColor = "#faad14"; // Yellow (warning)
// Border color based on error rate (theme-aware)
let borderColor: string;
if (isDarkMode) {
// Dark mode colors
borderColor = "#10b981"; // Green (healthy)
if (errorRate > 10) borderColor = "#ef4444"; // Red (critical)
else if (errorRate > 5) borderColor = "#f97316"; // Orange (warning)
else if (errorRate > 1) borderColor = "#fbbf24"; // Yellow (degraded)
} else {
// Light mode colors
borderColor = "#52c41a"; // Green (healthy)
if (errorRate > 10) borderColor = "#f5222d"; // Red (critical)
else if (errorRate > 5) borderColor = "#fa8c16"; // Orange (warning)
else if (errorRate > 1) borderColor = "#faad14"; // Yellow (degraded)
}
// Size based on request volume - much smaller nodes
const symbolSize = Math.max(40, Math.min(80, Math.log10(metrics.requests + 1) * 20));
// Check if this node is selected
const isSelected = selectedNodeId === node.id;
// Use cached position if available
const cachedPos = cachedPositions?.get(node.id);
const nodeData: any = {
@@ -582,18 +659,44 @@ export const convertServiceGraphToNetwork = (
name: node.label || node.id,
value: metrics.requests,
errors: metrics.errors,
symbolSize: symbolSize,
symbolSize: isSelected ? symbolSize * 1.1 : symbolSize, // Scale up selected node
itemStyle: {
color: '#ffffff', // White background
borderColor: borderColor,
color: isDarkMode ? '#1a1f2e' : '#ffffff',
borderColor: borderColor, // Keep health-based border color
borderWidth: 4,
shadowBlur: 10,
shadowColor: 'rgba(0, 0, 0, 0.3)',
shadowBlur: isSelected ? 25 : 10, // Enhanced shadow for selected node
shadowColor: isSelected
? 'rgba(59, 130, 246, 0.6)' // Blue glow for selected
: (isDarkMode ? 'rgba(0, 0, 0, 0.3)' : 'rgba(0, 0, 0, 0.1)'),
shadowOffsetX: 0,
shadowOffsetY: 0,
},
label: {
show: true,
},
emphasis: {
scale: true,
scaleSize: 1.15,
itemStyle: {
shadowBlur: 20, // Enhanced shadow on hover
shadowColor: isDarkMode ? 'rgba(0, 0, 0, 0.5)' : 'rgba(0, 0, 0, 0.3)',
},
label: {
show: true,
fontSize: 12,
fontWeight: 'bold',
},
},
select: {
// Persistent selection styling
itemStyle: {
borderColor: borderColor, // Keep health-based border color
borderWidth: 5,
shadowBlur: 45,
shadowColor: 'rgba(59, 130, 246, 0.9)', // Prominent blue glow for selected
shadowOffsetX: 0,
shadowOffsetY: 0,
},
label: {
show: true,
fontSize: 12,
@@ -821,9 +924,12 @@ export const convertServiceGraphToNetwork = (
const layoutMode = "none";
const options = {
backgroundColor: 'transparent', // Make chart background transparent
tooltip: {
trigger: "item",
triggerOn: "mousemove",
hideDelay: 0, // Hide immediately when mouse leaves
enterable: false, // Prevent mouse from entering tooltip
backgroundColor: 'rgba(50, 50, 50, 0.95)',
borderColor: '#777',
borderWidth: 1,
@@ -831,7 +937,9 @@ export const convertServiceGraphToNetwork = (
color: '#fff',
},
},
animation: hasPositions ? false : true, // Disable animation when using cached positions
animation: false, // Disable animation to prevent position jumping
animationDuration: 200,
animationEasing: 'cubicOut', // Smooth easing for hover effect
series: [
{
type: "graph",
@@ -841,59 +949,68 @@ export const convertServiceGraphToNetwork = (
roam: true,
draggable: true, // Enable dragging to allow manual position adjustments
focusNodeAdjacency: true,
selectedMode: 'single', // Enable single node selection
scaleLimit: {
min: 0.4,
max: 3,
},
animationDurationUpdate: 200,
animationEasingUpdate: 'cubicOut',
label: normalizedLayoutType === 'circular' ? {
show: true,
position: 'top',
formatter: (params: any) => params.data.name,
fontSize: 11,
color: '#333',
color: isDarkMode ? '#e4e7eb' : '#333', // Theme-aware text color
} : {
show: true,
position: 'inside',
formatter: (params: any) => {
const requests = params.data.value || 0;
const errors = params.data.errors || 0;
const errorRate = requests > 0 ? (errors / requests) : 0;
const reqPerSec = (requests / 60).toFixed(2);
const errorDisplay = (errorRate * 100).toFixed(2);
const serviceName = params.data.name;
const requests = params.data.value || 0;
// Display metrics inside, name below using rich text
return `{metrics|${errorDisplay} ms/r}\n{metrics|${reqPerSec} r/sec}\n{spacer|}\n{name|${serviceName}}`;
// Format request count with K, M notation
let requestsDisplay;
if (requests >= 1000000) {
requestsDisplay = (requests / 1000000).toFixed(1) + 'M';
} else if (requests >= 1000) {
requestsDisplay = (requests / 1000).toFixed(1) + 'K';
} else {
requestsDisplay = requests.toString();
}
// Display service name and request count in the middle
return `{name|${serviceName}}\n{requests|${requestsDisplay} req}`;
},
rich: {
metrics: {
fontSize: 10,
color: '#333',
lineHeight: 14,
align: 'center',
},
spacer: {
height: 40,
lineHeight: 40,
},
name: {
fontSize: 11,
fontWeight: 'normal',
color: '#333',
fontSize: 12,
fontWeight: '500',
color: isDarkMode ? '#e4e7eb' : '#333', // Dark: light text, Light: dark text
align: 'center',
lineHeight: 16,
},
requests: {
fontSize: 10,
fontWeight: 'normal',
color: isDarkMode ? '#9ca3af' : '#666', // Dark: light gray, Light: dark gray
align: 'center',
lineHeight: 14,
},
},
},
emphasis: {
focus: "adjacency",
scale: true,
scaleSize: 1.15,
label: {
show: true,
fontSize: 13,
fontWeight: 'bold',
},
itemStyle: {
shadowBlur: 15,
shadowColor: 'rgba(0, 0, 0, 0.5)',
shadowBlur: 20,
shadowColor: isDarkMode ? 'rgba(0, 0, 0, 0.5)' : 'rgba(0, 0, 0, 0.3)',
},
},
lineStyle: {