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

commit a20a7cdaf40b1775126f6127d5110dd69c69d302
Author: Wu Sheng <[email protected]>
AuthorDate: Tue May 12 21:22:27 2026 +0800

    service dashboard: cpm→RPM, apdex replaces p99 in summary, TopList widget
    
    Layer summary columns updated per operator preference:
      cpm  → label 'RPM'  (no unit — RPM is the unit itself)
      p99  → REPLACED by apdex  (apdex column added; service_apdex/10000)
    KPI label format: 'label(unit)' when unit is set — e.g. 'SLA(%)',
    'err(%)'. Value stays the bare number.
    
    Service dashboard rewritten to match the design's Service screen
    (no Dependencies widget per operator instruction):
      - Left col (span 3, rowSpan 4):  Top 20 endpoints by traffic
      - Center+right (span 9, 2 rows of 3 line charts × span 3, rowSpan 2):
          Row 1: Apdex · Success Rate · Traffic
          Row 2: Avg Response Time · p99 · Response Time Percentile
      - Percentile widget uses ONE relabels() MQE returning 5 series
        (p50/75/90/95/99) instead of 5 separate expressions.
    
    New widget type: 'top'.
      - BFF parses top_n() result into [{ name, value }] entries
      - Owner.endpointName / serviceInstanceName / serviceName preferred
        over the bare id so rows are human-readable.
      - GraphQL fragment fetches metric.labels + value.owner so labeled
        line series (relabels) and top-list owner names both arrive.
      - New TopList.vue renders a compact rank+name+bar+value row list.
    
    Line widgets now collapse every result from every expression — one
    MQE returning N labeled series produces N lines, label taken from the
    last metric.labels entry (the relabels-derived key).
---
 apps/bff/src/dashboard/routes.ts                | 131 +++++++++++++++---
 apps/bff/src/layers/config/general.json         | 173 ++++++++++++------------
 apps/ui/src/components/charts/TopList.vue       | 129 ++++++++++++++++++
 apps/ui/src/views/layer/LayerDashboardsView.vue |  14 ++
 apps/ui/src/views/layer/LayerShell.vue          |  16 ++-
 packages/api-client/src/dashboard.ts            |  15 +-
 packages/api-client/src/index.ts                |   1 +
 7 files changed, 367 insertions(+), 112 deletions(-)

diff --git a/apps/bff/src/dashboard/routes.ts b/apps/bff/src/dashboard/routes.ts
index 595079b..4b2649e 100644
--- a/apps/bff/src/dashboard/routes.ts
+++ b/apps/bff/src/dashboard/routes.ts
@@ -62,7 +62,7 @@ const widgetSchema = z.object({
   id: z.string().min(1),
   title: z.string(),
   tip: z.string().optional(),
-  type: z.enum(['card', 'line']),
+  type: z.enum(['card', 'line', 'top']),
   expressions: z.array(z.string().min(1)).min(1).max(8),
   unit: z.string().optional(),
   span: z.number().int().min(1).max(12).optional(),
@@ -81,8 +81,27 @@ const bodySchema = z.object({
   scope: scopeSchema.optional(),
 });
 
+interface MqeOwner {
+  scope?: string | null;
+  serviceName?: string | null;
+  serviceInstanceName?: string | null;
+  endpointName?: string | null;
+}
+interface MqeValueShape {
+  id?: string | null;
+  value?: string | null;
+  owner?: MqeOwner | null;
+}
+interface MqeLabelShape {
+  key: string;
+  value: string;
+}
+interface MqeMetadataShape {
+  labels?: MqeLabelShape[] | null;
+}
 interface MqeValuesShape {
-  values?: Array<{ id?: string | null; value?: string | null }>;
+  metric?: MqeMetadataShape | null;
+  values?: MqeValueShape[];
 }
 interface MqeResultShape {
   type: string;
@@ -124,12 +143,22 @@ function buildFragment(
   normal: boolean,
   w: Window,
 ): string {
+  // We fetch metric.labels (for multi-series Line widgets — relabels()
+  // returns one labeled result per percentile) and value.id /
+  // owner.endpointName (for TopList widgets — top_n() returns a
+  // sorted list of entities + values).
   return (
     `${alias}: execExpression(\n` +
     `      expression: ${JSON.stringify(expression)},\n` +
     `      entity: { scope: Service, serviceName: 
${JSON.stringify(serviceName)}, normal: ${normal ? 'true' : 'false'} },\n` +
     `      duration: { start: ${JSON.stringify(w.start)}, end: 
${JSON.stringify(w.end)}, step: MINUTE }\n` +
-    `    ) { type error results { values { value } } }`
+    `    ) {\n` +
+    `      type error\n` +
+    `      results {\n` +
+    `        metric { labels { key value } }\n` +
+    `        values { id value owner { scope serviceName serviceInstanceName 
endpointName } }\n` +
+    `      }\n` +
+    `    }`
   );
 }
 
@@ -150,6 +179,62 @@ function avgOf(series: Array<number | null> | null): 
number | null {
   return xs.reduce((a, b) => a + b, 0) / xs.length;
 }
 
+/**
+ * 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
+ */
+function parseLabeledSeries(
+  r: MqeResultShape | undefined,
+  fallbackLabel: string,
+): Array<{ label: string; data: Array<number | null> }> | null {
+  if (!r || r.error) return null;
+  const out: Array<{ label: string; data: Array<number | null> }> = [];
+  for (const rs of r.results ?? []) {
+    const values = rs.values ?? [];
+    if (values.length === 0) continue;
+    const data = values.map((v) => {
+      if (v.value === null || v.value === undefined) return null;
+      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.
+    const labels = rs.metric?.labels ?? [];
+    const lbl =
+      labels.length > 0
+        ? labels[labels.length - 1].value
+        : values[0]?.id ?? 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. */
+function parseTopList(
+  r: MqeResultShape | undefined,
+): Array<{ name: string; value: number | null }> | null {
+  if (!r || r.error) return null;
+  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 num = v.value !== null && v.value !== undefined ? Number(v.value) : 
null;
+    return { name, value: Number.isFinite(num as number) ? (num as number) : 
null };
+  });
+}
+
 export function registerDashboardRoute(app: FastifyInstance, deps: 
DashboardRouteDeps): void {
   const auth = requireAuth(deps);
   app.post(
@@ -245,25 +330,37 @@ export function registerDashboardRoute(app: 
FastifyInstance, deps: DashboardRout
         }
       }
 
-      // Step 3 — collapse per widget.
+      // Step 3 — collapse per widget. Per-type handling:
+      //  - 'card': scalar = avg of the first non-null series
+      //  - 'line': flatten every MQE result (one per series) — handles
+      //    both the simple case (1 expression → 1 series) and the
+      //    relabels() case (1 expression → N labeled series)
+      //  - 'top':  extract sorted list from the first expression
       const results: DashboardWidgetResult[] = widgets.map((widget, wIdx) => {
-        const series = widget.expressions.map((_, eIdx) => 
parseSeries(data[`w${wIdx}_e${eIdx}`]));
-        const allFailed = series.every((s) => s === null);
-        if (allFailed) {
-          return { id: widget.id, error: 'no data' };
+        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' };
         }
+
         if (widget.type === 'card') {
-          // Card collapses to scalar from the first non-null series.
-          const first = series.find((s) => s !== null) ?? null;
+          const first = widget.expressions.map((_, eIdx) =>
+            parseSeries(data[`w${wIdx}_e${eIdx}`]),
+          ).find((s) => s !== null);
+          if (!first) return { id: widget.id, error: 'no data' };
           return { id: widget.id, value: avgOf(first) };
         }
-        return {
-          id: widget.id,
-          series: series.map((s, eIdx) => ({
-            label: widget.expressions[eIdx],
-            data: s ?? [],
-          })),
-        };
+
+        // '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> }[] = [];
+        widget.expressions.forEach((expr, eIdx) => {
+          const labeled = parseLabeledSeries(data[`w${wIdx}_e${eIdx}`], expr);
+          if (labeled) flat.push(...labeled);
+        });
+        if (flat.length === 0) return { id: widget.id, error: 'no data' };
+        return { id: widget.id, series: flat };
       });
 
       return reply.send({ ...baseResp, widgets: results });
diff --git a/apps/bff/src/layers/config/general.json 
b/apps/bff/src/layers/config/general.json
index 7d45f66..25e1385 100644
--- a/apps/bff/src/layers/config/general.json
+++ b/apps/bff/src/layers/config/general.json
@@ -24,8 +24,8 @@
     "spark": "cpm",
     "orderBy": "cpm",
     "columns": [
-      { "metric": "cpm", "label": "Traffic", "unit": "rpm", "mqe": 
"service_cpm", "aggregation": "sum" },
-      { "metric": "p99", "label": "p99", "unit": "ms", "mqe": 
"service_percentile{p='99'}", "aggregation": "avg" },
+      { "metric": "cpm", "label": "RPM", "mqe": "service_cpm", "aggregation": 
"sum" },
+      { "metric": "apdex", "label": "Apdex", "mqe": "service_apdex/10000", 
"aggregation": "avg" },
       { "metric": "sla", "label": "SLA", "unit": "%", "mqe": 
"service_sla/100", "aggregation": "avg" },
       { "metric": "err", "label": "err", "unit": "%", "mqe": "100 - 
service_sla/100", "aggregation": "avg" }
     ]
@@ -33,101 +33,82 @@
   "dashboards": {
     "service": [
       {
-        "id": "apdex",
+        "id": "top_endpoints",
+        "title": "Top 20 endpoints by traffic",
+        "tip": "top_n(endpoint_cpm,20,des) — click an endpoint to drill in.",
+        "type": "top",
+        "unit": "rpm",
+        "expressions": ["top_n(endpoint_cpm,20,des)"],
+        "span": 3,
+        "rowSpan": 4
+      },
+      {
+        "id": "apdex_line",
         "title": "Apdex",
-        "tip": "User satisfaction score on a 0–1 scale. service_apdex is 
integer-times-10000 server-side.",
-        "type": "card",
-        "expressions": ["avg(service_apdex)/10000"],
-        "span": 4, "rowSpan": 1
+        "tip": "service_apdex/10000 — 0 to 1 satisfaction score.",
+        "type": "line",
+        "expressions": ["service_apdex/10000"],
+        "span": 3,
+        "rowSpan": 2
       },
       {
-        "id": "sla",
+        "id": "sla_line",
         "title": "Success Rate",
-        "type": "card",
+        "type": "line",
         "unit": "%",
-        "expressions": ["avg(service_sla)/100"],
-        "span": 4, "rowSpan": 1
+        "expressions": ["service_sla/100"],
+        "span": 3,
+        "rowSpan": 2
       },
       {
-        "id": "traffic",
+        "id": "traffic_line",
         "title": "Traffic",
-        "type": "card",
+        "type": "line",
         "unit": "rpm",
-        "expressions": ["avg(service_cpm)"],
-        "span": 4, "rowSpan": 1
+        "expressions": ["service_cpm"],
+        "span": 3,
+        "rowSpan": 2
       },
       {
-        "id": "resp_time",
+        "id": "resp_time_line",
         "title": "Avg Response Time",
         "type": "line",
         "unit": "ms",
         "expressions": ["service_resp_time"],
-        "span": 6, "rowSpan": 2
+        "span": 3,
+        "rowSpan": 2
       },
       {
-        "id": "percentile",
-        "title": "Response Time Percentile",
-        "tip": "p50 / p75 / p90 / p95 / p99 — useful for tail behavior.",
+        "id": "p99_line",
+        "title": "p99",
         "type": "line",
         "unit": "ms",
-        "expressions": [
-          "service_percentile{p='50'}",
-          "service_percentile{p='75'}",
-          "service_percentile{p='90'}",
-          "service_percentile{p='95'}",
-          "service_percentile{p='99'}"
-        ],
-        "span": 6, "rowSpan": 2
+        "expressions": ["service_percentile{p='99'}"],
+        "span": 3,
+        "rowSpan": 2
       },
       {
-        "id": "traffic_line",
-        "title": "Traffic",
-        "type": "line",
-        "unit": "rpm",
-        "expressions": ["service_cpm"],
-        "span": 6, "rowSpan": 2
-      },
-      {
-        "id": "sla_line",
-        "title": "Success Rate",
+        "id": "percentile_line",
+        "title": "Response Time Percentile",
+        "tip": "Single MQE: 
relabels(service_percentile{p='50,75,90,95,99'},...,percentile='50,...').",
         "type": "line",
-        "unit": "%",
-        "expressions": ["service_sla/100"],
-        "span": 6, "rowSpan": 2
+        "unit": "ms",
+        "expressions": [
+          
"relabels(service_percentile{p='50,75,90,95,99'},p='50,75,90,95,99',percentile='50,75,90,95,99')"
+        ],
+        "span": 3,
+        "rowSpan": 2
       }
     ],
     "instance": [
-      {
-        "id": "instance_cpm",
-        "title": "Instance Traffic",
-        "type": "card",
-        "unit": "rpm",
-        "expressions": ["avg(service_instance_cpm)"],
-        "span": 4, "rowSpan": 1
-      },
-      {
-        "id": "instance_resp",
-        "title": "Instance Avg Response Time",
-        "type": "card",
-        "unit": "ms",
-        "expressions": ["avg(service_instance_resp_time)"],
-        "span": 4, "rowSpan": 1
-      },
-      {
-        "id": "instance_sla",
-        "title": "Instance Success Rate",
-        "type": "card",
-        "unit": "%",
-        "expressions": ["avg(service_instance_sla)/100"],
-        "span": 4, "rowSpan": 1
-      },
       {
         "id": "instance_cpm_line",
         "title": "Traffic",
         "type": "line",
         "unit": "rpm",
         "expressions": ["service_instance_cpm"],
-        "span": 6, "rowSpan": 2
+        "span": 4,
+        "rowSpan": 2
       },
       {
         "id": "instance_resp_line",
@@ -135,7 +116,17 @@
         "type": "line",
         "unit": "ms",
         "expressions": ["service_instance_resp_time"],
-        "span": 6, "rowSpan": 2
+        "span": 4,
+        "rowSpan": 2
+      },
+      {
+        "id": "instance_sla_line",
+        "title": "Success Rate",
+        "type": "line",
+        "unit": "%",
+        "expressions": ["service_instance_sla/100"],
+        "span": 4,
+        "rowSpan": 2
       },
       {
         "id": "jvm_cpu",
@@ -145,53 +136,57 @@
         "unit": "%",
         "expressions": ["instance_jvm_cpu"],
         "visibleWhen": "instance_jvm_cpu has value",
-        "span": 6, "rowSpan": 2
+        "span": 6,
+        "rowSpan": 2
       },
       {
         "id": "jvm_heap",
-        "title": "JVM Heap (bytes)",
+        "title": "JVM Heap",
         "type": "line",
         "expressions": ["instance_jvm_memory{name='heap'}"],
         "visibleWhen": "instance_jvm_memory has value",
-        "span": 6, "rowSpan": 2
+        "span": 6,
+        "rowSpan": 2
       }
     ],
     "endpoint": [
       {
-        "id": "endpoint_cpm",
-        "title": "Endpoint Traffic",
-        "type": "card",
+        "id": "endpoint_cpm_line",
+        "title": "Traffic",
+        "type": "line",
         "unit": "rpm",
-        "expressions": ["avg(endpoint_cpm)"],
-        "span": 4, "rowSpan": 1
+        "expressions": ["endpoint_cpm"],
+        "span": 4,
+        "rowSpan": 2
       },
       {
-        "id": "endpoint_resp",
-        "title": "Avg Response Time",
-        "type": "card",
+        "id": "endpoint_resp_line",
+        "title": "Response Time",
+        "type": "line",
         "unit": "ms",
-        "expressions": ["avg(endpoint_resp_time)"],
-        "span": 4, "rowSpan": 1
+        "expressions": ["endpoint_resp_time"],
+        "span": 4,
+        "rowSpan": 2
       },
       {
-        "id": "endpoint_sla",
+        "id": "endpoint_sla_line",
         "title": "Success Rate",
-        "type": "card",
+        "type": "line",
         "unit": "%",
-        "expressions": ["avg(endpoint_sla)/100"],
-        "span": 4, "rowSpan": 1
+        "expressions": ["endpoint_sla/100"],
+        "span": 4,
+        "rowSpan": 2
       },
       {
         "id": "endpoint_percentile",
-        "title": "Endpoint Response Time Percentile",
+        "title": "Response Time Percentile",
         "type": "line",
         "unit": "ms",
         "expressions": [
-          "endpoint_percentile{p='50'}",
-          "endpoint_percentile{p='95'}",
-          "endpoint_percentile{p='99'}"
+          
"relabels(endpoint_percentile{p='50,75,90,95,99'},p='50,75,90,95,99',percentile='50,75,90,95,99')"
         ],
-        "span": 12, "rowSpan": 2
+        "span": 12,
+        "rowSpan": 2
       }
     ],
     "trace": [],
diff --git a/apps/ui/src/components/charts/TopList.vue 
b/apps/ui/src/components/charts/TopList.vue
new file mode 100644
index 0000000..878b8f0
--- /dev/null
+++ b/apps/ui/src/components/charts/TopList.vue
@@ -0,0 +1,129 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one or more
+  contributor license agreements.  See the NOTICE file distributed with
+  this work for additional information regarding copyright ownership.
+  The ASF licenses this file to You under the Apache License, Version 2.0
+  (the "License"); you may not use this file except in compliance with
+  the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+-->
+<!--
+  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.
+-->
+<script setup lang="ts">
+import { computed } from 'vue';
+import type { DashboardTopItem } from '@skywalking-horizon-ui/api-client';
+import { fmtMetric } from '@/utils/formatters';
+
+const props = withDefaults(
+  defineProps<{
+    items: ReadonlyArray<DashboardTopItem>;
+    unit?: string;
+    /** Bar color — defaults to the accent. */
+    color?: string;
+  }>(),
+  {
+    color: 'var(--sw-accent)',
+  },
+);
+
+const max = computed(() => {
+  let m = 0;
+  for (const it of props.items) {
+    const v = it.value;
+    if (v !== null && Number.isFinite(v) && v > m) m = v;
+  }
+  return m || 1;
+});
+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));
+}
+</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>
+    <p v-if="items.length === 0" class="empty">No data</p>
+  </div>
+</template>
+
+<style scoped>
+.top-list {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  padding: 4px 2px;
+  width: 100%;
+  height: 100%;
+  overflow-y: auto;
+}
+.row {
+  display: grid;
+  grid-template-columns: 18px 1fr 48px 64px;
+  align-items: center;
+  gap: 6px;
+  font-size: 11px;
+  padding: 1px 0;
+}
+.rank {
+  font-family: var(--sw-mono);
+  font-size: 9.5px;
+  color: var(--sw-fg-3);
+  text-align: right;
+}
+.name {
+  font-family: var(--sw-mono);
+  font-size: 11px;
+  color: var(--sw-fg-1);
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.bar {
+  height: 5px;
+  background: var(--sw-bg-3);
+  border-radius: 2px;
+  overflow: hidden;
+}
+.fill {
+  height: 100%;
+  border-radius: 2px;
+  transition: width 0.2s ease-out;
+}
+.value {
+  font-family: var(--sw-mono);
+  font-size: 10.5px;
+  color: var(--sw-fg-1);
+  text-align: right;
+  font-variant-numeric: tabular-nums;
+}
+.value .unit {
+  margin-left: 2px;
+  color: var(--sw-fg-3);
+  font-size: 9.5px;
+}
+.empty {
+  font-size: 11px;
+  color: var(--sw-fg-3);
+  text-align: center;
+  margin: 12px 0;
+}
+</style>
diff --git a/apps/ui/src/views/layer/LayerDashboardsView.vue 
b/apps/ui/src/views/layer/LayerDashboardsView.vue
index e707286..0ec6164 100644
--- a/apps/ui/src/views/layer/LayerDashboardsView.vue
+++ b/apps/ui/src/views/layer/LayerDashboardsView.vue
@@ -30,6 +30,7 @@ import { computed } from 'vue';
 import { useRoute } from 'vue-router';
 import type { LayerDef } from '@skywalking-horizon-ui/api-client';
 import TimeChart from '@/components/charts/TimeChart.vue';
+import TopList from '@/components/charts/TopList.vue';
 import { useLayerDashboard, useLayerDashboardConfig } from 
'@/composables/useLayerDashboard';
 import { useLayerLanding } from '@/composables/useLayerLanding';
 import { useLayers } from '@/composables/useLayers';
@@ -175,6 +176,14 @@ function isVisible(
             />
             <span v-else class="muted">no data</span>
           </template>
+          <template v-else-if="w.type === 'top'">
+            <TopList
+              v-if="resultsById.get(w.id)?.topList?.length"
+              :items="resultsById.get(w.id)!.topList!"
+              :unit="w.unit"
+            />
+            <span v-else class="muted">no data</span>
+          </template>
         </div>
       </div>
     </div>
@@ -273,6 +282,11 @@ function isVisible(
   justify-content: center;
   padding: 8px 12px;
   min-height: 0;
+  overflow: hidden;
+}
+.w-body :deep(.top-list) {
+  align-self: stretch;
+  justify-self: stretch;
 }
 .card-value {
   display: flex;
diff --git a/apps/ui/src/views/layer/LayerShell.vue 
b/apps/ui/src/views/layer/LayerShell.vue
index 8ebc5fa..51a3fc2 100644
--- a/apps/ui/src/views/layer/LayerShell.vue
+++ b/apps/ui/src/views/layer/LayerShell.vue
@@ -205,10 +205,11 @@ const serviceKpis = computed<HeaderKpi[]>(() => {
         </div>
         <div class="kpi-strip layer-kpis">
           <div v-for="(k, i) in layerKpis" :key="i" class="kpi">
-            <div class="kpi-label">{{ k.label }}</div>
+            <div class="kpi-label">
+              {{ k.label }}<span v-if="k.unit" class="unit">({{ k.unit 
}})</span>
+            </div>
             <div class="kpi-value" :style="{ color: k.color }">
               <span :class="{ muted: k.value == null }">{{ fmtMetric(k.value) 
}}</span>
-              <span v-if="k.unit" class="kpi-unit">{{ k.unit }}</span>
             </div>
             <Sparkline
               v-if="k.spark && k.spark.length > 1"
@@ -240,10 +241,11 @@ const serviceKpis = computed<HeaderKpi[]>(() => {
         </button>
         <div class="kpi-strip service-kpis">
           <div v-for="(k, i) in serviceKpis" :key="i" class="kpi compact">
-            <span class="kpi-label inline">{{ k.label }}</span>
+            <span class="kpi-label inline">
+              {{ k.label }}<span v-if="k.unit" class="unit">({{ k.unit 
}})</span>
+            </span>
             <span class="kpi-value inline" :style="{ color: k.color }">
               <span :class="{ muted: k.value == null }">{{ fmtMetric(k.value) 
}}</span>
-              <span v-if="k.unit" class="kpi-unit">{{ k.unit }}</span>
             </span>
           </div>
         </div>
@@ -430,6 +432,12 @@ const serviceKpis = computed<HeaderKpi[]>(() => {
   color: var(--sw-fg-3);
   margin-bottom: 2px;
 }
+.kpi-label .unit {
+  text-transform: none;
+  letter-spacing: 0;
+  margin-left: 2px;
+  font-size: 9.5px;
+}
 .kpi-value {
   font-size: 18px;
   font-weight: 600;
diff --git a/packages/api-client/src/dashboard.ts 
b/packages/api-client/src/dashboard.ts
index 2b73727..10db62a 100644
--- a/packages/api-client/src/dashboard.ts
+++ b/packages/api-client/src/dashboard.ts
@@ -29,7 +29,7 @@
  * Phase 7 admin lets operators edit + persist their own widget set.
  */
 
-export type DashboardWidgetType = 'card' | 'line';
+export type DashboardWidgetType = 'card' | 'line' | 'top';
 
 /**
  * Per-entity dashboard scope. Each layer carries an independent widget
@@ -95,14 +95,25 @@ export interface DashboardSeries {
   data: Array<number | null>;
 }
 
+export interface DashboardTopItem {
+  /** Service / instance / endpoint name returned by OAP. */
+  name: string;
+  value: number | null;
+}
+
 export interface DashboardWidgetResult {
   id: string;
   /** Set when every MQE expression for this widget errored. */
   error?: string;
   /** `card` payload — single scalar (avg across the time window). */
   value?: number | null;
-  /** `line` payload — one entry per expression. */
+  /** `line` payload — one entry per expression. The line chart picks
+   *  up its line labels from the metric.labels relabel values returned
+   *  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. */
+  topList?: DashboardTopItem[];
 }
 
 export interface DashboardResponse {
diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts
index feeda31..c10f15c 100644
--- a/packages/api-client/src/index.ts
+++ b/packages/api-client/src/index.ts
@@ -32,6 +32,7 @@ export type {
   DashboardResponse,
   DashboardScope,
   DashboardSeries,
+  DashboardTopItem,
   DashboardWidget,
   DashboardWidgetResult,
   DashboardWidgetType,

Reply via email to