mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 16:09:05 +00:00
fix: incident graphs and service graphs improvements (#10318)
### **User description**
## Changes
### 1. Reduce alert graph edge explosion
- Modified temporal edge logic to prevent O(n²) growth
- Changed same-service edges to only connect to most recent previous
alert
- Changed cross-service temporal edges to connect to chronologically
closest alert
- **Impact**: 88% reduction in edges (16 instead of 136 for 17 alerts)
### 2. Improve correlation error handling
- Added fallback mechanism when service registry is empty
- Better error messages for correlation failures
- Enhanced incident detail view with clearer feedback
**Files modified:**
- `src/service/alerts/incidents.rs`
- Incident correlation handling
___
### **PR Type**
Bug fix, Enhancement
___
### **Description**
- Prevent alert topology edge explosion
- Add frontend fallback incident correlation
- Improve correlation tab empty/error UX
- Share time/stream state across tabs
___
### Diagram Walkthrough
```mermaid
flowchart LR
A["Incident alerts ingested"]
B["Topology enrichment"]
C["Same-service temporal chain"]
D["Cross-service edges"]
E["ServiceGraph tab shared state"]
F["Incident detail correlation"]
G["FQN-priority drift detection"]
H["Frontend fallback correlation"]
A -- "build graph" --> B
B -- "temporal edges" --> C
B -- "dependency edges" --> D
E -- "share datetime/stream" --> B
F -- "fetch correlated streams" --> G
G -- "if not drift" --> H
```
<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>convertTraceData.ts</strong><dd><code>Simplify
service-graph tree request metrics</code>
</dd></summary>
<hr>
web/src/utils/traces/convertTraceData.ts
<ul><li>Use incoming edge metrics for non-root<br> <li> Root sums
outgoing edge request counts<br> <li> Default root error rate to
zero<br> <li> Keep connection count as in+out</ul>
</details>
</td>
<td><a
href="https://github.com/openobserve/openobserve/pull/10318/files#diff-f236b4107f8b11c8281dec8279238d02309a3c958bd1ee2076716df56b00c057">+5/-6</a>
</td>
</tr>
<tr>
<td>
<details>
<summary><strong>incidents.rs</strong><dd><code>Limit temporal topology
edges to nearest alerts</code>
</dd></summary>
<hr>
src/service/alerts/incidents.rs
<ul><li>Same-service temporal edge targets most recent prior<br> <li>
Keep full ServiceDependency edges when SG edge exists<br> <li> For
non-dependent services, link to closest later alert<br> <li> Avoid
duplicate edges with type-specific checks</ul>
</details>
</td>
<td><a
href="https://github.com/openobserve/openobserve/pull/10318/files#diff-2411d921fd8b8783c049deea11da06726ed986550d405477d486234892353ca9">+80/-47</a>
</td>
</tr>
</table></td></tr><tr><td><strong>Enhancement</strong></td><td><table>
<tr>
<td>
<details>
<summary><strong>IncidentDetailDrawer.vue</strong><dd><code>Add fallback
correlation and improve tab states</code>
</dd></summary>
<hr>
web/src/components/alerts/IncidentDetailDrawer.vue
<ul><li>Show refresh header only when streams exist<br> <li> Unify
error/no-data states with clearer messaging<br> <li> Detect FQN priority
changes post-incident creation<br> <li> Build frontend fallback
correlation from schema mapping</ul>
</details>
</td>
<td><a
href="https://github.com/openobserve/openobserve/pull/10318/files#diff-6973d58a5f49658b07d3533d4db5d32cc6430433b51732481e7497243dadb0b0">+217/-72</a></td>
</tr>
<tr>
<td>
<details>
<summary><strong>ServiceGraph.vue</strong><dd><code>Share Service Graph
time/stream with traces state</code>
</dd></summary>
<hr>
web/src/plugins/traces/ServiceGraph.vue
<ul><li>Bind datetime picker to <code>searchObj.data.datetime</code><br>
<li> Use shared <code>searchObj.data.stream.*</code> for stream
lists<br> <li> Remove local stream/time state and duplicate stream
loading<br> <li> Simplify empty-state guidance and code styling</ul>
</details>
</td>
<td><a
href="https://github.com/openobserve/openobserve/pull/10318/files#diff-da1aabfbe74e57c02db16fc5d3e00cd76dc76bf0ad9b57f9dfda7d6d9da06129">+50/-181</a></td>
</tr>
</table></td></tr></tr></tbody></table>
</details>
___
---------
Co-authored-by: sai nikhil kethe <nikhil@openobserve.ai>
This commit is contained in:
co-authored by
sai nikhil kethe
parent
41d0c591a6
commit
7df19cb00a
@@ -481,19 +481,17 @@ mod tests {
|
||||
template: template1,
|
||||
..
|
||||
} = dest1_retrieved.module
|
||||
{
|
||||
if let Module::Alert {
|
||||
&& let Module::Alert {
|
||||
template: template2,
|
||||
..
|
||||
} = dest2_retrieved.module
|
||||
{
|
||||
assert_eq!(template1, Some("prebuilt_slack".to_string()));
|
||||
assert_eq!(template2, Some("prebuilt_slack".to_string()));
|
||||
assert_eq!(
|
||||
template1, template2,
|
||||
"Both destinations should use the same template"
|
||||
);
|
||||
}
|
||||
{
|
||||
assert_eq!(template1, Some("prebuilt_slack".to_string()));
|
||||
assert_eq!(template2, Some("prebuilt_slack".to_string()));
|
||||
assert_eq!(
|
||||
template1, template2,
|
||||
"Both destinations should use the same template"
|
||||
);
|
||||
}
|
||||
|
||||
// Verify only ONE template exists
|
||||
|
||||
@@ -723,32 +723,37 @@ pub async fn enrich_with_topology(
|
||||
if is_new_node {
|
||||
let current_node_first_fired = topology.nodes[current_node_index].first_fired_at;
|
||||
|
||||
for (idx, node) in topology.nodes.iter().enumerate() {
|
||||
if idx == current_node_index {
|
||||
continue; // Skip self
|
||||
}
|
||||
// Find the most recent previous node from same service
|
||||
let most_recent_previous = topology
|
||||
.nodes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(idx, node)| {
|
||||
*idx != current_node_index
|
||||
&& node.service_name == service_name
|
||||
&& node.first_fired_at < current_node_first_fired
|
||||
})
|
||||
.max_by_key(|(_, node)| node.first_fired_at);
|
||||
|
||||
// Only connect nodes from same service
|
||||
if node.service_name == service_name && node.first_fired_at < current_node_first_fired {
|
||||
// Check if edge already exists
|
||||
let edge_exists = topology.edges.iter().any(|e| {
|
||||
e.from_node_index == idx
|
||||
&& e.to_node_index == current_node_index
|
||||
&& matches!(e.edge_type, EdgeType::Temporal)
|
||||
if let Some((prev_idx, _)) = most_recent_previous {
|
||||
// Check if edge already exists
|
||||
let edge_exists = topology.edges.iter().any(|e| {
|
||||
e.from_node_index == prev_idx
|
||||
&& e.to_node_index == current_node_index
|
||||
&& matches!(e.edge_type, EdgeType::Temporal)
|
||||
});
|
||||
|
||||
if !edge_exists {
|
||||
topology.edges.push(AlertEdge {
|
||||
from_node_index: prev_idx,
|
||||
to_node_index: current_node_index,
|
||||
edge_type: EdgeType::Temporal,
|
||||
});
|
||||
|
||||
if !edge_exists {
|
||||
topology.edges.push(AlertEdge {
|
||||
from_node_index: idx,
|
||||
to_node_index: current_node_index,
|
||||
edge_type: EdgeType::Temporal,
|
||||
});
|
||||
log::debug!(
|
||||
"[incidents] Added temporal edge: {} -> {} (same service)",
|
||||
idx,
|
||||
current_node_index
|
||||
);
|
||||
}
|
||||
log::debug!(
|
||||
"[incidents] Added temporal edge: {} -> {} (most recent from same service)",
|
||||
prev_idx,
|
||||
current_node_index
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -796,42 +801,71 @@ pub async fn enrich_with_topology(
|
||||
.iter()
|
||||
.any(|e| &e.from == from_service && &e.to == to_service);
|
||||
|
||||
// Add edge from each node in from_service to each node in to_service
|
||||
// that occurred chronologically after
|
||||
for &from_idx in from_indices {
|
||||
for &to_idx in to_indices {
|
||||
if has_sg_edge {
|
||||
// Service dependency: connect ALL nodes (full causality)
|
||||
for &from_idx in from_indices {
|
||||
for &to_idx in to_indices {
|
||||
let from_node = &topology.nodes[from_idx];
|
||||
let to_node = &topology.nodes[to_idx];
|
||||
|
||||
// Only add if from happened before to (chronological)
|
||||
if from_node.first_fired_at < to_node.first_fired_at {
|
||||
// Check if edge already exists
|
||||
let edge_exists = topology.edges.iter().any(|e| {
|
||||
e.from_node_index == from_idx
|
||||
&& e.to_node_index == to_idx
|
||||
&& matches!(e.edge_type, EdgeType::ServiceDependency)
|
||||
});
|
||||
|
||||
if !edge_exists {
|
||||
topology.edges.push(AlertEdge {
|
||||
from_node_index: from_idx,
|
||||
to_node_index: to_idx,
|
||||
edge_type: EdgeType::ServiceDependency,
|
||||
});
|
||||
log::debug!(
|
||||
"[incidents] Added ServiceDependency edge: {from_idx} ({from_service}) -> {to_idx} ({to_service})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Temporal only: connect to most recent node in target service
|
||||
// For each node in from_service, find the most recent
|
||||
// chronologically-after node in to_service
|
||||
for &from_idx in from_indices {
|
||||
let from_node = &topology.nodes[from_idx];
|
||||
let to_node = &topology.nodes[to_idx];
|
||||
|
||||
// Only add if from happened before to (chronological)
|
||||
if from_node.first_fired_at < to_node.first_fired_at {
|
||||
// Determine edge type: ServiceDependency if SG edge exists, otherwise
|
||||
// Temporal
|
||||
let edge_type = if has_sg_edge {
|
||||
EdgeType::ServiceDependency
|
||||
} else {
|
||||
EdgeType::Temporal
|
||||
};
|
||||
// Find most recent node in to_service that fired after from_node
|
||||
let most_recent_to = to_indices
|
||||
.iter()
|
||||
.filter_map(|&to_idx| {
|
||||
let to_node = &topology.nodes[to_idx];
|
||||
if to_node.first_fired_at > from_node.first_fired_at {
|
||||
Some((to_idx, to_node.first_fired_at))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.min_by_key(|(_, fired_at)| *fired_at); // Closest in time (earliest after from_node)
|
||||
|
||||
if let Some((to_idx, _)) = most_recent_to {
|
||||
// Check if edge already exists
|
||||
let edge_exists = topology.edges.iter().any(|e| {
|
||||
e.from_node_index == from_idx
|
||||
&& e.to_node_index == to_idx
|
||||
&& matches!(
|
||||
(&e.edge_type, &edge_type),
|
||||
(EdgeType::ServiceDependency, EdgeType::ServiceDependency)
|
||||
| (EdgeType::Temporal, EdgeType::Temporal)
|
||||
)
|
||||
&& matches!(e.edge_type, EdgeType::Temporal)
|
||||
});
|
||||
|
||||
if !edge_exists {
|
||||
topology.edges.push(AlertEdge {
|
||||
from_node_index: from_idx,
|
||||
to_node_index: to_idx,
|
||||
edge_type,
|
||||
edge_type: EdgeType::Temporal,
|
||||
});
|
||||
log::debug!(
|
||||
"[incidents] Added {edge_type:?} edge: {from_idx} ({from_service}) -> {to_idx} ({to_service})"
|
||||
"[incidents] Added Temporal edge: {from_idx} ({from_service}) -> {to_idx} ({to_service}, most recent)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-15
@@ -681,21 +681,9 @@ mod tests {
|
||||
assert!(cap1 >= len1);
|
||||
|
||||
// Values may change due to concurrent tests, but should not vary wildly
|
||||
let len_diff = if len2 > len1 {
|
||||
len2 - len1
|
||||
} else {
|
||||
len1 - len2
|
||||
};
|
||||
let cap_diff = if cap2 > cap1 {
|
||||
cap2 - cap1
|
||||
} else {
|
||||
cap1 - cap2
|
||||
};
|
||||
let mem_diff = if mem2 > mem1 {
|
||||
mem2 - mem1
|
||||
} else {
|
||||
mem1 - mem2
|
||||
};
|
||||
let len_diff = len2.abs_diff(len1);
|
||||
let cap_diff = cap2.abs_diff(cap1);
|
||||
let mem_diff = mem2.abs_diff(mem1);
|
||||
assert!(len_diff < 1000 || len1 == 0 || len2 == 0);
|
||||
assert!(cap_diff < 10000 || cap1 == 0 || cap2 == 0);
|
||||
assert!(mem_diff < 1000000 || mem1 == 0 || mem2 == 0);
|
||||
|
||||
@@ -1010,8 +1010,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
<!-- Logs Tab Content -->
|
||||
<div v-if="activeTab === 'logs'" class="tw-flex tw-flex-col tw-flex-1 tw-overflow-hidden">
|
||||
<!-- Refresh Button (shown when data is loaded) -->
|
||||
<div v-if="hasCorrelatedData && !correlationLoading" class="tw-px-4 tw-py-2 tw-border-b tw-border-solid tw-border-[var(--o2-border-color)] tw-flex tw-items-center tw-justify-between">
|
||||
<!-- Refresh Button (shown when logs data is loaded) -->
|
||||
<div v-if="hasCorrelatedData && !correlationLoading && correlationData?.logStreams?.length > 0" class="tw-px-4 tw-py-2 tw-border-b tw-border-solid tw-border-[var(--o2-border-color)] tw-flex tw-items-center tw-justify-between">
|
||||
<span class="tw-text-xs tw-text-gray-500">Showing correlated logs from incident timeframe</span>
|
||||
<q-btn
|
||||
flat
|
||||
@@ -1032,37 +1032,31 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<div class="tw-text-base">Loading correlated logs...</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="correlationError" class="tw-flex tw-flex-col tw-items-center tw-justify-center tw-flex-1 tw-p-4">
|
||||
<q-icon name="error_outline" size="3rem" color="negative" class="tw-mb-4" />
|
||||
<div class="tw-text-base tw-mb-2">Failed to load correlated logs</div>
|
||||
<div class="tw-text-sm tw-text-gray-500 tw-mb-4">{{ correlationError }}</div>
|
||||
<!-- Error/No Data State -->
|
||||
<div v-else-if="correlationError || !hasCorrelatedData || !hasAnyStreams" class="full-width column flex-center q-gutter-sm justify-center" style="margin: 15vh auto 2rem;">
|
||||
<q-icon
|
||||
:name="correlationError ? (correlationError.includes('FQN priority') ? 'warning' : 'error_outline') : 'info_outline'"
|
||||
:color="correlationError ? (correlationError.includes('FQN priority') ? 'warning' : 'negative') : 'grey-5'"
|
||||
size="4rem"
|
||||
/>
|
||||
<div class="text-h6 q-mt-md">
|
||||
{{ correlationError || 'No correlated logs found' }}
|
||||
</div>
|
||||
<div v-if="correlationError && correlationError.includes('FQN priority')" class="text-body2 text-grey-7 q-mt-sm" style="max-width: 500px; text-align: center;">
|
||||
The service discovery configuration (FQN priority dimensions) was changed after this incident was created.
|
||||
</div>
|
||||
<q-btn
|
||||
v-if="correlationError && !correlationError.includes('FQN priority')"
|
||||
color="primary"
|
||||
outline
|
||||
size="sm"
|
||||
size="md"
|
||||
@click="refreshCorrelation"
|
||||
icon="refresh"
|
||||
label="Retry"
|
||||
class="q-mt-md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- No Data State -->
|
||||
<div v-else-if="!hasCorrelatedData || !hasAnyStreams" class="tw-flex tw-flex-col tw-items-center tw-justify-center tw-flex-1 tw-p-4">
|
||||
<q-icon name="info_outline" size="3rem" color="grey-5" class="tw-mb-4" />
|
||||
<div class="tw-text-base tw-mb-4">No correlated logs found</div>
|
||||
<div v-if="incidentDetails" class="tw-text-sm tw-text-gray-500 tw-mb-4">
|
||||
Try searching manually using these dimensions:
|
||||
</div>
|
||||
<div v-if="incidentDetails" class="info-box tw-rounded tw-p-3 tw-text-xs" :class="isDarkMode ? 'info-box-dark' : 'info-box-light'">
|
||||
<div v-for="(value, key) in incidentDetails.stable_dimensions" :key="key" class="tw-flex tw-gap-2 tw-mb-1">
|
||||
<span class="label-text">{{ key }}:</span>
|
||||
<span class="tw-font-mono">{{ value }}</span>
|
||||
<q-btn flat dense size="xs" icon="content_copy" @click="() => { navigator.clipboard.writeText(value); $q.notify({ message: 'Copied!', type: 'positive' }); }" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success State - TelemetryCorrelationDashboard -->
|
||||
<div v-else-if="hasCorrelatedData && correlationData" class="tw-flex-1 tw-overflow-hidden">
|
||||
<TelemetryCorrelationDashboard
|
||||
@@ -1081,8 +1075,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
<!-- Metrics Tab Content -->
|
||||
<div v-if="activeTab === 'metrics'" class="tw-flex tw-flex-col tw-flex-1 tw-overflow-hidden">
|
||||
<!-- Refresh Button (shown when data is loaded) -->
|
||||
<div v-if="hasCorrelatedData && !correlationLoading" class="tw-px-4 tw-py-2 tw-border-b tw-border-solid tw-border-[var(--o2-border-color)] tw-flex tw-items-center tw-justify-between">
|
||||
<!-- Refresh Button (shown when metrics data is loaded) -->
|
||||
<div v-if="hasCorrelatedData && !correlationLoading && correlationData?.metricStreams?.length > 0" class="tw-px-4 tw-py-2 tw-border-b tw-border-solid tw-border-[var(--o2-border-color)] tw-flex tw-items-center tw-justify-between">
|
||||
<span class="tw-text-xs tw-text-gray-500">Showing correlated metrics from incident timeframe</span>
|
||||
<q-btn
|
||||
flat
|
||||
@@ -1103,37 +1097,31 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<div class="tw-text-base">Loading correlated metrics...</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="correlationError" class="tw-flex tw-flex-col tw-items-center tw-justify-center tw-flex-1 tw-p-4">
|
||||
<q-icon name="error_outline" size="3rem" color="negative" class="tw-mb-4" />
|
||||
<div class="tw-text-base tw-mb-2">Failed to load correlated metrics</div>
|
||||
<div class="tw-text-sm tw-text-gray-500 tw-mb-4">{{ correlationError }}</div>
|
||||
<!-- Error/No Data State -->
|
||||
<div v-else-if="correlationError || !hasCorrelatedData || !hasAnyStreams" class="full-width column flex-center q-gutter-sm justify-center" style="margin: 15vh auto 2rem;">
|
||||
<q-icon
|
||||
:name="correlationError ? (correlationError.includes('FQN priority') ? 'warning' : 'error_outline') : 'info_outline'"
|
||||
:color="correlationError ? (correlationError.includes('FQN priority') ? 'warning' : 'negative') : 'grey-5'"
|
||||
size="4rem"
|
||||
/>
|
||||
<div class="text-h6 q-mt-md">
|
||||
{{ correlationError || 'No correlated metrics found' }}
|
||||
</div>
|
||||
<div v-if="correlationError && correlationError.includes('FQN priority')" class="text-body2 text-grey-7 q-mt-sm" style="max-width: 500px; text-align: center;">
|
||||
The service discovery configuration (FQN priority dimensions) was changed after this incident was created.
|
||||
</div>
|
||||
<q-btn
|
||||
v-if="correlationError && !correlationError.includes('FQN priority')"
|
||||
color="primary"
|
||||
outline
|
||||
size="sm"
|
||||
size="md"
|
||||
@click="refreshCorrelation"
|
||||
icon="refresh"
|
||||
label="Retry"
|
||||
class="q-mt-md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- No Data State -->
|
||||
<div v-else-if="!hasCorrelatedData || !hasAnyStreams" class="tw-flex tw-flex-col tw-items-center tw-justify-center tw-flex-1 tw-p-4">
|
||||
<q-icon name="info_outline" size="3rem" color="grey-5" class="tw-mb-4" />
|
||||
<div class="tw-text-base tw-mb-4">No correlated metrics found</div>
|
||||
<div v-if="incidentDetails" class="tw-text-sm tw-text-gray-500 tw-mb-4">
|
||||
Try searching manually using these dimensions:
|
||||
</div>
|
||||
<div v-if="incidentDetails" class="info-box tw-rounded tw-p-3 tw-text-xs" :class="isDarkMode ? 'info-box-dark' : 'info-box-light'">
|
||||
<div v-for="(value, key) in incidentDetails.stable_dimensions" :key="key" class="tw-flex tw-gap-2 tw-mb-1">
|
||||
<span class="label-text">{{ key }}:</span>
|
||||
<span class="tw-font-mono">{{ value }}</span>
|
||||
<q-btn flat dense size="xs" icon="content_copy" @click="() => { navigator.clipboard.writeText(value); $q.notify({ message: 'Copied!', type: 'positive' }); }" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success State - TelemetryCorrelationDashboard -->
|
||||
<div v-else-if="hasCorrelatedData && correlationData" class="tw-flex-1 tw-overflow-hidden">
|
||||
<TelemetryCorrelationDashboard
|
||||
@@ -1152,8 +1140,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
<!-- Traces Tab Content -->
|
||||
<div v-if="activeTab === 'traces'" class="tw-flex tw-flex-col tw-flex-1 tw-overflow-hidden">
|
||||
<!-- Refresh Button (shown when data is loaded) -->
|
||||
<div v-if="hasCorrelatedData && !correlationLoading" class="tw-px-4 tw-py-2 tw-border-b tw-border-solid tw-border-[var(--o2-border-color)] tw-flex tw-items-center tw-justify-between">
|
||||
<!-- Refresh Button (shown when traces data is loaded) -->
|
||||
<div v-if="hasCorrelatedData && !correlationLoading && correlationData?.traceStreams?.length > 0" class="tw-px-4 tw-py-2 tw-border-b tw-border-solid tw-border-[var(--o2-border-color)] tw-flex tw-items-center tw-justify-between">
|
||||
<span class="tw-text-xs tw-text-gray-500">Showing correlated traces from incident timeframe</span>
|
||||
<q-btn
|
||||
flat
|
||||
@@ -1174,37 +1162,31 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<div class="tw-text-base">Loading correlated traces...</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="correlationError" class="tw-flex tw-flex-col tw-items-center tw-justify-center tw-flex-1 tw-p-4">
|
||||
<q-icon name="error_outline" size="3rem" color="negative" class="tw-mb-4" />
|
||||
<div class="tw-text-base tw-mb-2">Failed to load correlated traces</div>
|
||||
<div class="tw-text-sm tw-text-gray-500 tw-mb-4">{{ correlationError }}</div>
|
||||
<!-- Error/No Data State -->
|
||||
<div v-else-if="correlationError || !hasCorrelatedData || !hasAnyStreams" class="full-width column flex-center q-gutter-sm justify-center" style="margin: 15vh auto 2rem;">
|
||||
<q-icon
|
||||
:name="correlationError ? (correlationError.includes('FQN priority') ? 'warning' : 'error_outline') : 'info_outline'"
|
||||
:color="correlationError ? (correlationError.includes('FQN priority') ? 'warning' : 'negative') : 'grey-5'"
|
||||
size="4rem"
|
||||
/>
|
||||
<div class="text-h6 q-mt-md">
|
||||
{{ correlationError || 'No correlated traces found' }}
|
||||
</div>
|
||||
<div v-if="correlationError && correlationError.includes('FQN priority')" class="text-body2 text-grey-7 q-mt-sm" style="max-width: 500px; text-align: center;">
|
||||
The service discovery configuration (FQN priority dimensions) was changed after this incident was created.
|
||||
</div>
|
||||
<q-btn
|
||||
v-if="correlationError && !correlationError.includes('FQN priority')"
|
||||
color="primary"
|
||||
outline
|
||||
size="sm"
|
||||
size="md"
|
||||
@click="refreshCorrelation"
|
||||
icon="refresh"
|
||||
label="Retry"
|
||||
class="q-mt-md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- No Data State -->
|
||||
<div v-else-if="!hasCorrelatedData || !hasAnyStreams" class="tw-flex tw-flex-col tw-items-center tw-justify-center tw-flex-1 tw-p-4">
|
||||
<q-icon name="info_outline" size="3rem" color="grey-5" class="tw-mb-4" />
|
||||
<div class="tw-text-base tw-mb-4">No correlated traces found</div>
|
||||
<div v-if="incidentDetails" class="tw-text-sm tw-text-gray-500 tw-mb-4">
|
||||
Try searching manually using these dimensions:
|
||||
</div>
|
||||
<div v-if="incidentDetails" class="info-box tw-rounded tw-p-3 tw-text-xs" :class="isDarkMode ? 'info-box-dark' : 'info-box-light'">
|
||||
<div v-for="(value, key) in incidentDetails.stable_dimensions" :key="key" class="tw-flex tw-gap-2 tw-mb-1">
|
||||
<span class="label-text">{{ key }}:</span>
|
||||
<span class="tw-font-mono">{{ value }}</span>
|
||||
<q-btn flat dense size="xs" icon="content_copy" @click="() => { navigator.clipboard.writeText(value); $q.notify({ message: 'Copied!', type: 'positive' }); }" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success State - TelemetryCorrelationDashboard -->
|
||||
<div v-else-if="hasCorrelatedData && correlationData" class="tw-flex-1 tw-overflow-hidden">
|
||||
<TelemetryCorrelationDashboard
|
||||
@@ -1246,6 +1228,9 @@ import incidentsService, {
|
||||
IncidentAlert,
|
||||
IncidentCorrelatedStreams,
|
||||
} from "@/services/incidents";
|
||||
import http from "@/services/http";
|
||||
import streamService from "@/services/stream";
|
||||
import serviceStreamsApi from "@/services/service_streams";
|
||||
import { getImageURL } from "@/utils/zincutils";
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
@@ -1518,6 +1503,36 @@ export default defineComponent({
|
||||
org,
|
||||
incidentDetails.value
|
||||
);
|
||||
|
||||
// Check if correlation failed (null response or no data)
|
||||
if (!correlationData.value?.correlationData &&
|
||||
(!correlationData.value?.logStreams?.length &&
|
||||
!correlationData.value?.metricStreams?.length &&
|
||||
!correlationData.value?.traceStreams?.length)) {
|
||||
// Fetch FQN priority config to check if it was modified after incident creation
|
||||
try {
|
||||
const fqnConfigResponse = await http().get(
|
||||
`/api/${org}/settings/v2/fqn_priority_dimensions`
|
||||
);
|
||||
const fqnConfig = fqnConfigResponse.data;
|
||||
|
||||
// Compare timestamps (both in microseconds)
|
||||
if (fqnConfig.updated_at > incidentDetails.value.created_at) {
|
||||
correlationError.value =
|
||||
"FQN priority was modified since this incident was created, cannot correlate";
|
||||
console.warn(
|
||||
`[Incident Correlation] FQN config updated at ${fqnConfig.updated_at}, incident created at ${incidentDetails.value.created_at}`
|
||||
);
|
||||
} else {
|
||||
// Not a config issue - try building fallback correlation
|
||||
await buildFallbackCorrelation(org, incidentDetails.value);
|
||||
}
|
||||
} catch (configError) {
|
||||
// If can't fetch config, try fallback anyway
|
||||
console.error("Failed to fetch FQN config:", configError);
|
||||
await buildFallbackCorrelation(org, incidentDetails.value);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Failed to load correlated streams:", error);
|
||||
correlationError.value =
|
||||
@@ -1529,6 +1544,136 @@ export default defineComponent({
|
||||
}
|
||||
};
|
||||
|
||||
// Build fallback correlation using first alert's stream schema
|
||||
const buildFallbackCorrelation = async (org: string, incident: Incident) => {
|
||||
try {
|
||||
console.log("[Fallback Correlation] Building local correlation from first alert's stream");
|
||||
|
||||
// Get first alert to determine source stream
|
||||
const firstAlert = alerts.value?.[0];
|
||||
if (!firstAlert) {
|
||||
console.warn("[Fallback Correlation] No alerts found in incident");
|
||||
return;
|
||||
}
|
||||
|
||||
// Use actual stream type and name from the alert
|
||||
const streamType = firstAlert.stream_type || "logs";
|
||||
const streamName = firstAlert.stream_name || "default";
|
||||
|
||||
// Get stream schema
|
||||
const schemaResponse = await streamService.schema(org, streamName, streamType);
|
||||
const schema = schemaResponse.data;
|
||||
|
||||
// Get semantic groups
|
||||
const semanticGroupsResponse = await serviceStreamsApi.getSemanticGroups(org);
|
||||
const semanticGroups = semanticGroupsResponse.data;
|
||||
|
||||
// Map incident dimensions to field names in schema
|
||||
// Schema response has .schema array with {name, type} objects
|
||||
const schemaFields = new Set(
|
||||
(schema.schema || schema.fields || []).map((f: any) => f.name)
|
||||
);
|
||||
|
||||
console.log("[Fallback Correlation] Incident dimensions:", incident.stable_dimensions);
|
||||
console.log("[Fallback Correlation] Total schema fields:", schemaFields.size);
|
||||
console.log("[Fallback Correlation] Sample schema fields:", Array.from(schemaFields).slice(0, 10));
|
||||
|
||||
const filters: Record<string, string> = {};
|
||||
|
||||
for (const [dimId, dimValue] of Object.entries(incident.stable_dimensions)) {
|
||||
// Find semantic group
|
||||
const group = semanticGroups.find((g: any) => g.id === dimId);
|
||||
|
||||
console.log(`[Fallback Correlation] Processing ${dimId} = ${dimValue}`);
|
||||
|
||||
if (!group) {
|
||||
console.warn(`[Fallback Correlation] No semantic group found for: ${dimId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!group.fields || group.fields.length === 0) {
|
||||
console.warn(`[Fallback Correlation] Semantic group ${dimId} has empty fields array`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`[Fallback Correlation] Trying field variants for ${dimId}:`, group.fields.slice(0, 5));
|
||||
|
||||
// Collect ALL matching field names and pick the best one
|
||||
const matchingFields = [];
|
||||
for (const fieldName of group.fields) {
|
||||
if (schemaFields.has(fieldName)) {
|
||||
matchingFields.push(fieldName);
|
||||
}
|
||||
}
|
||||
|
||||
if (matchingFields.length > 0) {
|
||||
// Prefer shorter field names without prefixes
|
||||
const bestField = matchingFields.sort((a, b) => {
|
||||
// Penalize long prefixes
|
||||
const aPenalty = a.startsWith('service_') ? 1000 :
|
||||
a.startsWith('resource_') ? 500 :
|
||||
a.startsWith('attributes_') ? 300 : 0;
|
||||
const bPenalty = b.startsWith('service_') ? 1000 :
|
||||
b.startsWith('resource_') ? 500 :
|
||||
b.startsWith('attributes_') ? 300 : 0;
|
||||
|
||||
// Then by length (shorter = better)
|
||||
return (a.length + aPenalty) - (b.length + bPenalty);
|
||||
})[0];
|
||||
|
||||
filters[bestField] = dimValue;
|
||||
console.log(`[Fallback Correlation] ✅ Mapped ${dimId} → ${bestField} = ${dimValue} (from ${matchingFields.length} options: ${matchingFields.slice(0, 3).join(', ')})`);
|
||||
} else {
|
||||
console.warn(`[Fallback Correlation] ❌ No matching field in schema for ${dimId}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(filters).length === 0) {
|
||||
console.warn("[Fallback Correlation] No dimensions could be mapped to stream fields");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[Fallback Correlation] Mapped filters:", filters);
|
||||
console.log("[Fallback Correlation] Schema fields:", Array.from(schemaFields));
|
||||
console.log("[Fallback Correlation] Stream type:", streamType);
|
||||
|
||||
// Build StreamInfo object
|
||||
const streamInfo = {
|
||||
stream_name: streamName,
|
||||
stream_type: streamType === 'logs' ? 'Logs' : streamType === 'metrics' ? 'Metrics' : 'Traces',
|
||||
filters
|
||||
};
|
||||
|
||||
// Build correlation response with only the source stream type
|
||||
correlationData.value = {
|
||||
serviceName: `dimension-match-${incident.stable_dimensions.service || 'unknown'}`,
|
||||
matchedDimensions: incident.stable_dimensions,
|
||||
additionalDimensions: {},
|
||||
logStreams: streamType === 'logs' ? [streamInfo] : [],
|
||||
metricStreams: streamType === 'metrics' ? [streamInfo] : [],
|
||||
traceStreams: streamType === 'traces' ? [streamInfo] : [],
|
||||
correlationData: {
|
||||
service_name: `dimension-match-${incident.stable_dimensions.service || 'unknown'}`,
|
||||
matched_dimensions: incident.stable_dimensions,
|
||||
additional_dimensions: {},
|
||||
related_streams: {
|
||||
logs: streamType === 'logs' ? [streamInfo] : [],
|
||||
metrics: streamType === 'metrics' ? [streamInfo] : [],
|
||||
traces: streamType === 'traces' ? [streamInfo] : [],
|
||||
},
|
||||
correlation_method: "frontend-fallback"
|
||||
}
|
||||
};
|
||||
|
||||
console.log("[Fallback Correlation] Built correlation data:", correlationData.value);
|
||||
console.log("[Fallback Correlation] Log streams:", correlationData.value.logStreams);
|
||||
console.log("[Fallback Correlation] Filters being passed:", correlationData.value.logStreams[0]?.filters);
|
||||
} catch (fallbackError) {
|
||||
console.error("[Fallback Correlation] Failed to build fallback:", fallbackError);
|
||||
// Don't set error - let tabs show "No correlated X found"
|
||||
}
|
||||
};
|
||||
|
||||
// Refresh correlation data
|
||||
const refreshCorrelation = () => {
|
||||
fetchCorrelatedStreams(true);
|
||||
|
||||
@@ -432,9 +432,10 @@ describe("IncidentServiceGraph.vue", () => {
|
||||
const node2 = chartData.options.series[0].data[1]; // 7 alerts
|
||||
const node3 = chartData.options.series[0].data[2]; // 12 alerts
|
||||
|
||||
expect(node1.symbolSize).toBe(45); // 30 + 3*5
|
||||
expect(node2.symbolSize).toBe(65); // 30 + 7*5
|
||||
expect(node3.symbolSize).toBe(90); // 30 + 12*5
|
||||
// All nodes have fixed size of 60
|
||||
expect(node1.symbolSize).toBe(60);
|
||||
expect(node2.symbolSize).toBe(60);
|
||||
expect(node3.symbolSize).toBe(60);
|
||||
});
|
||||
|
||||
it("should cap node size at 100", async () => {
|
||||
@@ -463,7 +464,7 @@ describe("IncidentServiceGraph.vue", () => {
|
||||
|
||||
const chartData = wrapper.vm.chartData;
|
||||
const largeNode = chartData.options.series[0].data[0];
|
||||
expect(largeNode.symbolSize).toBe(100); // Capped at 100
|
||||
expect(largeNode.symbolSize).toBe(60); // Fixed size for all nodes
|
||||
});
|
||||
|
||||
it("should add border to primary service nodes", async () => {
|
||||
@@ -839,7 +840,7 @@ describe("IncidentServiceGraph.vue", () => {
|
||||
const chartData = wrapper.vm.chartData;
|
||||
// Second node has 0 alerts
|
||||
const zeroAlertNode = chartData.options.series[0].data[1];
|
||||
expect(zeroAlertNode.symbolSize).toBe(30); // Base size
|
||||
expect(zeroAlertNode.symbolSize).toBe(60); // Fixed size for all nodes
|
||||
});
|
||||
|
||||
it("should handle very large alert counts", async () => {
|
||||
@@ -867,7 +868,7 @@ describe("IncidentServiceGraph.vue", () => {
|
||||
|
||||
const chartData = wrapper.vm.chartData;
|
||||
const largeNode = chartData.options.series[0].data[0];
|
||||
expect(largeNode.symbolSize).toBe(100); // Capped at 100
|
||||
expect(largeNode.symbolSize).toBe(60); // Fixed size for all nodes
|
||||
});
|
||||
|
||||
it("should handle null orgId gracefully", () => {
|
||||
|
||||
@@ -15,7 +15,43 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div class="incident-service-graph" style="height: calc(100vh - 202px);">
|
||||
<div class="incident-service-graph" style="height: calc(100vh - 202px); position: relative;">
|
||||
<!-- Info Icon Button -->
|
||||
<q-btn
|
||||
v-if="!loading && graphData && graphData.nodes && graphData.nodes.length > 0"
|
||||
round
|
||||
flat
|
||||
icon="info_outline"
|
||||
size="sm"
|
||||
class="info-icon-btn"
|
||||
:class="isDarkMode ? 'tw-text-gray-400 hover:tw-text-gray-200' : 'tw-text-gray-500 hover:tw-text-gray-700'"
|
||||
>
|
||||
<q-tooltip
|
||||
:delay="200"
|
||||
anchor="bottom left"
|
||||
self="top right"
|
||||
:offset="[10, 10]"
|
||||
max-width="300px"
|
||||
>
|
||||
<div style="padding: 8px; font-size: 12px; line-height: 1.6;">
|
||||
<div style="font-weight: 600; margin-bottom: 8px; font-size: 13px;">Graph Legend</div>
|
||||
|
||||
<div style="margin-bottom: 6px;">
|
||||
<span style="color: #ef4444;">●</span> Red = Potential Root Cause
|
||||
</div>
|
||||
<div style="margin-bottom: 6px;">
|
||||
<span style="color: #f97316;">●</span> Orange = High Frequency
|
||||
</div>
|
||||
<div style="margin-bottom: 6px;">
|
||||
<span style="color: #3b82f6;">●</span> Blue = Normal
|
||||
</div>
|
||||
<div style="margin-top: 8px; padding-top: 8px; border-top: 1px solid rgba(255,255,255,0.2);">
|
||||
<span style="color: #a78bfa;">→</span> Purple arrows show temporal flow
|
||||
</div>
|
||||
</div>
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div
|
||||
v-if="loading"
|
||||
@@ -89,7 +125,7 @@ export default defineComponent({
|
||||
// Use topology_context directly from props
|
||||
const graphData = computed(() => props.topologyContext);
|
||||
|
||||
// D3-Force simulation to compute stable node positions
|
||||
// D3-Force simulation to compute stable node positions with left-to-right layout
|
||||
const computeForceLayout = (nodes: any[], edges: any[], width = 800, height = 600) => {
|
||||
const nodesCopy = nodes.map(n => ({ ...n }));
|
||||
const edgesCopy = edges.map(e => ({
|
||||
@@ -98,23 +134,74 @@ export default defineComponent({
|
||||
...e,
|
||||
}));
|
||||
|
||||
// Calculate depth/level for each node (for left-to-right positioning)
|
||||
const nodeDepth = new Map<string, number>();
|
||||
nodesCopy.forEach(n => nodeDepth.set(n.id, 0));
|
||||
|
||||
// Build adjacency list from temporal edges to determine hierarchy
|
||||
const temporalEdges = edgesCopy.filter(e => e.originalEdge?.edge_type === 'temporal');
|
||||
const visited = new Set<string>();
|
||||
|
||||
// BFS to calculate depth
|
||||
const queue: Array<{ id: string; depth: number }> = [];
|
||||
|
||||
// Find root nodes (nodes with no incoming temporal edges)
|
||||
const hasIncoming = new Set(temporalEdges.map(e => typeof e.target === 'string' ? e.target : e.target.id));
|
||||
nodesCopy.forEach(n => {
|
||||
if (!hasIncoming.has(n.id)) {
|
||||
queue.push({ id: n.id, depth: 0 });
|
||||
}
|
||||
});
|
||||
|
||||
while (queue.length > 0) {
|
||||
const { id, depth } = queue.shift()!;
|
||||
if (visited.has(id)) continue;
|
||||
visited.add(id);
|
||||
nodeDepth.set(id, depth);
|
||||
|
||||
// Find outgoing temporal edges
|
||||
temporalEdges.forEach(edge => {
|
||||
const sourceId = typeof edge.source === 'string' ? edge.source : edge.source.id;
|
||||
const targetId = typeof edge.target === 'string' ? edge.target : edge.target.id;
|
||||
if (sourceId === id) {
|
||||
queue.push({ id: targetId, depth: depth + 1 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const simulation = forceSimulation(nodesCopy)
|
||||
.force('charge', forceManyBody().strength(-200).distanceMax(1200)) // Minimal repulsion for very tight clustering
|
||||
.force('charge', forceManyBody().strength(-400).distanceMax(1200))
|
||||
.force('link', forceLink(edgesCopy)
|
||||
.id((d: any) => d.id)
|
||||
.distance(100) // Reduced from 250 to 100 for much closer nodes
|
||||
.strength(0.8) // Increased from 0.6 to pull nodes together more
|
||||
.iterations(3)
|
||||
)
|
||||
.force('center', forceCenter(width / 2, height / 2).strength(0.1)) // Increased from 0.05 for stronger centering
|
||||
.force('x', forceX(width / 2).strength(0.15)) // Increased for tighter clustering
|
||||
.force('y', forceY(height / 2).strength(0.15)) // Increased for tighter clustering
|
||||
.force('collision', forceCollide()
|
||||
.radius((d: any) => (d.symbolSize || 60) / 2 + 30) // Reduced padding from 80 to 30
|
||||
.strength(1.0)
|
||||
.distance(180)
|
||||
.strength(0.5)
|
||||
.iterations(2)
|
||||
)
|
||||
.velocityDecay(0.35)
|
||||
.force('x', forceX((d: any) => {
|
||||
// Position nodes left-to-right based on their depth, starting from extreme left
|
||||
const depth = nodeDepth.get(d.id) || 0;
|
||||
const maxDepth = Math.max(...Array.from(nodeDepth.values()));
|
||||
const leftMargin = 80; // Left margin to prevent nodes from touching the edge
|
||||
const rightMargin = 80; // Right margin
|
||||
const availableWidth = width - leftMargin - rightMargin;
|
||||
const spacing = maxDepth > 0 ? availableWidth / maxDepth : 0;
|
||||
return leftMargin + spacing * depth;
|
||||
}).strength(1.5)) // Strong horizontal positioning
|
||||
.force('y', forceY((d: any) => {
|
||||
// Stronger vertical centering for root nodes (depth 0)
|
||||
const depth = nodeDepth.get(d.id) || 0;
|
||||
return height / 2;
|
||||
}).strength((d: any) => {
|
||||
// Stronger centering for root nodes
|
||||
const depth = nodeDepth.get(d.id) || 0;
|
||||
return depth === 0 ? 0.8 : 0.1; // Much stronger centering for root nodes
|
||||
}))
|
||||
.force('collision', forceCollide()
|
||||
.radius((d: any) => (d.symbolSize || 60) / 2 + 50)
|
||||
.strength(1.0)
|
||||
.iterations(3)
|
||||
)
|
||||
.velocityDecay(0.4)
|
||||
.stop();
|
||||
|
||||
// Run simulation for 5000 iterations to stabilize
|
||||
@@ -143,8 +230,7 @@ export default defineComponent({
|
||||
};
|
||||
|
||||
const getNodeSize = (node: AlertNode): number => {
|
||||
const size = 30 + node.alert_count * 5;
|
||||
return Math.min(size, 100); // Cap at 100 to prevent oversized nodes
|
||||
return 60; // Fixed size for all nodes
|
||||
};
|
||||
|
||||
const chartData = computed(() => {
|
||||
@@ -156,7 +242,7 @@ export default defineComponent({
|
||||
|
||||
// Prepare nodes for D3-force simulation
|
||||
const preparedNodes = nodes.map((node, index) => ({
|
||||
name: `${node.alert_name}\n${node.service_name}`,
|
||||
name: node.alert_name, // Show only alert name for cleaner labels
|
||||
id: index.toString(),
|
||||
symbolSize: getNodeSize(node),
|
||||
originalNode: node,
|
||||
@@ -170,7 +256,7 @@ export default defineComponent({
|
||||
originalEdge: edge,
|
||||
}));
|
||||
|
||||
// Compute force-directed layout positions using D3 with smaller area for tighter clustering
|
||||
// Compute force-directed layout positions using D3 with left-to-right layout
|
||||
// Only compute if we don't have cached positions for these nodes
|
||||
let positionedNodes;
|
||||
const hasAllPositions = preparedNodes.every(n => nodePositions.value.has(n.id));
|
||||
@@ -183,8 +269,8 @@ export default defineComponent({
|
||||
y: nodePositions.value.get(n.id)!.y,
|
||||
}));
|
||||
} else {
|
||||
// Compute new positions and cache them
|
||||
positionedNodes = computeForceLayout(preparedNodes, preparedEdges, 300, 300);
|
||||
// Compute new positions and cache them with wider canvas for left-to-right layout
|
||||
positionedNodes = computeForceLayout(preparedNodes, preparedEdges, 1200, 400);
|
||||
positionedNodes.forEach((node: any) => {
|
||||
nodePositions.value.set(node.id, { x: node.x, y: node.y });
|
||||
});
|
||||
@@ -214,6 +300,11 @@ export default defineComponent({
|
||||
fontSize: 11,
|
||||
color: isDarkMode.value ? "#e5e7eb" : "#374151",
|
||||
formatter: `{b}`,
|
||||
backgroundColor: isDarkMode.value ? "rgba(31, 41, 55, 0.9)" : "rgba(255, 255, 255, 0.9)",
|
||||
borderRadius: 4,
|
||||
padding: [4, 8],
|
||||
shadowColor: "rgba(0, 0, 0, 0.3)",
|
||||
shadowBlur: 4,
|
||||
},
|
||||
tooltip: {
|
||||
formatter: () => {
|
||||
@@ -238,26 +329,62 @@ export default defineComponent({
|
||||
};
|
||||
});
|
||||
|
||||
const echartsEdges = edges.map((edge) => ({
|
||||
source: edge.from_node_index.toString(),
|
||||
target: edge.to_node_index.toString(),
|
||||
lineStyle: {
|
||||
color: edge.edge_type === "temporal"
|
||||
? (isDarkMode.value ? "#a78bfa" : "#8b5cf6") // purple for temporal
|
||||
: (isDarkMode.value ? "#6b7280" : "#9ca3af"), // gray for service dependency
|
||||
width: edge.edge_type === "temporal" ? 3 : 2,
|
||||
curveness: 0.2,
|
||||
type: edge.edge_type === "temporal" ? "dashed" : "solid",
|
||||
},
|
||||
symbol: ["none", "arrow"],
|
||||
symbolSize: [0, 10],
|
||||
label: {
|
||||
show: true,
|
||||
formatter: edge.edge_type === "temporal" ? "time →" : "",
|
||||
fontSize: 10,
|
||||
color: isDarkMode.value ? "#a78bfa" : "#8b5cf6",
|
||||
},
|
||||
}));
|
||||
const echartsEdges = edges.map((edge) => {
|
||||
const sourceNode = nodes[edge.from_node_index];
|
||||
const targetNode = nodes[edge.to_node_index];
|
||||
|
||||
return {
|
||||
source: edge.from_node_index.toString(),
|
||||
target: edge.to_node_index.toString(),
|
||||
lineStyle: {
|
||||
color: edge.edge_type === "temporal"
|
||||
? (isDarkMode.value ? "#a78bfa" : "#8b5cf6") // purple for temporal
|
||||
: (isDarkMode.value ? "#6b7280" : "#9ca3af"), // gray for service dependency
|
||||
width: edge.edge_type === "temporal" ? 3 : 2,
|
||||
curveness: 0.2,
|
||||
type: "solid",
|
||||
},
|
||||
symbol: ["none", "arrow"],
|
||||
symbolSize: [0, 10],
|
||||
label: {
|
||||
show: false, // Hide edge labels for cleaner visualization
|
||||
},
|
||||
tooltip: {
|
||||
formatter: () => {
|
||||
let html = `<div style="padding: 8px; font-size: 12px; text-align: center;">`;
|
||||
html += `<strong>${sourceNode.alert_name}</strong> <span style="color: #a78bfa;">→</span> <strong>${targetNode.alert_name}</strong><br/><br/>`;
|
||||
|
||||
if (edge.edge_type === "temporal") {
|
||||
const sourceTime = new Date(sourceNode.first_fired_at / 1000);
|
||||
const targetTime = new Date(targetNode.first_fired_at / 1000);
|
||||
const timeDiff = Math.abs(targetTime.getTime() - sourceTime.getTime());
|
||||
|
||||
// Format time difference
|
||||
const seconds = Math.floor(timeDiff / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
let timeStr = "";
|
||||
if (days > 0) timeStr = `${days}d ${hours % 24}h`;
|
||||
else if (hours > 0) timeStr = `${hours}h ${minutes % 60}m`;
|
||||
else if (minutes > 0) timeStr = `${minutes}m ${seconds % 60}s`;
|
||||
else timeStr = `${seconds}s`;
|
||||
|
||||
html += `<span style="color: #a78bfa;">⏱ Time difference: <strong>${timeStr}</strong></span><br/>`;
|
||||
html += `From: ${sourceTime.toLocaleString()}<br/>`;
|
||||
html += `To: ${targetTime.toLocaleString()}<br/>`;
|
||||
html += `<br/><span style="color: #a78bfa;">Temporal correlation</span>`;
|
||||
} else {
|
||||
html += `<span style="color: #9ca3af;">Service dependency</span>`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
return html;
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const options = {
|
||||
tooltip: {
|
||||
@@ -334,6 +461,18 @@ export default defineComponent({
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.info-icon-btn {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 10;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.info-icon-btn:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Light mode */
|
||||
.incident-service-graph {
|
||||
background: linear-gradient(135deg, #f9fafb 0%, #ffffff 100%);
|
||||
|
||||
@@ -158,6 +158,13 @@ describe("useMetricsCorrelationDashboard", () => {
|
||||
});
|
||||
|
||||
it("should use source stream when coming from logs page", () => {
|
||||
const logStreams: StreamInfo[] = [
|
||||
{
|
||||
stream_name: "default",
|
||||
filters: { service_name: "api", env: "prod" },
|
||||
},
|
||||
];
|
||||
|
||||
const config: MetricsCorrelationConfig = {
|
||||
serviceName: "api-server",
|
||||
matchedDimensions: { service: "api", environment: "prod" },
|
||||
@@ -176,17 +183,17 @@ describe("useMetricsCorrelationDashboard", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const dashboard = composable.generateLogsDashboard([], config);
|
||||
const dashboard = composable.generateLogsDashboard(logStreams, config);
|
||||
|
||||
expect(dashboard).toBeDefined();
|
||||
expect(dashboard!.tabs[0].panels[0].queries[0].fields.stream).toBe("default");
|
||||
|
||||
// Should only use matched dimension values, not all available fields
|
||||
// Should only use matched dimension values from API filters
|
||||
const query = dashboard!.tabs[0].panels[0].queries[0].query;
|
||||
expect(query).toContain('FROM "default"');
|
||||
expect(query).toContain("service_name = 'api'");
|
||||
expect(query).toContain("env = 'prod'");
|
||||
expect(query).not.toContain("host = 'server01'"); // Not a matched dimension
|
||||
expect(query).not.toContain("host = 'server01'"); // Not in API filters
|
||||
});
|
||||
|
||||
it("should use correlated log streams from API", () => {
|
||||
@@ -234,6 +241,18 @@ describe("useMetricsCorrelationDashboard", () => {
|
||||
});
|
||||
|
||||
it("should filter out non-string values from filters", () => {
|
||||
const logStreams: StreamInfo[] = [
|
||||
{
|
||||
stream_name: "default",
|
||||
filters: {
|
||||
service_name: "api",
|
||||
port: 8080 as any, // Non-string value
|
||||
enabled: true as any, // Non-string value
|
||||
_timestamp: 123456 as any, // Internal field
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const config: MetricsCorrelationConfig = {
|
||||
serviceName: "api-server",
|
||||
matchedDimensions: { service: "api" },
|
||||
@@ -245,15 +264,9 @@ describe("useMetricsCorrelationDashboard", () => {
|
||||
},
|
||||
sourceStream: "default",
|
||||
sourceType: "logs",
|
||||
availableDimensions: {
|
||||
service_name: "api",
|
||||
port: 8080 as any, // Non-string value
|
||||
enabled: true as any, // Non-string value
|
||||
_timestamp: 123456 as any, // Internal field
|
||||
},
|
||||
};
|
||||
|
||||
const dashboard = composable.generateLogsDashboard([], config);
|
||||
const dashboard = composable.generateLogsDashboard(logStreams, config);
|
||||
|
||||
const query = dashboard!.tabs[0].panels[0].queries[0].query;
|
||||
// Should only include service_name (string, non-internal) in WHERE clause
|
||||
|
||||
@@ -279,57 +279,28 @@ ORDER BY x_axis_1`;
|
||||
streams: StreamInfo[],
|
||||
config: MetricsCorrelationConfig
|
||||
) => {
|
||||
// When coming from logs page, use the source stream (the one user was viewing)
|
||||
// Otherwise, use the first correlated log stream from API
|
||||
// Determine stream and filters based on available data
|
||||
let streamName: string;
|
||||
let filters: Record<string, string>;
|
||||
|
||||
if (config.sourceType === "logs" && config.sourceStream) {
|
||||
// Use the original logs stream user was viewing
|
||||
// When viewing from logs page, prefer source stream
|
||||
streamName = config.sourceStream;
|
||||
// Use only the matched dimensions from availableDimensions
|
||||
// Extract only the fields that correspond to matched dimension keys
|
||||
filters = {};
|
||||
|
||||
if (config.availableDimensions && config.matchedDimensions) {
|
||||
// Build a reverse mapping: value -> field name from availableDimensions
|
||||
const valueToFieldMap = new Map<string, string>();
|
||||
for (const [fieldName, fieldValue] of Object.entries(config.availableDimensions)) {
|
||||
if (typeof fieldValue === 'string') {
|
||||
valueToFieldMap.set(String(fieldValue), fieldName);
|
||||
}
|
||||
}
|
||||
|
||||
// For each matched dimension, find the actual field name
|
||||
for (const [semanticKey, value] of Object.entries(config.matchedDimensions)) {
|
||||
if (value === "_o2_all_") {
|
||||
// For _o2_all_, we need to find the field name by matching against the semantic key
|
||||
// Try exact match first with underscores
|
||||
const normalizedKey = semanticKey.replace(/-/g, '_');
|
||||
if (config.availableDimensions[normalizedKey] !== undefined) {
|
||||
filters[normalizedKey] = "_o2_all_";
|
||||
} else {
|
||||
// Try to find by partial match
|
||||
for (const fieldName of Object.keys(config.availableDimensions)) {
|
||||
if (fieldName.toLowerCase() === normalizedKey.toLowerCase()) {
|
||||
filters[fieldName] = "_o2_all_";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For actual values, use the reverse mapping
|
||||
const fieldName = valueToFieldMap.get(value);
|
||||
if (fieldName) {
|
||||
filters[fieldName] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Try to find matching stream in API response
|
||||
const matchingStream = streams?.find(s => s.stream_name === config.sourceStream);
|
||||
if (matchingStream) {
|
||||
// Use filters from API response (best case - backend computed correct field names)
|
||||
filters = matchingStream.filters;
|
||||
} else if (streams && streams.length > 0) {
|
||||
// Source stream not in response, use first available stream's filters
|
||||
filters = streams[0].filters;
|
||||
} else {
|
||||
filters = config.matchedDimensions;
|
||||
// No streams from API, fallback to matched dimensions
|
||||
filters = config.matchedDimensions || {};
|
||||
}
|
||||
} else if (streams && streams.length > 0) {
|
||||
// Use correlated log streams from API response
|
||||
// Use first correlated log stream from API response
|
||||
const primaryStream = streams[0];
|
||||
streamName = primaryStream.stream_name;
|
||||
filters = primaryStream.filters;
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
<q-select
|
||||
v-model="streamFilter"
|
||||
:options="
|
||||
availableStreams.length > 0
|
||||
searchObj.data.stream.streamLists && searchObj.data.stream.streamLists.length > 0
|
||||
? [
|
||||
{ label: 'All Streams', value: 'all' },
|
||||
...availableStreams.map((s) => ({ label: s, value: s })),
|
||||
...searchObj.data.stream.streamLists,
|
||||
]
|
||||
: [{ label: 'All Streams', value: 'all' }]
|
||||
"
|
||||
@@ -21,14 +21,13 @@
|
||||
map-options
|
||||
class="tw:w-[180px] tw:flex-shrink-0"
|
||||
@update:model-value="onStreamFilterChange"
|
||||
:disable="availableStreams.length === 0"
|
||||
:disable="!searchObj.data.stream.streamLists || searchObj.data.stream.streamLists.length === 0"
|
||||
>
|
||||
<template #prepend>
|
||||
<q-icon name="storage" size="xs" />
|
||||
</template>
|
||||
<q-tooltip v-if="availableStreams.length === 0">
|
||||
No streams detected. Ensure service graph metrics include
|
||||
stream_name labels.
|
||||
<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>
|
||||
|
||||
@@ -54,12 +53,12 @@
|
||||
<date-time
|
||||
ref="dateTimeRef"
|
||||
auto-apply
|
||||
:default-type="timeRange.type"
|
||||
:default-type="searchObj.data.datetime.type"
|
||||
:default-absolute-time="{
|
||||
startTime: timeRange.startTime,
|
||||
endTime: timeRange.endTime,
|
||||
startTime: searchObj.data.datetime.startTime,
|
||||
endTime: searchObj.data.datetime.endTime,
|
||||
}"
|
||||
:default-relative-time="timeRange.relativeTimePeriod"
|
||||
:default-relative-time="searchObj.data.datetime.relativeTimePeriod"
|
||||
data-test="service-graph-date-time-picker"
|
||||
class="tw:h-[2rem]"
|
||||
@on:date-change="updateTimeRange"
|
||||
@@ -166,59 +165,7 @@
|
||||
class="text-weight-medium q-mb-sm"
|
||||
style="font-size: 1rem"
|
||||
>
|
||||
Service graph is disabled
|
||||
</div>
|
||||
<div>
|
||||
Enable the feature by setting environment variable:
|
||||
</div>
|
||||
<code
|
||||
class="q-mt-xs"
|
||||
style="
|
||||
background: rgba(var(--q-primary-rgb), 0.1);
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--q-primary);
|
||||
font-weight: 600;
|
||||
"
|
||||
>O2_SERVICE_GRAPH_ENABLED=true</code
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="q-pa-md q-mb-md"
|
||||
style="
|
||||
background: rgba(var(--q-primary-rgb), 0.05);
|
||||
border-radius: 6px;
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="text-weight-medium q-mb-sm"
|
||||
style="font-size: 1rem"
|
||||
>
|
||||
Query time range is too small
|
||||
</div>
|
||||
<div>
|
||||
The daemon queries traces within a time window. If your
|
||||
traces are older, increase the window:
|
||||
</div>
|
||||
<code
|
||||
class="q-mt-xs"
|
||||
style="
|
||||
background: rgba(var(--q-primary-rgb), 0.1);
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--q-primary);
|
||||
font-weight: 600;
|
||||
"
|
||||
>O2_SERVICE_GRAPH_QUERY_TIME_RANGE_MINUTES=120</code
|
||||
>
|
||||
<div
|
||||
class="text-caption q-mt-sm"
|
||||
style="color: #666; font-size: 0.85rem"
|
||||
>
|
||||
(Default: 60 minutes)
|
||||
Queried time range has no traces, try selecting a longer duration.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -233,74 +180,12 @@
|
||||
class="text-weight-medium q-mb-sm"
|
||||
style="font-size: 1rem"
|
||||
>
|
||||
Only INTERNAL spans detected
|
||||
</div>
|
||||
<div>
|
||||
Your traces have
|
||||
<code
|
||||
style="
|
||||
background: rgba(var(--q-primary-rgb), 0.1);
|
||||
padding: 3px 7px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--q-primary);
|
||||
font-weight: 600;
|
||||
"
|
||||
>span_kind=1</code
|
||||
>
|
||||
(INTERNAL operations within a service).
|
||||
Maybe your traces have only internal spans (<code>span_kind = 1</code>)?
|
||||
</div>
|
||||
<div class="q-mt-sm">
|
||||
To create service-to-service edges, send traces with:
|
||||
</div>
|
||||
<ul
|
||||
class="q-pl-md q-mt-sm q-mb-sm"
|
||||
style="line-height: 1.6"
|
||||
>
|
||||
<li>
|
||||
<strong>CLIENT spans</strong> (<code
|
||||
style="
|
||||
background: rgba(var(--q-primary-rgb), 0.1);
|
||||
color: var(--q-primary);
|
||||
font-weight: 600;
|
||||
"
|
||||
>span_kind=3</code
|
||||
>) with
|
||||
<code
|
||||
style="
|
||||
background: rgba(var(--q-primary-rgb), 0.1);
|
||||
color: var(--q-primary);
|
||||
font-weight: 600;
|
||||
"
|
||||
>peer.service</code
|
||||
>
|
||||
attribute, or
|
||||
</li>
|
||||
<li>
|
||||
<strong>SERVER spans</strong> (<code
|
||||
style="
|
||||
background: rgba(var(--q-primary-rgb), 0.1);
|
||||
color: var(--q-primary);
|
||||
font-weight: 600;
|
||||
"
|
||||
>span_kind=2</code
|
||||
>) receiving requests from other services
|
||||
</li>
|
||||
</ul>
|
||||
<div
|
||||
class="text-caption q-mt-sm"
|
||||
style="color: #666; font-size: 0.85rem"
|
||||
>
|
||||
Note: INTERNAL spans can be excluded by setting
|
||||
<code
|
||||
style="
|
||||
background: rgba(var(--q-primary-rgb), 0.1);
|
||||
color: var(--q-primary);
|
||||
font-weight: 600;
|
||||
"
|
||||
>O2_SERVICE_GRAPH_EXCLUDE_INTERNAL_SPANS=true</code
|
||||
>
|
||||
Make sure <code>O2_SERVICE_GRAPH_EXCLUDE_INTERNAL_SPANS=true</code>
|
||||
</div>
|
||||
<div>to process internal spans for service edges.</div>
|
||||
</div>
|
||||
</div>
|
||||
<q-btn
|
||||
@@ -379,6 +264,7 @@ import {
|
||||
convertServiceGraphToNetwork,
|
||||
} from "@/utils/traces/convertTraceData";
|
||||
import useStreams from "@/composables/useStreams";
|
||||
import useTraces from "@/composables/useTraces";
|
||||
|
||||
export default defineComponent({
|
||||
name: "ServiceGraph",
|
||||
@@ -390,6 +276,7 @@ export default defineComponent({
|
||||
setup() {
|
||||
const store = useStore();
|
||||
const { getStreams } = useStreams();
|
||||
const { searchObj } = useTraces();
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
@@ -416,12 +303,20 @@ export default defineComponent({
|
||||
const searchFilter = ref("");
|
||||
const connectionTypeFilter = ref("all");
|
||||
|
||||
// Stream filter
|
||||
const storedStreamFilter = localStorage.getItem(
|
||||
"serviceGraph_streamFilter",
|
||||
);
|
||||
const streamFilter = ref(storedStreamFilter || "all");
|
||||
const availableStreams = ref<string[]>([]);
|
||||
// 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 = [
|
||||
@@ -445,13 +340,8 @@ export default defineComponent({
|
||||
|
||||
const stats = ref<any>(null);
|
||||
|
||||
// Time range state - default to last 15 minutes
|
||||
const timeRange = ref({
|
||||
type: "relative",
|
||||
relativeTimePeriod: "15m",
|
||||
startTime: Date.now() * 1000 - 15 * 60 * 1000000, // 15 minutes ago in microseconds
|
||||
endTime: Date.now() * 1000, // Now in microseconds
|
||||
});
|
||||
// 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);
|
||||
|
||||
@@ -538,27 +428,7 @@ export default defineComponent({
|
||||
throw new Error("No organization selected");
|
||||
}
|
||||
|
||||
// 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: timeRange.value.startTime,
|
||||
endTime: timeRange.value.endTime,
|
||||
});
|
||||
if (
|
||||
allStreamsResponse.data.availableStreams &&
|
||||
allStreamsResponse.data.availableStreams.length > 0
|
||||
) {
|
||||
availableStreams.value = allStreamsResponse.data.availableStreams;
|
||||
}
|
||||
}
|
||||
// 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
|
||||
@@ -567,8 +437,8 @@ export default defineComponent({
|
||||
streamFilter.value && streamFilter.value !== "all"
|
||||
? streamFilter.value
|
||||
: undefined,
|
||||
startTime: timeRange.value.startTime,
|
||||
endTime: timeRange.value.endTime,
|
||||
startTime: searchObj.data.datetime.startTime,
|
||||
endTime: searchObj.data.datetime.endTime,
|
||||
});
|
||||
|
||||
// Convert API response to expected format
|
||||
@@ -767,7 +637,11 @@ export default defineComponent({
|
||||
};
|
||||
|
||||
const onStreamFilterChange = (stream: string) => {
|
||||
streamFilter.value = stream;
|
||||
// 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
|
||||
@@ -845,31 +719,25 @@ export default defineComponent({
|
||||
};
|
||||
|
||||
const updateTimeRange = (value: any) => {
|
||||
timeRange.value = {
|
||||
// Update shared datetime state
|
||||
searchObj.data.datetime = {
|
||||
startTime: value.startTime,
|
||||
endTime: value.endTime,
|
||||
relativeTimePeriod:
|
||||
value.relativeTimePeriod || timeRange.value.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
|
||||
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);
|
||||
}
|
||||
};
|
||||
// Streams are loaded by parent Index.vue into searchObj.data.stream.streamLists
|
||||
// No need to load them again here
|
||||
|
||||
onMounted(async () => {
|
||||
await loadTraceStreams();
|
||||
onMounted(() => {
|
||||
loadServiceGraph();
|
||||
});
|
||||
|
||||
@@ -883,7 +751,6 @@ export default defineComponent({
|
||||
lastUpdated,
|
||||
searchFilter,
|
||||
streamFilter,
|
||||
availableStreams,
|
||||
connectionTypeFilter,
|
||||
connectionTypeTabs,
|
||||
visualizationType,
|
||||
@@ -892,7 +759,7 @@ export default defineComponent({
|
||||
chartData,
|
||||
chartKey,
|
||||
chartRendererRef,
|
||||
timeRange,
|
||||
searchObj,
|
||||
dateTimeRef,
|
||||
loadServiceGraph,
|
||||
formatNumber,
|
||||
@@ -934,5 +801,7 @@ code {
|
||||
border-radius: 3px;
|
||||
font-family: "Courier New", monospace;
|
||||
font-size: 0.9em;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -246,25 +246,36 @@ export const convertServiceGraphToTree = (
|
||||
.map((edge: any) => buildTree(edge.to, new Set(visited), edge))
|
||||
.filter((child: any) => child !== null);
|
||||
|
||||
// Calculate node metrics - direction-aware based on tree position
|
||||
// Simple: show only incoming requests from parent in this tree path
|
||||
let totalRequests: number;
|
||||
let failedRequests: number;
|
||||
let errorRate: number;
|
||||
|
||||
if (incomingEdge) {
|
||||
// Child node: use the incoming edge's metrics (traffic via this specific edge)
|
||||
// Non-root: show incoming from parent
|
||||
totalRequests = incomingEdge.total_requests ?? 0;
|
||||
failedRequests = incomingEdge.failed_requests ?? 0;
|
||||
errorRate = incomingEdge.error_rate ?? 0;
|
||||
} else {
|
||||
// Root node: sum of all outgoing edges (traffic flowing from this node)
|
||||
// Root: sum of outgoing edges
|
||||
totalRequests = outgoingEdges.reduce((sum: number, edge: any) => sum + (edge.total_requests ?? 0), 0);
|
||||
failedRequests = outgoingEdges.reduce((sum: number, edge: any) => sum + (edge.failed_requests ?? 0), 0);
|
||||
// If no outgoing edges, fall back to node's own error_rate
|
||||
errorRate = totalRequests > 0 ? (failedRequests / totalRequests) * 100 : (node.error_rate ?? 0);
|
||||
|
||||
// If no edges, use node's own metrics
|
||||
if (totalRequests === 0 && node.requests !== undefined) {
|
||||
totalRequests = node.requests;
|
||||
failedRequests = node.errors ?? 0;
|
||||
}
|
||||
|
||||
errorRate = totalRequests > 0 ? (failedRequests / totalRequests) * 100 : 0;
|
||||
|
||||
// If still no data, try node.error_rate directly
|
||||
if (errorRate === 0 && node.error_rate !== undefined) {
|
||||
errorRate = node.error_rate;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate connections count (incoming + outgoing edges)
|
||||
// Calculate connections count
|
||||
const incomingEdges = incomingEdgesMap.get(nodeId) || [];
|
||||
const connectionCount = incomingEdges.length + outgoingEdges.length;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user