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 30aea74  ui/bff: network-profiling task+topology fixes, top-N pop-out, 
process-topology visuals, cascade-clear headers
30aea74 is described below

commit 30aea74878cea30a038716a4b8b77420492e31ff
Author: Wu Sheng <[email protected]>
AuthorDate: Wed May 20 19:02:13 2026 +0800

    ui/bff: network-profiling task+topology fixes, top-N pop-out, 
process-topology visuals, cascade-clear headers
    
    - network profiling: list tasks per SERVICE (a task on a now-replaced
      pod was hidden by the instance AND-filter), and make the topology
      follow the SELECTED TASK — query its instance over [taskStart,
      taskStart+duration] instead of the live picker instance + rolling
      window (which finds nothing once the pod is recycled). Topology route
      now accepts explicit startTime/endTime.
    - top-N widget: drop the redundant "<service> ·" prefix on
      single-service dashboards (keep it only when the list spans multiple
      services); add a teleported full-name hover tooltip and a pop-out
      modal (full ranked list, wrapping names, ESC/backdrop close).
    - process topology: smaller compact orange core centered in the pod,
      info-blue colored peers, clearer accent-tinted pod hexagon, wider peer
      ring, and protocol-colored edge pills (HTTP/HTTPS/TLS/TCP).
    - cascade-clear: layer header KPIs reset to placeholders on layer change
      until fresh landing data lands; the dashboard main zone shows one
      unified "Reading data…" reset covering the whole upstream wait instead
      of flashing through multiple loading messages.
---
 apps/bff/src/http/query/dashboard.ts               |  23 +-
 apps/bff/src/http/query/ebpf.ts                    |  27 ++-
 apps/ui/src/api/scopes/network-profile.ts          |  17 +-
 apps/ui/src/components/charts/TopList.vue          | 231 ++++++++++++++++++++-
 apps/ui/src/layer/LayerShell.vue                   |  20 +-
 .../layer/profiling/LayerNetworkProfilingView.vue  |  48 +++--
 .../src/layer/profiling/ProcessTopologyGraph.vue   |  71 +++++--
 .../render/layer-dashboard/LayerDashboardsView.vue |  40 ++--
 8 files changed, 410 insertions(+), 67 deletions(-)

diff --git a/apps/bff/src/http/query/dashboard.ts 
b/apps/bff/src/http/query/dashboard.ts
index a36b12a..0b71d91 100644
--- a/apps/bff/src/http/query/dashboard.ts
+++ b/apps/bff/src/http/query/dashboard.ts
@@ -310,12 +310,18 @@ export function parseLabeledSeries(
 
 /**
  * Extract a sorted list from a `top_n(...)` MQE response. Names follow
- * an entity-scope priority so layer-wide top lists (where the same
- * endpoint can appear in multiple services) stay disambiguated:
- *   Endpoint    →  "<service> · <endpoint>"  (or just endpoint when alone)
- *   Instance    →  "<service> · <instance>"
+ * an entity-scope priority:
+ *   Endpoint    →  "<service> · <endpoint>" or just "<endpoint>"
+ *   Instance    →  "<service> · <instance>" or just "<instance>"
  *   Service     →  service
  *   fallback    →  raw id
+ *
+ * The `<service> ·` prefix is only added when the list actually spans
+ * MULTIPLE services (layer-wide top lists, where the same endpoint can
+ * appear under different services and needs disambiguation). On a
+ * single-service dashboard ("Top 20 APIs" under one service) every row
+ * carries the same service, so the prefix is pure noise — drop it and
+ * show just the endpoint / instance.
  */
 export function parseTopList(
   r: MqeResultShape | undefined,
@@ -323,13 +329,18 @@ export function parseTopList(
   if (!r || r.error) return null;
   const values = r.results?.[0]?.values ?? [];
   if (values.length === 0) return null;
+  const services = new Set<string>();
+  for (const v of values) {
+    if (v.owner?.serviceName) services.add(v.owner.serviceName);
+  }
+  const multiService = services.size > 1;
   return values.map((v) => {
     const o = v.owner;
     let name = '—';
     if (o?.endpointName) {
-      name = o.serviceName ? `${o.serviceName} · ${o.endpointName}` : 
o.endpointName;
+      name = multiService && o.serviceName ? `${o.serviceName} · 
${o.endpointName}` : o.endpointName;
     } else if (o?.serviceInstanceName) {
-      name = o.serviceName
+      name = multiService && o.serviceName
         ? `${o.serviceName} · ${o.serviceInstanceName}`
         : o.serviceInstanceName;
     } else if (o?.serviceName) {
diff --git a/apps/bff/src/http/query/ebpf.ts b/apps/bff/src/http/query/ebpf.ts
index b7d7e7a..57162f7 100644
--- a/apps/bff/src/http/query/ebpf.ts
+++ b/apps/bff/src/http/query/ebpf.ts
@@ -424,13 +424,32 @@ export function registerEBPFRoutes(app: FastifyInstance, 
deps: EBPFRouteDeps): v
     '/api/ebpf/network/topology',
     { preHandler: auth },
     async (req: FastifyRequest, reply: FastifyReply) => {
-      const q = req.query as { serviceInstance?: string; windowMinutes?: 
string };
+      const q = req.query as {
+        serviceInstance?: string;
+        windowMinutes?: string;
+        startTime?: string;
+        endTime?: string;
+      };
       const instance = (q.serviceInstance ?? '').trim();
       const payload: ProcessTopologyResponse = { nodes: [], calls: [], 
reachable: true };
       if (!instance) return reply.send(payload);
-      const minutes = Math.max(5, Math.min(180, Number(q.windowMinutes) || 
30));
-      const end = new Date();
-      const start = new Date(end.getTime() - minutes * 60_000);
+      // Explicit start/end (ms epoch) takes precedence — used to pin the
+      // topology to a finished task's capture window (the task's
+      // instance only had eBPF processes reporting during that window,
+      // and may since have been replaced). Falls back to a rolling
+      // window for the live view when no task is selected.
+      const startMs = Number(q.startTime);
+      const endMs = Number(q.endTime);
+      let start: Date;
+      let end: Date;
+      if (Number.isFinite(startMs) && Number.isFinite(endMs) && endMs > 
startMs) {
+        start = new Date(startMs);
+        end = new Date(endMs);
+      } else {
+        const minutes = Math.max(5, Math.min(180, Number(q.windowMinutes) || 
30));
+        end = new Date();
+        start = new Date(end.getTime() - minutes * 60_000);
+      }
       const fmt = (d: Date) => {
         const z = (n: number) => String(n).padStart(2, '0');
         return `${d.getUTCFullYear()}-${z(d.getUTCMonth() + 
1)}-${z(d.getUTCDate())} ${z(d.getUTCHours())}${z(d.getUTCMinutes())}`;
diff --git a/apps/ui/src/api/scopes/network-profile.ts 
b/apps/ui/src/api/scopes/network-profile.ts
index 8900bf1..6a0a2d2 100644
--- a/apps/ui/src/api/scopes/network-profile.ts
+++ b/apps/ui/src/api/scopes/network-profile.ts
@@ -44,8 +44,21 @@ export class NetworkProfileApi {
     );
   }
 
-  topology(serviceInstance: string, windowMinutes = 30): 
Promise<ProcessTopologyResponse> {
-    const qs = new URLSearchParams({ serviceInstance, windowMinutes: 
String(windowMinutes) });
+  /** Process topology for an instance. Pass an explicit `startTime`/
+   *  `endTime` (ms epoch) to pin the view to a finished task's capture
+   *  window — the task's instance only reported eBPF processes during
+   *  that window. Omit them for the rolling live view (`windowMinutes`). */
+  topology(
+    serviceInstance: string,
+    opts: { windowMinutes?: number; startTime?: number; endTime?: number } = 
{},
+  ): Promise<ProcessTopologyResponse> {
+    const qs = new URLSearchParams({ serviceInstance });
+    if (opts.startTime !== undefined && opts.endTime !== undefined) {
+      qs.set('startTime', String(opts.startTime));
+      qs.set('endTime', String(opts.endTime));
+    } else {
+      qs.set('windowMinutes', String(opts.windowMinutes ?? 30));
+    }
     return this.bff.request<ProcessTopologyResponse>(
       'GET',
       `/api/ebpf/network/topology?${qs.toString()}`,
diff --git a/apps/ui/src/components/charts/TopList.vue 
b/apps/ui/src/components/charts/TopList.vue
index 39fe5b0..8c6bc05 100644
--- a/apps/ui/src/components/charts/TopList.vue
+++ b/apps/ui/src/components/charts/TopList.vue
@@ -27,7 +27,7 @@
      widget. The first group is active by default.
 -->
 <script setup lang="ts">
-import { computed, ref, watch } from 'vue';
+import { computed, onBeforeUnmount, ref, watch } from 'vue';
 import type { DashboardTopItem } from '@skywalking-horizon-ui/api-client';
 import { fmtMetric } from '@/utils/formatters';
 
@@ -48,6 +48,8 @@ const props = withDefaults(
     groups?: ReadonlyArray<TopGroup>;
     unit?: string;
     color?: string;
+    /** Widget title — shown as the pop-out modal header. */
+    title?: string;
   }>(),
   {
     color: 'var(--sw-accent)',
@@ -83,10 +85,62 @@ function pct(v: number | null): number {
   return Math.max(0, Math.min(100, (v / max.value) * 100));
 }
 const showTabs = computed(() => effectiveGroups.value.length > 1);
+
+// Pop-out: a widget often can't show the full ranked list (rows scroll)
+// or the full names (ellipsized). The pop-out renders the same active
+// list in a large teleported modal with wrapping names and roomy rows.
+const expanded = ref(false);
+function openExpanded(): void {
+  expanded.value = true;
+  window.addEventListener('keydown', onKeydown);
+}
+function closeExpanded(): void {
+  expanded.value = false;
+  window.removeEventListener('keydown', onKeydown);
+}
+function onKeydown(e: KeyboardEvent): void {
+  if (e.key === 'Escape') closeExpanded();
+}
+onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown));
+
+// Full-name hover tooltip. Row names are ellipsized to fit the widget,
+// so long api / endpoint / instance names are unreadable inline. A
+// teleported floating box (rendered to <body>, not inside the widget)
+// shows the complete name without being clipped by the widget border.
+const hoverTip = ref<{ text: string; x: number; y: number } | null>(null);
+function showTip(e: MouseEvent, text: string): void {
+  hoverTip.value = { text, x: e.clientX, y: e.clientY };
+}
+function moveTip(e: MouseEvent): void {
+  if (hoverTip.value) hoverTip.value = { ...hoverTip.value, x: e.clientX, y: 
e.clientY };
+}
+function hideTip(): void {
+  hoverTip.value = null;
+}
+// Flip to the left of the cursor near the right edge so the box stays
+// on-screen; vertical offset keeps it clear of the pointer.
+const tipStyle = computed(() => {
+  const t = hoverTip.value;
+  if (!t) return {};
+  const flipLeft = t.x > window.innerWidth * 0.6;
+  return {
+    left: `${t.x + (flipLeft ? -12 : 12)}px`,
+    top: `${t.y + 14}px`,
+    transform: flipLeft ? 'translateX(-100%)' : 'none',
+  } as Record<string, string>;
+});
 </script>
 
 <template>
   <div class="top-list">
+    <button
+      v-if="activeItems.length"
+      type="button"
+      class="tl-expand"
+      title="Pop out — full list"
+      aria-label="Pop out"
+      @click="openExpanded"
+    >⤢</button>
     <div v-if="showTabs" class="tabs">
       <button
         v-for="(g, i) in effectiveGroups"
@@ -101,7 +155,14 @@ const showTabs = computed(() => 
effectiveGroups.value.length > 1);
       </button>
     </div>
     <div class="rows">
-      <div v-for="(it, i) in activeItems" :key="i" class="row" 
:title="it.name">
+      <div
+        v-for="(it, i) in activeItems"
+        :key="i"
+        class="row"
+        @mouseenter="showTip($event, it.name)"
+        @mousemove="moveTip"
+        @mouseleave="hideTip"
+      >
         <!-- Background fill — trace-waterfall style. The bar paints the
              row from the left, value is proportional to row.value/max,
              and the rank+name+value text overlays the bar. -->
@@ -114,17 +175,85 @@ const showTabs = computed(() => 
effectiveGroups.value.length > 1);
       </div>
       <p v-if="activeItems.length === 0" class="empty">No data</p>
     </div>
+
+    <Teleport to="body">
+      <div v-if="hoverTip" class="top-tip" :style="tipStyle">{{ hoverTip.text 
}}</div>
+    </Teleport>
+
+    <!-- Pop-out: full ranked list in a roomy modal with wrapping names. -->
+    <Teleport to="body">
+      <div v-if="expanded" class="tl-modal" @click.self="closeExpanded">
+        <div class="tl-dialog">
+          <header class="tl-head">
+            <span class="tl-title">{{ title || 'Top list' }}</span>
+            <button class="tl-close" aria-label="Close" 
@click="closeExpanded">×</button>
+          </header>
+          <div v-if="showTabs" class="tabs">
+            <button
+              v-for="(g, i) in effectiveGroups"
+              :key="i"
+              type="button"
+              class="tab"
+              :class="{ on: activeIdx === i }"
+              :title="g.expression ? `${g.label}\n\n${g.expression}` : g.label"
+              @click="activeIdx = i"
+            >
+              {{ g.label }}
+            </button>
+          </div>
+          <div class="rows rows--big">
+            <div v-for="(it, i) in activeItems" :key="i" class="row row--big">
+              <span class="bar-bg" :style="{ width: `${pct(it.value)}%`, 
background: color }" />
+              <span class="rank">{{ i + 1 }}</span>
+              <span class="name name--wrap">{{ it.name }}</span>
+              <span class="value">
+                {{ fmtMetric(it.value) }}<span v-if="activeUnit" 
class="unit">{{ activeUnit }}</span>
+              </span>
+            </div>
+            <p v-if="activeItems.length === 0" class="empty">No data</p>
+          </div>
+        </div>
+      </div>
+    </Teleport>
   </div>
 </template>
 
 <style scoped>
 .top-list {
+  position: relative;
   display: flex;
   flex-direction: column;
   width: 100%;
   height: 100%;
   min-height: 0;
 }
+/* Pop-out affordance — top-right, surfaces on widget hover so it
+ * doesn't clutter the dense default view. */
+.tl-expand {
+  position: absolute;
+  top: 0;
+  right: 0;
+  z-index: 2;
+  width: 20px;
+  height: 20px;
+  padding: 0;
+  font-size: 13px;
+  line-height: 1;
+  color: var(--sw-fg-3);
+  background: var(--sw-bg-1);
+  border: 1px solid var(--sw-line);
+  border-radius: 4px;
+  cursor: pointer;
+  opacity: 0;
+  transition: opacity 0.12s, color 0.12s;
+}
+.top-list:hover .tl-expand {
+  opacity: 1;
+}
+.tl-expand:hover {
+  color: var(--sw-fg-0);
+  border-color: var(--sw-line-2);
+}
 .tabs {
   display: flex;
   gap: 2px;
@@ -232,4 +361,102 @@ const showTabs = computed(() => 
effectiveGroups.value.length > 1);
   text-align: center;
   margin: 10px 0;
 }
+.top-tip {
+  position: fixed;
+  z-index: 9999;
+  max-width: 420px;
+  padding: 6px 9px;
+  font-family: var(--sw-mono);
+  font-size: 11.5px;
+  line-height: 1.4;
+  color: var(--sw-fg-0);
+  background: var(--sw-bg-0, #1c2630);
+  border: 1px solid var(--sw-line-2);
+  border-radius: 5px;
+  box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55);
+  word-break: break-all;
+  pointer-events: none;
+}
+
+/* ── Pop-out modal ───────────────────────────────────────────────── */
+.tl-modal {
+  position: fixed;
+  inset: 0;
+  z-index: 1000;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: rgba(0, 0, 0, 0.55);
+  padding: 5vh 4vw;
+}
+.tl-dialog {
+  display: flex;
+  flex-direction: column;
+  width: min(720px, 92vw);
+  max-height: 86vh;
+  background: var(--sw-bg-1);
+  border: 1px solid var(--sw-line-2);
+  border-radius: 8px;
+  box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6);
+  overflow: hidden;
+}
+.tl-head {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 12px 14px;
+  border-bottom: 1px solid var(--sw-line);
+}
+.tl-title {
+  flex: 1;
+  font-size: 13px;
+  font-weight: 600;
+  color: var(--sw-fg-0);
+  letter-spacing: -0.01em;
+}
+.tl-close {
+  width: 24px;
+  height: 24px;
+  font-size: 16px;
+  line-height: 1;
+  color: var(--sw-fg-2);
+  background: transparent;
+  border: 1px solid var(--sw-line);
+  border-radius: 5px;
+  cursor: pointer;
+}
+.tl-close:hover {
+  color: var(--sw-fg-0);
+  background: var(--sw-bg-2);
+}
+.tl-dialog .tabs {
+  padding: 8px 14px 6px;
+}
+.rows--big {
+  padding: 8px 14px 14px;
+  gap: 4px;
+  overflow-y: auto;
+}
+.row--big {
+  grid-template-columns: 26px 1fr auto;
+  gap: 10px;
+  font-size: 13px;
+  padding: 7px 10px;
+  min-height: 26px;
+}
+.row--big .rank {
+  font-size: 11.5px;
+}
+.row--big .name {
+  font-size: 13px;
+}
+.row--big .value {
+  font-size: 12.5px;
+}
+.name--wrap {
+  white-space: normal;
+  overflow: visible;
+  text-overflow: clip;
+  word-break: break-all;
+}
 </style>
diff --git a/apps/ui/src/layer/LayerShell.vue b/apps/ui/src/layer/LayerShell.vue
index 1a05b1c..c1adf7c 100644
--- a/apps/ui/src/layer/LayerShell.vue
+++ b/apps/ui/src/layer/LayerShell.vue
@@ -165,7 +165,23 @@ const safeCfg = computed(() => cfg.value?.landing ?? {
   style: 'table' as const,
 });
 const landing = useLayerLanding(safeLayer, safeCfg);
-const aggregates = computed(() => landing.data.value?.aggregates ?? null);
+
+// Cascade-clear for the header: the instant the layer changes (menu
+// click), reset to "no data" so the KPI strip + selected-service values
+// blank out (labels stay) instead of lingering on the previous layer's
+// numbers. Marked fresh again only once landing data for the new layer
+// lands. Cached layers resolve in the same tick, so there's no visible
+// flash for them — only a genuine fetch shows the cleared header.
+const landingFresh = ref(false);
+watch(layerKey, () => { landingFresh.value = false; });
+watch(
+  () => landing.data.value,
+  (d) => { if (d != null) landingFresh.value = true; },
+  { immediate: true },
+);
+const aggregates = computed(() =>
+  landingFresh.value ? (landing.data.value?.aggregates ?? null) : null,
+);
 
 // Page-wide selected service — URL-backed, shared with every tab body.
 const { selectedId, setSelected } = useSelectedService();
@@ -345,7 +361,7 @@ const serviceKpis = computed<HeaderKpi[]>(() => {
        // stale no-op (raw metric key, lowercase or empty). Operators
        // who set a custom label still get it shown.
       label: col.label && col.label !== col.metric ? col.label : m.label,
-      value: row.metrics[col.metric] ?? null,
+      value: landingFresh.value ? (row.metrics[col.metric] ?? null) : null,
       unit: col.unit || m.unit,
       color: colorForMetric(col.metric),
     });
diff --git a/apps/ui/src/layer/profiling/LayerNetworkProfilingView.vue 
b/apps/ui/src/layer/profiling/LayerNetworkProfilingView.vue
index a11ec6c..721cc0b 100644
--- a/apps/ui/src/layer/profiling/LayerNetworkProfilingView.vue
+++ b/apps/ui/src/layer/profiling/LayerNetworkProfilingView.vue
@@ -73,8 +73,14 @@ const tasksError = ref<string | null>(null);
 const tasksLoading = ref(false);
 const currentTask = ref<EBPFTask | null>(null);
 
+// Tasks are listed per SERVICE, not per selected instance. A network
+// task runs against the instance that was live when it was created; if
+// that pod has since been replaced (new pod name), AND-ing the task
+// query with the currently-selected instance hides the task entirely.
+// The task object carries its own serviceInstanceId, so the list stays
+// correct and selecting a task can still drive the right topology.
 watch(
-  () => layerKey.value + '|' + (serviceId.value ?? '') + '|' + 
(selectedInstanceId.value ?? ''),
+  () => layerKey.value + '|' + (serviceId.value ?? ''),
   () => void refreshTasks(),
   { immediate: true },
 );
@@ -83,17 +89,19 @@ async function refreshTasks(): Promise<void> {
   tasksError.value = null;
   tasks.value = [];
   currentTask.value = null;
-  if (!serviceId.value && !selectedInstanceId.value) return;
+  if (!serviceId.value) return;
   tasksLoading.value = true;
   try {
     const resp = await bffClient.networkProfile.tasks(layerKey.value, {
       service: serviceId.value ?? undefined,
-      serviceInstance: selectedInstanceId.value ?? undefined,
     });
     if (!resp.reachable && resp.error) tasksError.value = resp.error;
     tasks.value = resp.tasks ?? [];
     currentTask.value = tasks.value[0] ?? null;
-    await loadTopology();
+    // currentTask change drives loadTopology via the watch below; when
+    // the list is empty currentTask stays null and we fall back to the
+    // live picker view.
+    if (!currentTask.value) await loadTopology();
   } catch (e) {
     tasksError.value = e instanceof Error ? e.message : String(e);
   } finally {
@@ -108,17 +116,33 @@ const topologyLoading = ref(false);
 const topologyError = ref<string | null>(null);
 const windowMinutes = ref(30);
 
+// Topology follows the SELECTED TASK: a finished FIXED_TIME task only
+// captured process relations on its own instance during its own window
+// (and that pod may since have been replaced). When a task is selected
+// we query its instance + [taskStartTime, taskStartTime+duration]; with
+// no task we fall back to the picker instance + rolling window.
+watch([currentTask, selectedInstanceId], () => void loadTopology());
+
 async function loadTopology(): Promise<void> {
   nodes.value = [];
   calls.value = [];
   topologyError.value = null;
-  if (!selectedInstanceId.value) return;
+  const task = currentTask.value;
+  const instanceId = task?.serviceInstanceId ?? selectedInstanceId.value;
+  if (!instanceId) return;
   topologyLoading.value = true;
   try {
-    const resp = await bffClient.networkProfile.topology(
-      selectedInstanceId.value,
-      windowMinutes.value,
-    );
+    let topoOpts: { windowMinutes?: number; startTime?: number; endTime?: 
number };
+    if (task?.taskStartTime) {
+      const dur = (task.fixedTriggerDuration ?? 0) * 1000;
+      topoOpts = {
+        startTime: task.taskStartTime,
+        endTime: dur > 0 ? task.taskStartTime + dur : Date.now(),
+      };
+    } else {
+      topoOpts = { windowMinutes: windowMinutes.value };
+    }
+    const resp = await bffClient.networkProfile.topology(instanceId, topoOpts);
     if (!resp.reachable && resp.error) topologyError.value = resp.error;
     nodes.value = resp.nodes ?? [];
     calls.value = resp.calls ?? [];
@@ -333,8 +357,8 @@ function fmtTime(ms: number): string {
           <button
             class="btn-refresh"
             :class="{ spinning: tasksLoading }"
-            :disabled="!selectedInstanceId || tasksLoading"
-            :title="!selectedInstanceId ? 'Pick an instance' : tasksLoading ? 
'Refreshing…' : 'Refresh task list'"
+            :disabled="!serviceId || tasksLoading"
+            :title="!serviceId ? 'Pick a service' : tasksLoading ? 
'Refreshing…' : 'Refresh task list'"
             aria-label="Refresh task list"
             @click="refreshTasks"
           ><Icon name="refresh" :size="11" /></button>
@@ -348,7 +372,7 @@ function fmtTime(ms: number): string {
       <div v-if="tasksError" class="side-err">{{ tasksError }}</div>
       <div v-else-if="tasksLoading && !tasks.length" 
class="side-empty">Loading…</div>
       <div v-else-if="!tasks.length" class="side-empty">
-        {{ selectedInstanceId ? 'No network tasks on this instance.' : 'Pick 
an instance to load tasks.' }}
+        {{ serviceId ? 'No network tasks for this service.' : 'Pick a service 
to load tasks.' }}
       </div>
       <ul v-else class="side-list">
         <li
diff --git a/apps/ui/src/layer/profiling/ProcessTopologyGraph.vue 
b/apps/ui/src/layer/profiling/ProcessTopologyGraph.vue
index 190548c..74c7fc5 100644
--- a/apps/ui/src/layer/profiling/ProcessTopologyGraph.vue
+++ b/apps/ui/src/layer/profiling/ProcessTopologyGraph.vue
@@ -74,6 +74,18 @@ function protocolOf(c: ProcessCall): string {
   if (t.includes('http')) return 'HTTP';
   return 'TCP';
 }
+/** Per-protocol accent for the edge pills — a colored border + label so
+ *  operators can read the transport at a glance instead of reading each
+ *  pill's text. */
+function protocolColor(p: string): string {
+  switch (p) {
+    case 'HTTP': return '#38bdf8'; // info blue
+    case 'HTTPS': return '#34d399'; // ok green
+    case 'TLS': return '#2dd4bf'; // teal
+    case 'TCP': return '#fbbf24'; // amber
+    default: return 'var(--sw-fg-3, #6c7080)';
+  }
+}
 
 let cellRadius = 26;
 let cellDraw = 20;
@@ -92,9 +104,13 @@ function layout(o: Pt): PositionedNode[] {
   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;
+  // Rendered hexes are notably smaller than their spacing slot so the
+  // internal processes read as a compact, unified core that sits inside
+  // the (larger) dashed pod hexagon. Spacing tracks the draw size so the
+  // cluster stays tight when the hexes shrink.
+  cellDraw = cellRadius * 0.62;
+  const dx = cellDraw * 2.15;
+  const dy = cellDraw * 1.9;
   const topCount = n - cols * (rows - 1); // cells in the (partial) top row
   let idx = 0;
   for (let r = 0; r < rows; r++) {
@@ -106,6 +122,18 @@ function layout(o: Pt): PositionedNode[] {
       node._below = r === rows - 1;
     }
   }
+  // The row pyramid is slightly top/bottom-heavy, so shift the whole
+  // cluster so its centroid lands exactly on the pod centre `o`. The
+  // boundary tracks the same centroid, so the unified core ends up
+  // dead-centre inside the dashed pod hexagon.
+  if (inside.length > 0) {
+    const ccx = d3.mean(inside, (d) => d.x) ?? o.x;
+    const ccy = d3.mean(inside, (d) => d.y) ?? o.y;
+    for (const node of inside) {
+      node.x += o.x - ccx;
+      node.y += o.y - ccy;
+    }
+  }
 
   // External peers ring the pod CLOSE to its boundary (not flung to the
   // canvas corners), across the left / top / right, leaving the bottom
@@ -114,9 +142,13 @@ function layout(o: Pt): PositionedNode[] {
   positioned = [...inside, ...outside];
   const b = insideBoundary();
   const k = outside.length;
-  const pad = 130 + Math.max(0, k - 8) * 6;
+  // Now that the core is a small, compact cluster the auto-fit boundary
+  // is tight — push peers out to a generous, near-constant ring so they
+  // spread around the pod (like the reference layout) rather than
+  // hugging the shrunken hexagon.
+  const pad = 175 + Math.max(0, k - 8) * 8;
   const rx = b.r + pad;
-  const ry = b.r + pad * 0.82;
+  const ry = b.r + pad * 0.8;
   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) => {
@@ -280,11 +312,15 @@ function render(): void {
   boundarySel = g
     .append('path')
     .attr('d', hexCellPath(b0.cx, b0.cy, b0.r))
-    .attr('fill', 'var(--sw-bg-1, #15171c)')
-    .attr('fill-opacity', 0.3)
-    .attr('stroke', 'var(--sw-line, #2a2d36)')
-    .attr('stroke-dasharray', '4 4')
-    .attr('stroke-width', 1.5);
+    // Warm-tinted fill + accent dashed border so the pod reads as a
+    // clearly-delineated orange container around its process core,
+    // instead of a near-invisible grey outline.
+    .attr('fill', 'var(--sw-accent, #f97316)')
+    .attr('fill-opacity', 0.08)
+    .attr('stroke', 'var(--sw-accent, #f97316)')
+    .attr('stroke-opacity', 0.55)
+    .attr('stroke-dasharray', '5 4')
+    .attr('stroke-width', 1.75);
   boundaryLabelSel = g
     .append('text')
     .attr('x', b0.cx)
@@ -336,17 +372,22 @@ function render(): void {
     .attr('height', 14)
     .attr('rx', 7)
     .attr('fill', 'var(--sw-bg-2, #1f2129)')
-    .attr('stroke', 'var(--sw-line, #2a2d36)');
+    .attr('stroke', (d) => protocolColor(d.protocol))
+    .attr('stroke-width', 1.25)
+    .attr('stroke-opacity', 0.9);
   pillSel
     .append('text')
     .attr('text-anchor', 'middle')
     .attr('dy', '0.32em')
-    .attr('fill', 'var(--sw-fg-2, #b4b7c2)')
+    .attr('fill', (d) => protocolColor(d.protocol))
     .style('font-family', 'var(--sw-mono, monospace)')
     .style('font-size', '9px')
+    .style('font-weight', '600')
     .text((d) => d.protocol);
 
-  // Nodes — hex cells (inside = accent, external = grey).
+  // Nodes — hex cells (inside = orange accent core, external peers =
+  // info-blue so they read as a distinct, colored class rather than a
+  // dim grey backdrop).
   nodeSel = g
     .append('g')
     .attr('class', 'nodes')
@@ -380,8 +421,8 @@ function render(): void {
   nodeSel
     .append('path')
     .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('fill', (d) => (isInside(d) ? 'var(--sw-accent, #f97316)' : 
'var(--sw-info, #38bdf8)'))
+    .attr('fill-opacity', (d) => (isInside(d) ? 0.9 : 0.55))
     .attr('stroke', 'var(--sw-bg-0, #0d0f14)')
     .attr('stroke-width', 1.5);
   // Labels: bottom-row inside cells label BELOW, upper-row cells ABOVE
diff --git a/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue 
b/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue
index a718385..ce29607 100644
--- a/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue
+++ b/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue
@@ -616,30 +616,16 @@ function isVisible(
       </template>
     </section>
 
-    <div v-if="configLoading" class="empty">Loading dashboard config…</div>
-    <div v-else-if="widgets.length === 0" class="empty">
-      No widgets defined for this layer. Add some via Dashboard setup → Layer 
dashboards.
-    </div>
-    <!-- The previous "Select an instance/endpoint above to view its
-         metrics" branches implied operator action was needed and
-         masked the (already-running) auto-pick — which made every
-         service click feel frozen for a beat before the cascade
-         visibly resumed. The picker handles its own empty state;
-         the "Reading data…" gate below covers the upstream wait. -->
-
-    <!-- Single page-level loading state while we don't yet have
-         widget data to render. Covers the whole upstream wait,
-         not just the dashboard fetch:
-           - landing query in flight (serviceName unresolved →
-             dashboard.enabled still false, isFetching=false,
-             but we're still loading)
-           - dashboard query in flight
-         The widget batch is server-side batched so widgets all
-         land together; one indicator is cleaner than N "loading…"
-         cards. Once `data` arrives, the grid takes over and shows
-         each widget's value / no-data / error normally. Background
-         refetches keep showing the prior data, no flash. -->
-    <div v-else-if="!dataIsFresh && reachable" class="empty reading">
+    <!-- Main-zone reset-then-load. ONE "Reading data…" state covers the
+         WHOLE upstream wait — the config-template fetch AND the
+         service→instance→dashboard chain — and it fires the instant any
+         upstream pick changes. The grid therefore visibly RESETS first
+         instead of lingering on the prior selection's widgets or
+         flashing "loading config" → "no widgets" → "reading" in
+         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">
       <span class="reading-dot" />
       <span>
         Reading data
@@ -657,6 +643,9 @@ function isVisible(
         </span>
       </span>
     </div>
+    <div v-else-if="widgets.length === 0" class="empty">
+      No widgets defined for this layer. Add some via Dashboard setup → Layer 
dashboards.
+    </div>
     <div v-else class="grid">
       <div
         v-for="w in widgets.filter((wi) => isVisible(wi, 
resultsById.get(wi.id)))"
@@ -699,12 +688,14 @@ function isVisible(
               :groups="resultsById.get(w.id)!.topGroups!"
               :unit="w.unit"
               :color="widgetColor(w)"
+              :title="w.title"
             />
             <TopList
               v-else-if="resultsById.get(w.id)?.topList?.length"
               :items="resultsById.get(w.id)!.topList!"
               :unit="w.unit"
               :color="widgetColor(w)"
+              :title="w.title"
             />
             <span v-else class="muted">{{ isFetching && !resultsById.has(w.id) 
? 'loading…' : 'no data' }}</span>
           </template>
@@ -717,6 +708,7 @@ function isVisible(
               :items="resultsById.get(w.id)!.records!.map((r) => ({ name: 
r.name, value: r.value ?? null }))"
               :unit="w.unit"
               :color="widgetColor(w)"
+              :title="w.title"
             />
             <span v-else class="muted">{{ isFetching && !resultsById.has(w.id) 
? 'loading…' : 'no data' }}</span>
           </template>

Reply via email to