feat: add comprehensive service graph visualization with tree/graph views

Backend:
- Add `stream_name` parameter to service graph query API for multi-stream filtering
- Pass `stream_name` through `handle_otlp_request` to service graph tracking
- Filter Prometheus metrics by both `org_id` and optional `stream_name`
- Update API documentation with stream filtering examples

Frontend:
- Add tree view and graph view visualization modes with toggle
- Implement tree layouts: horizontal, vertical, and radial
- Implement graph layouts: force-directed and circular
- Add stream selector dropdown to filter metrics by trace stream
- Add auto-refresh toggle with configurable interval (5-300 seconds)
- Refactor graph rendering to use `ChartRenderer` component with ECharts
- Implement D3-force physics simulation for force-directed layout
- Add `convertServiceGraphToTree` and `convertServiceGraphToNetwork` utilities
- Preserve node positions during graph updates to prevent re-layout jitter
- Use incremental updates (`notMerge: false`) for stable graph view
- Store user preferences (visualization type, layout, stream filter) in localStorage
- Update `ChartRenderer` to respect `notMerge` and `lazyUpdate` flags from props

Dependencies:
- Add `d3-force` for force-directed graph layout calculations
- Add `@types/d3-force` for TypeScript support
- Add `d3-quadtree` for spatial indexing in force simulation
This commit is contained in:
ByteBaker
2025-11-14 16:19:04 +05:30
committed by sktrpathi
parent 602eb6a7c3
commit bb37f8f1af
8 changed files with 907 additions and 640 deletions
+1
View File
@@ -405,6 +405,7 @@ pub async fn handle_otlp_request(
Arc::from(span_id.as_str()),
parent_span_id_opt,
Arc::from(service_name.as_str()),
Arc::from(traces_stream_name.as_str()),
&local_val.span_kind,
start_time,
end_time,
+24 -7
View File
@@ -27,6 +27,7 @@ pub struct ServiceGraphQuery {
pub start_time: Option<i64>,
pub end_time: Option<i64>,
pub filter: Option<String>,
pub stream_name: Option<String>,
}
/// GetServiceGraphMetrics
@@ -37,15 +38,16 @@ pub struct ServiceGraphQuery {
tag = "Traces",
operation_id = "GetServiceGraphMetrics",
summary = "Get service graph metrics",
description = "Retrieves Prometheus-formatted metrics for the service graph. Returns metrics filtered by organization to ensure multi-tenant isolation. Service graph metrics provide insights into service-to-service communication patterns, request rates, and latencies.",
description = "Retrieves Prometheus-formatted metrics for the service graph. Returns metrics filtered by organization and optionally by stream name to ensure multi-tenant isolation. Service graph metrics provide insights into service-to-service communication patterns, request rates, and latencies.",
security(
("Authorization" = [])
),
params(
("org_id" = String, Path, description = "Organization name"),
("stream_name" = Option<String>, Query, description = "Optional stream name to filter service graph metrics"),
),
responses(
(status = 200, description = "Success", content_type = "text/plain; version=0.0.4", body = String, example = json!("# HELP traces_service_graph_request_total Total number of requests between services\n# TYPE traces_service_graph_request_total counter\ntraces_service_graph_request_total{client=\"service-a\",org_id=\"default\",server=\"service-b\"} 42\n")),
(status = 200, description = "Success", content_type = "text/plain; version=0.0.4", body = String, example = json!("# HELP traces_service_graph_request_total Total number of requests between services\n# TYPE traces_service_graph_request_total counter\ntraces_service_graph_request_total{client=\"service-a\",org_id=\"default\",server=\"service-b\",stream_name=\"default\"} 42\n")),
(status = 403, description = "Forbidden - Enterprise feature", content_type = "application/json", body = String),
(status = 500, description = "Internal Server Error", content_type = "application/json", body = ()),
),
@@ -57,25 +59,40 @@ pub struct ServiceGraphQuery {
#[cfg(feature = "enterprise")]
pub async fn get_service_graph_metrics(
org_id: web::Path<String>,
query: web::Query<ServiceGraphQuery>,
) -> 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
// Filter metrics to only include those matching the requested org_id and optional stream_name
let filtered_families: Vec<_> = metric_families
.into_iter()
.filter_map(|mut family| {
// Filter out metrics that don't belong to this org
// Filter out metrics that don't belong to this org/stream
let filtered_metrics: Vec<_> = family
.take_metric()
.into_iter()
.filter(|metric| {
let labels = metric.get_label();
// Check if metric has org_id label matching the requested org
metric
.get_label()
let org_matches = labels
.iter()
.any(|label| label.name() == "org_id" && label.value() == org_id.as_str())
.any(|label| label.name() == "org_id" && label.value() == org_id.as_str());
if !org_matches {
return false;
}
// If stream_name filter is provided, also check stream_name label
if let Some(ref stream_name) = query.stream_name {
labels.iter().any(|label| {
label.name() == "stream_name" && label.value() == stream_name.as_str()
})
} else {
true
}
})
.collect();
+76 -50
View File
@@ -24,6 +24,7 @@
"acorn": "^8.15.0",
"acorn-walk": "^8.3.4",
"cron-parser": "^4.9.0",
"d3-force": "^3.0.0",
"d3-scale": "^4.0.2",
"date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
@@ -68,6 +69,7 @@
"@rushstack/eslint-patch": "^1.10.5",
"@tailwindcss/typography": "^0.5.16",
"@types/axios": "^0.14.4",
"@types/d3-force": "^3.0.10",
"@types/d3-scale": "^4.0.8",
"@types/dompurify": "^3.2.0",
"@types/fs-extra": "^11.0.4",
@@ -196,7 +198,6 @@
"integrity": "sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.27.1",
@@ -743,7 +744,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -767,7 +767,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -838,7 +837,6 @@
"integrity": "sha512-vVUAdk5JKXe3IWbPiE9TLKADTIAS1Nm15Am2ADBOlPRbvXRYSD6KBUto5bO4inXbiBRoJV1o6h7SCxS1o5RL9w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"find-up": "6.3.0",
"fs-extra": "9.1.0",
@@ -2171,7 +2169,6 @@
"resolved": "https://registry.npmjs.org/@openobserve/browser-logs/-/browser-logs-0.2.11.tgz",
"integrity": "sha512-m3WGMe7qh+MMEFcorQoaXet2WNl1opK08La96TYR64JzTYWdwo/ct3EDn9tRuwMo7tgk0B7sHvFX1DpoB386Mw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@openobserve/browser-core": "0.2.11"
},
@@ -2189,7 +2186,6 @@
"resolved": "https://registry.npmjs.org/@openobserve/browser-rum/-/browser-rum-0.2.11.tgz",
"integrity": "sha512-TehsRwOz4eGsTftY5b6nXzrZxhwRuPOT0IM/jO9Smr/E0YdB/YZsM6EmMp1/QYZzdIUcBL2jD+kMpknepipCLQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@openobserve/browser-core": "0.2.11",
"@openobserve/browser-rum-core": "0.2.11"
@@ -3127,6 +3123,13 @@
"integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==",
"license": "MIT"
},
"node_modules/@types/d3-force": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
"integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
@@ -3178,6 +3181,7 @@
"resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz",
"integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/eslint": "*",
"@types/estree": "*"
@@ -3612,7 +3616,6 @@
"integrity": "sha512-4O3idHxhyzjClSMJ0a29AcoK0+YwnEqzI6oz3vlRf3xw0zbzt15MzXwItOlnr5nIth6zlY2RENLsOPvhyrKAQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.34.1",
"@typescript-eslint/types": "8.34.1",
@@ -3817,7 +3820,6 @@
"integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
@@ -3996,7 +3998,6 @@
"integrity": "sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/utils": "3.2.4",
"fflate": "^0.8.2",
@@ -4082,7 +4083,6 @@
"resolved": "https://registry.npmjs.org/@vue-flow/core/-/core-1.45.0.tgz",
"integrity": "sha512-+Qd4fTnCfrhfYQzlHyf5Jt7rNE4PlDnEJEJZH9v6hDZoTOeOy1RhS85cSxKYxdsJ31Ttj2v3yabhoVfBf+bmJA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vueuse/core": "^10.5.0",
"d3-drag": "^3.0.0",
@@ -4453,6 +4453,7 @@
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
"integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@webassemblyjs/helper-numbers": "1.13.2",
"@webassemblyjs/helper-wasm-bytecode": "1.13.2"
@@ -4462,25 +4463,29 @@
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz",
"integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@webassemblyjs/helper-api-error": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz",
"integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@webassemblyjs/helper-buffer": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz",
"integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@webassemblyjs/helper-numbers": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz",
"integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@webassemblyjs/floating-point-hex-parser": "1.13.2",
"@webassemblyjs/helper-api-error": "1.13.2",
@@ -4491,13 +4496,15 @@
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz",
"integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@webassemblyjs/helper-wasm-section": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz",
"integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@webassemblyjs/ast": "1.14.1",
"@webassemblyjs/helper-buffer": "1.14.1",
@@ -4510,6 +4517,7 @@
"resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz",
"integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@xtuc/ieee754": "^1.2.0"
}
@@ -4519,6 +4527,7 @@
"resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz",
"integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@xtuc/long": "4.2.2"
}
@@ -4527,13 +4536,15 @@
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz",
"integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@webassemblyjs/wasm-edit": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz",
"integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@webassemblyjs/ast": "1.14.1",
"@webassemblyjs/helper-buffer": "1.14.1",
@@ -4550,6 +4561,7 @@
"resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz",
"integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@webassemblyjs/ast": "1.14.1",
"@webassemblyjs/helper-wasm-bytecode": "1.13.2",
@@ -4563,6 +4575,7 @@
"resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz",
"integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@webassemblyjs/ast": "1.14.1",
"@webassemblyjs/helper-buffer": "1.14.1",
@@ -4575,6 +4588,7 @@
"resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz",
"integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@webassemblyjs/ast": "1.14.1",
"@webassemblyjs/helper-api-error": "1.13.2",
@@ -4589,6 +4603,7 @@
"resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz",
"integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@webassemblyjs/ast": "1.14.1",
"@xtuc/long": "4.2.2"
@@ -4604,13 +4619,15 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
"integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==",
"license": "BSD-3-Clause"
"license": "BSD-3-Clause",
"peer": true
},
"node_modules/@xtuc/long": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz",
"integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==",
"license": "Apache-2.0"
"license": "Apache-2.0",
"peer": true
},
"node_modules/abbrev": {
"version": "2.0.0",
@@ -4650,7 +4667,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5499,7 +5515,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001718",
"electron-to-chromium": "^1.5.160",
@@ -5936,6 +5951,7 @@
"resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz",
"integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.0"
}
@@ -6494,7 +6510,6 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@cypress/request": "^3.0.8",
"@cypress/xvfb": "^1.2.4",
@@ -6636,6 +6651,20 @@
"node": ">=12"
}
},
"node_modules/d3-force": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
"integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-quadtree": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
@@ -6657,6 +6686,15 @@
"node": ">=12"
}
},
"node_modules/d3-quadtree": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
@@ -6678,7 +6716,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@@ -6847,7 +6884,6 @@
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
@@ -7296,7 +7332,6 @@
"resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz",
"integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"tslib": "2.3.0",
"zrender": "5.6.1"
@@ -7437,6 +7472,7 @@
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz",
"integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==",
"license": "MIT",
"peer": true,
"dependencies": {
"graceful-fs": "^4.2.4",
"tapable": "^2.2.0"
@@ -7450,6 +7486,7 @@
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz",
"integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6"
}
@@ -7460,7 +7497,6 @@
"integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-colors": "^4.1.1",
"strip-ansi": "^6.0.1"
@@ -7643,7 +7679,6 @@
"integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==",
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
@@ -7734,7 +7769,6 @@
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -7796,7 +7830,6 @@
"integrity": "sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -7870,7 +7903,6 @@
"integrity": "sha512-174lJKuNsuDIlLpjeXc5E2Tss8P44uIimAfGD0b90k0NoirJqpG7stLuU9Vp/9ioTOrQdWVREc4mRd1BD+CvGw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"globals": "^13.24.0",
@@ -9121,7 +9153,8 @@
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
"integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==",
"license": "BSD-2-Clause"
"license": "BSD-2-Clause",
"peer": true
},
"node_modules/global-dirs": {
"version": "3.0.1",
@@ -10786,6 +10819,7 @@
"resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz",
"integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/node": "*",
"merge-stream": "^2.0.0",
@@ -10801,7 +10835,6 @@
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -10981,7 +11014,8 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/json-schema": {
"version": "0.4.0",
@@ -11110,8 +11144,7 @@
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause",
"peer": true
"license": "BSD-2-Clause"
},
"node_modules/levn": {
"version": "0.4.1",
@@ -11233,6 +11266,7 @@
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz",
"integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.11.5"
}
@@ -11753,8 +11787,7 @@
"version": "0.52.2",
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz",
"integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/moo": {
"version": "0.5.2",
@@ -13129,7 +13162,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -13290,7 +13322,6 @@
"integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -13486,7 +13517,6 @@
"resolved": "https://registry.npmjs.org/quasar/-/quasar-2.18.1.tgz",
"integrity": "sha512-db/P64Mzpt1uXJ0MapaG+IYJQ9hHDb5KtTCoszwC78DR7sA+Uoj7nBW2EytwYykIExEmqavOvKrdasTvqhkgEg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 10.18.1",
"npm": ">= 6.13.4",
@@ -13895,7 +13925,6 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.43.0.tgz",
"integrity": "sha512-wdN2Kd3Twh8MAEOEJZsuxuLKCsBEo4PVNLK6tQWAn10VhsVewQLzcucMgLolRlhFybGxfclbPeEYBaP6RvUFGg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.7"
},
@@ -14259,7 +14288,6 @@
"integrity": "sha512-xCmtksBKd/jdJ9Bt9p7nPKiuqrlBMBuuGkQlkhZjjQk3Ty48lv93k5Dq6OPkKt4XwxDJ7tvlfrTa1MPA9bf+QA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"chokidar": "^4.0.0",
"immutable": "^5.0.2",
@@ -14353,7 +14381,6 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -14483,6 +14510,7 @@
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
"integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
"license": "BSD-3-Clause",
"peer": true,
"dependencies": {
"randombytes": "^2.1.0"
}
@@ -15515,7 +15543,6 @@
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
@@ -15646,6 +15673,7 @@
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz",
"integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.25",
"jest-worker": "^27.4.5",
@@ -15820,7 +15848,6 @@
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16029,8 +16056,7 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD",
"peer": true
"license": "0BSD"
},
"node_modules/tty-browserify": {
"version": "0.0.1",
@@ -16178,7 +16204,6 @@
"integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -16463,7 +16488,6 @@
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@@ -16676,7 +16700,6 @@
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16690,7 +16713,6 @@
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -16802,7 +16824,6 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.17.tgz",
"integrity": "sha512-LbHV3xPN9BeljML+Xctq4lbz2lVHCR6DtbpTf5XIO6gugpXUN49j2QQPcMj086r9+AkJ0FfUT8xjulKKBkkr9g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.17",
"@vue/compiler-sfc": "3.5.17",
@@ -16999,6 +17020,7 @@
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz",
"integrity": "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==",
"license": "MIT",
"peer": true,
"dependencies": {
"glob-to-regexp": "^0.4.1",
"graceful-fs": "^4.1.2"
@@ -17356,6 +17378,7 @@
"resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.2.tgz",
"integrity": "sha512-ykKKus8lqlgXX/1WjudpIEjqsafjOTcOJqxnAbMLAu/KCsDCJ6GBtvscewvTkrn24HsnvFwrSCbenFrhtcCsAA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10.13.0"
}
@@ -17365,6 +17388,7 @@
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
"integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"esrecurse": "^4.3.0",
"estraverse": "^4.1.1"
@@ -17378,6 +17402,7 @@
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
"integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
"license": "BSD-2-Clause",
"peer": true,
"engines": {
"node": ">=4.0"
}
@@ -17387,6 +17412,7 @@
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz",
"integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6"
}
+2
View File
@@ -40,6 +40,7 @@
"acorn": "^8.15.0",
"acorn-walk": "^8.3.4",
"cron-parser": "^4.9.0",
"d3-force": "^3.0.0",
"d3-scale": "^4.0.2",
"date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
@@ -84,6 +85,7 @@
"@rushstack/eslint-patch": "^1.10.5",
"@tailwindcss/typography": "^0.5.16",
"@types/axios": "^0.14.4",
"@types/d3-force": "^3.0.10",
"@types/d3-scale": "^4.0.8",
"@types/dompurify": "^3.2.0",
"@types/fs-extra": "^11.0.4",
@@ -582,7 +582,10 @@ export default defineComponent({
theme === "dark" ? "rgba(0,0,0,1)" : "rgba(255,255,255,1)");
options.animation = false;
try {
chart?.setOption(options, { lazyUpdate: true, notMerge: true });
// Use notMerge flag from data prop if available, otherwise default to true
const notMerge = props.data?.notMerge !== undefined ? props.data.notMerge : true;
const lazyUpdate = props.data?.lazyUpdate !== undefined ? props.data.lazyUpdate : true;
chart?.setOption(options, { lazyUpdate, notMerge });
chart?.setOption({ animation: true }, { lazyUpdate: true });
} catch (e: any) {
emit("error", {
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -18,9 +18,12 @@ import http from "./http";
const serviceGraphService = {
/**
* Get service graph metrics in Prometheus format
* @param orgId - Organization ID
* @param streamName - Optional stream name to filter metrics
*/
getMetrics: (orgId: string) => {
return http().get(`/api/${orgId}/traces/service_graph/metrics`);
getMetrics: (orgId: string, streamName?: string) => {
const params = streamName && streamName !== 'all' ? { stream_name: streamName } : {};
return http().get(`/api/${orgId}/traces/service_graph/metrics`, { params });
},
/**
+530
View File
@@ -1,4 +1,5 @@
import { toZonedTime, format } from "date-fns-tz";
import { forceSimulation, forceManyBody, forceLink, forceCenter, forceCollide, forceX, forceY } from "d3-force";
export const convertTraceData = (props: any, timezone: string) => {
const options: any = {
backgroundColor: "transparent",
@@ -189,6 +190,535 @@ export const convertTraceServiceMapData = (
return { options };
};
/**
* Convert service graph data (nodes/edges) to ECharts tree format
* @param graphData - Object containing nodes and edges arrays
* @param layoutType - Layout orientation: 'horizontal' | 'vertical' | 'radial'
* @returns ECharts tree options
*/
export const convertServiceGraphToTree = (
graphData: { nodes: any[]; edges: any[] },
layoutType: string = 'horizontal'
) => {
// Build adjacency map for edges
const edgesMap = new Map<string, any[]>();
graphData.edges.forEach((edge: any) => {
if (!edgesMap.has(edge.from)) {
edgesMap.set(edge.from, []);
}
edgesMap.get(edge.from)!.push(edge);
});
// Find root nodes (nodes with no incoming edges)
const nodesWithIncoming = new Set(graphData.edges.map((e: any) => e.to));
const rootNodes = graphData.nodes.filter((n: any) => !nodesWithIncoming.has(n.id));
// Helper to build tree recursively
const buildTree = (nodeId: string, visited = new Set<string>()): any => {
if (visited.has(nodeId)) return null; // Prevent cycles
visited.add(nodeId);
const node = graphData.nodes.find((n: any) => n.id === nodeId);
if (!node) return null;
const outgoingEdges = edgesMap.get(nodeId) || [];
const children = outgoingEdges
.map((edge: any) => buildTree(edge.to, new Set(visited)))
.filter((child: any) => child !== null);
// Calculate node metrics
const totalRequests = outgoingEdges.reduce((sum: number, e: any) => sum + (e.total_requests || 0), 0);
const failedRequests = outgoingEdges.reduce((sum: number, e: any) => sum + (e.failed_requests || 0), 0);
const errorRate = totalRequests > 0 ? (failedRequests / totalRequests) * 100 : 0;
// Determine node color based on error rate
let nodeColor = "#4CAF50"; // Green for healthy
if (errorRate > 10) nodeColor = "#F44336"; // Red
else if (errorRate > 5) nodeColor = "#FF9800"; // Orange
else if (errorRate > 1) nodeColor = "#FFC107"; // Yellow
return {
name: node.label || node.id,
value: totalRequests,
symbolSize: Math.max(20, Math.min(60, Math.log10(totalRequests + 1) * 15)),
itemStyle: {
color: nodeColor,
borderColor: nodeColor,
borderWidth: 2,
},
label: {
show: true,
position: layoutType === 'vertical' ? 'bottom' : 'right',
formatter: (params: any) => {
return `${params.name}\n${formatNumber(totalRequests)} req`;
},
},
tooltip: {
formatter: (params: any) => {
return `
<strong>${params.name}</strong><br/>
Requests: ${formatNumber(totalRequests)}<br/>
Errors: ${failedRequests}<br/>
Error Rate: ${errorRate.toFixed(2)}%
`;
},
},
children: children.length > 0 ? children : undefined,
};
};
// If no clear root nodes, use all nodes with outgoing edges as roots
const roots = rootNodes.length > 0
? rootNodes
: graphData.nodes.filter((n: any) => edgesMap.has(n.id));
const treeData = roots.map((node: any) => buildTree(node.id)).filter((n: any) => n !== null);
// If still no tree data, create a flat structure
if (treeData.length === 0 && graphData.nodes.length > 0) {
return {
tooltip: { show: true, trigger: 'item' },
series: [{
type: 'tree',
data: graphData.nodes.map((node: any) => ({
name: node.label || node.id,
value: 0,
symbolSize: 20,
itemStyle: { color: '#9E9E9E' },
})),
layout: 'orthogonal',
orient: layoutType === 'vertical' ? 'TB' : 'LR',
initialTreeDepth: -1,
symbolSize: 20,
label: {
position: layoutType === 'vertical' ? 'bottom' : 'right',
verticalAlign: layoutType === 'vertical' ? 'top' : 'middle',
distance: 15,
fontSize: 12,
},
}],
};
}
const options = {
tooltip: {
show: true,
trigger: 'item',
triggerOn: 'mousemove',
},
series: [
{
type: 'tree',
data: treeData,
layout: layoutType === 'radial' ? 'radial' : 'orthogonal',
orient: layoutType === 'vertical' ? 'TB' : 'LR',
initialTreeDepth: -1,
symbol: 'circle',
symbolSize: 20,
label: {
position: layoutType === 'vertical' ? 'bottom' : 'right',
verticalAlign: layoutType === 'vertical' ? 'top' : 'middle',
distance: 15,
fontSize: 12,
rotate: 0, // Keep text horizontal, no rotation
},
leaves: {
label: {
position: layoutType === 'vertical' ? 'bottom' : 'right',
verticalAlign: layoutType === 'vertical' ? 'top' : 'middle',
distance: 15,
rotate: 0, // Keep text horizontal, no rotation
},
},
expandAndCollapse: true,
animationDuration: 550,
animationDurationUpdate: 750,
},
],
};
return { options };
};
// D3-Force simulation physics parameters
const FORCE_PHYSICS_PARAMS = {
// Many-Body Force (Repulsion)
chargeStrength: -3000, // Lower repulsion to tighten clusters
chargeDistanceMax: 1200, // Limit long-range repulsion
// Link Force
linkDistance: 250, // Shorter links for tighter grouping
linkStrength: 0.6, // Stronger link pull to hold structure
linkIterations: 3, // Fewer passes, less "rubberiness"
// Center Force
centerStrength: 0.05, // Stronger centering keeps it compact
// Position Forces
forceXStrength: 0.08, // Gentle horizontal correction
forceYStrength: 0.08, // Gentle vertical correction
// Collision Force
collisionPadding: 80, // Reasonable padding without huge gaps
collisionStrength: 1.0, // Full collision enforcement
collisionIterations: 2, // Enough to resolve overlaps
// Simulation
velocityDecay: 0.35, // Slightly more friction for faster settle
totalTicks: 5000, // Iterations for stabilization
};
/**
* Compute force-directed layout using D3-force simulation
* @param nodes - Array of nodes
* @param edges - Array of edges
* @param width - Container width
* @param height - Container height
* @returns Nodes with computed x, y positions
*/
const computeForceLayout = (
nodes: any[],
edges: any[],
width: number = 800,
height: number = 600
) => {
// Create a copy of nodes to avoid mutation
const nodesCopy = nodes.map(n => ({ ...n }));
// Prepare edges with proper source/target references
const edgesCopy = edges.map(e => ({
source: e.from,
target: e.to,
...e,
}));
// Create simulation and compute layout using physics params
const simulation = forceSimulation(nodesCopy)
.force('charge', forceManyBody()
.strength(FORCE_PHYSICS_PARAMS.chargeStrength)
.distanceMax(FORCE_PHYSICS_PARAMS.chargeDistanceMax)
)
.force('link', forceLink(edgesCopy)
.id((d: any) => d.id)
.distance(FORCE_PHYSICS_PARAMS.linkDistance)
.strength(FORCE_PHYSICS_PARAMS.linkStrength)
.iterations(FORCE_PHYSICS_PARAMS.linkIterations)
)
.force('center', forceCenter(width / 2, height / 2)
.strength(FORCE_PHYSICS_PARAMS.centerStrength)
)
.force('x', forceX(width / 2).strength(FORCE_PHYSICS_PARAMS.forceXStrength))
.force('y', forceY(height / 2).strength(FORCE_PHYSICS_PARAMS.forceYStrength))
.force('collision', forceCollide()
.radius((d: any) => (d.symbolSize || 60) / 2 + FORCE_PHYSICS_PARAMS.collisionPadding)
.strength(FORCE_PHYSICS_PARAMS.collisionStrength)
.iterations(FORCE_PHYSICS_PARAMS.collisionIterations)
)
.velocityDecay(FORCE_PHYSICS_PARAMS.velocityDecay)
.stop();
// Run simulation for specified iterations to stabilize layout
for (let i = 0; i < FORCE_PHYSICS_PARAMS.totalTicks; i++) {
simulation.tick();
}
// Return nodes with computed positions
return simulation.nodes().map(n => ({ ...n }));
};
/**
* Convert service graph data to ECharts Graph format (force-directed/circular network)
*/
export const convertServiceGraphToNetwork = (
graphData: { nodes: any[]; edges: any[] },
layoutType: string = "force",
cachedPositions?: Map<string, { x: number; y: number }>
) => {
// Build node metrics map (requests, errors, connections)
const nodeMetrics = new Map<string, { requests: number; errors: number; connections: number }>();
// Count connections for each node
const connectionCount = new Map<string, number>();
graphData.nodes.forEach((node: any) => {
connectionCount.set(node.id, 0);
});
graphData.edges.forEach((edge: any) => {
connectionCount.set(edge.from, (connectionCount.get(edge.from) || 0) + 1);
connectionCount.set(edge.to, (connectionCount.get(edge.to) || 0) + 1);
// Update metrics for both source and target nodes
[edge.from, edge.to].forEach((nodeId: string) => {
if (!nodeMetrics.has(nodeId)) {
nodeMetrics.set(nodeId, { requests: 0, errors: 0, connections: 0 });
}
const metrics = nodeMetrics.get(nodeId)!;
metrics.requests += edge.total_requests || 0;
metrics.errors += edge.failed_requests || 0;
});
});
// Update connection counts in metrics
connectionCount.forEach((count, nodeId) => {
if (nodeMetrics.has(nodeId)) {
nodeMetrics.get(nodeId)!.connections = count;
} else {
nodeMetrics.set(nodeId, { requests: 0, errors: 0, connections: count });
}
});
const nodes = graphData.nodes.map((node: any) => {
const metrics = nodeMetrics.get(node.id) || { requests: 0, errors: 0 };
const errorRate = metrics.requests > 0 ? (metrics.errors / metrics.requests) * 100 : 0;
// Border color based on error rate
let borderColor = "#52c41a"; // Green (healthy)
if (errorRate > 10) borderColor = "#f5222d"; // Red (critical)
else if (errorRate > 5) borderColor = "#fa8c16"; // Orange (high)
else if (errorRate > 1) borderColor = "#faad14"; // Yellow (warning)
// Size based on request volume - much smaller nodes
const symbolSize = Math.max(40, Math.min(80, Math.log10(metrics.requests + 1) * 20));
// Use cached position if available
const cachedPos = cachedPositions?.get(node.id);
const nodeData: any = {
id: node.id,
name: node.label || node.id,
value: metrics.requests,
errors: metrics.errors,
symbolSize: symbolSize,
itemStyle: {
color: '#ffffff', // White background
borderColor: borderColor,
borderWidth: 4,
shadowBlur: 10,
shadowColor: 'rgba(0, 0, 0, 0.3)',
},
label: {
show: true,
},
emphasis: {
label: {
show: true,
fontSize: 12,
fontWeight: 'bold',
},
},
tooltip: {
formatter: `
<strong>${node.label || node.id}</strong><br/>
Requests: ${formatNumber(metrics.requests)}<br/>
Errors: ${formatNumber(metrics.errors)}<br/>
Error Rate: ${errorRate.toFixed(2)}%<br/>
Connections: ${connectionCount.get(node.id) || 0}
`,
},
};
// If we have cached positions, use them and set fixed: true
if (cachedPos) {
nodeData.x = cachedPos.x;
nodeData.y = cachedPos.y;
nodeData.fixed = true;
} else {
nodeData.fixed = false;
}
return nodeData;
});
// Prepare edges with arrows showing flow direction
// For circular layout, use curved lines; for force layout, use straight lines
const edges = graphData.edges.map((edge: any, edgeIndex: number) => {
const errorRate = edge.total_requests > 0 ? (edge.failed_requests / edge.total_requests) * 100 : 0;
// For circular layout, calculate curveness to route through center
// Use positive curveness values to curve inward toward center
let curveness = 0;
if (layoutType === 'circular') {
// Vary curveness based on edge index to create visual separation
// Range from 0.3 to 0.6 (positive values should curve inward)
curveness = 0.3 + (edgeIndex % 4) * 0.1;
}
return {
source: edge.from,
target: edge.to,
value: edge.total_requests || 0,
symbol: ['none', 'arrow'], // Arrow at target end
symbolSize: [0, 12], // Smaller arrows for cleaner look
lineStyle: {
width: Math.max(1, Math.min(4, 1 + (edge.total_requests || 0) / 150)),
color: errorRate > 5 ? "#f5222d" : errorRate > 1 ? "#faad14" : "#52c41a",
curveness: curveness,
opacity: 0.5, // More transparent for less visual clutter
},
label: {
show: false,
},
emphasis: {
lineStyle: {
width: 5,
opacity: 0.9,
},
},
};
});
// Determine if we should use force layout, circular layout, or fixed positions
const hasPositions = cachedPositions && cachedPositions.size > 0;
// For force layout without cached positions, compute layout with D3-force
if (layoutType === 'force' && !hasPositions) {
console.log('[convertServiceGraphToNetwork] Computing force layout with D3-force');
const positionedNodes = computeForceLayout(nodes, graphData.edges, 800, 600);
// Apply computed positions to nodes and mark them as fixed
positionedNodes.forEach((positioned: any) => {
const node = nodes.find((n: any) => n.id === positioned.id);
if (node) {
node.x = positioned.x;
node.y = positioned.y;
node.fixed = true; // Lock positions so ECharts doesn't re-layout
}
});
console.log('[convertServiceGraphToNetwork] Applied D3-force positions to', positionedNodes.length, 'nodes');
}
// For circular layout, calculate positions manually on the periphery
if (layoutType === 'circular' && !hasPositions) {
const nodeCount = nodes.length;
const nodeSize = 15; // Node diameter
const radius = 280; // Radius where node centers are positioned
const centerX = 0;
const centerY = 0;
nodes.forEach((node: any, index: number) => {
const angle = (2 * Math.PI * index) / nodeCount - Math.PI / 2; // Start from top
node.x = centerX + radius * Math.cos(angle);
node.y = centerY + radius * Math.sin(angle);
node.fixed = true;
// For circular layout, use small uniform nodes (chord diagram style)
node.symbolSize = nodeSize;
// Simplify styling for chord diagram - use solid colors without gradients
node.itemStyle = {
color: node.itemStyle.borderColor, // Use the border color as fill
borderColor: '#ffffff',
borderWidth: 2,
shadowBlur: 5,
shadowColor: 'rgba(0, 0, 0, 0.2)',
};
});
console.log('[convertServiceGraphToNetwork] Using circular layout with', nodeCount, 'nodes on periphery');
} else if (hasPositions) {
console.log('[convertServiceGraphToNetwork] Using cached positions for', cachedPositions.size, 'nodes');
} else if (layoutType !== 'force') {
console.log('[convertServiceGraphToNetwork] Using', layoutType, 'layout');
}
// Use "none" layout when we have fixed positions (D3-force computed, circular, or cached)
const layoutMode = (hasPositions || layoutType === 'circular' || layoutType === 'force') ? "none" : layoutType;
const options = {
tooltip: {
trigger: "item",
triggerOn: "mousemove",
backgroundColor: 'rgba(50, 50, 50, 0.95)',
borderColor: '#777',
borderWidth: 1,
textStyle: {
color: '#fff',
},
},
animation: hasPositions ? false : true, // Disable animation when using cached positions
series: [
{
type: "graph",
layout: layoutMode,
data: nodes,
links: edges,
roam: true,
draggable: true, // Enable dragging to allow manual position adjustments
focusNodeAdjacency: true,
scaleLimit: {
min: 0.4,
max: 3,
},
label: layoutType === 'circular' ? {
show: true,
position: 'top',
formatter: (params: any) => params.data.name,
fontSize: 11,
color: '#333',
} : {
show: true,
formatter: (params: any) => {
const requests = params.data.value || 0;
const errors = params.data.errors || 0;
const errorRate = requests > 0 ? (errors / requests) : 0;
const reqPerSec = (requests / 60).toFixed(2);
const errorDisplay = (errorRate * 100).toFixed(2);
const serviceName = params.data.name;
// Display metrics inside, name below using rich text
return `{metrics|${errorDisplay} ms/r}\n{metrics|${reqPerSec} r/sec}\n{spacer|}\n{name|${serviceName}}`;
},
rich: {
metrics: {
fontSize: 10,
color: '#333',
lineHeight: 14,
align: 'center',
},
spacer: {
height: 40,
lineHeight: 40,
},
name: {
fontSize: 11,
fontWeight: 'normal',
color: '#333',
align: 'center',
lineHeight: 16,
},
},
},
emphasis: {
focus: "adjacency",
label: {
show: true,
fontSize: 13,
fontWeight: 'bold',
},
itemStyle: {
shadowBlur: 15,
shadowColor: 'rgba(0, 0, 0, 0.5)',
},
},
lineStyle: {
opacity: layoutType === 'circular' ? 0.5 : 0.7,
curveness: 'auto', // Let individual edges control curveness
},
edgeSymbol: ['none', 'arrow'],
edgeSymbolSize: [0, layoutType === 'circular' ? 10 : 15],
},
],
};
return { options };
};
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 formatDate = (date: any) => {
const year = String(date.getFullYear());
const month = String(date.getMonth() + 1).padStart(2, "0");