This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
The following commit(s) were added to refs/heads/main by this push:
new 69c9036 feat(layer): API dependency graph — expand-to-walk, draggable
nodes, topology-consistent nodes, new-tab drill-downs (#44)
69c9036 is described below
commit 69c9036ddca3d2f0cc8fe77c12ceab4124c1fb5f
Author: 吴晟 Wu Sheng <[email protected]>
AuthorDate: Mon Jun 8 07:32:53 2026 +0800
feat(layer): API dependency graph — expand-to-walk, draggable nodes,
topology-consistent nodes, new-tab drill-downs (#44)
Reworks and polishes the per-layer **API dependency** (endpoint-dependency)
view into a topology-consistent drill-down, then a full self-review of the
branch.
## What an operator sees
- **Direction-aware graph.** Pick an endpoint and its caller → callee chain
lays out in columns — callers left, focus centre, callees right — from a single
`getEndpointDependencies` call (both directions in one request). Nodes carry
the service-map's health-ring border, SLA-coloured RPM, and latency; edges
animate direction and label the heaviest by RPM.
- **Expand to walk the chain.** A selected endpoint shows one neutral **+**
handle that pulls in its own callers + callees in a click (callers land left,
callees right). It spins while loading; a leaf endpoint fades and shows a brief
"no further callers or callees" banner so a silent no-op never reads as a bug.
- **Draggable nodes.** Drag a box to pull a dense graph apart — edges
follow live; distinct from background pan, and a 3px threshold keeps
click-to-select working.
- **Stable scale.** The graph holds a steady node/text size whether or not
the detail sidebar is open (fit-scale cap + refit on sidebar toggle / resize),
without stomping a manual zoom/pan.
- **New-tab drill-downs.** The endpoint-dependency **Open endpoint** /
**Service →** and the service-map **Open service** / **API map →** / **Instance
map →** jumps now open in a new browser tab, so the graph you're exploring
stays put.
- **Topology vocabulary + i18n + a11y.** Shared SLA-band border, agent
badge on instrumented endpoints, focus star; compact boxes with fonts matched
to the service map; keyboard-selectable nodes/handle with `:focus-visible`
rings; localized across all eight UI languages.
---
CHANGELOG.md | 24 +
apps/ui/src/i18n/locales/de.json | 32 ++
apps/ui/src/i18n/locales/en.json | 32 ++
apps/ui/src/i18n/locales/es.json | 32 ++
apps/ui/src/i18n/locales/fr.json | 32 ++
apps/ui/src/i18n/locales/ja.json | 32 ++
apps/ui/src/i18n/locales/ko.json | 32 ++
apps/ui/src/i18n/locales/pt.json | 32 ++
apps/ui/src/i18n/locales/zh-CN.json | 32 ++
.../LayerEndpointDependencyView.vue | 600 +++++++++++++++------
.../src/layer/service-map/LayerServiceMapView.vue | 16 +-
11 files changed, 736 insertions(+), 160 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 453d2f7..d34be79 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -48,6 +48,30 @@ packages) plus the BFF's `HORIZON_VERSION` default.
stays the source) — no feature renders English-only for non-English
operators.
+### API dependency
+
+- The per-layer **API dependency** tab renders an endpoint's caller → callee
+ chain as a graph. Pick an endpoint and it lays out in columns by direction —
+ callers on the left, the focus endpoint in the centre, callees on the right —
+ with the same node health-ring border, SLA-coloured RPM, and latency you read
+ on the service map; edges animate the call direction and label the heaviest
by
+ RPM.
+- **Expand to walk the chain.** A selected endpoint shows a single **+** handle
+ that pulls in *its* own callers and callees in one click (new callers land
+ left, callees right). The handle spins while the dependency query is in
+ flight; when an endpoint is a leaf with nothing further to load it fades and
a
+ brief banner says so — a silent "nothing happened" never reads as a bug.
+- **Rearrange freely.** Drag any node box to pull a dense graph apart — edges
+ follow live. Pan, wheel-zoom, and a fit button act on the whole canvas, and a
+ node holds a steady on-screen size whether or not the detail sidebar is open.
+- **Drill straight out, in a new tab.** The node detail's **Open endpoint** and
+ **Service →**, and the service-map node/edge jumps (**Open service**,
+ **API map →**, **Instance map →**), now open in a new browser tab — so you
+ keep the graph you're exploring while the drill-down opens alongside it.
+- Nodes share the service-map's visual vocabulary (SLA-band border, an agent
+ badge on instrumented endpoints, the focus star), and the tab is localized
+ across all eight UI languages.
+
### Dashboard template portability
- Every template admin page — Overview templates, Layer dashboards, and the
diff --git a/apps/ui/src/i18n/locales/de.json b/apps/ui/src/i18n/locales/de.json
index 953bebf..9229898 100644
--- a/apps/ui/src/i18n/locales/de.json
+++ b/apps/ui/src/i18n/locales/de.json
@@ -361,6 +361,38 @@
"No matches": "Keine Treffer",
"filter by name…": "nach Name filtern…",
"{n} of {total}": "{n} von {total}",
+ "API dependency": "API-Abhängigkeit",
+ "API dependency chain": "API-Abhängigkeitskette",
+ "API dependency feed failed — check the BFF and OAP.":
"API-Abhängigkeits-Feed fehlgeschlagen – BFF und OAP prüfen.",
+ "Callees ({n})": "Aufgerufene ({n})",
+ "Callers ({n})": "Aufrufer ({n})",
+ "Calls": "Aufrufe",
+ "Clear": "Leeren",
+ "Click an edge to inspect the call": "Kante anklicken, um den Aufruf zu
untersuchen",
+ "Click an endpoint node to inspect it": "Endpoint-Knoten anklicken, um ihn
zu untersuchen",
+ "Expand {name} — show its callers and callees": "{name} erweitern – Aufrufer
und Aufgerufene anzeigen",
+ "Fit to view": "An Ansicht anpassen",
+ "Focus endpoint": "Fokus-Endpoint",
+ "L+{i} · Callees": "L+{i} · Aufgerufene",
+ "L0 · Focus": "L0 · Fokus",
+ "Line metrics (server-side)": "Kantenmetriken (serverseitig)",
+ "Loading callers and callees of {name}…": "Aufrufer und Aufgerufene von
{name} werden geladen…",
+ "L{i} · Callers": "L{i} · Aufrufer",
+ "No dependency graph available for this endpoint in the last 15 minutes.":
"Für diesen Endpoint sind in den letzten 15 Minuten keine Abhängigkeitsdaten
verfügbar.",
+ "No endpoints found.": "Keine Endpoints gefunden.",
+ "No further callers or callees for {name}": "Keine weiteren Aufrufer oder
Aufgerufene für {name}",
+ "OAP unreachable.": "OAP nicht erreichbar.",
+ "Open endpoint": "Endpoint öffnen",
+ "Pick a service in the header above to search its endpoints.": "Oben im
Header einen Service wählen, um dessen Endpoints zu durchsuchen.",
+ "Search": "Suchen",
+ "Search endpoints, press Enter…": "Endpoints suchen, Enter drücken…",
+ "Service →": "Service →",
+ "Top": "Top",
+ "focus": "Fokus",
+ "no callees in this window": "keine Aufgerufenen in diesem Zeitfenster",
+ "no callers in this window": "keine Aufrufer in diesem Zeitfenster",
+ "thicker = heaviest (by {metric})": "dicker = höchste Last (nach {metric})",
+ "{cols} columns · {eps} endpoints · click a node or edge for details":
"{cols} Spalten · {eps} Endpoints · Knoten oder Kante für Details anklicken",
"Dashboard template store unreachable": "Dashboard-Vorlagenspeicher nicht
erreichbar",
"Layer dashboards, overviews and topology are blocked until OAP’s
UI-template store is reachable.": "Layer-Dashboards, Übersichten und Topologie
sind blockiert, bis der UI-Vorlagenspeicher von OAP erreichbar ist.",
"Last sync": "Letzte Synchronisierung",
diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json
index cdda62a..1d07d7b 100644
--- a/apps/ui/src/i18n/locales/en.json
+++ b/apps/ui/src/i18n/locales/en.json
@@ -365,6 +365,38 @@
"No matches": "No matches",
"filter by name…": "filter by name…",
"{n} of {total}": "{n} of {total}",
+ "API dependency": "API dependency",
+ "API dependency chain": "API dependency chain",
+ "API dependency feed failed — check the BFF and OAP.": "API dependency feed
failed — check the BFF and OAP.",
+ "Callees ({n})": "Callees ({n})",
+ "Callers ({n})": "Callers ({n})",
+ "Calls": "Calls",
+ "Clear": "Clear",
+ "Click an edge to inspect the call": "Click an edge to inspect the call",
+ "Click an endpoint node to inspect it": "Click an endpoint node to inspect
it",
+ "Expand {name} — show its callers and callees": "Expand {name} — show its
callers and callees",
+ "Fit to view": "Fit to view",
+ "Focus endpoint": "Focus endpoint",
+ "L+{i} · Callees": "L+{i} · Callees",
+ "L0 · Focus": "L0 · Focus",
+ "Line metrics (server-side)": "Line metrics (server-side)",
+ "Loading callers and callees of {name}…": "Loading callers and callees of
{name}…",
+ "L{i} · Callers": "L{i} · Callers",
+ "No dependency graph available for this endpoint in the last 15 minutes.":
"No dependency graph available for this endpoint in the last 15 minutes.",
+ "No endpoints found.": "No endpoints found.",
+ "No further callers or callees for {name}": "No further callers or callees
for {name}",
+ "OAP unreachable.": "OAP unreachable.",
+ "Open endpoint": "Open endpoint",
+ "Pick a service in the header above to search its endpoints.": "Pick a
service in the header above to search its endpoints.",
+ "Search": "Search",
+ "Search endpoints, press Enter…": "Search endpoints, press Enter…",
+ "Service →": "Service →",
+ "Top": "Top",
+ "focus": "focus",
+ "no callees in this window": "no callees in this window",
+ "no callers in this window": "no callers in this window",
+ "thicker = heaviest (by {metric})": "thicker = heaviest (by {metric})",
+ "{cols} columns · {eps} endpoints · click a node or edge for details":
"{cols} columns · {eps} endpoints · click a node or edge for details",
"metrics: top {n}": "metrics: top {n}",
"low": "low",
"Metrics are probed for the top {n} services by {metric}; the rest are
listed as low-traffic. Raise query.landingServiceCap to probe more.": "Metrics
are probed for the top {n} services by {metric}; the rest are listed as
low-traffic. Raise query.landingServiceCap to probe more.",
diff --git a/apps/ui/src/i18n/locales/es.json b/apps/ui/src/i18n/locales/es.json
index 711e512..f49062e 100644
--- a/apps/ui/src/i18n/locales/es.json
+++ b/apps/ui/src/i18n/locales/es.json
@@ -361,6 +361,38 @@
"No matches": "Sin coincidencias",
"filter by name…": "filtrar por nombre…",
"{n} of {total}": "{n} de {total}",
+ "API dependency": "Dependencia de API",
+ "API dependency chain": "Cadena de dependencias de API",
+ "API dependency feed failed — check the BFF and OAP.": "Falló la obtención
de dependencias de API — revisa el BFF y OAP.",
+ "Callees ({n})": "Destinos ({n})",
+ "Callers ({n})": "Emisores ({n})",
+ "Calls": "Llamadas",
+ "Clear": "Limpiar",
+ "Click an edge to inspect the call": "Haz clic en una arista para
inspeccionar la llamada",
+ "Click an endpoint node to inspect it": "Haz clic en un nodo de endpoint
para inspeccionarlo",
+ "Expand {name} — show its callers and callees": "Expandir {name} — mostrar
sus emisores y destinos",
+ "Fit to view": "Ajustar a la vista",
+ "Focus endpoint": "Endpoint principal",
+ "L+{i} · Callees": "L+{i} · Destinos",
+ "L0 · Focus": "L0 · Foco",
+ "Line metrics (server-side)": "Métricas de línea (lado servidor)",
+ "Loading callers and callees of {name}…": "Cargando emisores y destinos de
{name}…",
+ "L{i} · Callers": "L{i} · Emisores",
+ "No dependency graph available for this endpoint in the last 15 minutes.":
"No hay grafo de dependencias para este endpoint en los últimos 15 minutos.",
+ "No endpoints found.": "No se encontraron endpoints.",
+ "No further callers or callees for {name}": "No hay más emisores ni destinos
para {name}",
+ "OAP unreachable.": "OAP inaccesible.",
+ "Open endpoint": "Abrir endpoint",
+ "Pick a service in the header above to search its endpoints.": "Selecciona
un servicio en la cabecera para buscar sus endpoints.",
+ "Search": "Buscar",
+ "Search endpoints, press Enter…": "Busca endpoints y pulsa Enter…",
+ "Service →": "Servicio →",
+ "Top": "Top",
+ "focus": "foco",
+ "no callees in this window": "sin destinos en esta ventana",
+ "no callers in this window": "sin emisores en esta ventana",
+ "thicker = heaviest (by {metric})": "más grueso = mayor carga (por
{metric})",
+ "{cols} columns · {eps} endpoints · click a node or edge for details":
"{cols} columnas · {eps} endpoints · haz clic en un nodo o arista para ver
detalles",
"Dashboard template store unreachable": "Almacén de plantillas de paneles
inaccesible",
"Layer dashboards, overviews and topology are blocked until OAP’s
UI-template store is reachable.": "Los paneles por capa, los resúmenes y la
topología están bloqueados hasta que el almacén de plantillas de UI de OAP sea
accesible.",
"Last sync": "Última sincronización",
diff --git a/apps/ui/src/i18n/locales/fr.json b/apps/ui/src/i18n/locales/fr.json
index 5cd6b4f..beb1648 100644
--- a/apps/ui/src/i18n/locales/fr.json
+++ b/apps/ui/src/i18n/locales/fr.json
@@ -361,6 +361,38 @@
"No matches": "Aucune correspondance",
"filter by name…": "filtrer par nom…",
"{n} of {total}": "{n} sur {total}",
+ "API dependency": "Dépendance d'API",
+ "API dependency chain": "Chaîne de dépendances d'API",
+ "API dependency feed failed — check the BFF and OAP.": "Échec du flux de
dépendances d'API — vérifiez le BFF et OAP.",
+ "Callees ({n})": "Appelés ({n})",
+ "Callers ({n})": "Appelants ({n})",
+ "Calls": "Appels",
+ "Clear": "Effacer",
+ "Click an edge to inspect the call": "Cliquez sur un lien pour inspecter
l'appel",
+ "Click an endpoint node to inspect it": "Cliquez sur un nœud d'endpoint pour
l'inspecter",
+ "Expand {name} — show its callers and callees": "Développer {name} —
afficher ses appelants et appelés",
+ "Fit to view": "Ajuster à la vue",
+ "Focus endpoint": "Endpoint ciblé",
+ "L+{i} · Callees": "L+{i} · Appelés",
+ "L0 · Focus": "L0 · Cible",
+ "Line metrics (server-side)": "Métriques du lien (côté serveur)",
+ "Loading callers and callees of {name}…": "Chargement des appelants et
appelés de {name}…",
+ "L{i} · Callers": "L{i} · Appelants",
+ "No dependency graph available for this endpoint in the last 15 minutes.":
"Aucun graphe de dépendances disponible pour cet endpoint sur les 15 dernières
minutes.",
+ "No endpoints found.": "Aucun endpoint trouvé.",
+ "No further callers or callees for {name}": "Aucun autre appelant ou appelé
pour {name}",
+ "OAP unreachable.": "OAP injoignable.",
+ "Open endpoint": "Ouvrir l'endpoint",
+ "Pick a service in the header above to search its endpoints.": "Sélectionnez
un service dans l'en-tête ci-dessus pour rechercher ses endpoints.",
+ "Search": "Rechercher",
+ "Search endpoints, press Enter…": "Rechercher des endpoints, appuyez sur
Entrée…",
+ "Service →": "Service →",
+ "Top": "Top",
+ "focus": "cible",
+ "no callees in this window": "aucun appelé dans cette fenêtre",
+ "no callers in this window": "aucun appelant dans cette fenêtre",
+ "thicker = heaviest (by {metric})": "plus épais = le plus important (par
{metric})",
+ "{cols} columns · {eps} endpoints · click a node or edge for details":
"{cols} colonnes · {eps} endpoints · cliquez sur un nœud ou un lien pour les
détails",
"Dashboard template store unreachable": "Magasin de modèles de tableaux de
bord inaccessible",
"Layer dashboards, overviews and topology are blocked until OAP’s
UI-template store is reachable.": "Les tableaux de bord de couche, les vues
d'ensemble et la topologie restent bloqués tant que le magasin de modèles d'UI
d'OAP n'est pas accessible.",
"Last sync": "Dernière synchro",
diff --git a/apps/ui/src/i18n/locales/ja.json b/apps/ui/src/i18n/locales/ja.json
index ddc4c7d..8f47983 100644
--- a/apps/ui/src/i18n/locales/ja.json
+++ b/apps/ui/src/i18n/locales/ja.json
@@ -361,6 +361,38 @@
"No matches": "該当なし",
"filter by name…": "名前で絞り込み…",
"{n} of {total}": "{total} 件中 {n} 件",
+ "API dependency": "API 依存関係",
+ "API dependency chain": "API 依存チェーン",
+ "API dependency feed failed — check the BFF and OAP.": "API 依存関係の取得に失敗しました —
BFF と OAP を確認してください。",
+ "Callees ({n})": "呼び出し先 ({n})",
+ "Callers ({n})": "呼び出し元 ({n})",
+ "Calls": "呼び出し",
+ "Clear": "クリア",
+ "Click an edge to inspect the call": "エッジをクリックして呼び出しを確認",
+ "Click an endpoint node to inspect it": "エンドポイントノードをクリックして確認",
+ "Expand {name} — show its callers and callees": "{name} を展開 —
呼び出し元と呼び出し先を表示",
+ "Fit to view": "全体に合わせる",
+ "Focus endpoint": "フォーカス中のエンドポイント",
+ "L+{i} · Callees": "L+{i} · 呼び出し先",
+ "L0 · Focus": "L0 · フォーカス",
+ "Line metrics (server-side)": "ラインメトリクス (サーバー側)",
+ "Loading callers and callees of {name}…": "{name} の呼び出し元と呼び出し先を読み込み中…",
+ "L{i} · Callers": "L{i} · 呼び出し元",
+ "No dependency graph available for this endpoint in the last 15 minutes.":
"このエンドポイントには直近 15 分間の依存関係グラフがありません。",
+ "No endpoints found.": "エンドポイントが見つかりません。",
+ "No further callers or callees for {name}": "{name} にこれ以上の呼び出し元・呼び出し先はありません",
+ "OAP unreachable.": "OAP に接続できません。",
+ "Open endpoint": "エンドポイントを開く",
+ "Pick a service in the header above to search its endpoints.":
"上部のヘッダーでサービスを選択して、エンドポイントを検索してください。",
+ "Search": "検索",
+ "Search endpoints, press Enter…": "エンドポイントを検索(Enter で実行)…",
+ "Service →": "サービス →",
+ "Top": "上位",
+ "focus": "フォーカス",
+ "no callees in this window": "この期間に呼び出し先はありません",
+ "no callers in this window": "この期間に呼び出し元はありません",
+ "thicker = heaviest (by {metric})": "太いほど多い ({metric} 基準)",
+ "{cols} columns · {eps} endpoints · click a node or edge for details":
"{cols} 列 · {eps} エンドポイント · ノードまたはエッジをクリックで詳細",
"Dashboard template store unreachable": "ダッシュボードテンプレートストアに接続できません",
"Layer dashboards, overviews and topology are blocked until OAP’s
UI-template store is reachable.": "OAP の UI
テンプレートストアに接続できるまで、レイヤーダッシュボード・概要・トポロジーは利用できません。",
"Last sync": "最終同期",
diff --git a/apps/ui/src/i18n/locales/ko.json b/apps/ui/src/i18n/locales/ko.json
index 4a3c9cb..4931169 100644
--- a/apps/ui/src/i18n/locales/ko.json
+++ b/apps/ui/src/i18n/locales/ko.json
@@ -361,6 +361,38 @@
"No matches": "일치 항목 없음",
"filter by name…": "이름으로 필터링…",
"{n} of {total}": "{total}건 중 {n}건",
+ "API dependency": "API 의존성",
+ "API dependency chain": "API 의존성 체인",
+ "API dependency feed failed — check the BFF and OAP.": "API 의존성 피드를 가져오지
못했습니다 — BFF와 OAP를 확인하세요.",
+ "Callees ({n})": "피호출 ({n})",
+ "Callers ({n})": "호출자 ({n})",
+ "Calls": "호출 수",
+ "Clear": "지우기",
+ "Click an edge to inspect the call": "간선을 클릭하면 호출을 확인합니다",
+ "Click an endpoint node to inspect it": "엔드포인트 노드를 클릭하면 확인합니다",
+ "Expand {name} — show its callers and callees": "{name} 펼치기 — 호출자와 피호출 표시",
+ "Fit to view": "화면에 맞추기",
+ "Focus endpoint": "엔드포인트 포커스",
+ "L+{i} · Callees": "L+{i} · 피호출",
+ "L0 · Focus": "L0 · 포커스",
+ "Line metrics (server-side)": "선 메트릭 (서버 측)",
+ "Loading callers and callees of {name}…": "{name}의 호출자와 피호출 불러오는 중…",
+ "L{i} · Callers": "L{i} · 호출자",
+ "No dependency graph available for this endpoint in the last 15 minutes.":
"최근 15분 동안 이 엔드포인트의 의존성 그래프가 없습니다.",
+ "No endpoints found.": "엔드포인트를 찾을 수 없습니다.",
+ "No further callers or callees for {name}": "{name}의 추가 호출자나 피호출이 없습니다",
+ "OAP unreachable.": "OAP에 연결할 수 없습니다.",
+ "Open endpoint": "엔드포인트 열기",
+ "Pick a service in the header above to search its endpoints.": "엔드포인트를 검색하려면
위 헤더에서 서비스를 선택하세요.",
+ "Search": "검색",
+ "Search endpoints, press Enter…": "엔드포인트 검색, Enter…",
+ "Service →": "서비스 →",
+ "Top": "상위",
+ "focus": "포커스",
+ "no callees in this window": "이 구간에 피호출 없음",
+ "no callers in this window": "이 구간에 호출자 없음",
+ "thicker = heaviest (by {metric})": "굵을수록 많음 ({metric} 기준)",
+ "{cols} columns · {eps} endpoints · click a node or edge for details":
"{cols}개 열 · {eps}개 엔드포인트 · 노드나 간선을 클릭하면 자세히 표시",
"Dashboard template store unreachable": "대시보드 템플릿 저장소에 연결할 수 없음",
"Layer dashboards, overviews and topology are blocked until OAP’s
UI-template store is reachable.": "OAP의 UI 템플릿 저장소에 연결되기 전까지 레이어 대시보드, 개요,
토폴로지를 사용할 수 없습니다.",
"Last sync": "마지막 동기화",
diff --git a/apps/ui/src/i18n/locales/pt.json b/apps/ui/src/i18n/locales/pt.json
index 44eb05f..951bf7c 100644
--- a/apps/ui/src/i18n/locales/pt.json
+++ b/apps/ui/src/i18n/locales/pt.json
@@ -361,6 +361,38 @@
"No matches": "Nenhuma correspondência",
"filter by name…": "filtrar por nome…",
"{n} of {total}": "{n} de {total}",
+ "API dependency": "Dependência de API",
+ "API dependency chain": "Cadeia de dependências de API",
+ "API dependency feed failed — check the BFF and OAP.": "Falha no feed de
dependências de API — verifique o BFF e o OAP.",
+ "Callees ({n})": "Chamados ({n})",
+ "Callers ({n})": "Chamadores ({n})",
+ "Calls": "Chamadas",
+ "Clear": "Limpar",
+ "Click an edge to inspect the call": "Clique em uma aresta para inspecionar
a chamada",
+ "Click an endpoint node to inspect it": "Clique em um nó de endpoint para
inspecioná-lo",
+ "Expand {name} — show its callers and callees": "Expandir {name} — mostrar
seus chamadores e chamados",
+ "Fit to view": "Ajustar à tela",
+ "Focus endpoint": "Focar endpoint",
+ "L+{i} · Callees": "L+{i} · Chamados",
+ "L0 · Focus": "L0 · Foco",
+ "Line metrics (server-side)": "Métricas de linha (lado do servidor)",
+ "Loading callers and callees of {name}…": "Carregando chamadores e chamados
de {name}…",
+ "L{i} · Callers": "L{i} · Chamadores",
+ "No dependency graph available for this endpoint in the last 15 minutes.":
"Nenhum grafo de dependências disponível para este endpoint nos últimos 15
minutos.",
+ "No endpoints found.": "Nenhum endpoint encontrado.",
+ "No further callers or callees for {name}": "Sem mais chamadores ou chamados
para {name}",
+ "OAP unreachable.": "OAP inacessível.",
+ "Open endpoint": "Abrir endpoint",
+ "Pick a service in the header above to search its endpoints.": "Selecione um
serviço no cabeçalho acima para buscar seus endpoints.",
+ "Search": "Buscar",
+ "Search endpoints, press Enter…": "Buscar endpoints, pressione Enter…",
+ "Service →": "Serviço →",
+ "Top": "Top",
+ "focus": "foco",
+ "no callees in this window": "nenhum chamado nesta janela",
+ "no callers in this window": "nenhum chamador nesta janela",
+ "thicker = heaviest (by {metric})": "mais grossa = mais pesada (por
{metric})",
+ "{cols} columns · {eps} endpoints · click a node or edge for details":
"{cols} colunas · {eps} endpoints · clique em um nó ou aresta para ver
detalhes",
"Dashboard template store unreachable": "Repositório de templates de
dashboard inacessível",
"Layer dashboards, overviews and topology are blocked until OAP’s
UI-template store is reachable.": "Dashboards de camada, visões gerais e
topologia ficam bloqueados até o repositório de templates de UI do OAP ficar
acessível.",
"Last sync": "Última sincronização",
diff --git a/apps/ui/src/i18n/locales/zh-CN.json
b/apps/ui/src/i18n/locales/zh-CN.json
index ea50119..3a04cd3 100644
--- a/apps/ui/src/i18n/locales/zh-CN.json
+++ b/apps/ui/src/i18n/locales/zh-CN.json
@@ -361,6 +361,38 @@
"No matches": "无匹配",
"filter by name…": "按名称过滤…",
"{n} of {total}": "{n} / {total}",
+ "API dependency": "API 依赖",
+ "API dependency chain": "API 依赖链",
+ "API dependency feed failed — check the BFF and OAP.": "API 依赖数据获取失败 — 请检查
BFF 与 OAP。",
+ "Callees ({n})": "被调方({n})",
+ "Callers ({n})": "调用方({n})",
+ "Calls": "调用量",
+ "Clear": "清除",
+ "Click an edge to inspect the call": "点击连线查看此调用",
+ "Click an endpoint node to inspect it": "点击端点节点查看详情",
+ "Expand {name} — show its callers and callees": "展开 {name} — 显示其调用方与被调方",
+ "Fit to view": "适应视图",
+ "Focus endpoint": "焦点端点",
+ "L+{i} · Callees": "L+{i} · 被调方",
+ "L0 · Focus": "L0 · 焦点",
+ "Line metrics (server-side)": "连线指标(服务端)",
+ "Loading callers and callees of {name}…": "正在加载 {name} 的调用方与被调方…",
+ "L{i} · Callers": "L{i} · 调用方",
+ "No dependency graph available for this endpoint in the last 15 minutes.":
"最近 15 分钟内该端点暂无可用的依赖图。",
+ "No endpoints found.": "未找到端点。",
+ "No further callers or callees for {name}": "{name} 没有更多的调用方或被调方",
+ "OAP unreachable.": "无法连接 OAP。",
+ "Open endpoint": "打开端点",
+ "Pick a service in the header above to search its endpoints.":
"请在上方顶栏选择服务以搜索其端点。",
+ "Search": "搜索",
+ "Search endpoints, press Enter…": "搜索端点,按回车…",
+ "Service →": "服务 →",
+ "Top": "Top",
+ "focus": "焦点",
+ "no callees in this window": "此时间窗内无被调方",
+ "no callers in this window": "此时间窗内无调用方",
+ "thicker = heaviest (by {metric})": "越粗 = 流量越大(按 {metric})",
+ "{cols} columns · {eps} endpoints · click a node or edge for details":
"{cols} 列 · {eps} 个端点 · 点击节点或连线查看详情",
"Dashboard template store unreachable": "仪表盘模板存储无法访问",
"Layer dashboards, overviews and topology are blocked until OAP’s
UI-template store is reachable.": "在 OAP 的 UI 模板存储恢复可用之前,层级仪表盘、概览与拓扑均无法使用。",
"Last sync": "上次同步",
diff --git
a/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue
b/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue
index c59c6db..4892aea 100644
--- a/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue
+++ b/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue
@@ -31,8 +31,9 @@
callout). Only one popout open at a time.
-->
<script setup lang="ts">
-import { computed, ref, watchEffect } from 'vue';
-import { useRoute, useRouter } from 'vue-router';
+import { computed, nextTick, onBeforeUnmount, onMounted, ref, watchEffect }
from 'vue';
+import { useRoute, useRouter, type RouteLocationRaw } from 'vue-router';
+import { useI18n } from 'vue-i18n';
import type {
EndpointDependencyCall,
EndpointDependencyNode,
@@ -56,6 +57,7 @@ import Sparkline from '@/components/charts/Sparkline.vue';
const route = useRoute();
const router = useRouter();
+const { t } = useI18n({ useScope: 'global' });
const layerKey = computed(() => String(route.params.layerKey ?? ''));
const { selectedId, setSelected: setSelectedService } = useSelectedService();
@@ -141,43 +143,83 @@ const reachable = computed(() => data.value?.reachable
!== false);
const errorText = computed(() => data.value?.error ?? null);
// ── Interactive expansion ─────────────────────────────────────────
-// Each click on a node's left / right expand button fires another
-// `endpoint-dependency` query for THAT endpoint and merges the
-// returned graph into the rendered set. Keyed by `${nodeId}:dir` so
-// repeat clicks are idempotent (same direction = no-op; future
-// "collapse" affordance can lift the entry instead).
+// `getEndpointDependencies` returns a node's WHOLE neighbourhood (both
+// directions in ONE response — OAP has no directional endpoint query),
+// so there is ONE expand per node, not a left/right pair. New callers
+// land left, callees right via the BFS layout. The handle lives on the
+// node's OUTWARD edge (the way the chain extends); keyed by node id so a
+// repeat click is a no-op. A click that surfaces nothing new marks the
+// node exhausted, fading the handle so it isn't a dead control.
const expansions = ref<Map<string, EndpointDependencyResponse>>(new Map());
const expansionsLoading = ref<Set<string>>(new Set());
-function hasExpansion(node: EndpointDependencyNode, dir: 'upstream' |
'downstream'): boolean {
- return expansions.value.has(`${node.id}:${dir}`);
-}
-async function expandNode(node: EndpointDependencyNode, dir: 'upstream' |
'downstream'): Promise<void> {
- const key = `${node.id}:${dir}`;
+const exhausted = ref<Set<string>>(new Set());
+function hasExpansion(node: EndpointDependencyNode): boolean {
+ return expansions.value.has(node.id);
+}
+function isExhausted(node: EndpointDependencyNode): boolean {
+ return exhausted.value.has(node.id);
+}
+function isLoadingExpansion(node: EndpointDependencyNode): boolean {
+ return expansionsLoading.value.has(node.id);
+}
+// Transient banner when an expand returns no NEW neighbour, so a leaf-node
+// expand gives explicit feedback ("loaded, but nothing more") instead of
+// the easily-missed handle fade. Auto-clears after a few seconds.
+const noDepFlash = ref<string | null>(null);
+let noDepFlashTimer: ReturnType<typeof setTimeout> | null = null;
+function flashNoDep(name: string): void {
+ noDepFlash.value = name;
+ if (noDepFlashTimer) clearTimeout(noDepFlashTimer);
+ noDepFlashTimer = setTimeout(() => {
+ noDepFlash.value = null;
+ noDepFlashTimer = null;
+ }, 3200);
+}
+async function expandNode(node: EndpointDependencyNode): Promise<void> {
+ const key = node.id;
if (expansions.value.has(key) || expansionsLoading.value.has(key)) return;
- expansionsLoading.value.add(key);
+ const loading = new Set(expansionsLoading.value);
+ loading.add(key);
+ expansionsLoading.value = loading;
try {
+ const before = new Set(nodes.value.map((n) => n.id));
const resp = await bffClient.layer.endpointDependency(
layerKey.value,
node.serviceName,
node.name,
);
- // Mutate the Map and re-assign to force reactivity.
const next = new Map(expansions.value);
next.set(key, resp);
expansions.value = next;
+ // No new neighbour surfaced (chain leaf / all already shown) — fade
+ // the handle AND flash an explicit "nothing more" banner.
+ if (!resp.nodes.some((n) => !before.has(n.id))) {
+ const e = new Set(exhausted.value);
+ e.add(key);
+ exhausted.value = e;
+ flashNoDep(node.name);
+ }
} catch {
// Soft-fail — the operator can click again to retry.
} finally {
- const loading = new Set(expansionsLoading.value);
- loading.delete(key);
- expansionsLoading.value = loading;
+ const done = new Set(expansionsLoading.value);
+ done.delete(key);
+ expansionsLoading.value = done;
}
}
-// Reset expansions whenever the focus endpoint changes — the
-// previous expansion graph is irrelevant against a new focus.
+// Reset expansions whenever the focus endpoint changes — the previous
+// expansion graph is irrelevant against a new focus.
watch(selectedEndpoint, () => {
expansions.value = new Map();
expansionsLoading.value = new Set();
+ exhausted.value = new Set();
+ dragOffsets.value = new Map();
+ // Cascade-clear the per-graph view state too. Endpoint ids are stable
+ // across focuses, so without this a node/edge selected under the old
+ // focus keeps the detail sidebar open against the new graph.
+ selectedNodeId.value = null;
+ selectedCallId.value = null;
+ noDepFlash.value = null;
});
// ── Merged graph = focus response ∪ all expansion responses.
@@ -304,34 +346,37 @@ const layoutNodes = computed<LayoutNode[]>(() => {
}
const layerOf = new Map<string, number>();
if (focusId && byId.has(focusId)) {
+ // ONE direction-aware BFS over the whole connected component: from
+ // each reached node a downstream neighbour sits one layer right (+1),
+ // an upstream neighbour one layer left (-1). A single combined pass —
+ // not forward-only then backward-only — so cross-links land relative
+ // to their own neighbour. The old two-pass version couldn't reach a
+ // CALLER of a callee-of-focus (e.g. a node revealed by expanding a
+ // downstream endpoint) and dumped it into a far straggler column.
layerOf.set(focusId, 0);
- // Forward BFS (downstream).
- const fwd = [focusId];
- while (fwd.length > 0) {
- const id = fwd.shift()!;
+ const queue = [focusId];
+ while (queue.length > 0) {
+ const id = queue.shift()!;
const cur = layerOf.get(id)!;
for (const t of downstream.get(id) ?? []) {
if (!layerOf.has(t)) {
layerOf.set(t, cur + 1);
- fwd.push(t);
+ queue.push(t);
}
}
- }
- // Backward BFS (upstream).
- const back = [focusId];
- while (back.length > 0) {
- const id = back.shift()!;
- const cur = layerOf.get(id)!;
for (const s of upstream.get(id) ?? []) {
if (!layerOf.has(s)) {
layerOf.set(s, cur - 1);
- back.push(s);
+ queue.push(s);
}
}
}
}
- // Stragglers — anything still un-bucketed gets a "far" layer.
- let extra = 4;
+ // Stragglers — genuinely disconnected nodes get a column just past the
+ // rightmost real layer (not a hard-coded index that could collide).
+ let maxLayer = 0;
+ for (const v of layerOf.values()) if (v > maxLayer) maxLayer = v;
+ let extra = maxLayer + 1;
for (const n of all) {
if (!layerOf.has(n.id)) {
layerOf.set(n.id, extra++);
@@ -368,14 +413,14 @@ const layerColumns = computed<LayerColumn[]>(() => {
const visible = list.slice(0, NODES_PER_LAYER);
const hidden = list.length - visible.length;
let label: string;
- // Layer convention: focus = L0; layers to the LEFT (negative
- // index, callers / "before") = Downstream in the operator's
- // wording; layers to the RIGHT (positive, callees / "after")
- // = Upstream. Matches the nginx/proxy mental model the team
- // already uses.
- if (i < 0) label = `L${i} · Downstream`;
- else if (i === 0) label = 'L0 · Focus';
- else label = `L+${i} · Upstream`;
+ // Focus = L0; layers to the LEFT (negative index) are the focus's
+ // callers, to the RIGHT (positive) its callees. Labelled `Callers`
+ // / `Callees` to match the node popout (Callers/Callees) and the
+ // expand handles — the old `Upstream`/`Downstream` pair was both
+ // ambiguous and inverted (nginx vs data-flow conventions clashed).
+ if (i < 0) label = t('L{i} · Callers', { i });
+ else if (i === 0) label = t('L0 · Focus');
+ else label = t('L+{i} · Callees', { i });
return { index: i, label, visible, hidden };
});
});
@@ -383,14 +428,13 @@ const layerColumns = computed<LayerColumn[]>(() => {
// ── SVG layout math. The template binds NW / COL_GAP via the same
// names; exposing them on a const-bag keeps Vue's setup-script
// auto-binding happy without resorting to `defineExpose`.
-const NW = 180;
-// Taller box: 3 stacked rows (service name / API name / RPM).
-const NH = 76;
-// Wider gap between columns so the curved edge has room to carry
-// the line-metric chip (60-80px) without colliding with adjacent
-// node boxes.
-const COL_GAP = 320;
-const ROW_GAP = 96;
+const NW = 152;
+// Compact box: 3 tight stacked rows (service name / API name / RPM).
+const NH = 56;
+// Gap between columns leaves room for the curved edge's line-metric
+// chip (60-80px) without colliding with adjacent node boxes.
+const COL_GAP = 300;
+const ROW_GAP = 80;
const W = computed(() => Math.max(800, layerColumns.value.length * COL_GAP +
80));
const H = computed(() => {
const maxNodes = Math.max(1, ...layerColumns.value.map((c) =>
c.visible.length));
@@ -423,6 +467,20 @@ const nodePos = computed<Map<string, Pos>>(() => {
return map;
});
+// ── Manual node drag. The operator can drag a box to declutter a dense
+// graph; the offset layers on the BFS layout so edges (which read
+// displayPos) follow. Cleared when a new focus endpoint rebuilds the graph.
+const dragOffsets = ref<Map<string, { dx: number; dy: number }>>(new Map());
+const displayPos = computed<Map<string, Pos>>(() => {
+ if (dragOffsets.value.size === 0) return nodePos.value;
+ const out = new Map<string, Pos>();
+ for (const [id, p] of nodePos.value) {
+ const off = dragOffsets.value.get(id);
+ out.set(id, off ? { ...p, x: p.x + off.dx, y: p.y + off.dy } : p);
+ }
+ return out;
+});
+
// Filter calls whose endpoints survived the per-layer cap.
const visibleCalls = computed<EndpointDependencyCall[]>(() => {
const ids = new Set(nodePos.value.keys());
@@ -434,16 +492,44 @@ const visibleCalls =
computed<EndpointDependencyCall[]>(() => {
// off a clipped column. Wheel + +/−/fit buttons zoom; drag pans.
const svgRef = ref<SVGSVGElement | null>(null);
const viewBox = ref<{ x: number; y: number; w: number; h: number } |
null>(null);
+// Set once the operator wheels / pans / zooms — an automatic refit
+// (canvas resize, sidebar open/close) must not stomp a deliberate viewport.
+const userAdjusted = ref(false);
const viewBoxStr = computed(() => {
const v = viewBox.value ?? { x: 0, y: 0, w: W.value, h: H.value };
return `${v.x} ${v.y} ${v.w} ${v.h}`;
});
+// On-screen px per graph unit cap. A sparse graph (2-3 nodes) in a wide
+// canvas would otherwise scale up under `meet` until the text balloons —
+// most visible when nothing is selected and the graph spans full width
+// (no detail sidebar). Capping holds node text at the same size whether
+// or not the sidebar is open; the surplus room becomes centered padding.
+const MAX_FIT_SCALE = 1.15;
function fitView(): void {
- viewBox.value = { x: 0, y: 0, w: W.value, h: H.value };
-}
-// Refit when the graph itself changes (focus pick / first load / refresh
-// that adds or drops a column). Operator zoom/pan persists otherwise.
-watch([focusedId, () => layerColumns.value.length], () => fitView(), {
immediate: true });
+ userAdjusted.value = false;
+ const r = svgRef.value?.getBoundingClientRect();
+ if (!r || !r.width || !r.height) {
+ viewBox.value = { x: 0, y: 0, w: W.value, h: H.value };
+ return;
+ }
+ const scale = Math.min(r.width / W.value, r.height / H.value, MAX_FIT_SCALE);
+ const vw = r.width / scale;
+ const vh = r.height / scale;
+ viewBox.value = { x: (W.value - vw) / 2, y: (H.value - vh) / 2, w: vw, h: vh
};
+}
+// A new focus endpoint rebuilds the graph from scratch — always refit
+// (this also clears userAdjusted). nextTick so the canvas has its
+// post-change size.
+watch(focusedId, () => void nextTick(fitView), { immediate: true });
+// Column count changes mid-exploration (expanding a node adds a caller /
+// callee layer). Refit so the new column is in view — unless the operator
+// has zoomed/panned, in which case keep their viewport.
+watch(
+ () => layerColumns.value.length,
+ () => {
+ if (!userAdjusted.value) void nextTick(fitView);
+ },
+);
/** Rendered scale + letterbox offset for the current viewBox under
* preserveAspectRatio="xMidYMid meet" — so cursor zoom + drag pan map
@@ -461,6 +547,7 @@ function clientToView(clientX: number, clientY: number): {
x: number; y: number
return { x: v.x + (clientX - left - offX) / scale, y: v.y + (clientY - top -
offY) / scale };
}
function zoomAround(factor: number, cx: number, cy: number): void {
+ userAdjusted.value = true;
const v = viewBox.value ?? { x: 0, y: 0, w: W.value, h: H.value };
// viewBox width bounded to [30%, 160%] of the full graph (zoom-in / out
caps).
const newW = Math.min(W.value * 1.6, Math.max(W.value * 0.3, v.w * factor));
@@ -487,6 +574,7 @@ function onPanStart(e: PointerEvent): void {
}
function onPanMove(e: PointerEvent): void {
if (!panning) return;
+ userAdjusted.value = true;
const { v, scale } = viewMetrics();
viewBox.value = { ...v, x: panStart.vx - (e.clientX - panStart.cx) / scale,
y: panStart.vy - (e.clientY - panStart.cy) / scale };
}
@@ -534,6 +622,42 @@ function ringColor(n: EndpointDependencyNode): string {
// ── Selected node popout state. Anchors the design's tail callout
// just right of the clicked node.
const selectedNodeId = ref<string | null>(null);
+
+// Per-node drag (distinct from the background pan). Pointer-captured on
+// the box so move/up land here even off-box; screen pixels → graph units
+// via the view scale. Under the 3px threshold it's a click → toggle
+// selection (the box has no @click any more).
+const nodeDragId = ref<string | null>(null);
+let nodeDragStart = { x: 0, y: 0, baseDx: 0, baseDy: 0, moved: false };
+function onNodePointerDown(e: PointerEvent, id: string): void {
+ if (e.button !== 0) return;
+ e.stopPropagation(); // don't also start a background pan
+ const off = dragOffsets.value.get(id) ?? { dx: 0, dy: 0 };
+ nodeDragId.value = id;
+ nodeDragStart = { x: e.clientX, y: e.clientY, baseDx: off.dx, baseDy:
off.dy, moved: false };
+ (e.currentTarget as Element).setPointerCapture?.(e.pointerId);
+}
+function onNodePointerMove(e: PointerEvent): void {
+ if (nodeDragId.value === null) return;
+ const dxs = e.clientX - nodeDragStart.x;
+ const dys = e.clientY - nodeDragStart.y;
+ if (!nodeDragStart.moved && Math.hypot(dxs, dys) > 3) nodeDragStart.moved =
true;
+ if (!nodeDragStart.moved) return;
+ const { scale } = viewMetrics();
+ const next = new Map(dragOffsets.value);
+ next.set(nodeDragId.value, { dx: nodeDragStart.baseDx + dxs / scale, dy:
nodeDragStart.baseDy + dys / scale });
+ dragOffsets.value = next;
+}
+function onNodePointerUp(e: PointerEvent, id: string): void {
+ const el = e.currentTarget as Element;
+ if (el.hasPointerCapture?.(e.pointerId))
el.releasePointerCapture(e.pointerId);
+ const downOnThis = nodeDragId.value === id;
+ const moved = nodeDragStart.moved;
+ nodeDragId.value = null;
+ if (downOnThis && !moved) {
+ selectedNodeId.value = selectedNodeId.value === id ? null : id;
+ }
+}
const selectedNode = computed<EndpointDependencyNode | null>(() => {
const id = selectedNodeId.value;
if (!id) return null;
@@ -569,16 +693,24 @@ function formatEdgeRowLabel(row: { label: string; unit?:
string | null }): strin
return `${lab} (${u.toUpperCase()})`;
}
+/** Open a route in a fresh browser tab — the graph stays put so the
+ * operator can fan out to several services/endpoints without losing
+ * their place. History mode means `resolve(...).href` is a real URL. */
+function openRouteInNewTab(to: RouteLocationRaw): void {
+ const href = router.resolve(to).href;
+ window.open(href, '_blank', 'noopener');
+}
+
function jumpToService(): void {
const sel = selectedNode.value;
if (!sel) return;
- void router.push({
+ openRouteInNewTab({
path: `/layer/${layerKey.value}/service`,
query: { service: sel.serviceId },
});
}
/**
- * Navigate to the Endpoint dashboard for the clicked node — same
+ * Open the Endpoint dashboard for the clicked node in a new tab — same
* pattern as topology's "Open service" jump. No client-side keyword
* search: we hand the endpoint name + service id to the page via the
* URL, and the Endpoint view's own auto-pick effect resolves it.
@@ -586,7 +718,7 @@ function jumpToService(): void {
function jumpToEndpointDashboard(): void {
const sel = selectedNode.value;
if (!sel) return;
- void router.push({
+ openRouteInNewTab({
path: `/layer/${layerKey.value}/endpoint`,
query: {
service: sel.serviceId,
@@ -607,8 +739,8 @@ function bowSign(c: EndpointDependencyCall): number {
return c.source < c.target ? 1 : -1;
}
function callPathD(c: EndpointDependencyCall): string {
- const a = nodePos.value.get(c.source);
- const b = nodePos.value.get(c.target);
+ const a = displayPos.value.get(c.source);
+ const b = displayPos.value.get(c.target);
if (!a || !b) return '';
const x1 = a.x + NW;
const y1 = a.y + NH / 2;
@@ -630,8 +762,8 @@ function callPathD(c: EndpointDependencyCall): string {
return `M ${x1} ${y1} Q ${ctrlX} ${ctrlY} ${x2} ${y2}`;
}
function callMidpoint(c: EndpointDependencyCall): { x: number; y: number } |
null {
- const a = nodePos.value.get(c.source);
- const b = nodePos.value.get(c.target);
+ const a = displayPos.value.get(c.source);
+ const b = displayPos.value.get(c.target);
if (!a || !b) return null;
const x1 = a.x + NW;
const y1 = a.y + NH / 2;
@@ -676,6 +808,29 @@ const selectedCallTarget = computed<EndpointDependencyNode
| null>(() => {
if (!c) return null;
return nodes.value.find((n) => n.id === c.target) ?? null;
});
+
+// The detail sidebar appears only when a node or edge is selected, and its
+// presence changes the canvas width — which would otherwise rescale the
+// viewBox and resize node text. Refit on that toggle (and on window
+// resize) so the capped scale recomputes for the new width, unless the
+// operator has taken manual control of the viewport.
+watch(
+ () => Boolean(selectedNode.value || selectedCall.value),
+ () => {
+ if (!userAdjusted.value) void nextTick(fitView);
+ },
+);
+function onWindowResize(): void {
+ if (!userAdjusted.value) fitView();
+}
+onMounted(() => {
+ fitView();
+ window.addEventListener('resize', onWindowResize);
+});
+onBeforeUnmount(() => {
+ window.removeEventListener('resize', onWindowResize);
+ if (noDepFlashTimer) clearTimeout(noDepFlashTimer);
+});
function edgeSeries(c: EndpointDependencyCall, def: TopologyMetricDef):
Array<number | null> {
return c.metricSeries?.[def.id] ?? [];
}
@@ -708,7 +863,7 @@ function edgeRowCrosshair(rowId: string): number | null {
<!-- Endpoint picker — search-on-Enter (mirrors the Endpoint tab). -->
<section class="ep-picker sw-card">
<header class="picker-head">
- <span class="kicker">API dependency</span>
+ <span class="kicker">{{ t('API dependency') }}</span>
<span v-if="serviceName" class="for-svc">
on
<span v-if="identity(serviceName).cluster" class="sw-tag accent tiny
inline-tag">
@@ -721,27 +876,27 @@ function edgeRowCrosshair(rowId: string): number | null {
</span>
<b>{{ identity(serviceName).display }}</b>
</span>
- <span v-if="isFetching" class="hint">refreshing…</span>
+ <span v-if="isFetching" class="hint">{{ t('refreshing…') }}</span>
</header>
<div v-if="!serviceName" class="empty inline">
- Pick a service in the header above to search its endpoints.
+ {{ t('Pick a service in the header above to search its endpoints.') }}
</div>
<template v-else>
<div class="ep-controls">
<input
class="ep-search"
type="search"
- placeholder="Search endpoints, press Enter…"
+ :placeholder="t('Search endpoints, press Enter…')"
v-model="endpointSearchInput"
@keydown.enter.prevent="submitEndpointSearch"
@search="submitEndpointSearch"
/>
- <button class="sw-btn small" type="button"
@click="submitEndpointSearch">Search</button>
+ <button class="sw-btn small" type="button"
@click="submitEndpointSearch">{{ t('Search') }}</button>
<button v-if="endpointQuery" class="sw-btn ghost small"
type="button" @click="clearEndpointSearch">
- Clear
+ {{ t('Clear') }}
</button>
<label class="ep-limit">
- <span>Top</span>
+ <span>{{ t('Top') }}</span>
<select v-model.number="endpointLimit">
<option :value="20">20</option>
<option :value="30">30</option>
@@ -762,37 +917,42 @@ function edgeRowCrosshair(rowId: string): number | null {
</li>
</ul>
<div v-else-if="!endpointsLoading" class="empty inline">
- No endpoints found.
+ {{ t('No endpoints found.') }}
</div>
</template>
</section>
<div v-if="!reachable" class="banner err">
- <strong>OAP unreachable.</strong>
- {{ errorText ?? 'API dependency feed failed — check the BFF and OAP.' }}
+ <strong>{{ t('OAP unreachable.') }}</strong>
+ {{ errorText ?? t('API dependency feed failed — check the BFF and OAP.')
}}
</div>
- <section v-if="selectedEndpoint" class="ep-graph-card sw-card" :style="{
height: cardHeightPx + 'px' }">
+ <section v-if="selectedEndpoint" class="ep-graph-card sw-card" :class="{
'has-detail': selectedNode || selectedCall }" :style="{ height: cardHeightPx +
'px' }">
<!-- Two-column layout: graph on the left, selection detail
panel on the right. Mirrors the topology view's sidebar so
operators get the same interaction pattern across the two
dependency tabs. -->
<div class="ep-graph">
<header class="graph-head">
- <h4>API dependency chain</h4>
+ <h4>{{ t('API dependency chain') }}</h4>
<span class="hint">
- {{ layerColumns.length }} columns · {{ nodes.length }} endpoints
- · click a node for details
+ {{ t('{cols} columns · {eps} endpoints · click a node or edge for
details', { cols: layerColumns.length, eps: nodes.length }) }}
</span>
</header>
<div class="ep-scroll">
+ <!-- Transient feedback when an expand returns no new dependency. -->
+ <transition name="ep-flash">
+ <div v-if="noDepFlash" class="ep-nodep-flash">
+ <span>{{ t('No further callers or callees for {name}', { name:
noDepFlash }) }}</span>
+ </div>
+ </transition>
<!-- Zoom toolbar — over the canvas (not the header); wheel + drag
also work directly on the graph. -->
<div v-if="layoutNodes.length > 0" class="ep-zoom">
- <button type="button" title="Zoom in"
@click="zoomBtn(0.8)">+</button>
- <button type="button" title="Zoom out"
@click="zoomBtn(1.25)">−</button>
- <button type="button" title="Fit to view" @click="fitView">⤢</button>
+ <button type="button" :title="t('Zoom in')"
@click="zoomBtn(0.8)">+</button>
+ <button type="button" :title="t('Zoom out')"
@click="zoomBtn(1.25)">−</button>
+ <button type="button" :title="t('Fit to view')"
@click="fitView">⤢</button>
</div>
<svg
v-if="layoutNodes.length > 0"
@@ -875,24 +1035,25 @@ function edgeRowCrosshair(rowId: string): number | null {
>
<title v-if="lineDef">{{ lineDef.label }}: {{
fmtMetric(edgeVal(c, lineDef)) }} {{ lineDef.unit ?? '' }}</title>
</path>
- <!-- Animated traffic dots on focus/heaviest/selected edges. -->
- <template v-if="c.source === focusedId || c.target === focusedId
|| selectedCallId === c.id">
- <circle
- v-for="off in [0, 0.5, 1.0]"
- :key="off"
- r="2.2"
- :fill="selectedCallId === c.id ? 'var(--sw-accent-2)' :
'var(--sw-accent)'"
- opacity="0.85"
- style="pointer-events: none"
- >
- <animateMotion
- :dur="`${2.4 + (off * 0.4)}s`"
- :begin="`${off}s`"
- repeatCount="indefinite"
- :path="callPathD(c)"
- />
- </circle>
- </template>
+ <!-- Animated traffic dots on EVERY edge — they advertise call
+ direction (source→target) in place of arrowheads, so an
+ expanded edge that doesn't touch the focus still shows
+ which way the call flows. Selected edge dots brighten. -->
+ <circle
+ v-for="off in [0, 0.5, 1.0]"
+ :key="off"
+ r="2.2"
+ :fill="selectedCallId === c.id ? 'var(--sw-accent-2)' :
'var(--sw-accent)'"
+ :opacity="selectedCallId === c.id || c.source === focusedId ||
c.target === focusedId ? 0.9 : 0.6"
+ style="pointer-events: none"
+ >
+ <animateMotion
+ :dur="`${2.4 + (off * 0.4)}s`"
+ :begin="`${off}s`"
+ repeatCount="indefinite"
+ :path="callPathD(c)"
+ />
+ </circle>
<!-- Compact metric chip at the curve midpoint. -->
<template v-if="lineDef && edgeVal(c, lineDef) !== null &&
callMidpoint(c)">
<g
@@ -928,9 +1089,17 @@ function edgeRowCrosshair(rowId: string): number | null {
<g
v-for="n in layoutNodes.filter((nn) => nodePos.get(nn.id))"
:key="n.id"
- :transform="`translate(${nodePos.get(n.id)!.x},
${nodePos.get(n.id)!.y})`"
+ :transform="`translate(${displayPos.get(n.id)!.x},
${displayPos.get(n.id)!.y})`"
class="ep-node"
- @click="selectedNodeId = selectedNodeId === n.id ? null : n.id"
+ :class="{ dragging: nodeDragId === n.id }"
+ role="button"
+ tabindex="0"
+ :aria-label="`${n.name} — ${identity(n.serviceName).display}`"
+ @pointerdown="onNodePointerDown($event, n.id)"
+ @pointermove="onNodePointerMove($event)"
+ @pointerup="onNodePointerUp($event, n.id)"
+ @keydown.enter.prevent="selectedNodeId = selectedNodeId === n.id ?
null : n.id"
+ @keydown.space.prevent="selectedNodeId = selectedNodeId === n.id ?
null : n.id"
>
<!-- Selection halo only — the FOCUS node is identifiable
by its column header (`L0 · Focus`) and an inset
@@ -962,55 +1131,69 @@ function edgeRowCrosshair(rowId: string): number | null {
<!-- Focus marker — small star bottom-right. Operator's
mental cue: "this is the endpoint I clicked into",
without sharing the orange halo with selection. -->
- <g v-if="n.id === focusedId" :transform="`translate(${NW - 14},
${NH - 14})`">
- <circle r="8" fill="var(--sw-bg-0)"
stroke="var(--sw-accent-line)" stroke-width="1" />
+ <g v-if="n.id === focusedId" :transform="`translate(${NW - 12},
${NH - 12})`">
+ <circle r="7" fill="var(--sw-bg-0)"
stroke="var(--sw-accent-line)" stroke-width="1" />
<text
text-anchor="middle"
y="3"
- font-size="10"
+ font-size="9"
font-weight="700"
fill="var(--sw-accent-2)"
>★</text>
- <title>Focus endpoint</title>
+ <title>{{ t('Focus endpoint') }}</title>
+ </g>
+ <!-- Agent badge — straddles the top-left corner the way the
+ service-map hexagon's does (top-right is the expand
+ handle, bottom-right the focus star). Marks an endpoint
+ on an instrumented (real) service; the synthetic User and
+ external callees carry none. Shares the Topology node
+ vocabulary. No kind icon — endpoint nodes don't carry a
+ component classification on the OAP wire. -->
+ <g v-if="n.isReal" transform="translate(0, 0)">
+ <circle r="8" fill="var(--sw-bg-0)"
stroke="var(--sw-accent-line)" stroke-width="1" />
+ <circle r="6.8" fill="var(--sw-accent)" opacity="0.18" />
+ <g transform="translate(-5.4, -5.4) scale(0.48)"
fill="var(--sw-accent-2)">
+ <path d="M3 14c4-3 8-3 12-1 3 1.4 5 .5 6-1-1 5-4 8-9 8-4
0-7-2-9-6z" />
+ <path
+ d="M5 10c3-2 7-2 11 0 3 1.3 5 .6 6-1-1 3.6-4 6-8 6-4
0-7-1.6-9-5z"
+ fill="#fff"
+ opacity="0.25"
+ />
+ </g>
</g>
- <!-- Kind stripe removed — endpoint nodes don't carry a
- meaningful component classification (booster derives
- kind from service type which doesn't apply to plain
- HTTP endpoints). Border + focus star carry all the
- visual signal. -->
<!-- Row 1: full service name (small, fg-3 mono). -->
<text
- x="12"
- y="18"
+ x="11"
+ y="16"
fill="var(--sw-fg-3)"
- font-size="10"
+ font-size="9"
font-family="var(--sw-mono)"
clip-path="url(#ep-node-text-clip)"
>
<title>{{ n.serviceName }}</title>
- {{ identity(n.serviceName).display.length > 24 ?
identity(n.serviceName).display.slice(0, 22) + '…' :
identity(n.serviceName).display }}
+ {{ identity(n.serviceName).display.length > 21 ?
identity(n.serviceName).display.slice(0, 19) + '…' :
identity(n.serviceName).display }}
</text>
<!-- Row 2: API (endpoint) name — the headline. -->
<text
- x="12"
- y="38"
+ x="11"
+ y="31"
fill="var(--sw-fg-0)"
- font-size="12"
+ font-size="11.5"
font-family="var(--sw-mono)"
:font-weight="n.id === focusedId ? 700 : 600"
clip-path="url(#ep-node-text-clip)"
>
<title>{{ n.name }}</title>
- {{ n.name.length > 21 ? n.name.slice(0, 19) + '…' : n.name }}
+ {{ n.name.length > 18 ? n.name.slice(0, 16) + '…' : n.name }}
</text>
<!-- Row 3: configured `center` metric (typically RPM).
Coloured in the ring band so the visual signal
reinforces the border. -->
<text
- x="12"
- y="60"
+ x="11"
+ y="45"
:fill="centerDef && nodeVal(n, centerDef) !== null ?
ringColor(n) : 'var(--sw-fg-3)'"
- font-size="11.5"
+ font-size="10"
font-family="var(--sw-mono)"
font-weight="700"
>
@@ -1022,36 +1205,58 @@ function edgeRowCrosshair(rowId: string): number | null
{
: ''
}}<template v-if="secondaryDef && nodeVal(n, secondaryDef) !==
null"><tspan fill="var(--sw-fg-3)"> · </tspan><tspan fill="var(--sw-fg-2)"
font-weight="500">{{ fmtMetric(nodeVal(n, secondaryDef)) }}{{ secondaryDef.unit
? ' ' + secondaryDef.unit.toUpperCase() : '' }}</tspan></template>
</text>
- <!-- Expand left (upstream) / right (downstream) buttons,
- visible on the SELECTED node so the operator can
- walk the chain without leaving the canvas. Already-
- expanded directions show a filled mark instead. -->
- <g
- v-if="selectedNodeId === n.id && n.id !== focusedId"
- class="ep-expand"
- :transform="`translate(-14, ${NH / 2 - 10})`"
- @click.stop="expandNode(n, 'upstream')"
- >
- <circle r="10" cx="10" cy="10" fill="var(--sw-bg-0)"
:stroke="hasExpansion(n, 'upstream') ? 'var(--sw-accent-2)' :
'var(--sw-line-2)'" stroke-width="1" />
- <text x="10" y="13.5" text-anchor="middle" font-size="11"
font-weight="700" :fill="hasExpansion(n, 'upstream') ? 'var(--sw-accent-2)' :
'var(--sw-fg-1)'">‹</text>
- <title>Expand upstream callers</title>
- </g>
+ <!-- One neutral expand handle (top-right corner) on the
+ SELECTED non-focus node. A single `getEndpointDependencies`
+ call returns the node's WHOLE neighbourhood, so one click
+ expands BOTH directions — new callers land left, callees
+ right via the layout. States: `+` (expandable) → spinner
+ (loading callers & callees) → `+` accent (expanded) or a
+ faded `·` (no further dependency). -->
<g
v-if="selectedNodeId === n.id && n.id !== focusedId"
class="ep-expand"
- :transform="`translate(${NW - 6}, ${NH / 2 - 10})`"
- @click.stop="expandNode(n, 'downstream')"
+ :class="{ exhausted: isExhausted(n), loading:
isLoadingExpansion(n) }"
+ :transform="`translate(${NW - 9}, -9)`"
+ role="button"
+ tabindex="0"
+ :aria-label="t('Expand {name} — show its callers and callees', {
name: n.name })"
+ @pointerdown.stop
+ @click.stop="expandNode(n)"
+ @keydown.enter.prevent="expandNode(n)"
+ @keydown.space.prevent="expandNode(n)"
>
- <circle r="10" cx="10" cy="10" fill="var(--sw-bg-0)"
:stroke="hasExpansion(n, 'downstream') ? 'var(--sw-accent-2)' :
'var(--sw-line-2)'" stroke-width="1" />
- <text x="10" y="13.5" text-anchor="middle" font-size="11"
font-weight="700" :fill="hasExpansion(n, 'downstream') ? 'var(--sw-accent-2)' :
'var(--sw-fg-1)'">›</text>
- <title>Expand downstream callees</title>
+ <circle r="9" cx="9" cy="9" fill="var(--sw-bg-0)"
:stroke="hasExpansion(n) || isLoadingExpansion(n) ? 'var(--sw-accent-2)' :
'var(--sw-line-2)'" stroke-width="1" />
+ <!-- loading spinner: a spinning arc while the dependency query
is in flight -->
+ <circle
+ v-if="isLoadingExpansion(n)"
+ cx="9"
+ cy="9"
+ r="6"
+ fill="none"
+ stroke="var(--sw-accent-2)"
+ stroke-width="2"
+ stroke-dasharray="11 30"
+ stroke-linecap="round"
+ >
+ <animateTransform attributeName="transform" type="rotate"
from="0 9 9" to="360 9 9" dur="0.7s" repeatCount="indefinite" />
+ </circle>
+ <text
+ v-else
+ x="9"
+ y="13"
+ text-anchor="middle"
+ font-size="14"
+ font-weight="600"
+ :fill="isExhausted(n) ? 'var(--sw-fg-3)' : hasExpansion(n) ?
'var(--sw-accent-2)' : 'var(--sw-fg-1)'"
+ >{{ isExhausted(n) ? '·' : '+' }}</text>
+ <title>{{ isLoadingExpansion(n) ? t('Loading callers and callees
of {name}…', { name: n.name }) : isExhausted(n) ? t('No further callers or
callees for {name}', { name: n.name }) : t('Expand {name} — show its callers
and callees', { name: n.name }) }}</title>
</g>
</g>
</svg>
- <div v-else-if="isLoading" class="loader">loading…</div>
+ <div v-else-if="isLoading" class="loader">{{ t('loading…') }}</div>
<div v-else class="loader">
- No dependency graph available for this endpoint in the last 15
minutes.
+ {{ t('No dependency graph available for this endpoint in the last
15 minutes.') }}
</div>
</div>
@@ -1069,14 +1274,14 @@ function edgeRowCrosshair(rowId: string): number | null
{
</span>
</div>
<div class="lg-block">
- <span class="lg-lbl">Calls</span>
+ <span class="lg-lbl">{{ t('Calls') }}</span>
<span class="lg-aside">
- thicker = heaviest (by {{ lineDef?.label ?? 'RPM' }})
+ {{ t('thicker = heaviest (by {metric})', { metric:
lineDef?.label ?? 'RPM' }) }}
</span>
</div>
<div class="lg-block">
<span class="lg-lbl">★</span>
- <span class="lg-aside">Focus endpoint</span>
+ <span class="lg-aside">{{ t('Focus endpoint') }}</span>
</div>
</div>
</div>
@@ -1099,7 +1304,7 @@ function edgeRowCrosshair(rowId: string): number | null {
<span class="tag-val">{{
identity(selectedNode.serviceName).legacyGroup }}</span>
</span>
<span class="ed-svc">{{
identity(selectedNode.serviceName).display }}</span>
- <span v-if="selectedNode.id === focusedId" class="sw-tag
accent">focus</span>
+ <span v-if="selectedNode.id === focusedId" class="sw-tag
accent">{{ t('focus') }}</span>
</div>
<div class="ed-name">{{ selectedNode.name }}</div>
</div>
@@ -1117,32 +1322,32 @@ function edgeRowCrosshair(rowId: string): number | null
{
</div>
</div>
<div class="ed-section">
- <div class="ed-section-title">Inbound ({{ popoutUpstream.length
}})</div>
+ <div class="ed-section-title">{{ t('Callers ({n})', { n:
popoutUpstream.length }) }}</div>
<ul class="ed-list">
<li v-for="u in popoutUpstream" :key="u.id">
<span class="ed-mono small">{{ u.name }}</span>
<span class="ed-arrow">→</span>
<span class="ed-mono small accent">{{ selectedNode.name }}</span>
</li>
- <li v-if="popoutUpstream.length === 0" class="ed-empty">no inbound
calls in window</li>
+ <li v-if="popoutUpstream.length === 0" class="ed-empty">{{ t('no
callers in this window') }}</li>
</ul>
</div>
<div class="ed-section">
- <div class="ed-section-title">Outbound ({{ popoutDownstream.length
}})</div>
+ <div class="ed-section-title">{{ t('Callees ({n})', { n:
popoutDownstream.length }) }}</div>
<ul class="ed-list">
<li v-for="d in popoutDownstream" :key="d.id">
<span class="ed-mono small accent">{{ selectedNode.name }}</span>
<span class="ed-arrow">→</span>
<span class="ed-mono small">{{ d.name }}</span>
</li>
- <li v-if="popoutDownstream.length === 0" class="ed-empty">no
outbound calls in window</li>
+ <li v-if="popoutDownstream.length === 0" class="ed-empty">{{ t('no
callees in this window') }}</li>
</ul>
</div>
<div class="ed-actions">
<button class="sw-btn small primary" type="button"
@click="jumpToEndpointDashboard">
- Open endpoint
+ {{ t('Open endpoint') }}
</button>
- <button class="sw-btn small" type="button"
@click="jumpToService">Service →</button>
+ <button class="sw-btn small" type="button" @click="jumpToService">{{
t('Service →') }}</button>
</div>
</section>
@@ -1158,13 +1363,13 @@ function edgeRowCrosshair(rowId: string): number | null
{
<span class="ed-mono small">{{ selectedCallTarget.name }}</span>
</div>
<div class="ed-edge-svc">
- {{ selectedCallSource.serviceName }} → {{
selectedCallTarget.serviceName }}
+ {{ identity(selectedCallSource.serviceName).display }} → {{
identity(selectedCallTarget.serviceName).display }}
</div>
</div>
<button class="sw-btn small" type="button" @click="selectedCallId =
null">×</button>
</header>
<div class="ed-section">
- <div class="ed-section-title">Line metrics (server-side)</div>
+ <div class="ed-section-title">{{ t('Line metrics (server-side)')
}}</div>
<div v-if="(cfg.linkMetrics ?? []).length > 0" class="ed-edge-rows">
<div
v-for="m in (cfg.linkMetrics ?? [])"
@@ -1193,16 +1398,16 @@ function edgeRowCrosshair(rowId: string): number | null
{
/>
</div>
</div>
- <div v-else class="ed-empty">no line metrics configured</div>
+ <div v-else class="ed-empty">{{ t('no line metrics configured')
}}</div>
</div>
</section>
<!-- Empty prompts. -->
<section v-if="!selectedNode" class="ep-detail-empty">
- <span>Click an endpoint node to inspect it</span>
+ <span>{{ t('Click an endpoint node to inspect it') }}</span>
</section>
<section v-if="!(selectedCall && selectedCallSource &&
selectedCallTarget)" class="ep-detail-empty">
- <span>Click an edge to inspect the call</span>
+ <span>{{ t('Click an edge to inspect the call') }}</span>
</section>
</aside>
</section>
@@ -1220,6 +1425,27 @@ function edgeRowCrosshair(rowId: string): number | null {
gap: 12px;
padding: 4px 0 0;
}
+/* Per-node outward expand handle. */
+.ep-expand {
+ cursor: pointer;
+}
+/* Only the actionable (expandable) handle brightens on hover. */
+.ep-expand:not(.exhausted):not(.loading):hover circle {
+ stroke: var(--sw-accent);
+}
+.ep-expand:not(.exhausted):not(.loading):hover text {
+ fill: var(--sw-accent);
+}
+/* No-dependency = a click revealed nothing new (chain leaf): faded `·`,
+ not clickable-feeling, but still hoverable so the tooltip explains it
+ (the click handler is already a no-op once expanded). */
+.ep-expand.exhausted {
+ opacity: 0.5;
+ cursor: default;
+}
+.ep-expand.loading {
+ cursor: progress;
+}
.ep-picker { padding: 0; }
.picker-head {
display: flex;
@@ -1333,9 +1559,15 @@ function edgeRowCrosshair(rowId: string): number | null {
the section wins over this declaration. */
padding: 0;
display: grid;
- grid-template-columns: 1fr 320px;
+ /* Single column by default; the 320px detail sidebar only takes space
+ once a node/edge is selected, so the graph fills the full width on
+ the default page instead of reserving an empty panel. */
+ grid-template-columns: 1fr;
overflow: hidden;
}
+.ep-graph-card.has-detail {
+ grid-template-columns: 1fr 320px;
+}
/* Legend strip at the bottom of the graph column. Sits inside
`.ep-graph` so it shares the card height with the SVG scroll. */
.ep-legend {
@@ -1616,6 +1848,47 @@ function edgeRowCrosshair(rowId: string): number | null {
min-height: 0;
overflow: hidden;
}
+/* Transient "no further dependency" banner over the canvas. */
+.ep-nodep-flash {
+ position: absolute;
+ top: 10px;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 5;
+ max-width: 90%;
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ padding: 6px 14px;
+ background: var(--sw-info-soft);
+ border: 1px solid var(--sw-info);
+ border-radius: 999px;
+ font-size: 11.5px;
+ font-weight: 600;
+ color: var(--sw-fg-0);
+ pointer-events: none;
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.45);
+}
+.ep-nodep-flash::before {
+ content: 'ⓘ';
+ flex: 0 0 auto;
+ font-size: 13px;
+ line-height: 1;
+ color: var(--sw-info);
+}
+.ep-nodep-flash span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.ep-flash-enter-active,
+.ep-flash-leave-active {
+ transition: opacity 0.25s ease;
+}
+.ep-flash-enter-from,
+.ep-flash-leave-to {
+ opacity: 0;
+}
.ep-svg {
width: 100%;
height: 100%;
@@ -1655,8 +1928,23 @@ function edgeRowCrosshair(rowId: string): number | null {
border-color: var(--sw-accent);
color: var(--sw-fg-0);
}
-.ep-node { cursor: pointer; }
+.ep-node { cursor: grab; }
+.ep-node.dragging { cursor: grabbing; }
+.ep-node.dragging rect { stroke: var(--sw-accent-2); }
.ep-node:hover rect { stroke: var(--sw-accent-2); }
+/* The node + expand handle are focusable for keyboard a11y (tabindex).
+ Suppress the browser's default (blue) focus ring on pointer focus —
+ the orange selection border already shows state — but keep a
+ design-consistent accent ring for keyboard focus. */
+.ep-node:focus,
+.ep-expand:focus {
+ outline: none;
+}
+.ep-node:focus-visible,
+.ep-expand:focus-visible {
+ outline: 2px solid var(--sw-accent-2);
+ outline-offset: 2px;
+}
.loader {
padding: 60px;
text-align: center;
diff --git a/apps/ui/src/layer/service-map/LayerServiceMapView.vue
b/apps/ui/src/layer/service-map/LayerServiceMapView.vue
index 21425cf..02d955e 100644
--- a/apps/ui/src/layer/service-map/LayerServiceMapView.vue
+++ b/apps/ui/src/layer/service-map/LayerServiceMapView.vue
@@ -54,7 +54,7 @@
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from
'vue';
import * as d3 from 'd3';
import { useI18n } from 'vue-i18n';
-import { useRoute, useRouter } from 'vue-router';
+import { useRoute, useRouter, type RouteLocationRaw } from 'vue-router';
import type {
LayerDef,
TopologyCall,
@@ -999,12 +999,20 @@ const canDrillInstance = computed<boolean>(
hasInstanceTopology.value &&
Boolean(selectedCallSource.value?.isReal &&
selectedCallTarget.value?.isReal),
);
+/** Open a route in a fresh browser tab — the operator keeps the service
+ * map they're exploring while the drill-down opens alongside it. History
+ * mode means `resolve(...).href` is a real URL. */
+function openRouteInNewTab(to: RouteLocationRaw): void {
+ const href = router.resolve(to).href;
+ window.open(href, '_blank', 'noopener');
+}
+
function openInstanceTopology(): void {
const c = selectedCall.value;
const src = selectedCallSource.value;
const dst = selectedCallTarget.value;
if (!c || !src || !dst || !src.isReal || !dst.isReal) return;
- void router.push({
+ openRouteInNewTab({
path: `/layer/${layerKey.value}/topology`,
query: { ...route.query, view: 'instance', client: c.source, server:
c.target },
});
@@ -1200,7 +1208,7 @@ function targetLayerFor(n: TopologyNode): string {
function jumpToService(): void {
const sel = selectedNode.value;
if (!sel) return;
- void router.push({
+ openRouteInNewTab({
path: `/layer/${targetLayerFor(sel)}/service`,
query: { service: sel.id },
});
@@ -1208,7 +1216,7 @@ function jumpToService(): void {
function jumpToEndpointDependency(): void {
const sel = selectedNode.value;
if (!sel) return;
- void router.push({
+ openRouteInNewTab({
path: `/layer/${targetLayerFor(sel)}/dependency`,
query: { service: sel.id },
});