You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by wu...@apache.org on 2026/05/31 02:11:16 UTC
(skywalking-horizon-ui) branch fix/3d-fps-and-layer-rehydrate created (now 4fe7d02)
This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a change to branch fix/3d-fps-and-layer-rehydrate
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
at 4fe7d02 feat(layer): zoom/pan + fit-to-view for the API dependency graph
This branch includes the following new commits:
new 339e8e9 perf(infra-3d): cap the 3D map render loop to 30fps
new a527f03 fix(layer): rehydrate selection on same-layer deep links
new 42e6780 fix(layer): re-seed selection on same-layer deep-link params
new 3627d15 fix(layer): validate URL ?service= against the full roster
new 0e38920 fix(layer): honor URL ?endpoint= outside top-N; gate metrics on config
new 025738a fix(layer): complete the endpoint/config dashboard gating
new 9f22c36 fix(layer): empty widget scope shows 'No widgets', not 'Reading data' forever
new 4fe7d02 feat(layer): zoom/pan + fit-to-view for the API dependency graph
The 8 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(skywalking-horizon-ui) 05/08: fix(layer): honor URL ?endpoint= outside top-N; gate metrics on config
Posted by wu...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a commit to branch fix/3d-fps-and-layer-rehydrate
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
commit 0e3892064d70ed076130514a80e92fe922f25ec0
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Sun May 31 09:00:00 2026 +0800
fix(layer): honor URL ?endpoint= outside top-N; gate metrics on config
#2: a deep-linked endpoint outside the recent top-N (empty-query) list was
discarded; add a targeted lookup by the pinned endpoint's name and only
fall back when that also finds nothing.
#3: the dashboard metrics query fired before the config bundle resolved
(empty widget list -> BFF defaults -> refetch). Gate it on a configReady
ref so it fires once with the resolved widgets.
---
.../render/layer-dashboard/LayerDashboardsView.vue | 22 +++++++++++++++++++++-
.../render/layer-dashboard/useLayerDashboard.ts | 10 ++++++++++
2 files changed, 31 insertions(+), 1 deletion(-)
diff --git a/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue b/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue
index dedc6f3..7ef58ea 100644
--- a/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue
+++ b/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue
@@ -264,6 +264,18 @@ const { endpoints: endpointList, isFetching: endpointsLoading } = useLayerEndpoi
endpointQuery,
endpointLimit,
);
+// URL-pinned endpoint validation. The list above is the recent top-N
+// (empty query); a deep-linked endpoint outside it would look "stale".
+// This re-queries by the pinned endpoint's own name to confirm it really
+// exists for this service before we discard the deep link. Inactive
+// (empty query) once the pin is null or already present in the default list.
+const pinnedEndpointQuery = computed(() => {
+ const pinned = selectedEndpoint.value;
+ if (!pinned) return '';
+ return endpointList.value.some((e) => e.name === pinned) ? '' : pinned;
+});
+const { endpoints: pinnedEndpointMatches, isFetching: pinnedEndpointLoading } =
+ useLayerEndpoints(layerKey, serviceName, pinnedEndpointQuery, endpointLimit);
// Endpoint-scope orchestration — explicit sequence so the loading
// flow is deterministic:
// 1. wait for landing rows
@@ -293,10 +305,14 @@ watchEffect(() => {
return;
}
if (!list.some((e) => e.name === selectedEndpoint.value)) {
+ // Outside the default top-N — confirm via the targeted name search
+ // before discarding the deep link.
+ if (pinnedEndpointQuery.value && pinnedEndpointLoading.value) return; // wait for the lookup
+ if (pinnedEndpointMatches.value.some((e) => e.name === selectedEndpoint.value)) return; // valid → keep
pushEvent(
'fallback',
'info',
- `URL endpoint "${selectedEndpoint.value}" not in ${serviceName.value} · falling back to "${list[0].name}"`,
+ `URL endpoint "${selectedEndpoint.value}" not found in ${serviceName.value} · falling back to "${list[0].name}"`,
);
setSelectedEndpoint(list[0].name);
}
@@ -334,6 +350,9 @@ const effectiveEndpoint = computed<string | null>(() => {
return endpointList.value.some((e) => e.name === v) ? v : null;
});
const widgetsForQuery = computed(() => config.value?.widgets ?? []);
+// Hold the metrics fetch until the dashboard config bundle has resolved,
+// so the widget list fires once (resolved) rather than empty-then-refetch.
+const configReady = computed(() => config.value !== null);
const { data, isFetching, error } = useLayerDashboard(
layerKey,
serviceName,
@@ -342,6 +361,7 @@ const { data, isFetching, error } = useLayerDashboard(
{ instance: effectiveInstance, endpoint: effectiveEndpoint },
rangeRef,
widgetsForQuery,
+ configReady,
);
// Sequential page-init events for the EventTicker — config →
diff --git a/apps/ui/src/render/layer-dashboard/useLayerDashboard.ts b/apps/ui/src/render/layer-dashboard/useLayerDashboard.ts
index e70fe72..7b86a2b 100644
--- a/apps/ui/src/render/layer-dashboard/useLayerDashboard.ts
+++ b/apps/ui/src/render/layer-dashboard/useLayerDashboard.ts
@@ -110,6 +110,13 @@ export function useLayerDashboard(
* back to a single BFF call that resolves widgets server-side
* (used by callers that don't have the config bundle handy). */
widgetsList?: Ref<DashboardWidget[]>,
+ /** Optional config-bundle readiness gate. When supplied, the metrics
+ * query waits until it is true, so the dashboard fires ONCE with the
+ * resolved widget list instead of firing first with an empty list
+ * (which makes the BFF substitute defaults) and refetching when the
+ * bundle lands. Callers without a config bundle omit it (treated as
+ * ready) and keep the server-resolves-widgets behaviour. */
+ configReady?: Ref<boolean>,
) {
// Auto-refresh is metrics-only. Trace / log / profiling pages are
// explore-style (operator-driven queries, log tails, etc.) and would
@@ -194,6 +201,9 @@ export function useLayerDashboard(
// - endpoint scope needs service + endpoint.
enabled: computed(() => {
if (layerKey.value.length === 0) return false;
+ // Wait for the config bundle so widgets are resolved before the
+ // metrics fire (no empty-list → BFF-default → refetch round-trip).
+ if (configReady && !configReady.value) return false;
const s = scope?.value ?? 'service';
if (s === 'service') return Boolean(service.value);
if (s === 'instance') return Boolean(service.value && entityRefs.instance?.value);
(skywalking-horizon-ui) 01/08: perf(infra-3d): cap the 3D map render loop to 30fps
Posted by wu...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a commit to branch fix/3d-fps-and-layer-rehydrate
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
commit 339e8e9270f603e04284386d5a7c6ec135c223eb
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Sat May 30 23:58:24 2026 +0800
perf(infra-3d): cap the 3D map render loop to 30fps
The scene renders continuously; on a high-refresh display the loop ran at
60-120fps, pegging a CPU core (the per-frame cientos <Html> reprojection
for ~80 chips/labels dominates). TresJS's native fpsLimit caps the whole
loop (render + onSceneLoop + <Html> updates), roughly halving (or
quartering on 120Hz) the renderer CPU with no visual change.
---
apps/ui/src/features/infra-3d/Infra3DScene.vue | 1 +
1 file changed, 1 insertion(+)
diff --git a/apps/ui/src/features/infra-3d/Infra3DScene.vue b/apps/ui/src/features/infra-3d/Infra3DScene.vue
index 3b0fc3f..983cc08 100644
--- a/apps/ui/src/features/infra-3d/Infra3DScene.vue
+++ b/apps/ui/src/features/infra-3d/Infra3DScene.vue
@@ -1592,6 +1592,7 @@ onUnmounted(() => {
clear-color="#0a0d12"
:antialias="true"
power-preference="high-performance"
+ :fps-limit="30"
@loop="onSceneLoop"
>
<TresPerspectiveCamera
(skywalking-horizon-ui) 04/08: fix(layer): validate URL ?service= against the full roster
Posted by wu...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a commit to branch fix/3d-fps-and-layer-rehydrate
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
commit 3627d154651d30c2f4824037a6eafd28b0d21eb6
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Sun May 31 09:00:00 2026 +0800
fix(layer): validate URL ?service= against the full roster
The auto-pick watch validated selectedId only against the landing top-N
sample, so a valid low-traffic ?service= outside the sample was treated as
stale and overwritten by the first sampled row (clearing instance/endpoint
too). Validate against useLayerServices (the layer's real catalog); only a
genuinely-absent id auto-corrects, and only once the roster has loaded.
---
apps/ui/src/layer/LayerShell.vue | 25 +++++++++++++++++++------
1 file changed, 19 insertions(+), 6 deletions(-)
diff --git a/apps/ui/src/layer/LayerShell.vue b/apps/ui/src/layer/LayerShell.vue
index 4247c59..e0b2438 100644
--- a/apps/ui/src/layer/LayerShell.vue
+++ b/apps/ui/src/layer/LayerShell.vue
@@ -42,6 +42,7 @@ import { useTimeRangeStore } from '@/controls/timeRange';
import { useLayers, firstLayerTab } from '@/shell/useLayers';
import { layerContentToDef, type LayerTemplateContent } from '@/shell/layerFromTemplate';
import { useSelectedService } from '@/layer/useSelectedService';
+import { useLayerServices } from '@/layer/useLayerServices';
import { useLayerSelectionStore } from '@/state/layerSelection';
import { useSetupStore } from '@/state/setup';
import { fmtMetric } from '@/utils/formatters';
@@ -303,19 +304,31 @@ const isZipkinTrace = computed<boolean>(() => {
return scopeSegment.value === 'trace' && layer.value?.traces?.source === 'zipkin';
});
+// Full service roster (the layer's REAL catalog, independent of landing's
+// top-N sample which misses low-traffic services). A URL `?service=` is
+// validated against THIS, not the sample — otherwise a valid but
+// low-traffic deep link is wrongly treated as stale.
+const { services: fullRoster, isLoading: rosterLoading } = useLayerServices(layerKey);
+
// Keep the URL-backed service selection honest for every page that
-// uses the shell picker. A stale `?service=` can survive navigation or
-// manual URL entry; the switch label used to fall back visually to the
-// first row while the metric query still waited for a valid service.
+// uses the shell picker. A `?service=` outside the landing sample is
+// trusted when it exists in the full roster; only a genuinely stale id
+// (absent from the roster) auto-corrects to the first sampled row, and
+// only once the roster has loaded so a valid pin isn't clobbered in flight.
watch(
- [sampledServices, selectedId, viewOwnsServiceSelector],
- ([rows, id, ownsSelector]) => {
+ [sampledServices, selectedId, viewOwnsServiceSelector, fullRoster, rosterLoading],
+ ([rows, id, ownsSelector, roster, rosterIsLoading]) => {
if (ownsSelector) return;
const first = rows[0];
if (!first) return;
- if (!id || !rows.some((s) => s.serviceId === id)) {
+ if (!id) {
setSelected(first.serviceId);
+ return;
}
+ if (rows.some((s) => s.serviceId === id)) return; // in the sample → keep
+ if (rosterIsLoading) return; // don't clobber a pin while the roster loads
+ if (roster.some((s) => s.id === id)) return; // valid in the full roster → keep
+ setSelected(first.serviceId); // genuinely stale → fall back
},
{ immediate: true },
);
(skywalking-horizon-ui) 07/08: fix(layer): empty widget scope shows 'No widgets', not 'Reading data' forever
Posted by wu...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a commit to branch fix/3d-fps-and-layer-rehydrate
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
commit 9f22c36a8cd6a177402c6d84b9ffeedb7730cecb
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Sun May 31 09:17:19 2026 +0800
fix(layer): empty widget scope shows 'No widgets', not 'Reading data' forever
Gating metrics on resolved widgets means an empty-config scope runs no
dashboard query, so dataIsFresh never flips true. The 'Reading data' gate
fired on (configLoading || !dataIsFresh), so it stuck forever and never
reached the 'No widgets defined' branch. Only wait on !dataIsFresh when
there are widgets to load.
---
apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue b/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue
index 49a57fc..3a49d8f 100644
--- a/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue
+++ b/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue
@@ -704,7 +704,10 @@ function isVisible(
sequence (which read as a slow, jumpy entry). The "no widgets"
branch below only shows once config has actually loaded and the
layer genuinely defines none. -->
- <div v-if="reachable && (configLoading || !dataIsFresh)" class="empty reading">
+ <div
+ v-if="reachable && (configLoading || (!dataIsFresh && widgets.length > 0))"
+ class="empty reading"
+ >
<span class="reading-dot" />
<span>
Reading data
(skywalking-horizon-ui) 03/08: fix(layer): re-seed selection on same-layer deep-link params
Posted by wu...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a commit to branch fix/3d-fps-and-layer-rehydrate
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
commit 42e67809bb9fc71046be4f6ccc4ffa1f8825b450
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Sun May 31 09:00:00 2026 +0800
fix(layer): re-seed selection on same-layer deep-link params
Completes the same-layer rehydrate fix: the LayerShell watch now fires on
query changes, but resetForLayer early-returned when ownerKey === layerKey,
so a same-mounted-layer deep link still no-opped. Re-seed when the query
carries ?service/?instance/?endpoint even on the same layer; keep the
sticky pick only for paramless scope/tab navigation.
---
apps/ui/src/state/layerSelection.ts | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/apps/ui/src/state/layerSelection.ts b/apps/ui/src/state/layerSelection.ts
index 6d5d02c..0422cbf 100644
--- a/apps/ui/src/state/layerSelection.ts
+++ b/apps/ui/src/state/layerSelection.ts
@@ -64,7 +64,13 @@ export const useLayerSelectionStore = defineStore('layer-selection', () => {
* a no-op — the operator's pick survives.
*/
function resetForLayer(layerKey: string, query: Record<string, unknown>): void {
- if (ownerKey.value === layerKey) return;
+ const hasSeed =
+ query.service != null || query.instance != null || query.endpoint != null;
+ // Same layer + NO deep-link params → keep the sticky pick (scope/tab
+ // nav within the layer). Same layer WITH params (a deep link into the
+ // layer the operator is already on) DOES re-seed, otherwise the new
+ // ?service/?instance/?endpoint would be silently ignored.
+ if (ownerKey.value === layerKey && !hasSeed) return;
ownerKey.value = layerKey;
service.value = pickQueryString(query.service);
instance.value = pickQueryString(query.instance);
(skywalking-horizon-ui) 08/08: feat(layer): zoom/pan + fit-to-view for the API dependency graph
Posted by wu...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a commit to branch fix/3d-fps-and-layer-rehydrate
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
commit 4fe7d02b6467ed7bdda7a5f7af3e601f354f8a57
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Sun May 31 10:02:26 2026 +0800
feat(layer): zoom/pan + fit-to-view for the API dependency graph
- The graph fits-to-view by default (viewBox + preserveAspectRatio), so
every column is visible — the 'extra line' was an edge to a node clipped
off the old horizontal-scroll viewport; fit reveals its real target.
- Wheel + drag-pan + +/−/fit buttons (toolbar over the canvas, not the
header).
- Clip node text to the box interior (clipPath) + tighter truncation so
long endpoint names no longer overflow the boundary.
- Drop the L0/L+1 column-name headers (operator feedback: not useful).
---
.../LayerEndpointDependencyView.vue | 184 ++++++++++++++++-----
1 file changed, 143 insertions(+), 41 deletions(-)
diff --git a/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue b/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue
index 90a72d6..6e506b4 100644
--- a/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue
+++ b/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue
@@ -432,6 +432,71 @@ const visibleCalls = computed<EndpointDependencyCall[]>(() => {
return calls.value.filter((c) => ids.has(c.source) && ids.has(c.target));
});
+// ── Pan / zoom. The SVG fits the whole graph by default (viewBox = full
+// extent, aspect-preserved), so every column is visible — no edge dangles
+// 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);
+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}`;
+});
+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 });
+
+/** Rendered scale + letterbox offset for the current viewBox under
+ * preserveAspectRatio="xMidYMid meet" — so cursor zoom + drag pan map
+ * screen pixels to graph coordinates exactly. */
+function viewMetrics() {
+ const v = viewBox.value ?? { x: 0, y: 0, w: W.value, h: H.value };
+ const r = svgRef.value?.getBoundingClientRect();
+ const rw = r?.width || v.w;
+ const rh = r?.height || v.h;
+ const scale = Math.min(rw / v.w, rh / v.h) || 1;
+ return { v, left: r?.left ?? 0, top: r?.top ?? 0, scale, offX: (rw - v.w * scale) / 2, offY: (rh - v.h * scale) / 2 };
+}
+function clientToView(clientX: number, clientY: number): { x: number; y: number } {
+ const { v, left, top, scale, offX, offY } = viewMetrics();
+ return { x: v.x + (clientX - left - offX) / scale, y: v.y + (clientY - top - offY) / scale };
+}
+function zoomAround(factor: number, cx: number, cy: number): void {
+ 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));
+ const k = newW / v.w;
+ viewBox.value = { x: cx - (cx - v.x) * k, y: cy - (cy - v.y) * k, w: newW, h: v.h * k };
+}
+function onWheel(e: WheelEvent): void {
+ e.preventDefault();
+ const p = clientToView(e.clientX, e.clientY);
+ zoomAround(e.deltaY > 0 ? 1.12 : 0.89, p.x, p.y);
+}
+function zoomBtn(factor: number): void {
+ const v = viewBox.value ?? { x: 0, y: 0, w: W.value, h: H.value };
+ zoomAround(factor, v.x + v.w / 2, v.y + v.h / 2);
+}
+// Drag-pan from the background (node/edge clicks keep their own handlers).
+let panning = false;
+let panStart = { cx: 0, cy: 0, vx: 0, vy: 0 };
+function onPanStart(e: PointerEvent): void {
+ panning = true;
+ const v = viewBox.value ?? { x: 0, y: 0, w: W.value, h: H.value };
+ panStart = { cx: e.clientX, cy: e.clientY, vx: v.x, vy: v.y };
+ (e.target as Element).setPointerCapture?.(e.pointerId);
+}
+function onPanMove(e: PointerEvent): void {
+ if (!panning) return;
+ const { v, scale } = viewMetrics();
+ viewBox.value = { ...v, x: panStart.vx - (e.clientX - panStart.cx) / scale, y: panStart.vy - (e.clientY - panStart.cy) / scale };
+}
+function onPanEnd(): void {
+ panning = false;
+}
+
// Kind colour band — uses the endpoint's `type` field, then service
// name fallbacks (db/cache/mq/ext).
/**
@@ -724,26 +789,45 @@ function edgeRowCrosshair(rowId: string): number | null {
</span>
</header>
- <!-- Layer headers row -->
- <div class="layer-hdr-row" :style="{ minWidth: W + 'px' }">
- <div
- v-for="(col, i) in layerColumns"
- :key="col.index"
- class="layer-hdr"
- :style="{ left: 40 + i * COL_GAP + 'px', width: NW + 'px' }"
- >
- <span>{{ col.label }}</span>
- <span v-if="col.hidden > 0" class="hdr-overflow">+{{ col.hidden }} more</span>
- </div>
- </div>
-
<div class="ep-scroll">
+ <!-- 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>
+ </div>
<svg
v-if="layoutNodes.length > 0"
- :viewBox="`0 0 ${W} ${H}`"
- :style="{ width: W + 'px', height: H + 'px', display: 'block' }"
+ ref="svgRef"
+ class="ep-svg"
+ :viewBox="viewBoxStr"
+ preserveAspectRatio="xMidYMid meet"
+ @wheel="onWheel"
>
<!-- No arrow markers — the animated dots advertise direction. -->
+ <defs>
+ <!-- Clip node text to the box interior so long endpoint names
+ are cut at the boundary instead of overflowing it. Evaluated
+ in each node's local space, so one def clips every node. -->
+ <clipPath id="ep-node-text-clip">
+ <rect :x="8" :y="0" :width="NW - 16" :height="NH" />
+ </clipPath>
+ </defs>
+ <!-- Background pan target. Behind everything; node / edge clicks
+ keep their own handlers. -->
+ <rect
+ class="ep-pan-bg"
+ :x="-W"
+ :y="-H"
+ :width="W * 3"
+ :height="H * 3"
+ fill="transparent"
+ @pointerdown="onPanStart"
+ @pointermove="onPanMove"
+ @pointerup="onPanEnd"
+ @pointerleave="onPanEnd"
+ />
<!-- column guide lines -->
<line
@@ -904,9 +988,10 @@ function edgeRowCrosshair(rowId: string): number | null {
fill="var(--sw-fg-3)"
font-size="10"
font-family="var(--sw-mono)"
+ clip-path="url(#ep-node-text-clip)"
>
<title>{{ n.serviceName }}</title>
- {{ identity(n.serviceName).display.length > 26 ? identity(n.serviceName).display.slice(0, 24) + '…' : identity(n.serviceName).display }}
+ {{ identity(n.serviceName).display.length > 24 ? identity(n.serviceName).display.slice(0, 22) + '…' : identity(n.serviceName).display }}
</text>
<!-- Row 2: API (endpoint) name — the headline. -->
<text
@@ -916,9 +1001,10 @@ function edgeRowCrosshair(rowId: string): number | null {
font-size="12"
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 > 28 ? n.name.slice(0, 26) + '…' : n.name }}
+ {{ n.name.length > 21 ? n.name.slice(0, 19) + '…' : n.name }}
</text>
<!-- Row 3: configured `center` metric (typically RPM).
Coloured in the ring band so the visual signal
@@ -1306,6 +1392,7 @@ function edgeRowCrosshair(rowId: string): number | null {
border-radius: 2px;
}
.ep-graph {
+ position: relative;
min-width: 0;
display: flex;
flex-direction: column;
@@ -1524,37 +1611,52 @@ function edgeRowCrosshair(rowId: string): number | null {
font-weight: 600;
color: var(--sw-fg-0);
}
-.layer-hdr-row {
+/* The graph fits-to-view by default and zooms via the viewBox, so the
+ container no longer scrolls — it just gives the SVG its height. */
+.ep-scroll {
position: relative;
- height: 30px;
- border-bottom: 1px solid var(--sw-line);
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
}
-.layer-hdr {
+.ep-svg {
+ width: 100%;
+ height: 100%;
+ display: block;
+ /* Stop the page from scrolling while wheel-zooming / dragging. */
+ touch-action: none;
+}
+.ep-pan-bg {
+ cursor: grab;
+}
+.ep-pan-bg:active {
+ cursor: grabbing;
+}
+/* Zoom toolbar — top-right of the graph column. */
+.ep-zoom {
position: absolute;
top: 8px;
+ right: 8px;
+ z-index: 2;
display: flex;
- align-items: baseline;
- gap: 8px;
- font-size: 9.5px;
- text-transform: uppercase;
- letter-spacing: 0.06em;
- color: var(--sw-fg-3);
- font-weight: 700;
+ gap: 4px;
}
-.hdr-overflow {
- font-size: 9px;
- color: var(--sw-fg-2);
- padding: 1px 5px;
- background: var(--sw-bg-2);
- border-radius: 3px;
- text-transform: none;
- letter-spacing: 0;
- font-weight: 500;
+.ep-zoom button {
+ width: 24px;
+ height: 24px;
+ display: grid;
+ place-items: center;
+ font-size: 13px;
+ line-height: 1;
+ color: var(--sw-fg-1);
+ background: var(--sw-bg-1);
+ border: 1px solid var(--sw-line);
+ border-radius: 6px;
+ cursor: pointer;
}
-.ep-scroll {
- position: relative;
- overflow: auto;
- max-height: 640px;
+.ep-zoom button:hover {
+ border-color: var(--sw-accent);
+ color: var(--sw-fg-0);
}
.ep-node { cursor: pointer; }
.ep-node:hover rect { stroke: var(--sw-accent-2); }
(skywalking-horizon-ui) 06/08: fix(layer): complete the endpoint/config dashboard gating
Posted by wu...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a commit to branch fix/3d-fps-and-layer-rehydrate
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
commit 025738a8971fa338222fff0c6b3d587f0c8aea69
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Sun May 31 09:06:21 2026 +0800
fix(layer): complete the endpoint/config dashboard gating
Three follow-ons to the prior layer-URL fixes, all in LayerDashboardsView:
- effectiveEndpoint now also accepts a pin confirmed by the targeted name
lookup, so a valid endpoint outside the top-N actually drives the fetch
instead of leaving the dashboard gated forever.
- configReady requires resolved widgets (not just config != null), so an
empty resolved config no longer fires metrics that the BFF fills with
default widgets — metrics run only for resolved widgets.
- the page-init orchestrator now receives effectiveInstance/effectiveEndpoint
(validated), not the raw selected refs, so its event sequence matches when
metrics are actually allowed to run.
---
.../render/layer-dashboard/LayerDashboardsView.vue | 19 +++++++++++++------
1 file changed, 13 insertions(+), 6 deletions(-)
diff --git a/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue b/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue
index 7ef58ea..49a57fc 100644
--- a/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue
+++ b/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue
@@ -347,12 +347,19 @@ const effectiveInstance = computed<string | null>(() => {
const effectiveEndpoint = computed<string | null>(() => {
const v = selectedEndpoint.value;
if (!v) return null;
- return endpointList.value.some((e) => e.name === v) ? v : null;
+ // Valid if in the default top-N OR confirmed by the targeted name lookup
+ // (a deep-linked endpoint outside the recent list) — otherwise the
+ // dashboard would stay gated forever for a perfectly valid pin.
+ if (endpointList.value.some((e) => e.name === v)) return v;
+ if (pinnedEndpointMatches.value.some((e) => e.name === v)) return v;
+ return null;
});
const widgetsForQuery = computed(() => config.value?.widgets ?? []);
-// Hold the metrics fetch until the dashboard config bundle has resolved,
-// so the widget list fires once (resolved) rather than empty-then-refetch.
-const configReady = computed(() => config.value !== null);
+// Hold the metrics fetch until the config bundle has resolved WITH widgets.
+// A resolved-but-empty config means "no dashboard for this layer/scope",
+// so we don't fire (which would otherwise make the BFF substitute its own
+// default widget set); metrics run only for resolved widgets.
+const configReady = computed(() => widgetsForQuery.value.length > 0);
const { data, isFetching, error } = useLayerDashboard(
layerKey,
serviceName,
@@ -376,9 +383,9 @@ useLayerPageOrchestrator({
serviceList: landingRows,
effectiveService: serviceName,
instanceList,
- effectiveInstance: selectedInstance,
+ effectiveInstance,
endpointList,
- effectiveEndpoint: selectedEndpoint,
+ effectiveEndpoint,
dashboard: data,
});
(skywalking-horizon-ui) 02/08: fix(layer): rehydrate selection on same-layer deep links
Posted by wu...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a commit to branch fix/3d-fps-and-layer-rehydrate
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
commit a527f0342a4203601336cab0df1a58f3dae9a6d3
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Sun May 31 08:25:13 2026 +0800
fix(layer): rehydrate selection on same-layer deep links
The shell watch keyed only on layerKey, so navigating to another URL on
the SAME layer with different ?service/?instance/?endpoint never re-seeded
the selection store (stale in-memory pick). Key the watch on the layer
key plus the three seed params; guard so the post-seed param strip
(params -> absent) does not re-seed and loop.
---
apps/ui/src/layer/LayerShell.vue | 24 +++++++++++++++++-------
1 file changed, 17 insertions(+), 7 deletions(-)
diff --git a/apps/ui/src/layer/LayerShell.vue b/apps/ui/src/layer/LayerShell.vue
index 75b58d5..4247c59 100644
--- a/apps/ui/src/layer/LayerShell.vue
+++ b/apps/ui/src/layer/LayerShell.vue
@@ -80,16 +80,26 @@ const scopeSegment = computed<string>(() => {
onBeforeUnmount(() => {
selectionStore.clear();
});
+// Re-seed the selection store on layer ENTRY and on any SAME-LAYER
+// navigation that arrives with fresh ?service/?instance/?endpoint (deep
+// links into the layer the operator is already on). Keyed on the layer
+// key plus the three seed params — but the strip below removes those
+// params right after seeding, and that removal (params → absent) must NOT
+// re-seed, so we only act when the layer changed OR seed params are
+// actually present.
watch(
- layerKey,
- (key) => {
+ [layerKey, () => route.query.service, () => route.query.instance, () => route.query.endpoint],
+ ([key], prev) => {
if (!key) return;
- selectionStore.resetForLayer(key, route.query);
- // After hydrating, strip the seed params so the address bar reads
- // as a clean `/layer/<key>/<scope>` URL. The store now owns the
- // live selection; the params were a one-shot seed.
const q = route.query;
- if (q.service != null || q.instance != null || q.endpoint != null) {
+ const hasSeed = q.service != null || q.instance != null || q.endpoint != null;
+ const layerChanged = key !== (prev?.[0] as string | undefined);
+ if (!layerChanged && !hasSeed) return;
+ selectionStore.resetForLayer(key, q);
+ // After hydrating, strip the seed params so the address bar reads as a
+ // clean `/layer/<key>/<scope>` URL. The store now owns the live
+ // selection; the params were a one-shot seed.
+ if (hasSeed) {
const { service: _s, instance: _i, endpoint: _e, ...rest } = q;
void _s; void _i; void _e;
void router.replace({ path: route.path, query: rest });