mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 16:09:05 +00:00
feat: add service graph as enterprise-only feature
Service graph automatically discovers service topology from distributed traces. OSS includes: - API routing endpoints (gated with enterprise feature flag) - UI components (functional only with enterprise backend) - Configuration options Enterprise includes: - 256-shard concurrent edge store with LRU eviction - Span matching and processing logic - Prometheus metrics and background workers
This commit is contained in:
@@ -35,3 +35,4 @@ tests/ui-testing/playwright-tests/utils/auth/user.json
|
||||
/tests/ui-testing/utils/test-mapping/archived-utils
|
||||
/tests/ui-testing/playwright-results
|
||||
/tests/ui-testing/testDinoPOC/MD Files
|
||||
*.bak
|
||||
|
||||
Generated
+2
-2
@@ -9491,9 +9491,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.13.2"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
@@ -496,6 +496,7 @@ pub struct Config {
|
||||
pub health_check: HealthCheck,
|
||||
pub encryption: Encryption,
|
||||
pub enrichment_table: EnrichmentTable,
|
||||
pub service_graph: ServiceGraph,
|
||||
}
|
||||
|
||||
#[derive(EnvConfig, Default)]
|
||||
@@ -2115,6 +2116,38 @@ pub struct EnrichmentTable {
|
||||
pub merge_interval: u64,
|
||||
}
|
||||
|
||||
/// Service Graph Configuration
|
||||
///
|
||||
/// Note: Worker count is fixed at 256 (one per shard) for optimal concurrency.
|
||||
/// No async channels are used in the current implementation.
|
||||
#[derive(EnvConfig, Default)]
|
||||
pub struct ServiceGraph {
|
||||
#[env_config(
|
||||
name = "ZO_SGRAPH_ENABLED",
|
||||
default = false,
|
||||
help = "Enable service graph feature"
|
||||
)]
|
||||
pub enabled: bool,
|
||||
#[env_config(
|
||||
name = "ZO_SGRAPH_WAIT_DURATION_MS",
|
||||
default = 10000,
|
||||
help = "Wait duration for span pairing in milliseconds"
|
||||
)]
|
||||
pub wait_duration_ms: u64,
|
||||
#[env_config(
|
||||
name = "ZO_SGRAPH_MAX_ITEMS_PER_SHARD",
|
||||
default = 100000,
|
||||
help = "Maximum items per shard in edge store (256 shards total)"
|
||||
)]
|
||||
pub max_items_per_shard: usize,
|
||||
#[env_config(
|
||||
name = "ZO_SGRAPH_CLEANUP_INTERVAL_MS",
|
||||
default = 2000,
|
||||
help = "Cleanup interval for expired edges in milliseconds"
|
||||
)]
|
||||
pub cleanup_interval_ms: u64,
|
||||
}
|
||||
|
||||
pub fn init() -> Config {
|
||||
if let Err(e) = load_config() {
|
||||
log::error!("Failed to load config {e}");
|
||||
|
||||
@@ -26,6 +26,9 @@ use hashbrown::HashMap;
|
||||
use serde::Serialize;
|
||||
use tracing::{Instrument, Span};
|
||||
|
||||
// Re-export service graph API handlers
|
||||
#[cfg(feature = "enterprise")]
|
||||
pub use crate::service::traces::service_graph::{get_service_graph_metrics, get_store_stats};
|
||||
use crate::{
|
||||
common::{
|
||||
meta::{self, http::HttpResponse as MetaHttpResponse},
|
||||
|
||||
@@ -612,7 +612,9 @@ pub fn get_service_routes(svc: &mut web::ServiceConfig) {
|
||||
.service(re_pattern::delete)
|
||||
.service(re_pattern::test)
|
||||
.service(domain_management::get_domain_management_config)
|
||||
.service(domain_management::set_domain_management_config);
|
||||
.service(domain_management::set_domain_management_config)
|
||||
.service(traces::get_service_graph_metrics)
|
||||
.service(traces::get_store_stats);
|
||||
|
||||
#[cfg(feature = "cloud")]
|
||||
let service = service
|
||||
|
||||
@@ -309,6 +309,13 @@ async fn main() -> Result<(), anyhow::Error> {
|
||||
panic!("job init failed: {e}");
|
||||
}
|
||||
|
||||
// init service graph workers
|
||||
#[cfg(feature = "enterprise")]
|
||||
if cfg.service_graph.enabled {
|
||||
log::info!("Initializing service graph background workers");
|
||||
openobserve::service::traces::service_graph::init_background_workers();
|
||||
}
|
||||
|
||||
// Register job runtime for metrics collection
|
||||
if let Ok(handle) = tokio::runtime::Handle::try_current() {
|
||||
openobserve::service::runtime_metrics::register_runtime("job".to_string(), handle);
|
||||
|
||||
@@ -41,6 +41,9 @@ use opentelemetry_proto::tonic::{
|
||||
use prost::Message;
|
||||
use serde_json::Map;
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
pub mod service_graph;
|
||||
|
||||
#[cfg(feature = "cloud")]
|
||||
use crate::service::stream::get_stream;
|
||||
use crate::{
|
||||
@@ -343,22 +346,65 @@ pub async fn handle_otlp_request(
|
||||
}
|
||||
let local_val = Span {
|
||||
trace_id: trace_id.clone(),
|
||||
span_id,
|
||||
span_id: span_id.clone(),
|
||||
span_kind: span.kind.to_string(),
|
||||
span_status: get_span_status(span.status),
|
||||
span_status: get_span_status(span.status.clone()),
|
||||
operation_name: span.name.clone(),
|
||||
start_time,
|
||||
end_time,
|
||||
duration: (end_time - start_time) / 1000, // microseconds
|
||||
reference: span_ref,
|
||||
reference: span_ref.clone(),
|
||||
service_name: service_name.clone(),
|
||||
attributes: span_att_map,
|
||||
attributes: span_att_map.clone(),
|
||||
service: service_att_map.clone(),
|
||||
flags: 1, // TODO add appropriate value
|
||||
events: json::to_string(&events).unwrap(),
|
||||
links: json::to_string(&links).unwrap(),
|
||||
};
|
||||
|
||||
// Process span for service graph if enabled
|
||||
#[cfg(feature = "enterprise")]
|
||||
if cfg.service_graph.enabled {
|
||||
// Wrap in catch_unwind to prevent panics from crashing trace ingestion
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
log::trace!(
|
||||
"[ServiceGraph] Processing span: trace_id={}, span_id={}",
|
||||
trace_id,
|
||||
span_id
|
||||
);
|
||||
let parent_span_id_opt =
|
||||
span_ref.get(PARENT_SPAN_ID).map(|s| Arc::from(s.as_str()));
|
||||
// Convert HashMap to json::Map
|
||||
let mut attrs_map = json::Map::new();
|
||||
for (k, v) in &span_att_map {
|
||||
attrs_map.insert(k.clone(), v.clone());
|
||||
}
|
||||
let graph_span = service_graph::span_to_graph_span(
|
||||
Arc::from(trace_id.as_str()),
|
||||
Arc::from(span_id.as_str()),
|
||||
parent_span_id_opt,
|
||||
Arc::from(service_name.as_str()),
|
||||
&local_val.span_kind,
|
||||
start_time,
|
||||
end_time,
|
||||
&local_val.span_status,
|
||||
&attrs_map,
|
||||
);
|
||||
service_graph::process_span(org_id, graph_span);
|
||||
}));
|
||||
|
||||
if let Err(e) = result {
|
||||
log::error!(
|
||||
"[ServiceGraph] Panic caught during span processing: {:?}",
|
||||
e
|
||||
);
|
||||
// Increment error metric
|
||||
service_graph::SERVICE_GRAPH_DROPPED_SPANS
|
||||
.with_label_values(&[org_id])
|
||||
.inc();
|
||||
}
|
||||
}
|
||||
|
||||
let mut value: json::Value = json::to_value(local_val).unwrap();
|
||||
// add timestamp
|
||||
value.as_object_mut().unwrap().insert(
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// Copyright 2025 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/>.
|
||||
|
||||
use actix_web::{HttpResponse, web};
|
||||
use config::utils::json;
|
||||
use prometheus::{Encoder, TextEncoder};
|
||||
use serde::Deserialize;
|
||||
|
||||
/// Query parameters for service graph API
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ServiceGraphQuery {
|
||||
pub start_time: Option<i64>,
|
||||
pub end_time: Option<i64>,
|
||||
pub filter: Option<String>,
|
||||
}
|
||||
|
||||
/// Handler for service graph metrics endpoint (Prometheus format)
|
||||
///
|
||||
/// Returns Prometheus metrics filtered by org_id.
|
||||
/// Metrics are filtered by the `org_id` label to ensure multi-tenant isolation.
|
||||
#[actix_web::get("/{org_id}/traces/service_graph/metrics")]
|
||||
pub async fn get_service_graph_metrics(
|
||||
org_id: web::Path<String>,
|
||||
) -> Result<HttpResponse, actix_web::Error> {
|
||||
let metric_families = prometheus::gather();
|
||||
let encoder = TextEncoder::new();
|
||||
let mut buffer = vec![];
|
||||
|
||||
// Filter metrics to only include those matching the requested org_id
|
||||
let filtered_families: Vec<_> = metric_families
|
||||
.into_iter()
|
||||
.filter_map(|mut family| {
|
||||
// Filter out metrics that don't belong to this org
|
||||
let filtered_metrics: Vec<_> = family
|
||||
.take_metric()
|
||||
.into_iter()
|
||||
.filter(|metric| {
|
||||
// Check if metric has org_id label matching the requested org
|
||||
metric
|
||||
.get_label()
|
||||
.iter()
|
||||
.any(|label| label.name() == "org_id" && label.value() == org_id.as_str())
|
||||
})
|
||||
.collect();
|
||||
|
||||
if filtered_metrics.is_empty() {
|
||||
None
|
||||
} else {
|
||||
family.set_metric(filtered_metrics);
|
||||
Some(family)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
encoder.encode(&filtered_families, &mut buffer).unwrap();
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("text/plain; version=0.0.4")
|
||||
.body(buffer))
|
||||
}
|
||||
|
||||
/// Get service graph store statistics
|
||||
///
|
||||
/// Returns statistics filtered by org_id to ensure multi-tenant isolation.
|
||||
#[actix_web::get("/{org_id}/traces/service_graph/stats")]
|
||||
pub async fn get_store_stats(org_id: web::Path<String>) -> Result<HttpResponse, actix_web::Error> {
|
||||
let store = o2_enterprise::enterprise::service_graph::get_store();
|
||||
let org_sizes = store.get_org_sizes();
|
||||
let org_capacities = store.get_org_capacity_utilization();
|
||||
|
||||
// Get stats for the requested org only
|
||||
let org_store_size = org_sizes.get(org_id.as_str()).copied().unwrap_or(0);
|
||||
let org_capacity_util = org_capacities.get(org_id.as_str()).copied().unwrap_or(0.0);
|
||||
|
||||
let stats = json::json!({
|
||||
"enabled": config::get_config().service_graph.enabled,
|
||||
"store_size": org_store_size,
|
||||
"capacity_utilization_percent": org_capacity_util,
|
||||
"shard_count": store.shard_count(),
|
||||
"config": {
|
||||
"max_items_per_shard": store.config().max_items_per_shard,
|
||||
"total_capacity": store.config().max_items_per_shard * store.shard_count(),
|
||||
"wait_duration_ms": store.config().wait_duration.as_millis(),
|
||||
}
|
||||
});
|
||||
|
||||
Ok(HttpResponse::Ok().json(stats))
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright 2025 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/>.
|
||||
|
||||
//! Service Graph Module
|
||||
//!
|
||||
//! This module provides API endpoints for the service graph feature.
|
||||
//! The core logic is implemented in the enterprise repository.
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
pub mod api;
|
||||
|
||||
// Re-export API handlers for routing
|
||||
#[cfg(feature = "enterprise")]
|
||||
pub use api::{get_service_graph_metrics, get_store_stats};
|
||||
// Re-export enterprise types and functions for internal use
|
||||
#[cfg(feature = "enterprise")]
|
||||
pub use o2_enterprise::enterprise::service_graph::{
|
||||
ConnectionType, SERVICE_GRAPH_DROPPED_SPANS, SpanForGraph, SpanKind, init_background_workers,
|
||||
process_span, shutdown_workers, span_to_graph_span,
|
||||
};
|
||||
@@ -30,6 +30,7 @@ const AppMetrics = () => import("@/plugins/metrics/Index.vue");
|
||||
const AppTraces = () => import("@/plugins/traces/Index.vue");
|
||||
|
||||
const TraceDetails = () => import("@/plugins/traces/TraceDetails.vue");
|
||||
const ServiceGraph = () => import("@/plugins/traces/ServiceGraph.vue");
|
||||
|
||||
const ViewDashboard = () => import("@/views/Dashboards/ViewDashboard.vue");
|
||||
const AddPanel = () => import("@/views/Dashboards/addPanel/AddPanel.vue");
|
||||
@@ -163,6 +164,18 @@ const useRoutes = () => {
|
||||
routeGuard(to, from, next);
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "service-graph",
|
||||
name: "serviceGraph",
|
||||
component: ServiceGraph,
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
title: "Service Graph",
|
||||
},
|
||||
beforeEnter(to: any, from: any, next: any) {
|
||||
routeGuard(to, from, next);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "streamExplorer",
|
||||
path: "streams/stream-explore",
|
||||
|
||||
@@ -631,6 +631,7 @@ import {
|
||||
outlinedDescription,
|
||||
outlinedCode,
|
||||
outlinedDevices,
|
||||
outlinedBubbleChart,
|
||||
} from "@quasar/extras/material-icons-outlined";
|
||||
import SlackIcon from "@/components/icons/SlackIcon.vue";
|
||||
import ManagementIcon from "@/components/icons/ManagementIcon.vue";
|
||||
@@ -806,6 +807,12 @@ export default defineComponent({
|
||||
link: "/traces",
|
||||
name: "traces",
|
||||
},
|
||||
{
|
||||
title: "Service Graph",
|
||||
icon: outlinedBubbleChart,
|
||||
link: "/service-graph",
|
||||
name: "serviceGraph",
|
||||
},
|
||||
{
|
||||
title: t("menu.rum"),
|
||||
icon: outlinedDevices,
|
||||
|
||||
@@ -0,0 +1,993 @@
|
||||
<template>
|
||||
<div class="service-graph-container">
|
||||
<q-card flat class="full-height">
|
||||
<q-card-section class="q-pa-md">
|
||||
<div class="row items-center justify-between q-mb-md">
|
||||
<div class="col">
|
||||
<div class="text-h5 text-bold">Service Graph</div>
|
||||
<div class="text-subtitle2 text-grey-7">
|
||||
Visualize service dependencies and communication patterns from distributed traces
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto row q-gutter-sm">
|
||||
<q-btn
|
||||
outline
|
||||
dense
|
||||
color="primary"
|
||||
icon="refresh"
|
||||
label="Refresh"
|
||||
@click="loadServiceGraph"
|
||||
:loading="loading"
|
||||
/>
|
||||
<q-btn-dropdown
|
||||
outline
|
||||
dense
|
||||
color="primary"
|
||||
icon="tune"
|
||||
label="Layout"
|
||||
>
|
||||
<q-list>
|
||||
<q-item clickable v-close-popup @click="setLayout('hierarchical')">
|
||||
<q-item-section>
|
||||
<q-item-label>Hierarchical</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item clickable v-close-popup @click="setLayout('force')">
|
||||
<q-item-section>
|
||||
<q-item-label>Force-Directed</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item clickable v-close-popup @click="setLayout('circular')">
|
||||
<q-item-section>
|
||||
<q-item-label>Circular</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-btn-dropdown>
|
||||
<q-btn
|
||||
outline
|
||||
dense
|
||||
color="primary"
|
||||
icon="settings"
|
||||
@click="showSettings = true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Enhanced Stats Bar -->
|
||||
<div class="row q-col-gutter-md q-mb-md" v-if="stats">
|
||||
<div class="col-12 col-md-2">
|
||||
<q-card flat bordered class="stat-card">
|
||||
<q-card-section class="q-pa-md">
|
||||
<div class="row items-center">
|
||||
<q-icon name="hub" size="32px" color="primary" class="q-mr-sm" />
|
||||
<div>
|
||||
<div class="text-caption text-grey-7">Services</div>
|
||||
<div class="text-h5 text-bold">{{ stats.services || 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
<div class="col-12 col-md-2">
|
||||
<q-card flat bordered class="stat-card">
|
||||
<q-card-section class="q-pa-md">
|
||||
<div class="row items-center">
|
||||
<q-icon name="share" size="32px" color="blue" class="q-mr-sm" />
|
||||
<div>
|
||||
<div class="text-caption text-grey-7">Connections</div>
|
||||
<div class="text-h5 text-bold">{{ stats.connections || 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
<div class="col-12 col-md-3">
|
||||
<q-card flat bordered class="stat-card">
|
||||
<q-card-section class="q-pa-md">
|
||||
<div class="row items-center">
|
||||
<q-icon name="speed" size="32px" color="orange" class="q-mr-sm" />
|
||||
<div>
|
||||
<div class="text-caption text-grey-7">Total Requests</div>
|
||||
<div class="text-h5 text-bold">{{ formatNumber(stats.totalRequests || 0) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
<div class="col-12 col-md-3">
|
||||
<q-card flat bordered class="stat-card">
|
||||
<q-card-section class="q-pa-md">
|
||||
<div class="row items-center">
|
||||
<q-icon name="error_outline" size="32px" :color="stats.errorRate > 5 ? 'negative' : 'grey-5'" class="q-mr-sm" />
|
||||
<div>
|
||||
<div class="text-caption text-grey-7">Error Rate</div>
|
||||
<div class="text-h5 text-bold" :class="stats.errorRate > 5 ? 'text-negative' : ''">
|
||||
{{ stats.errorRate?.toFixed(2) || 0 }}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
<div class="col-12 col-md-2">
|
||||
<q-card flat bordered class="stat-card">
|
||||
<q-card-section class="q-pa-md">
|
||||
<div class="row items-center">
|
||||
<q-icon name="check_circle" size="32px" :color="storeStats?.enabled ? 'positive' : 'grey'" class="q-mr-sm" />
|
||||
<div>
|
||||
<div class="text-caption text-grey-7">Status</div>
|
||||
<div class="text-body1 text-bold">
|
||||
{{ storeStats?.enabled ? 'Active' : 'Inactive' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="row q-col-gutter-sm q-mb-md">
|
||||
<div class="col-12 col-md-4">
|
||||
<q-input
|
||||
v-model="searchFilter"
|
||||
dense
|
||||
outlined
|
||||
placeholder="Search services..."
|
||||
@update:model-value="applyFilters"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="search" />
|
||||
</template>
|
||||
<template v-slot:append v-if="searchFilter">
|
||||
<q-icon name="close" @click="searchFilter = ''; applyFilters()" class="cursor-pointer" />
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col-12 col-md-8">
|
||||
<q-btn-toggle
|
||||
v-model="connectionTypeFilter"
|
||||
toggle-color="primary"
|
||||
size="sm"
|
||||
no-caps
|
||||
dense
|
||||
@update:model-value="applyFilters"
|
||||
:options="[
|
||||
{label: 'All', value: 'all'},
|
||||
{label: 'Standard', value: 'standard'},
|
||||
{label: 'Database', value: 'database'},
|
||||
{label: 'Messaging', value: 'messaging'},
|
||||
{label: 'Virtual', value: 'virtual'},
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Graph Visualization -->
|
||||
<q-card flat bordered class="graph-card">
|
||||
<q-card-section class="q-pa-none" style="height: 100%;">
|
||||
<div
|
||||
ref="graphContainer"
|
||||
class="graph-container"
|
||||
:style="{ height: graphHeight + 'px', minHeight: graphHeight + 'px' }"
|
||||
>
|
||||
<div v-if="loading" class="flex flex-center full-height">
|
||||
<div class="text-center">
|
||||
<q-spinner-grid 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 full-height text-center q-pa-md"
|
||||
>
|
||||
<div>
|
||||
<q-icon name="error_outline" size="4em" color="negative" />
|
||||
<div class="text-h6 q-mt-md">{{ 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 full-height text-center q-pa-md"
|
||||
>
|
||||
<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" style="max-width: 500px">
|
||||
Send distributed traces with client and server spans to see the service graph.
|
||||
Make sure <code>ZO_SGRAPH_ENABLED=true</code> is set.
|
||||
</div>
|
||||
<q-btn
|
||||
outline
|
||||
color="primary"
|
||||
label="Refresh"
|
||||
icon="refresh"
|
||||
@click="loadServiceGraph"
|
||||
class="q-mt-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else style="position: relative; width: 100%; height: 100%;">
|
||||
<div id="graph-network"></div>
|
||||
<!-- Node Details Panel (shown on node click) -->
|
||||
<q-card
|
||||
v-if="selectedNode"
|
||||
class="node-details-card"
|
||||
flat
|
||||
bordered
|
||||
>
|
||||
<q-card-section class="q-pa-md">
|
||||
<div class="row items-center justify-between q-mb-sm">
|
||||
<div class="text-h6">{{ selectedNode.label }}</div>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
round
|
||||
icon="close"
|
||||
size="sm"
|
||||
@click="selectedNode = null"
|
||||
/>
|
||||
</div>
|
||||
<q-separator class="q-mb-sm" />
|
||||
<div class="text-caption text-grey-7 q-mb-xs">Connections</div>
|
||||
<div class="q-mb-sm">
|
||||
<div><strong>Incoming:</strong> {{ getIncomingConnections(selectedNode.id).length }}</div>
|
||||
<div><strong>Outgoing:</strong> {{ getOutgoingConnections(selectedNode.id).length }}</div>
|
||||
</div>
|
||||
<div class="text-caption text-grey-7 q-mb-xs">Traffic</div>
|
||||
<div>
|
||||
<div><strong>Requests:</strong> {{ formatNumber(getNodeRequests(selectedNode.id)) }}</div>
|
||||
<div><strong>Errors:</strong> {{ getNodeErrors(selectedNode.id) }}</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<!-- Enhanced Legend -->
|
||||
<div class="row items-center q-mt-md">
|
||||
<div class="col-auto text-subtitle2 text-grey-7 q-mr-md">
|
||||
Connection Types:
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="row q-col-gutter-sm">
|
||||
<div class="col-auto">
|
||||
<q-chip dense square color="blue" text-color="white" icon="sync_alt">
|
||||
Standard
|
||||
</q-chip>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-chip dense square color="purple" text-color="white" icon="message">
|
||||
Messaging
|
||||
</q-chip>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-chip dense square color="orange" text-color="white" icon="storage">
|
||||
Database
|
||||
</q-chip>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-chip dense square color="grey" text-color="white" icon="cloud">
|
||||
Virtual/External
|
||||
</q-chip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto text-caption text-grey-6">
|
||||
Last updated: {{ lastUpdated }}
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
<q-input
|
||||
v-model.number="graphHeight"
|
||||
type="number"
|
||||
label="Graph Height (px)"
|
||||
:min="400"
|
||||
:max="1200"
|
||||
outlined
|
||||
dense
|
||||
/>
|
||||
<q-toggle
|
||||
v-model="autoRefresh"
|
||||
label="Auto-refresh (every 30 seconds)"
|
||||
@update:model-value="toggleAutoRefresh"
|
||||
/>
|
||||
<q-toggle
|
||||
v-model="showLabels"
|
||||
label="Show edge labels"
|
||||
@update:model-value="renderGraph"
|
||||
/>
|
||||
<q-toggle
|
||||
v-model="enablePhysics"
|
||||
label="Enable physics simulation"
|
||||
@update:model-value="updatePhysics"
|
||||
/>
|
||||
<div class="text-caption text-grey-7 q-mt-md">
|
||||
Store Size: {{ storeStats?.store_size || 0 }} edges
|
||||
<q-tooltip>Number of pending span pairs waiting to be matched</q-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-separator />
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat label="Reset" color="grey" @click="resetSettings" />
|
||||
<q-btn flat label="Close" color="primary" v-close-popup />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, onMounted, onBeforeUnmount } from "vue";
|
||||
import { useStore } from "vuex";
|
||||
import { useQuasar } from "quasar";
|
||||
import serviceGraphService from "@/services/service_graph";
|
||||
|
||||
export default defineComponent({
|
||||
name: "ServiceGraph",
|
||||
setup() {
|
||||
const store = useStore();
|
||||
const $q = useQuasar();
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const graphContainer = ref<HTMLElement | null>(null);
|
||||
const showSettings = ref(false);
|
||||
const graphHeight = ref(650);
|
||||
const autoRefresh = ref(true);
|
||||
const showLabels = ref(true);
|
||||
const enablePhysics = ref(false); // Disabled by default for stable graph
|
||||
const lastUpdated = ref("");
|
||||
const layoutType = ref("force");
|
||||
|
||||
const searchFilter = ref("");
|
||||
const connectionTypeFilter = ref("all");
|
||||
const selectedNode = ref<any>(null);
|
||||
|
||||
const graphData = ref<any>({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
});
|
||||
|
||||
const filteredGraphData = ref<any>({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
});
|
||||
|
||||
const stats = ref<any>(null);
|
||||
const storeStats = ref<any>(null);
|
||||
|
||||
let networkInstance: any = null;
|
||||
let refreshInterval: any = null;
|
||||
|
||||
const loadServiceGraph = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const orgId = store.state.selectedOrganization.identifier;
|
||||
|
||||
if (!orgId) {
|
||||
throw new Error("No organization selected");
|
||||
}
|
||||
|
||||
// Load store stats with timeout
|
||||
const statsResponse = await Promise.race([
|
||||
serviceGraphService.getStats(orgId),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error("Request timeout")), 30000)
|
||||
)
|
||||
]);
|
||||
storeStats.value = statsResponse.data;
|
||||
|
||||
if (!storeStats.value?.enabled) {
|
||||
error.value = "Service Graph is not enabled. Set ZO_SGRAPH_ENABLED=true";
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Load metrics and parse
|
||||
const metricsResponse = await serviceGraphService.getMetrics(orgId);
|
||||
const parsedData = parsePrometheusMetrics(metricsResponse.data);
|
||||
|
||||
graphData.value = parsedData;
|
||||
|
||||
// Calculate stats
|
||||
const totalRequests = parsedData.edges.reduce((sum: number, e: any) => sum + e.total_requests, 0);
|
||||
const totalErrors = parsedData.edges.reduce((sum: number, e: any) => sum + e.failed_requests, 0);
|
||||
|
||||
stats.value = {
|
||||
services: parsedData.nodes.length,
|
||||
connections: parsedData.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 a version that supports Service Graph.";
|
||||
} 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 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 = labelsStr.matchAll(/(\w+)="([^"]+)"/g);
|
||||
for (const [, key, val] of labelMatches) {
|
||||
labels[key] = val;
|
||||
}
|
||||
|
||||
if (!labels.client || !labels.server) continue;
|
||||
|
||||
// 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,
|
||||
};
|
||||
};
|
||||
|
||||
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 };
|
||||
|
||||
if (nodes.length > 0) {
|
||||
setTimeout(() => renderGraph(), 100);
|
||||
}
|
||||
};
|
||||
|
||||
const setLayout = (type: string) => {
|
||||
layoutType.value = type;
|
||||
renderGraph();
|
||||
};
|
||||
|
||||
const renderGraph = () => {
|
||||
if (!graphContainer.value || !window.vis) {
|
||||
console.warn("Graph container or vis.js not available");
|
||||
return;
|
||||
}
|
||||
|
||||
const container = document.getElementById("graph-network");
|
||||
if (!container) return;
|
||||
|
||||
// Calculate error rates for each node
|
||||
const nodeErrorRates = new Map<string, number>();
|
||||
filteredGraphData.value.edges.forEach((edge: any) => {
|
||||
const calcRate = (nodeId: string) => {
|
||||
const current = nodeErrorRates.get(nodeId) || { total: 0, errors: 0 };
|
||||
nodeErrorRates.set(nodeId, {
|
||||
total: current.total + edge.total_requests,
|
||||
errors: current.errors + edge.failed_requests,
|
||||
});
|
||||
};
|
||||
calcRate(edge.from);
|
||||
calcRate(edge.to);
|
||||
});
|
||||
|
||||
// Prepare nodes for vis.js with Tempo-style arc borders
|
||||
const nodes = filteredGraphData.value.nodes.map((node: any) => {
|
||||
const errorData = nodeErrorRates.get(node.id);
|
||||
const errorRate = errorData && errorData.total > 0
|
||||
? (errorData.errors / errorData.total) * 100
|
||||
: 0;
|
||||
|
||||
// Calculate border color based on error rate
|
||||
let borderColor = "#4CAF50"; // Green for no/low errors
|
||||
if (errorRate > 0 && errorRate <= 1) {
|
||||
borderColor = "#8BC34A"; // Light green
|
||||
} else if (errorRate > 1 && errorRate <= 5) {
|
||||
borderColor = "#FFC107"; // Yellow
|
||||
} else if (errorRate > 5 && errorRate <= 10) {
|
||||
borderColor = "#FF9800"; // Orange
|
||||
} else if (errorRate > 10) {
|
||||
borderColor = "#F44336"; // Red
|
||||
}
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
label: node.label,
|
||||
title: `${node.label}\nRequests: ${formatNumber(errorData?.total || 0)}\nErrors: ${errorData?.errors || 0}\nError Rate: ${errorRate.toFixed(2)}%`,
|
||||
color: {
|
||||
background: node.is_virtual ? "#ECEFF1" : "#E3F2FD",
|
||||
border: borderColor,
|
||||
highlight: {
|
||||
background: node.is_virtual ? "#CFD8DC" : "#BBDEFB",
|
||||
border: borderColor,
|
||||
},
|
||||
},
|
||||
shape: "circle",
|
||||
size: 40,
|
||||
margin: 15,
|
||||
font: {
|
||||
size: 13,
|
||||
color: "#263238",
|
||||
face: "Arial",
|
||||
bold: true,
|
||||
},
|
||||
borderWidth: 4,
|
||||
borderWidthSelected: 6,
|
||||
shadow: {
|
||||
enabled: true,
|
||||
color: "rgba(0,0,0,0.2)",
|
||||
size: 8,
|
||||
x: 2,
|
||||
y: 2,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Prepare edges for vis.js
|
||||
const edges = filteredGraphData.value.edges.map((edge: any) => {
|
||||
const errorRate =
|
||||
edge.total_requests > 0
|
||||
? (edge.failed_requests / edge.total_requests) * 100
|
||||
: 0;
|
||||
|
||||
const color = getConnectionTypeColor(edge.connection_type);
|
||||
const width = Math.max(2, Math.min(10, Math.log10(edge.total_requests + 1) * 2));
|
||||
|
||||
const hasErrors = errorRate > 0;
|
||||
const label = showLabels.value
|
||||
? `${formatNumber(edge.total_requests)}${hasErrors ? '\n' + errorRate.toFixed(1) + '% err' : ''}`
|
||||
: '';
|
||||
|
||||
return {
|
||||
from: edge.from,
|
||||
to: edge.to,
|
||||
label,
|
||||
arrows: { to: { enabled: true, scaleFactor: 0.8 } },
|
||||
color: {
|
||||
color,
|
||||
highlight: color,
|
||||
opacity: errorRate > 5 ? 0.5 : 1.0,
|
||||
},
|
||||
width,
|
||||
font: { size: 11, align: "middle", background: "white" },
|
||||
smooth: { type: "curvedCW", roundness: 0.2 },
|
||||
dashes: errorRate > 10,
|
||||
};
|
||||
});
|
||||
|
||||
const data = { nodes, edges };
|
||||
|
||||
let layoutOptions: any = {};
|
||||
|
||||
if (layoutType.value === "hierarchical") {
|
||||
layoutOptions = {
|
||||
hierarchical: {
|
||||
enabled: true,
|
||||
direction: "UD",
|
||||
sortMethod: "directed",
|
||||
levelSeparation: 150,
|
||||
nodeSpacing: 150,
|
||||
},
|
||||
};
|
||||
} else if (layoutType.value === "circular") {
|
||||
layoutOptions = {
|
||||
hierarchical: false,
|
||||
};
|
||||
} else {
|
||||
layoutOptions = {
|
||||
improvedLayout: true,
|
||||
hierarchical: { enabled: false },
|
||||
};
|
||||
}
|
||||
|
||||
const options = {
|
||||
layout: layoutOptions,
|
||||
physics: {
|
||||
enabled: enablePhysics.value,
|
||||
stabilization: {
|
||||
enabled: true,
|
||||
iterations: 400,
|
||||
updateInterval: 20,
|
||||
},
|
||||
barnesHut: {
|
||||
gravitationalConstant: -15000, // Much stronger repulsion for sparse layout
|
||||
centralGravity: 0.1, // Less central pull
|
||||
springLength: 400, // Longer springs = more spacing
|
||||
springConstant: 0.01, // Weaker springs
|
||||
damping: 0.7,
|
||||
avoidOverlap: 1,
|
||||
},
|
||||
solver: 'barnesHut',
|
||||
minVelocity: 0.75,
|
||||
},
|
||||
interaction: {
|
||||
hover: true,
|
||||
tooltipDelay: 200,
|
||||
navigationButtons: true,
|
||||
keyboard: true,
|
||||
zoomView: true,
|
||||
dragView: true,
|
||||
},
|
||||
nodes: {
|
||||
borderWidth: 5,
|
||||
borderWidthSelected: 7,
|
||||
},
|
||||
edges: {
|
||||
smooth: {
|
||||
enabled: true,
|
||||
type: "curvedCW",
|
||||
roundness: 0.2,
|
||||
},
|
||||
},
|
||||
configure: {
|
||||
enabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
if (networkInstance) {
|
||||
networkInstance.destroy();
|
||||
}
|
||||
|
||||
networkInstance = new (window as any).vis.Network(container, data, options);
|
||||
|
||||
// Stabilize then disable physics for static display
|
||||
if (!enablePhysics.value) {
|
||||
networkInstance.once("stabilizationIterationsDone", () => {
|
||||
networkInstance.setOptions({ physics: false });
|
||||
});
|
||||
}
|
||||
|
||||
// Add click handler
|
||||
networkInstance.on("click", (params: any) => {
|
||||
if (params.nodes.length > 0) {
|
||||
const nodeId = params.nodes[0];
|
||||
const node = filteredGraphData.value.nodes.find((n: any) => n.id === nodeId);
|
||||
selectedNode.value = node;
|
||||
} else {
|
||||
selectedNode.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Circular layout implementation
|
||||
if (layoutType.value === "circular") {
|
||||
const positions: any = {};
|
||||
const nodeCount = nodes.length;
|
||||
const radius = Math.max(250, nodeCount * 30);
|
||||
|
||||
nodes.forEach((node: any, i: number) => {
|
||||
const angle = (i / nodeCount) * 2 * Math.PI;
|
||||
positions[node.id] = {
|
||||
x: radius * Math.cos(angle),
|
||||
y: radius * Math.sin(angle),
|
||||
};
|
||||
});
|
||||
|
||||
networkInstance.setOptions({ physics: { enabled: false } });
|
||||
setTimeout(() => {
|
||||
networkInstance.setPositions(positions);
|
||||
networkInstance.fit();
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
const getConnectionTypeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case "messaging":
|
||||
return "#9C27B0"; // Purple
|
||||
case "database":
|
||||
return "#FF9800"; // Orange
|
||||
case "virtual":
|
||||
return "#9E9E9E"; // Grey
|
||||
default:
|
||||
return "#2196F3"; // Blue
|
||||
}
|
||||
};
|
||||
|
||||
const getIncomingConnections = (nodeId: string) => {
|
||||
return filteredGraphData.value.edges.filter((e: any) => e.to === nodeId);
|
||||
};
|
||||
|
||||
const getOutgoingConnections = (nodeId: string) => {
|
||||
return filteredGraphData.value.edges.filter((e: any) => e.from === nodeId);
|
||||
};
|
||||
|
||||
const getNodeRequests = (nodeId: string) => {
|
||||
const incoming = getIncomingConnections(nodeId);
|
||||
const outgoing = getOutgoingConnections(nodeId);
|
||||
return [...incoming, ...outgoing].reduce((sum, e) => sum + e.total_requests, 0);
|
||||
};
|
||||
|
||||
const getNodeErrors = (nodeId: string) => {
|
||||
const incoming = getIncomingConnections(nodeId);
|
||||
const outgoing = getOutgoingConnections(nodeId);
|
||||
return [...incoming, ...outgoing].reduce((sum, e) => sum + e.failed_requests, 0);
|
||||
};
|
||||
|
||||
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 toggleAutoRefresh = (enabled: boolean) => {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
refreshInterval = null;
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
refreshInterval = setInterval(loadServiceGraph, 30000);
|
||||
}
|
||||
};
|
||||
|
||||
const updatePhysics = () => {
|
||||
if (networkInstance) {
|
||||
networkInstance.setOptions({
|
||||
physics: { enabled: enablePhysics.value },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const resetSettings = () => {
|
||||
graphHeight.value = 650;
|
||||
autoRefresh.value = true;
|
||||
showLabels.value = true;
|
||||
enablePhysics.value = false;
|
||||
layoutType.value = "force";
|
||||
toggleAutoRefresh(true);
|
||||
renderGraph();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// Load vis.js library
|
||||
const script = document.createElement("script");
|
||||
script.src = "https://unpkg.com/vis-network@9.1.6/dist/vis-network.min.js";
|
||||
script.onload = () => {
|
||||
loadServiceGraph();
|
||||
|
||||
// Auto-refresh every 30 seconds
|
||||
if (autoRefresh.value) {
|
||||
refreshInterval = setInterval(loadServiceGraph, 30000);
|
||||
}
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = "https://unpkg.com/vis-network@9.1.6/dist/dist/vis-network.min.css";
|
||||
document.head.appendChild(link);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (networkInstance) {
|
||||
networkInstance.destroy();
|
||||
}
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
graphContainer,
|
||||
graphData,
|
||||
filteredGraphData,
|
||||
stats,
|
||||
storeStats,
|
||||
showSettings,
|
||||
graphHeight,
|
||||
autoRefresh,
|
||||
showLabels,
|
||||
enablePhysics,
|
||||
lastUpdated,
|
||||
searchFilter,
|
||||
connectionTypeFilter,
|
||||
selectedNode,
|
||||
loadServiceGraph,
|
||||
formatNumber,
|
||||
applyFilters,
|
||||
setLayout,
|
||||
toggleAutoRefresh,
|
||||
updatePhysics,
|
||||
resetSettings,
|
||||
getIncomingConnections,
|
||||
getOutgoingConnections,
|
||||
getNodeRequests,
|
||||
getNodeErrors,
|
||||
renderGraph,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.service-graph-container {
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.graph-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.graph-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background: #fafafa;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#graph-network {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(to bottom, #ffffff 0%, #f9f9f9 100%);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.node-details-card {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
width: 280px;
|
||||
background: white;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
border-radius: 8px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
code {
|
||||
background: #f0f0f0;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright 2025 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 http from "./http";
|
||||
|
||||
const serviceGraphService = {
|
||||
/**
|
||||
* Get service graph metrics in Prometheus format
|
||||
*/
|
||||
getMetrics: (orgId: string) => {
|
||||
return http().get(`/api/${orgId}/traces/service_graph/metrics`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get service graph store statistics
|
||||
*/
|
||||
getStats: (orgId: string) => {
|
||||
return http().get(`/api/${orgId}/traces/service_graph/stats`);
|
||||
},
|
||||
};
|
||||
|
||||
export default serviceGraphService;
|
||||
Reference in New Issue
Block a user