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 242ba09 ui: process topology — trefoil rows (ui-21), peers hug
boundary; popout actual-time axis; drop keep-alive
242ba09 is described below
commit 242ba09804abeddda13d3c32b8fd85a543972a45
Author: Wu Sheng <[email protected]>
AuthorDate: Wed May 20 16:42:35 2026 +0800
ui: process topology — trefoil rows (ui-21), peers hug boundary; popout
actual-time axis; drop keep-alive
Topology graph:
- Inside cells lay out as a centred honeycomb pyramid (partial row on
top, full rows below) so the BOTTOM row shares one y — the ui-21
trefoil: UNKNOWN_LOCAL on top, pilot-agent + envoy level below.
- Separate `cellDraw` (rendered hexagon) < `cellRadius` (spacing) so
cells keep a visible gap and sit inside the boundary with margin
(were oversized / overflowing in ui-19/20).
- Labels: bottom-row cells below, upper-row cells above; 12px mono.
- External peers now ring the pod CLOSE to its boundary across
left / top / right (bottom arc kept clear for the pod label) instead
of being flung to the canvas corners (ui-20). Fixed the skip-arc,
which was at the top (270°) — the label is at the bottom (90° in
SVG's +y-down coords).
Edge popout:
- X-axis is the ACTUAL profiling clock time per bucket (task start →
end, browser-local HH:mm:ss) instead of relative `-Nm` markers.
Network profiling view:
- Removed the "Keep-alive ping" button + status (confusing for the
fixed-duration tasks the UI creates; OAP keep-alive is a
continuous-task concern). Task list refreshes only via the manual ↻
button — no polling (profiling pages don't subscribe to the ticker).
---
.../layer/profiling/LayerNetworkProfilingView.vue | 60 ++++---------
.../src/layer/profiling/ProcessTopologyGraph.vue | 98 ++++++++++------------
2 files changed, 60 insertions(+), 98 deletions(-)
diff --git a/apps/ui/src/layer/profiling/LayerNetworkProfilingView.vue
b/apps/ui/src/layer/profiling/LayerNetworkProfilingView.vue
index 7b330ed..a11ec6c 100644
--- a/apps/ui/src/layer/profiling/LayerNetworkProfilingView.vue
+++ b/apps/ui/src/layer/profiling/LayerNetworkProfilingView.vue
@@ -21,10 +21,10 @@
process-level topology snapshot.
┌──────────────┬─────────────────────────────────────────────┐
- │ Instance │ Topology window selector · Keep-alive btn │
+ │ Instance │ Topology window selector + refresh │
│ picker ├─────────────────────────────────────────────┤
│ Tasks list │ │
- │ + New Task │ ProcessTopologyGraph (d3 force layout) │
+ │ + New Task │ ProcessTopologyGraph (honeycomb layout) │
│ │ │
│ ├─────────────────────────────────────────────┤
│ │ Selected node / call detail panel │
@@ -72,7 +72,6 @@ const tasks = ref<EBPFTask[]>([]);
const tasksError = ref<string | null>(null);
const tasksLoading = ref(false);
const currentTask = ref<EBPFTask | null>(null);
-const aliveStatus = ref<boolean | null>(null);
watch(
() => layerKey.value + '|' + (serviceId.value ?? '') + '|' +
(selectedInstanceId.value ?? ''),
@@ -225,26 +224,31 @@ const targetProcessName = computed(
() => (selectedCall.value && nodeById(selectedCall.value.target)?.name) ||
'—',
);
-/** Edge-chart x-axis labels as elapsed `mm:ss` across the profiling
- * window — compact and meaningful regardless of how long the task ran
- * (relative `-Nm` markers blow up for multi-hour tasks). Derived from
- * the metric series length + the task's run window. */
+/** Edge-chart x-axis labels = the ACTUAL profiling clock time per bucket
+ * (task start → end), browser-local `HH:mm:ss`. Not relative `-Nm`
+ * markers (which blow up for long tasks) — the operator reads the real
+ * wall-clock span the task profiled. */
+const edgeTimeFmt = new Intl.DateTimeFormat(undefined, {
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ hour12: false,
+});
const edgeXLabels = computed<string[]>(() => {
const m = relationMetrics.value;
const n = m?.client[0]?.values.length ?? m?.server[0]?.values.length ?? 0;
if (n <= 0) return [];
const task = currentTask.value;
+ const startMs =
+ task?.taskStartTime ?? Date.now() - windowMinutes.value * 60_000;
const spanMs =
task?.taskStartTime && task.fixedTriggerDuration
? task.fixedTriggerDuration * 1000
: windowMinutes.value * 60_000;
const stepMs = n > 1 ? spanMs / (n - 1) : spanMs;
- return Array.from({ length: n }, (_, i) => {
- const sec = Math.round((i * stepMs) / 1000);
- const mm = Math.floor(sec / 60);
- const ss = sec % 60;
- return `${String(mm).padStart(2, '0')}:${String(ss).padStart(2, '0')}`;
- });
+ return Array.from({ length: n }, (_, i) =>
+ edgeTimeFmt.format(new Date(startMs + i * stepMs)),
+ );
});
function onEdgeKeydown(ev: KeyboardEvent): void {
@@ -253,17 +257,6 @@ function onEdgeKeydown(ev: KeyboardEvent): void {
onMounted(() => window.addEventListener('keydown', onEdgeKeydown));
onBeforeUnmount(() => window.removeEventListener('keydown', onEdgeKeydown));
-async function keepAlive(): Promise<void> {
- if (!currentTask.value) return;
- aliveStatus.value = null;
- try {
- const resp = await
bffClient.networkProfile.keepAlive(currentTask.value.taskId);
- aliveStatus.value = resp.status;
- } catch {
- aliveStatus.value = false;
- }
-}
-
// ── New task modal ────────────────────────────────────────────────
const showNewTask = ref(false);
const newTaskError = ref<string | null>(null);
@@ -388,14 +381,6 @@ function fmtTime(ms: number): string {
<option :value="180">3 hr</option>
</select>
</div>
- <button
- class="btn-secondary"
- :disabled="!currentTask"
- @click="keepAlive"
- >Keep-alive ping</button>
- <span v-if="aliveStatus !== null" :class="['alive', aliveStatus ? 'ok'
: 'err']">
- {{ aliveStatus ? 'Sent ✓' : 'OAP rejected' }}
- </span>
<span class="spacer"></span>
<span class="muted" v-if="!topologyLoading">{{ nodes.length }}
processes · {{ calls.length }} edges</span>
<span v-if="topologyLoading" class="muted">loading topology…</span>
@@ -714,17 +699,6 @@ function fmtTime(ms: number): string {
opacity: 0.5;
cursor: not-allowed;
}
-.alive {
- font-size: 10.5px;
- padding: 0 6px;
- border-radius: 2px;
-}
-.alive.ok {
- color: var(--sw-accent);
-}
-.alive.err {
- color: var(--sw-err);
-}
.spacer {
flex: 1 1 0;
}
diff --git a/apps/ui/src/layer/profiling/ProcessTopologyGraph.vue
b/apps/ui/src/layer/profiling/ProcessTopologyGraph.vue
index 1ca3ddd..190548c 100644
--- a/apps/ui/src/layer/profiling/ProcessTopologyGraph.vue
+++ b/apps/ui/src/layer/profiling/ProcessTopologyGraph.vue
@@ -37,7 +37,9 @@ import * as d3 from 'd3';
import type { ProcessCall, ProcessNode } from '@/api/client';
interface Pt { x: number; y: number }
-type PositionedNode = ProcessNode & Pt;
+// `_below` (inside cells only): label sits below the cell (bottom row)
+// vs above (upper rows), so a tight cluster's labels don't collide.
+type PositionedNode = ProcessNode & Pt & { _below?: boolean };
interface PositionedCall {
id: string;
source: PositionedNode;
@@ -53,32 +55,6 @@ const host = ref<HTMLDivElement | null>(null);
const selectedCallId = ref<string | null>(null);
// ── Hex geometry (flat-top) ─────────────────────────────────────────
-const SQRT3 = Math.sqrt(3);
-/** Flat-top axial → pixel. Neighbour centres land √3·r apart, so a cell
- * hexagon of radius r tiles (touches) its neighbours — a honeycomb. */
-function axialToPixel(ax: number, ay: number, r: number, o: Pt): Pt {
- return { x: 1.5 * ax * r + o.x, y: ((SQRT3 / 2) * ax + SQRT3 * ay) * r + o.y
};
-}
-/** Axial coords in spiral order from the centre — packs a compact
- * honeycomb cluster (centre cell, then rings around it). */
-function spiralHex(n: number): Array<{ x: number; y: number }> {
- const dirs = [[1, 0], [1, -1], [0, -1], [-1, 0], [-1, 1], [0, 1]];
- const out = [{ x: 0, y: 0 }];
- let k = 1;
- while (out.length < n) {
- let cx = dirs[4][0] * k;
- let cy = dirs[4][1] * k;
- for (let side = 0; side < 6 && out.length < n + 6; side++) {
- for (let step = 0; step < k; step++) {
- out.push({ x: cx, y: cy });
- cx += dirs[side][0];
- cy += dirs[side][1];
- }
- }
- k++;
- }
- return out.slice(0, n);
-}
function hexCellPath(cx: number, cy: number, R: number): string {
const v: [number, number][] = [];
for (let i = 0; i < 6; i++) {
@@ -100,35 +76,49 @@ function protocolOf(c: ProcessCall): string {
}
let cellRadius = 26;
+let cellDraw = 20;
let positioned: PositionedNode[] = [];
-function layout(o: Pt, w: number, h: number): PositionedNode[] {
+function layout(o: Pt): PositionedNode[] {
const inside = props.nodes.filter(isInside).map((n) => ({ ...n }) as
PositionedNode);
const outside = props.nodes.filter((n) => !isInside(n)).map((n) => ({ ...n
}) as PositionedNode);
- // Inside processes: a compact honeycomb cluster (cells touch), packed
- // spiral-from-centre. Cell size scales down as the cluster grows.
+ // Inside processes: a centred honeycomb pyramid — the partial row sits
+ // on TOP, full rows below, so the BOTTOM row's cells share one y (the
+ // ui-18 trefoil for 3 nodes: one on top, two level below). `cellRadius`
+ // is the spacing; `cellDraw` is the smaller rendered hexagon so cells
+ // keep a clear gap.
const n = inside.length;
- const rings = Math.max(1, Math.ceil((-3 + Math.sqrt(9 + 12 * Math.max(1, n -
1))) / 6));
- cellRadius = Math.max(26, Math.min(50, 130 / (rings + 0.5)));
- const cells = spiralHex(n);
- inside.forEach((node, i) => {
- const c = cells[i] ?? { x: 0, y: 0 };
- const p = axialToPixel(c.x, c.y, cellRadius, o);
- node.x = p.x;
- node.y = p.y;
- });
+ const cols = Math.max(1, Math.ceil(Math.sqrt(n)));
+ const rows = Math.max(1, Math.ceil(n / cols));
+ cellRadius = Math.max(24, Math.min(40, 120 / (rows + 1.2)));
+ cellDraw = cellRadius * 0.85;
+ const dx = cellRadius * 1.95;
+ const dy = cellRadius * 1.7;
+ const topCount = n - cols * (rows - 1); // cells in the (partial) top row
+ let idx = 0;
+ for (let r = 0; r < rows; r++) {
+ const count = r === 0 ? topCount : cols;
+ for (let c = 0; c < count; c++) {
+ const node = inside[idx++];
+ node.x = o.x + (c - (count - 1) / 2) * dx;
+ node.y = o.y + (r - (rows - 1) / 2) * dy;
+ node._below = r === rows - 1;
+ }
+ }
- // External peers spread on a canvas-filling ellipse (skipping the
- // bottom arc so the pod label stays clear), so the graph uses the
- // whole widget instead of clustering near the centre.
+ // External peers ring the pod CLOSE to its boundary (not flung to the
+ // canvas corners), across the left / top / right, leaving the bottom
+ // arc (~90° in SVG coords, +y down) clear for the pod label. Radius
+ // grows a little with peer count so a busy pod doesn't crowd.
positioned = [...inside, ...outside];
const b = insideBoundary();
- const rx = Math.max(w / 2 - 64, b.r + 130);
- const ry = Math.max(h / 2 - 52, b.r + 96);
const k = outside.length;
- const START_DEG = 305;
- const SPAN_DEG = 290; // leaves ~70° gap at the bottom
+ const pad = 130 + Math.max(0, k - 8) * 6;
+ const rx = b.r + pad;
+ const ry = b.r + pad * 0.82;
+ const START_DEG = 120; // just past bottom-left, sweep CCW-of-screen
+ const SPAN_DEG = 300; // 120→180→270→0→60, skipping 60–120 (bottom)
outside.forEach((node, i) => {
const t = k <= 1 ? 0.5 : i / (k - 1);
const rad = (((START_DEG + t * SPAN_DEG) % 360) * Math.PI) / 180;
@@ -251,7 +241,7 @@ function render(): void {
const h = rect.height || 520;
const o: Pt = { x: 0, y: 0 };
- positioned = layout(o, w, h);
+ positioned = layout(o);
const byId = new Map(positioned.map((n) => [n.id, n]));
const calls = buildCalls(byId);
@@ -389,19 +379,17 @@ function render(): void {
);
nodeSel
.append('path')
- .attr('d', (d) => hexCellPath(0, 0, isInside(d) ? cellRadius * 0.92 : 18))
+ .attr('d', (d) => hexCellPath(0, 0, isInside(d) ? cellDraw : 18))
.attr('fill', (d) => (isInside(d) ? 'var(--sw-accent, #f97316)' :
'var(--sw-bg-3, #2a2d36)'))
.attr('fill-opacity', (d) => (isInside(d) ? 0.85 : 0.75))
.attr('stroke', 'var(--sw-bg-0, #0d0f14)')
.attr('stroke-width', 1.5);
- // Inside cells touch, so their labels alternate BELOW / ABOVE the
- // honeycomb by index (cell 0 below, cell 1 above, cell 2 below, …) to
- // avoid colliding with neighbours. External peers always label below
- // their (isolated) cell. `positioned` is [inside…, outside…] so the
- // datum index `i` is the inside index for inside cells.
- function labelY(d: PositionedNode, i: number): number {
+ // Labels: bottom-row inside cells label BELOW, upper-row cells ABOVE
+ // (so the two level bottom cells read together and nothing collides).
+ // External peers always label below their isolated cell.
+ function labelY(d: PositionedNode): number {
if (!isInside(d)) return 30;
- return i % 2 === 1 ? -(cellRadius + 8) : cellRadius + 16;
+ return d._below ? cellDraw + 15 : -(cellDraw + 7);
}
nodeSel
.append('text')