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 8ce4059  service dashboard: rearrange + dual-axis MQ + label fixes
8ce4059 is described below

commit 8ce405962a1e72f38cc16128a1ed374cff40be18
Author: Wu Sheng <[email protected]>
AuthorDate: Tue May 12 21:53:16 2026 +0800

    service dashboard: rearrange + dual-axis MQ + label fixes
    
    Service grid restructure per operator:
      Row 1 (line):  Traffic · Error Rate · Apdex
      Row 2 (line):  Response Time Percentile · Avg Response Time · MQ combined
    Dropped the SLA line (the Apdex card + sla in the layer header already
    covers the SR signal) and merged the two MQ widgets into one
    'MQ Consume rate + latency' chart with dual y-axis: count on the left,
    latency on the right.
    
    New widget capability — dual-axis line charts:
      - DashboardWidget gains optional expressionAxes: number[] (0 = left,
        1 = right) parallel to expressions.
      - DashboardSeries carries yAxisIndex + per-series unit.
      - TimeChart adds a second yAxis on the right when any series asks
        for axis 1; grid.right shifts to leave room for the right axis
        labels.
    
    TopList: per-tab unit overrides (expressionUnits) so the Top 20 APIs
    widget can mix rpm / ms / % across its three switchable tabs without
    mis-labeling values.
    
    Label fix: single-series Line widgets were rendering legend entries
    as the bucket timestamp (e.g. "1778592960000") because the old code
    fell back to value.id, which OAP populates with bucket id for
    time-series MQEs. Use the operator's expression text instead. Multi-
    series relabels() responses still pull from metric.labels.
    
    TopList: rename 'SR' tab to 'Successful Rate' (acronym wasn't clear).
    Layer-wide TopList rows now include service context — endpoint names
    of the form 'serviceA · POST:/users' to disambiguate when the same
    endpoint exists across multiple services.
---
 apps/bff/src/dashboard/routes.ts            | 90 +++++++++++++++++++++--------
 apps/bff/src/layers/config/general.json     | 70 +++++++++-------------
 apps/ui/src/components/charts/TimeChart.vue | 42 +++++++++++---
 apps/ui/src/components/charts/TopList.vue   | 11 ++--
 packages/api-client/src/dashboard.ts        | 31 +++++++++-
 5 files changed, 165 insertions(+), 79 deletions(-)

diff --git a/apps/bff/src/dashboard/routes.ts b/apps/bff/src/dashboard/routes.ts
index 1f3f2d0..76965f7 100644
--- a/apps/bff/src/dashboard/routes.ts
+++ b/apps/bff/src/dashboard/routes.ts
@@ -65,6 +65,8 @@ const widgetSchema = z.object({
   type: z.enum(['card', 'line', 'top']),
   expressions: z.array(z.string().min(1)).min(1).max(8),
   expressionLabels: z.array(z.string()).max(8).optional(),
+  expressionUnits: z.array(z.string()).max(8).optional(),
+  expressionAxes: z.array(z.number().int().min(0).max(1)).max(8).optional(),
   unit: z.string().optional(),
   span: z.number().int().min(1).max(12).optional(),
   rowSpan: z.number().int().min(1).max(64).optional(),
@@ -193,9 +195,12 @@ function avgOf(series: Array<number | null> | null): 
number | null {
  * Time-series MQE responses can carry multiple labeled results (one
  * relabel() call returns 5 results, one per percentile). Convert each
  * to a `DashboardSeries`. The label preference order:
- *  - explicit `relabels(..., key='...')` from metric.labels
- *  - the OAP id field (e.g., `endpoint_percentile{p='99'}`)
- *  - fallback to the raw expression
+ *  - explicit `relabels(..., key='...')` from metric.labels (multi-series)
+ *  - fallback to the caller's expression text (single-series)
+ *
+ * Do NOT use `values[0].id` as a label — for time-series MQEs, OAP
+ * returns the per-bucket timestamp/index as the value id, which is
+ * useless as a series label.
  */
 function parseLabeledSeries(
   r: MqeResultShape | undefined,
@@ -211,22 +216,26 @@ function parseLabeledSeries(
       const n = Number(v.value);
       return Number.isFinite(n) ? n : null;
     });
-    // Prefer the most-specific label OAP returned. relabels() adds a
-    // `percentile` key; raw `service_percentile{p='99'}` shows up as
-    // `p='99'`. Either way, take the last (most-derived) entry.
+    // For relabels() results OAP returns multi-result responses with
+    // metric.labels populated — take the last (most-derived) label
+    // value, e.g. `percentile='99'`. Single-series results have no
+    // labels; fall back to the operator's expression text.
     const labels = rs.metric?.labels ?? [];
-    const lbl =
-      labels.length > 0
-        ? labels[labels.length - 1].value
-        : values[0]?.id ?? fallbackLabel;
+    const lbl = labels.length > 0 ? labels[labels.length - 1].value : 
fallbackLabel;
     out.push({ label: lbl, data });
   }
   return out.length > 0 ? out : null;
 }
 
-/** Extract a sorted list from a `top_n(...)` MQE response. Owner.endpointName
- *  / serviceInstanceName / serviceName takes priority over the bare id
- *  so operators see readable rows. */
+/**
+ * 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>"
+ *   Service     →  service
+ *   fallback    →  raw id
+ */
 function parseTopList(
   r: MqeResultShape | undefined,
 ): Array<{ name: string; value: number | null }> | null {
@@ -234,12 +243,19 @@ function parseTopList(
   const values = r.results?.[0]?.values ?? [];
   if (values.length === 0) return null;
   return values.map((v) => {
-    const name =
-      v.owner?.endpointName ??
-      v.owner?.serviceInstanceName ??
-      v.owner?.serviceName ??
-      v.id ??
-      '—';
+    const o = v.owner;
+    let name = '—';
+    if (o?.endpointName) {
+      name = o.serviceName ? `${o.serviceName} · ${o.endpointName}` : 
o.endpointName;
+    } else if (o?.serviceInstanceName) {
+      name = o.serviceName
+        ? `${o.serviceName} · ${o.serviceInstanceName}`
+        : o.serviceInstanceName;
+    } else if (o?.serviceName) {
+      name = o.serviceName;
+    } else if (v.id) {
+      name = v.id;
+    }
     const num = v.value !== null && v.value !== undefined ? Number(v.value) : 
null;
     return { name, value: Number.isFinite(num as number) ? (num as number) : 
null };
   });
@@ -360,13 +376,20 @@ export function registerDashboardRoute(app: 
FastifyInstance, deps: DashboardRout
           const groups: Array<{
             label: string;
             expression: string;
+            unit?: string;
             items: NonNullable<ReturnType<typeof parseTopList>>;
           }> = [];
           widget.expressions.forEach((expr, eIdx) => {
             const items = parseTopList(data[`w${wIdx}_e${eIdx}`]);
             if (!items) return;
             const label = widget.expressionLabels?.[eIdx] ?? expr;
-            groups.push({ label, expression: expr, items });
+            const unit = widget.expressionUnits?.[eIdx];
+            groups.push({
+              label,
+              expression: expr,
+              ...(unit ? { unit } : {}),
+              items,
+            });
           });
           if (groups.length === 0) return { id: widget.id, error: 'no data' };
           return {
@@ -388,11 +411,32 @@ export function registerDashboardRoute(app: 
FastifyInstance, deps: DashboardRout
 
         // 'line' — concat every result from every expression. One MQE
         // can return N labeled series (relabels()), so we don't assume
-        // 1:1 between expressions and series.
-        const flat: { label: string; data: Array<number | null> }[] = [];
+        // 1:1 between expressions and series. yAxisIndex + unit come
+        // from the widget's per-expression overrides (when present).
+        const flat: Array<{
+          label: string;
+          data: Array<number | null>;
+          yAxisIndex?: number;
+          unit?: string;
+        }> = [];
         widget.expressions.forEach((expr, eIdx) => {
           const labeled = parseLabeledSeries(data[`w${wIdx}_e${eIdx}`], expr);
-          if (labeled) flat.push(...labeled);
+          if (!labeled) return;
+          const labelOverride = widget.expressionLabels?.[eIdx];
+          const axis = widget.expressionAxes?.[eIdx];
+          const unit = widget.expressionUnits?.[eIdx];
+          for (const s of labeled) {
+            flat.push({
+              // When the operator supplied an explicit expressionLabel
+              // for a single-series expression, prefer that over the
+              // OAP-side relabels value. Multi-series MQEs (relabels)
+              // already arrive with sensible labels.
+              label: labeled.length === 1 && labelOverride ? labelOverride : 
s.label,
+              data: s.data,
+              ...(axis !== undefined ? { yAxisIndex: axis } : {}),
+              ...(unit !== undefined ? { unit } : {}),
+            });
+          }
         });
         if (flat.length === 0) return { id: widget.id, error: 'no data' };
         return { id: widget.id, series: flat };
diff --git a/apps/bff/src/layers/config/general.json 
b/apps/bff/src/layers/config/general.json
index d292af1..cc6f27a 100644
--- a/apps/bff/src/layers/config/general.json
+++ b/apps/bff/src/layers/config/general.json
@@ -37,50 +37,41 @@
         "title": "Top 20 APIs",
         "tip": "Layer-wide ranking. Switch tabs to re-rank by:\n  Traffic      
  top_n(endpoint_cpm,20,des)\n  Slow response  
top_n(endpoint_resp_time,20,des)\n  Worst SR       
top_n(endpoint_sla,20,asc)/100",
         "type": "top",
-        "unit": "rpm",
         "expressions": [
           "top_n(endpoint_cpm,20,des)",
           "top_n(endpoint_resp_time,20,des)",
           "top_n(endpoint_sla,20,asc)/100"
         ],
-        "expressionLabels": ["Traffic", "Slow", "SR"],
+        "expressionLabels": ["Traffic", "Slow", "Successful Rate"],
+        "expressionUnits": ["rpm", "ms", "%"],
         "layerScope": true,
         "span": 3,
         "rowSpan": 4
       },
       {
-        "id": "apdex_line",
-        "title": "Apdex",
-        "tip": "service_apdex/10000 — 0 to 1 satisfaction score.",
+        "id": "traffic_line",
+        "title": "Traffic",
         "type": "line",
-        "expressions": ["service_apdex/10000"],
+        "unit": "rpm",
+        "expressions": ["service_cpm"],
         "span": 3,
         "rowSpan": 2
       },
       {
-        "id": "sla_line",
-        "title": "Success Rate",
+        "id": "err_line",
+        "title": "Error Rate",
         "type": "line",
         "unit": "%",
-        "expressions": ["service_sla/100"],
-        "span": 3,
-        "rowSpan": 2
-      },
-      {
-        "id": "traffic_line",
-        "title": "Traffic",
-        "type": "line",
-        "unit": "rpm",
-        "expressions": ["service_cpm"],
+        "expressions": ["100 - service_sla/100"],
         "span": 3,
         "rowSpan": 2
       },
       {
-        "id": "resp_time_line",
-        "title": "Avg Response Time",
+        "id": "apdex_line",
+        "title": "Apdex",
+        "tip": "service_apdex/10000 — 0 to 1 satisfaction score.",
         "type": "line",
-        "unit": "ms",
-        "expressions": ["service_resp_time"],
+        "expressions": ["service_apdex/10000"],
         "span": 3,
         "rowSpan": 2
       },
@@ -97,33 +88,28 @@
         "rowSpan": 2
       },
       {
-        "id": "err_line",
-        "title": "Error Rate",
+        "id": "resp_time_line",
+        "title": "Avg Response Time",
         "type": "line",
-        "unit": "%",
-        "expressions": ["100 - service_sla/100"],
+        "unit": "ms",
+        "expressions": ["service_resp_time"],
         "span": 3,
         "rowSpan": 2
       },
       {
-        "id": "mq_consume_count",
-        "title": "MQ Consuming Count",
-        "tip": "service_mq_consume_count — only shown when the service emits 
MQ metrics.",
+        "id": "mq_combined",
+        "title": "MQ Consume rate + latency",
+        "tip": "Dual y-axis: service_mq_consume_count (left) + 
service_mq_consume_latency (right).",
         "type": "line",
-        "expressions": ["service_mq_consume_count"],
+        "expressions": [
+          "service_mq_consume_count",
+          "service_mq_consume_latency"
+        ],
+        "expressionLabels": ["count", "latency"],
+        "expressionUnits": ["/min", "ms"],
+        "expressionAxes": [0, 1],
         "visibleWhen": "service_mq_consume_count has value",
-        "span": 6,
-        "rowSpan": 2
-      },
-      {
-        "id": "mq_consume_latency",
-        "title": "MQ Avg Consuming Latency",
-        "tip": "service_mq_consume_latency — only shown when the service emits 
MQ metrics.",
-        "type": "line",
-        "unit": "ms",
-        "expressions": ["service_mq_consume_latency"],
-        "visibleWhen": "service_mq_consume_latency has value",
-        "span": 6,
+        "span": 3,
         "rowSpan": 2
       },
       {
diff --git a/apps/ui/src/components/charts/TimeChart.vue 
b/apps/ui/src/components/charts/TimeChart.vue
index 118d09a..2b9cc4f 100644
--- a/apps/ui/src/components/charts/TimeChart.vue
+++ b/apps/ui/src/components/charts/TimeChart.vue
@@ -36,6 +36,9 @@ echarts.use([LineChart, GridComponent, LegendComponent, 
TooltipComponent, Canvas
 interface Series {
   label: string;
   data: Array<number | null>;
+  /** `0` = left axis (default), `1` = right axis. */
+  yAxisIndex?: number;
+  unit?: string;
 }
 
 const props = withDefaults(
@@ -117,7 +120,7 @@ function buildOption(): echarts.EChartsCoreOption {
     },
     grid: {
       left: 36,
-      right: 8,
+      right: props.series.some((s) => (s.yAxisIndex ?? 0) === 1) ? 32 : 8,
       top: props.series.length > 1 ? 22 : 8,
       bottom: 8,
       containLabel: false,
@@ -129,12 +132,36 @@ function buildOption(): echarts.EChartsCoreOption {
       axisLabel: { color: '#64748b', fontSize: 9, interval: Math.floor(length 
/ 6) },
       splitLine: { show: false },
     },
-    yAxis: {
-      type: 'value',
-      axisLine: { show: false },
-      axisLabel: { color: '#64748b', fontSize: 9 },
-      splitLine: { lineStyle: { color: 'rgba(255,255,255,0.06)' } },
-    },
+    /* Dual y-axis when any series asks for axis 1. Right axis label
+     * picks up the unit from the first series on that axis when set. */
+    yAxis: (() => {
+      const hasRight = props.series.some((s) => (s.yAxisIndex ?? 0) === 1);
+      const rightUnit = props.series.find((s) => (s.yAxisIndex ?? 0) === 
1)?.unit;
+      const leftUnit = props.series.find((s) => (s.yAxisIndex ?? 0) === 
0)?.unit ?? props.unit;
+      const axes: Record<string, unknown>[] = [
+        {
+          type: 'value',
+          name: leftUnit ?? '',
+          nameTextStyle: { color: '#64748b', fontSize: 9, padding: [0, 0, 0, 
0] },
+          nameGap: 6,
+          axisLine: { show: false },
+          axisLabel: { color: '#64748b', fontSize: 9 },
+          splitLine: { lineStyle: { color: 'rgba(255,255,255,0.06)' } },
+        },
+      ];
+      if (hasRight) {
+        axes.push({
+          type: 'value',
+          name: rightUnit ?? '',
+          nameTextStyle: { color: '#64748b', fontSize: 9 },
+          nameGap: 6,
+          axisLine: { show: false },
+          axisLabel: { color: '#64748b', fontSize: 9 },
+          splitLine: { show: false },
+        });
+      }
+      return axes;
+    })(),
     series: props.series.map((s, i) => {
       // First series uses the widget's accent color (resolved from a
       // CSS var); secondary lines cycle through SECONDARY. Single-series
@@ -146,6 +173,7 @@ function buildOption(): echarts.EChartsCoreOption {
         type: 'line',
         smooth: true,
         symbol: 'none',
+        yAxisIndex: s.yAxisIndex ?? 0,
         lineStyle: { width: 1.5 },
         data: s.data.map((v) => (v === null ? '-' : v)),
         itemStyle: { color },
diff --git a/apps/ui/src/components/charts/TopList.vue 
b/apps/ui/src/components/charts/TopList.vue
index 610d580..194f031 100644
--- a/apps/ui/src/components/charts/TopList.vue
+++ b/apps/ui/src/components/charts/TopList.vue
@@ -36,6 +36,9 @@ interface TopGroup {
   /** MQE that produced this list — surfaced in the tab tooltip so the
    *  operator can copy/reuse the expression. */
   expression?: string;
+  /** Per-tab unit override. Falls back to the widget-level `unit` prop
+   *  when missing. Lets one widget mix rpm / ms / % across tabs. */
+  unit?: string;
   items: DashboardTopItem[];
 }
 
@@ -63,9 +66,9 @@ watch(effectiveGroups, (g) => {
   // Reset to first tab when the group set changes shape.
   if (activeIdx.value >= g.length) activeIdx.value = 0;
 });
-const activeItems = computed<DashboardTopItem[]>(
-  () => effectiveGroups.value[activeIdx.value]?.items ?? [],
-);
+const activeGroup = computed(() => effectiveGroups.value[activeIdx.value] ?? 
null);
+const activeItems = computed<DashboardTopItem[]>(() => 
activeGroup.value?.items ?? []);
+const activeUnit = computed<string | undefined>(() => activeGroup.value?.unit 
?? props.unit);
 
 const max = computed(() => {
   let m = 0;
@@ -103,7 +106,7 @@ const showTabs = computed(() => 
effectiveGroups.value.length > 1);
         <span class="name">{{ it.name }}</span>
         <div class="bar"><div class="fill" :style="{ width: 
`${pct(it.value)}%`, background: color }" /></div>
         <span class="value">
-          {{ fmtMetric(it.value) }}<span v-if="unit" class="unit">{{ unit 
}}</span>
+          {{ fmtMetric(it.value) }}<span v-if="activeUnit" class="unit">{{ 
activeUnit }}</span>
         </span>
       </div>
       <p v-if="activeItems.length === 0" class="empty">No data</p>
diff --git a/packages/api-client/src/dashboard.ts 
b/packages/api-client/src/dashboard.ts
index 7744afd..5c49311 100644
--- a/packages/api-client/src/dashboard.ts
+++ b/packages/api-client/src/dashboard.ts
@@ -62,6 +62,20 @@ export interface DashboardWidget {
    * to the expression text. Indices align with `expressions`.
    */
   expressionLabels?: string[];
+  /**
+   * Optional per-expression unit overrides. Used by `top` widgets
+   * where switching tabs changes the metric unit too (e.g. Traffic →
+   * rpm, Slow → ms, SR → %). Falls back to the widget-level `unit`
+   * when entries are missing. Indices align with `expressions`.
+   */
+  expressionUnits?: string[];
+  /**
+   * Optional y-axis index per expression (`0` = left, `1` = right).
+   * Lets a single `line` widget plot two metrics on separate scales —
+   * e.g. MQ consume count + latency. Defaults to 0 for every series
+   * when omitted. Indices align with `expressions`.
+   */
+  expressionAxes?: number[];
   /** Suffix unit (`%`, `ms`, `calls / min`). */
   unit?: string;
   /**
@@ -108,6 +122,12 @@ export interface DashboardConfig {
 export interface DashboardSeries {
   label: string;
   data: Array<number | null>;
+  /** `0` = left axis (default), `1` = right axis. Used by dual-axis
+   *  line widgets like "MQ count + latency". */
+  yAxisIndex?: number;
+  /** Optional axis unit hint — shown as a small label near the axis
+   *  when present. */
+  unit?: string;
 }
 
 export interface DashboardTopItem {
@@ -133,9 +153,14 @@ export interface DashboardWidgetResult {
   /** `top` payload — multi-expression results, one entry per
    *  `expressions[i]`. UI renders a switcher (one tab per group) and
    *  shows the active group's list. `expression` is echoed so the UI
-   *  can surface the MQE in the tab tooltip. Indices align with
-   *  `widget.expressions`. */
-  topGroups?: Array<{ label: string; expression: string; items: 
DashboardTopItem[] }>;
+   *  can surface the MQE in the tab tooltip; `unit` may override the
+   *  widget-level unit per tab. Indices align with `widget.expressions`. */
+  topGroups?: Array<{
+    label: string;
+    expression: string;
+    unit?: string;
+    items: DashboardTopItem[];
+  }>;
 }
 
 export interface DashboardResponse {

Reply via email to