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>