mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-06 08:09:24 +00:00
fix: servie graph v2 (#10410)
This commit is contained in:
@@ -848,7 +848,14 @@ export default defineComponent({
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
return { chartRef, hoveredSeriesState, handleNativeContextMenu };
|
||||
return {
|
||||
chartRef,
|
||||
hoveredSeriesState,
|
||||
handleNativeContextMenu,
|
||||
get chart() {
|
||||
return chart;
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user