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 {