mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 16:09:05 +00:00
### **User description** ## Problem Fixes openobserve/o2-enterprise#1229 Alert graphs were not rendering edges (arrows) when nodes had identical timestamps, causing the topology graph to show nodes without connections. ## Root Cause The edge creation logic used strict timestamp inequality (`<`, `>`), which failed when alerts fired at the exact same microsecond. This commonly occurs with: - High-frequency alerting - Bulk alert ingestion - Simulated/test data with fixed timestamps - Clock resolution limitations ## Solution Added timestamp tiebreaker logic using node index (insertion order) for all three edge creation scenarios: 1. **Same-service temporal edges**: Connect to most recent previous node, use index as tiebreaker 2. **Cross-service temporal edges**: Connect to chronologically closest node, use index as tiebreaker 3. **Service dependency edges**: Connect related services, use index as tiebreaker ## Changes - `src/service/alerts/incidents.rs`: Updated 3 edge creation locations with timestamp + index comparisons ## Impact - ✅ Edges now created even when alerts have identical timestamps - ✅ Deterministic behavior based on insertion order - ✅ Graph visualization shows proper alert flow with arrows - ✅ Root cause analysis now possible ## Testing - Verified code compiles successfully - Manual testing recommended with alerts at same timestamp ___ ### **PR Type** Bug fix ___ ### **Description** - Add tiebreaker for equal timestamps - Ensure deterministic topology edge creation - Fix missing incident graph arrows ___ ### Diagram Walkthrough ```mermaid flowchart LR A["New alert node added"] --> B["Find candidate prior/next nodes"] B --> C["Compare (first_fired_at, node_index)"] C --> D["Create temporal/dependency edges deterministically"] ``` <details> <summary><h3> File Walkthrough</h3></summary> <table><thead><tr><th></th><th align="left">Relevant files</th></tr></thead><tbody><tr><td><strong>Bug fix</strong></td><td><table> <tr> <td> <details> <summary><strong>incidents.rs</strong><dd><code>Deterministic edge creation for equal timestamps</code> </dd></summary> <hr> src/service/alerts/incidents.rs <ul><li>Add index-based tiebreaker for same-service edges<br> <li> Allow dependency edges when timestamps equal<br> <li> Refine cross-service temporal matching with index<br> <li> Update ordering keys to include node index</ul> </details> </td> <td><a href="https://github.com/openobserve/openobserve/pull/10347/files#diff-2411d921fd8b8783c049deea11da06726ed986550d405477d486234892353ca9">+17/-5</a> </td> </tr> </table></td></tr></tr></tbody></table> </details> ___
780 lines
24 KiB
Vue
780 lines
24 KiB
Vue
<template>
|
|
<q-card flat class="tw:h-full">
|
|
<q-card-section class="tw:p-[0.375rem] tw:h-full card-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]">
|
|
<!-- Stream selector - always show, populated once streams are discovered -->
|
|
<q-select
|
|
v-model="streamFilter"
|
|
:options="
|
|
searchObj.data.stream.streamLists && searchObj.data.stream.streamLists.length > 0
|
|
? [
|
|
{ label: 'All Streams', value: 'all' },
|
|
...searchObj.data.stream.streamLists,
|
|
]
|
|
: [{ label: 'All Streams', value: 'all' }]
|
|
"
|
|
dense
|
|
outlined
|
|
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"
|
|
>
|
|
<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>
|
|
</q-select>
|
|
|
|
<!-- Search input -->
|
|
<q-input
|
|
v-model="searchFilter"
|
|
borderless
|
|
dense
|
|
class="no-border tw:h-[36px] tw:flex-grow"
|
|
placeholder="Search services..."
|
|
debounce="300"
|
|
@update:model-value="applyFilters"
|
|
>
|
|
<template #prepend>
|
|
<q-icon class="o2-search-input-icon" name="search" />
|
|
</template>
|
|
</q-input>
|
|
</div>
|
|
<div
|
|
class="col-12 col-md-7 tw:flex tw:justify-end tw:items-center tw:gap-[0.75rem]"
|
|
>
|
|
<!-- 1. Time range selector -->
|
|
<date-time
|
|
ref="dateTimeRef"
|
|
auto-apply
|
|
:default-type="searchObj.data.datetime.type"
|
|
:default-absolute-time="{
|
|
startTime: searchObj.data.datetime.startTime,
|
|
endTime: searchObj.data.datetime.endTime,
|
|
}"
|
|
:default-relative-time="searchObj.data.datetime.relativeTimePeriod"
|
|
data-test="service-graph-date-time-picker"
|
|
class="tw:h-[2rem]"
|
|
@on:date-change="updateTimeRange"
|
|
/>
|
|
|
|
<!-- 2. Refresh button -->
|
|
<q-btn
|
|
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)]"
|
|
@click="loadServiceGraph"
|
|
:loading="loading"
|
|
>
|
|
<q-tooltip>Refresh Service Graph</q-tooltip>
|
|
</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"
|
|
/>
|
|
|
|
<!-- 4. Layout dropdown -->
|
|
<q-select
|
|
v-model="layoutType"
|
|
:options="layoutOptions"
|
|
dense
|
|
filled
|
|
class="tw:w-[160px]"
|
|
emit-value
|
|
map-options
|
|
@update:model-value="setLayout"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Graph Visualization -->
|
|
<q-card flat bordered class="graph-card tw:h-[calc(100%-4rem)]">
|
|
<q-card-section class="q-pa-none tw:h-full" style="height: 100%">
|
|
<div class="graph-container tw:h-full tw:bg-[var(--o2-bg)]">
|
|
<div v-if="loading" class="flex flex-center tw:h-full">
|
|
<div class="text-center tw:flex tw:flex-col tw:items-center">
|
|
<q-spinner-hourglass color="primary" size="4em" />
|
|
<div class="text-subtitle1 q-mt-md text-grey-7">
|
|
Loading service graph...
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div
|
|
v-else-if="error"
|
|
class="flex flex-center tw:h-full text-center tw:p-[0.675rem]"
|
|
>
|
|
<div>
|
|
<q-icon name="error_outline" size="4em" color="negative" />
|
|
<div class="text-h6 q-mt-md tw:text-[var(--o2-text-primary)]">
|
|
{{ error }}
|
|
</div>
|
|
<q-btn
|
|
outline
|
|
color="primary"
|
|
label="Retry"
|
|
@click="loadServiceGraph"
|
|
class="q-mt-md"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div
|
|
v-else-if="!graphData.nodes.length"
|
|
class="flex flex-center tw:h-full text-center tw:p-[0.675rem]"
|
|
>
|
|
<div>
|
|
<q-icon name="hub" size="5em" color="grey-4" />
|
|
<div class="text-h6 q-mt-md text-grey-7">
|
|
No Service Graph Data
|
|
</div>
|
|
<div class="text-body2 text-grey-6 q-mt-sm">
|
|
Try querying a longer duration
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-else class="tw:h-full">
|
|
<ChartRenderer
|
|
ref="chartRendererRef"
|
|
data-test="service-graph-chart"
|
|
:data="chartData"
|
|
:key="chartKey"
|
|
class="tw:h-full"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</q-card-section>
|
|
</q-card>
|
|
</q-card-section>
|
|
</q-card>
|
|
|
|
<!-- Enhanced Settings Dialog -->
|
|
<q-dialog v-model="showSettings">
|
|
<q-card style="min-width: 450px">
|
|
<q-card-section>
|
|
<div class="text-h6">Service Graph Settings</div>
|
|
</q-card-section>
|
|
<q-separator />
|
|
<q-card-section>
|
|
<div class="q-gutter-md">
|
|
<div class="text-caption text-grey-7">
|
|
Stream-based topology - all data persisted to storage
|
|
<q-tooltip
|
|
>Service graph uses stream-only architecture with zero in-memory
|
|
state</q-tooltip
|
|
>
|
|
</div>
|
|
</div>
|
|
</q-card-section>
|
|
<q-separator />
|
|
<q-card-actions align="right">
|
|
<q-btn
|
|
flat
|
|
dense
|
|
no-caps
|
|
label="Close"
|
|
color="primary"
|
|
v-close-popup
|
|
class="o2-secondary-button tw:h-[2rem]"
|
|
/>
|
|
<q-btn
|
|
label="Reset"
|
|
@click="resetSettings"
|
|
class="o2-primary-button tw:h-[2rem]"
|
|
/>
|
|
</q-card-actions>
|
|
</q-card>
|
|
</q-dialog>
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
import { defineComponent, ref, onMounted, computed } from "vue";
|
|
import { useStore } from "vuex";
|
|
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 {
|
|
convertServiceGraphToTree,
|
|
convertServiceGraphToNetwork,
|
|
} from "@/utils/traces/convertTraceData";
|
|
import useStreams from "@/composables/useStreams";
|
|
import useTraces from "@/composables/useTraces";
|
|
|
|
export default defineComponent({
|
|
name: "ServiceGraph",
|
|
components: {
|
|
AppTabs,
|
|
ChartRenderer,
|
|
DateTime,
|
|
},
|
|
setup() {
|
|
const store = useStore();
|
|
const { getStreams } = useStreams();
|
|
const { searchObj } = useTraces();
|
|
|
|
const loading = ref(false);
|
|
const error = ref<string | null>(null);
|
|
const showSettings = ref(false);
|
|
const lastUpdated = ref("");
|
|
|
|
// Persist visualization type in localStorage
|
|
const storedVisualizationType = localStorage.getItem(
|
|
"serviceGraph_visualizationType",
|
|
);
|
|
const visualizationType = ref<"tree" | "graph">(
|
|
(storedVisualizationType as "tree" | "graph") || "graph",
|
|
);
|
|
|
|
// Initialize layout type based on visualization type
|
|
const storedLayoutType = localStorage.getItem("serviceGraph_layoutType");
|
|
let defaultLayoutType = "force";
|
|
if (visualizationType.value === "tree") {
|
|
defaultLayoutType = "horizontal";
|
|
}
|
|
const layoutType = ref(storedLayoutType || defaultLayoutType);
|
|
const chartRendererRef = ref<any>(null);
|
|
|
|
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
|
|
|
|
// Connection type tabs configuration
|
|
const connectionTypeTabs = [
|
|
{ label: "All", value: "all" },
|
|
{ label: "Standard", value: "standard" },
|
|
{ label: "Database", value: "database" },
|
|
{ label: "Messaging", value: "messaging" },
|
|
{ label: "Virtual", value: "virtual" },
|
|
];
|
|
|
|
const graphData = ref<any>({
|
|
nodes: [],
|
|
edges: [],
|
|
availableStreams: [],
|
|
});
|
|
|
|
const filteredGraphData = ref<any>({
|
|
nodes: [],
|
|
edges: [],
|
|
});
|
|
|
|
const stats = ref<any>(null);
|
|
|
|
// Use shared datetime from searchObj instead of local timeRange
|
|
// searchObj.data.datetime is managed by useTraces composable and shared across tabs
|
|
|
|
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);
|
|
|
|
// Track last chart options to prevent unnecessary recreation for graph view
|
|
const lastChartOptions = ref<any>(null);
|
|
|
|
// Layout options based on visualization type
|
|
const layoutOptions = computed(() => {
|
|
if (visualizationType.value === "tree") {
|
|
return [
|
|
{ label: "Horizontal", value: "horizontal" },
|
|
{ label: "Vertical", value: "vertical" },
|
|
{ label: "Radial", value: "radial" },
|
|
];
|
|
} else {
|
|
return [
|
|
{ label: "Force Directed", value: "force" },
|
|
{ label: "Circular", value: "circular" },
|
|
];
|
|
}
|
|
});
|
|
|
|
const chartData = computed(() => {
|
|
if (!filteredGraphData.value.nodes.length) {
|
|
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
|
|
// };
|
|
// }
|
|
|
|
console.log(
|
|
"[ServiceGraph] Generating new chart options, chartKey:",
|
|
chartKey.value,
|
|
"visualizationType:",
|
|
visualizationType.value,
|
|
);
|
|
const newOptions =
|
|
visualizationType.value === "tree"
|
|
? convertServiceGraphToTree(filteredGraphData.value, layoutType.value)
|
|
: convertServiceGraphToNetwork(
|
|
filteredGraphData.value,
|
|
layoutType.value,
|
|
graphNodePositions.value,
|
|
);
|
|
|
|
// Cache the options with the current key for graph view
|
|
if (visualizationType.value === "graph") {
|
|
lastChartOptions.value = {
|
|
key: chartKey.value,
|
|
data: newOptions,
|
|
};
|
|
}
|
|
|
|
return {
|
|
...newOptions,
|
|
notMerge: visualizationType.value === "graph" ? false : true, // Merge for graph, replace for tree
|
|
};
|
|
});
|
|
|
|
const loadServiceGraph = async () => {
|
|
loading.value = true;
|
|
error.value = null;
|
|
|
|
try {
|
|
const orgId = store.state.selectedOrganization.identifier;
|
|
|
|
if (!orgId) {
|
|
throw new Error("No organization selected");
|
|
}
|
|
|
|
// Streams are loaded by parent Index.vue and available in searchObj.data.stream.streamLists
|
|
|
|
// Stream-only implementation - no store stats needed
|
|
// Use JSON topology endpoint with time range
|
|
const response = await serviceGraphService.getCurrentTopology(orgId, {
|
|
streamName:
|
|
streamFilter.value && streamFilter.value !== "all"
|
|
? streamFilter.value
|
|
: undefined,
|
|
startTime: searchObj.data.datetime.startTime,
|
|
endTime: searchObj.data.datetime.endTime,
|
|
});
|
|
|
|
// Convert API response to expected format
|
|
const rawData = response.data;
|
|
|
|
// Ensure nodes have all required fields
|
|
const nodes = (rawData.nodes || []).map((node: any) => ({
|
|
id: node.id,
|
|
label: node.label || node.id,
|
|
requests: node.requests || 0,
|
|
errors: node.errors || 0,
|
|
error_rate: node.error_rate || 0,
|
|
is_virtual: node.is_virtual || false,
|
|
}));
|
|
|
|
// Ensure edges have all required fields and valid node references
|
|
const nodeIds = new Set(nodes.map((n: any) => n.id));
|
|
const edges = (rawData.edges || [])
|
|
.filter((edge: any) => {
|
|
// Filter out edges with missing endpoints
|
|
const hasValidEndpoints =
|
|
edge.from &&
|
|
edge.to &&
|
|
nodeIds.has(edge.from) &&
|
|
nodeIds.has(edge.to);
|
|
if (!hasValidEndpoints) {
|
|
console.warn(
|
|
"[ServiceGraph] Skipping edge with invalid endpoints:",
|
|
edge,
|
|
);
|
|
}
|
|
return hasValidEndpoints;
|
|
})
|
|
.map((edge: any) => ({
|
|
id: `${edge.from}->${edge.to}`,
|
|
from: edge.from,
|
|
to: edge.to,
|
|
connection_type: edge.connection_type || "standard",
|
|
total_requests: edge.total_requests || 0,
|
|
failed_requests: edge.failed_requests || 0,
|
|
error_rate: edge.error_rate || 0,
|
|
p50_latency_ns: edge.p50_latency_ns || 0,
|
|
p95_latency_ns: edge.p95_latency_ns || 0,
|
|
p99_latency_ns: edge.p99_latency_ns || 0,
|
|
}));
|
|
|
|
graphData.value = {
|
|
nodes,
|
|
edges,
|
|
availableStreams: rawData.availableStreams || [],
|
|
};
|
|
|
|
// Calculate stats
|
|
const totalRequests = graphData.value.edges.reduce(
|
|
(sum: number, e: any) => sum + e.total_requests,
|
|
0,
|
|
);
|
|
const totalErrors = graphData.value.edges.reduce(
|
|
(sum: number, e: any) => sum + e.failed_requests,
|
|
0,
|
|
);
|
|
|
|
stats.value = {
|
|
services: graphData.value.nodes.length,
|
|
connections: graphData.value.edges.length,
|
|
totalRequests,
|
|
totalErrors,
|
|
errorRate:
|
|
totalRequests > 0 ? (totalErrors / totalRequests) * 100 : 0,
|
|
};
|
|
|
|
lastUpdated.value = new Date().toLocaleTimeString();
|
|
|
|
// Apply filters and render
|
|
applyFilters();
|
|
} catch (err: any) {
|
|
console.error("Failed to load service graph:", err);
|
|
|
|
// Provide detailed error messages based on error type
|
|
if (err.message === "Request timeout") {
|
|
error.value =
|
|
"Request timed out. The service graph may be processing large amounts of data. Please try again.";
|
|
} else if (err.response?.status === 404) {
|
|
error.value =
|
|
"Service Graph API endpoint not found. Ensure you're running enterprise version of OpenObserve.";
|
|
} else if (err.response?.status === 403) {
|
|
error.value =
|
|
"Access denied. You may not have permission to view the service graph for this organization.";
|
|
} else if (err.response?.status === 500) {
|
|
error.value = "Server error occurred. Check server logs for details.";
|
|
} else if (err.message === "Network Error" || !navigator.onLine) {
|
|
error.value = "Network error. Please check your internet connection.";
|
|
} else {
|
|
error.value =
|
|
err.response?.data?.message ||
|
|
err.message ||
|
|
"Failed to load service graph data. Please check server logs.";
|
|
}
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
const parsePrometheusMetrics = (metricsText: string) => {
|
|
const nodes = new Map<string, any>();
|
|
const edges: any[] = [];
|
|
const availableStreams = new Set<string>();
|
|
|
|
const lines = metricsText.split("\n");
|
|
|
|
for (const line of lines) {
|
|
if (line.startsWith("#") || !line.trim()) continue;
|
|
|
|
// Parse metric line: metric_name{labels} value
|
|
const match = line.match(/^(\w+)\{([^}]+)\}\s+([\d.eE+-]+)/);
|
|
if (!match) continue;
|
|
|
|
const [, metricName, labelsStr, value] = match;
|
|
const labels: any = {};
|
|
|
|
// Parse labels
|
|
const labelMatches = Array.from(labelsStr.matchAll(/(\w+)="([^"]+)"/g));
|
|
for (const [, key, val] of labelMatches) {
|
|
labels[key] = val;
|
|
}
|
|
|
|
if (!labels.client || !labels.server) continue;
|
|
|
|
// Track available stream names for the stream selector
|
|
if (labels.stream_name) {
|
|
availableStreams.add(labels.stream_name);
|
|
}
|
|
|
|
// Add nodes
|
|
if (!nodes.has(labels.client)) {
|
|
nodes.set(labels.client, {
|
|
id: labels.client,
|
|
label: labels.client,
|
|
is_virtual: labels.client === "unknown",
|
|
});
|
|
}
|
|
if (!nodes.has(labels.server)) {
|
|
nodes.set(labels.server, {
|
|
id: labels.server,
|
|
label: labels.server,
|
|
is_virtual: labels.server.includes("unknown"),
|
|
});
|
|
}
|
|
|
|
// Add edge data
|
|
if (metricName === "traces_service_graph_request_total") {
|
|
const edgeId = `${labels.client}->${labels.server}`;
|
|
let edge = edges.find((e) => e.id === edgeId);
|
|
|
|
if (!edge) {
|
|
edge = {
|
|
id: edgeId,
|
|
from: labels.client,
|
|
to: labels.server,
|
|
connection_type: labels.connection_type || "standard",
|
|
total_requests: 0,
|
|
failed_requests: 0,
|
|
};
|
|
edges.push(edge);
|
|
}
|
|
|
|
edge.total_requests = parseFloat(value);
|
|
}
|
|
|
|
if (metricName === "traces_service_graph_request_failed_total") {
|
|
const edgeId = `${labels.client}->${labels.server}`;
|
|
let edge = edges.find((e) => e.id === edgeId);
|
|
|
|
// Create edge if it doesn't exist yet (failed_total may come before request_total)
|
|
if (!edge) {
|
|
edge = {
|
|
id: edgeId,
|
|
from: labels.client,
|
|
to: labels.server,
|
|
connection_type: labels.connection_type || "standard",
|
|
total_requests: 0,
|
|
failed_requests: 0,
|
|
};
|
|
edges.push(edge);
|
|
}
|
|
|
|
edge.failed_requests = parseFloat(value);
|
|
}
|
|
}
|
|
|
|
return {
|
|
nodes: Array.from(nodes.values()),
|
|
edges,
|
|
availableStreams: Array.from(availableStreams).sort(),
|
|
};
|
|
};
|
|
|
|
const onStreamFilterChange = (stream: string) => {
|
|
// Update shared stream selection
|
|
searchObj.data.stream.selectedStream = {
|
|
label: stream === "all" ? "" : stream,
|
|
value: stream === "all" ? "" : stream,
|
|
};
|
|
localStorage.setItem("serviceGraph_streamFilter", stream);
|
|
|
|
// Reload service graph with new stream filter
|
|
loadServiceGraph();
|
|
};
|
|
|
|
const applyFilters = () => {
|
|
let nodes = [...graphData.value.nodes];
|
|
let edges = [...graphData.value.edges];
|
|
|
|
// Filter by search
|
|
if (searchFilter.value) {
|
|
const search = searchFilter.value.toLowerCase();
|
|
const matchingNodeIds = new Set(
|
|
nodes
|
|
.filter((n) => n.label.toLowerCase().includes(search))
|
|
.map((n) => n.id),
|
|
);
|
|
|
|
edges = edges.filter(
|
|
(e) => matchingNodeIds.has(e.from) || matchingNodeIds.has(e.to),
|
|
);
|
|
|
|
const usedNodeIds = new Set([
|
|
...edges.map((e) => e.from),
|
|
...edges.map((e) => e.to),
|
|
]);
|
|
nodes = nodes.filter((n) => usedNodeIds.has(n.id));
|
|
}
|
|
|
|
// Filter by connection type
|
|
if (connectionTypeFilter.value !== "all") {
|
|
edges = edges.filter(
|
|
(e) => e.connection_type === connectionTypeFilter.value,
|
|
);
|
|
|
|
const usedNodeIds = new Set([
|
|
...edges.map((e) => e.from),
|
|
...edges.map((e) => e.to),
|
|
]);
|
|
nodes = nodes.filter((n) => usedNodeIds.has(n.id));
|
|
}
|
|
|
|
filteredGraphData.value = { nodes, edges };
|
|
};
|
|
|
|
const setLayout = (type: string) => {
|
|
layoutType.value = type;
|
|
// Persist layout type to localStorage
|
|
localStorage.setItem("serviceGraph_layoutType", type);
|
|
// Force chart recreation when layout changes
|
|
chartKey.value++;
|
|
};
|
|
|
|
const setVisualizationType = (type: "tree" | "graph") => {
|
|
visualizationType.value = type;
|
|
// Persist visualization type to localStorage
|
|
localStorage.setItem("serviceGraph_visualizationType", type);
|
|
// Set default layout for each visualization type
|
|
if (type === "tree") {
|
|
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++;
|
|
};
|
|
|
|
const formatNumber = (num: number) => {
|
|
if (num >= 1000000) return (num / 1000000).toFixed(1) + "M";
|
|
if (num >= 1000) return (num / 1000).toFixed(1) + "K";
|
|
return num.toString();
|
|
};
|
|
|
|
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
|
|
|
|
onMounted(() => {
|
|
loadServiceGraph();
|
|
});
|
|
|
|
return {
|
|
loading,
|
|
error,
|
|
graphData,
|
|
filteredGraphData,
|
|
stats,
|
|
showSettings,
|
|
lastUpdated,
|
|
searchFilter,
|
|
streamFilter,
|
|
connectionTypeFilter,
|
|
connectionTypeTabs,
|
|
visualizationType,
|
|
layoutType,
|
|
layoutOptions,
|
|
chartData,
|
|
chartKey,
|
|
chartRendererRef,
|
|
searchObj,
|
|
dateTimeRef,
|
|
loadServiceGraph,
|
|
formatNumber,
|
|
applyFilters,
|
|
onStreamFilterChange,
|
|
setLayout,
|
|
setVisualizationType,
|
|
updateTimeRange,
|
|
};
|
|
},
|
|
});
|
|
</script>
|
|
|
|
<style scoped lang="scss">
|
|
.stat-card {
|
|
transition: all 0.3s ease;
|
|
|
|
&:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
}
|
|
}
|
|
|
|
.graph-card {
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
|
}
|
|
|
|
.graph-container {
|
|
position: relative;
|
|
width: 100%;
|
|
border-radius: 4px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
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>
|