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 {