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 871b57b  topList: multi-expression switcher + MQE in tab tooltip
871b57b is described below

commit 871b57bb96d3a7b018f37708c6029013166b2cc9
Author: Wu Sheng <[email protected]>
AuthorDate: Tue May 12 21:45:09 2026 +0800

    topList: multi-expression switcher + MQE in tab tooltip
    
    The 'top' widget now accepts multiple MQE expressions plus optional
    expressionLabels — each entry becomes a switchable view inside the
    same widget. UI renders a tab strip at the top; clicking a tab swaps
    the rendered list. Tab tooltip surfaces the MQE that drives it so
    operators can copy / re-use the expression.
    
    DashboardWidget gains expressionLabels?: string[]; result type gains
    topGroups?: Array<{ label, expression, items }>. Legacy single-list
    `topList` still set to the first group for back-compat.
    
    General Service > Top 20 APIs widget switched from a single 'by
    traffic' list to three switchable rankings:
      Traffic  →  top_n(endpoint_cpm,20,des)
      Slow     →  top_n(endpoint_resp_time,20,des)
      SR       →  top_n(endpoint_sla,20,asc)/100   (worst-first)
    Tip on the widget head lists all three MQEs in full. Widget keeps
    layerScope:true so the ranking spans every service in the layer.
---
 apps/bff/src/dashboard/routes.ts                |  28 +++++-
 apps/bff/src/layers/config/general.json         |  11 ++-
 apps/ui/src/components/charts/TopList.vue       | 114 ++++++++++++++++++++----
 apps/ui/src/views/layer/LayerDashboardsView.vue |   8 +-
 packages/api-client/src/dashboard.ts            |  19 +++-
 5 files changed, 155 insertions(+), 25 deletions(-)

diff --git a/apps/bff/src/dashboard/routes.ts b/apps/bff/src/dashboard/routes.ts
index f9ce08e..1f3f2d0 100644
--- a/apps/bff/src/dashboard/routes.ts
+++ b/apps/bff/src/dashboard/routes.ts
@@ -64,6 +64,7 @@ const widgetSchema = z.object({
   tip: z.string().optional(),
   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(),
   unit: z.string().optional(),
   span: z.number().int().min(1).max(12).optional(),
   rowSpan: z.number().int().min(1).max(64).optional(),
@@ -351,9 +352,30 @@ export function registerDashboardRoute(app: 
FastifyInstance, deps: DashboardRout
       //  - 'top':  extract sorted list from the first expression
       const results: DashboardWidgetResult[] = widgets.map((widget, wIdx) => {
         if (widget.type === 'top') {
-          const r = data[`w${wIdx}_e0`];
-          const top = parseTopList(r);
-          return top ? { id: widget.id, topList: top } : { id: widget.id, 
error: 'no data' };
+          // Every expression contributes one switchable group (e.g.
+          // "Top by traffic" / "Top slowest" / "Top by SR"). The UI
+          // renders a tab per group and shows the active one; the
+          // expression travels along so the tab tooltip can surface
+          // the MQE.
+          const groups: Array<{
+            label: string;
+            expression: 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 });
+          });
+          if (groups.length === 0) return { id: widget.id, error: 'no data' };
+          return {
+            id: widget.id,
+            topGroups: groups,
+            // Keep `topList` set to the first group's items for any
+            // older SPA paths that still read the flat field.
+            topList: groups[0].items,
+          };
         }
 
         if (widget.type === 'card') {
diff --git a/apps/bff/src/layers/config/general.json 
b/apps/bff/src/layers/config/general.json
index 6428cd1..d292af1 100644
--- a/apps/bff/src/layers/config/general.json
+++ b/apps/bff/src/layers/config/general.json
@@ -34,11 +34,16 @@
     "service": [
       {
         "id": "top_apis",
-        "title": "Top 20 APIs by traffic",
-        "tip": "top_n(endpoint_cpm,20,des) — scoped to the whole layer (not 
the selected service).",
+        "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)"],
+        "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"],
         "layerScope": true,
         "span": 3,
         "rowSpan": 4
diff --git a/apps/ui/src/components/charts/TopList.vue 
b/apps/ui/src/components/charts/TopList.vue
index 878b8f0..610d580 100644
--- a/apps/ui/src/components/charts/TopList.vue
+++ b/apps/ui/src/components/charts/TopList.vue
@@ -17,19 +17,33 @@
 <!--
   Compact sorted-list renderer for `top_n(...)` MQE results. Each row
   has a name + value + a horizontal bar normalized to the row's value
-  vs the list max. Designed for the per-layer Service dashboard's
-  "Top N endpoints" widget — fits a tall narrow card.
+  vs the list max.
+
+  Supports two shapes:
+   - `items` — single sorted list (one MQE, one view)
+   - `groups` — multiple sorted lists, one per MQE expression. Renders
+     a tab switcher at the top so operators can toggle between e.g.
+     "Top by traffic" / "Top slowest" / "Top by SR" without leaving the
+     widget. The first group is active by default.
 -->
 <script setup lang="ts">
-import { computed } from 'vue';
+import { computed, ref, watch } from 'vue';
 import type { DashboardTopItem } from '@skywalking-horizon-ui/api-client';
 import { fmtMetric } from '@/utils/formatters';
 
+interface TopGroup {
+  label: string;
+  /** MQE that produced this list — surfaced in the tab tooltip so the
+   *  operator can copy/reuse the expression. */
+  expression?: string;
+  items: DashboardTopItem[];
+}
+
 const props = withDefaults(
   defineProps<{
-    items: ReadonlyArray<DashboardTopItem>;
+    items?: ReadonlyArray<DashboardTopItem>;
+    groups?: ReadonlyArray<TopGroup>;
     unit?: string;
-    /** Bar color — defaults to the accent. */
     color?: string;
   }>(),
   {
@@ -37,9 +51,25 @@ const props = withDefaults(
   },
 );
 
+// Normalize: derive the effective groups (always render via the tab
+// path, single-list case becomes a 1-group set with no tabs visible).
+const effectiveGroups = computed<TopGroup[]>(() => {
+  if (props.groups && props.groups.length > 0) return [...props.groups];
+  if (props.items) return [{ label: '', items: [...props.items] }];
+  return [];
+});
+const activeIdx = ref(0);
+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 max = computed(() => {
   let m = 0;
-  for (const it of props.items) {
+  for (const it of activeItems.value) {
     const v = it.value;
     if (v !== null && Number.isFinite(v) && v > m) m = v;
   }
@@ -49,19 +79,35 @@ function pct(v: number | null): number {
   if (v === null || !Number.isFinite(v)) return 0;
   return Math.max(0, Math.min(100, (v / max.value) * 100));
 }
+const showTabs = computed(() => effectiveGroups.value.length > 1);
 </script>
 
 <template>
   <div class="top-list">
-    <div v-for="(it, i) in items" :key="i" class="row" :title="it.name">
-      <span class="rank">{{ i + 1 }}</span>
-      <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>
-      </span>
+    <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">
+      <div v-for="(it, i) in activeItems" :key="i" class="row" 
:title="it.name">
+        <span class="rank">{{ i + 1 }}</span>
+        <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>
+        </span>
+      </div>
+      <p v-if="activeItems.length === 0" class="empty">No data</p>
     </div>
-    <p v-if="items.length === 0" class="empty">No data</p>
   </div>
 </template>
 
@@ -69,11 +115,47 @@ function pct(v: number | null): number {
 .top-list {
   display: flex;
   flex-direction: column;
-  gap: 4px;
-  padding: 4px 2px;
   width: 100%;
   height: 100%;
+  min-height: 0;
+}
+.tabs {
+  display: flex;
+  gap: 2px;
+  padding: 2px 0 6px;
+  border-bottom: 1px solid var(--sw-line);
+  margin-bottom: 4px;
+}
+.tab {
+  padding: 3px 8px;
+  font-size: 10px;
+  font-weight: 500;
+  color: var(--sw-fg-2);
+  background: transparent;
+  border: none;
+  border-radius: 3px;
+  cursor: pointer;
+  font: inherit;
+  text-transform: capitalize;
+  white-space: nowrap;
+}
+.tab:hover {
+  background: var(--sw-bg-2);
+  color: var(--sw-fg-1);
+}
+.tab.on {
+  background: var(--sw-bg-3);
+  color: var(--sw-fg-0);
+  font-weight: 600;
+}
+.rows {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  padding: 2px 2px 4px;
   overflow-y: auto;
+  flex: 1;
+  min-height: 0;
 }
 .row {
   display: grid;
diff --git a/apps/ui/src/views/layer/LayerDashboardsView.vue 
b/apps/ui/src/views/layer/LayerDashboardsView.vue
index b925386..171c83b 100644
--- a/apps/ui/src/views/layer/LayerDashboardsView.vue
+++ b/apps/ui/src/views/layer/LayerDashboardsView.vue
@@ -208,7 +208,13 @@ function isVisible(
           </template>
           <template v-else-if="w.type === 'top'">
             <TopList
-              v-if="resultsById.get(w.id)?.topList?.length"
+              v-if="resultsById.get(w.id)?.topGroups?.length"
+              :groups="resultsById.get(w.id)!.topGroups!"
+              :unit="w.unit"
+              :color="widgetColor(w)"
+            />
+            <TopList
+              v-else-if="resultsById.get(w.id)?.topList?.length"
               :items="resultsById.get(w.id)!.topList!"
               :unit="w.unit"
               :color="widgetColor(w)"
diff --git a/packages/api-client/src/dashboard.ts 
b/packages/api-client/src/dashboard.ts
index ba7758f..7744afd 100644
--- a/packages/api-client/src/dashboard.ts
+++ b/packages/api-client/src/dashboard.ts
@@ -52,8 +52,16 @@ export interface DashboardWidget {
   tip?: string;
   type: DashboardWidgetType;
   /** One or more MQE expressions. `card` collapses to a scalar (avg);
-   *  `line` renders one labeled series per expression. */
+   *  `line` renders one labeled series per expression; `top` treats
+   *  every expression as a switchable view (see `expressionLabels`). */
   expressions: string[];
+  /**
+   * Optional human-readable label per entry in `expressions`. For
+   * `top` widgets these drive the in-widget switcher tabs (e.g.
+   * "Traffic" / "Slow" / "Errors"). When missing the SPA falls back
+   * to the expression text. Indices align with `expressions`.
+   */
+  expressionLabels?: string[];
   /** Suffix unit (`%`, `ms`, `calls / min`). */
   unit?: string;
   /**
@@ -119,8 +127,15 @@ export interface DashboardWidgetResult {
    *  by OAP when present (e.g. `percentile='99'`); otherwise the raw
    *  expression string is used. */
   series?: DashboardSeries[];
-  /** `top` payload — sorted list returned by a `top_n(...)` MQE. */
+  /** `top` payload — legacy single sorted list. Present when the
+   *  widget has exactly one expression. */
   topList?: DashboardTopItem[];
+  /** `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[] }>;
 }
 
 export interface DashboardResponse {

Reply via email to