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 7e0968b  menu/setup: layer metrics from JSON template drive UI 
columns; clean tips
7e0968b is described below

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

    menu/setup: layer metrics from JSON template drive UI columns; clean tips
    
    Architectural fix: the UI's KPI columns (Overview KPI strip + per-layer
    header summary + selected-service row) read from the JSON layer
    template's metrics.columns now, not the static metricCatalog defaults.
    The user's edits to apps/bff/src/layers/config/general.json finally
    land in the UI.
    
    Wire types: LayerDef gains LayerMetricsConfig (columns / orderBy /
    throughput / spark). BFF menu route populates it from
    getLayerTemplate(rawKey).metrics. UI setupStore.defaultLandingFor
    accepts an optional template-metrics arg and prefers it over the
    static catalog when columns are defined. setupStore.ensure / reset
    signatures gain an optional metrics field on the defaults arg; every
    caller (LayerShell, LayerKpiTile, LayerKpiStripCard, LayerLandingCard,
    LayerDashboardsView, LayerSetupCard, SetupView, useLandingOrder) now
    forwards layer.metrics through.
    
    For General this means the UI now shows RPM / Apdex / Error Rate
    columns (from general.json) instead of cpm / p99 / sla / err (from
    the static catalog).
    
    Tips cleaned to drop embedded MQE — operators see expressions in the
    admin/edit form, not in the hover tip:
      Apdex      'User-satisfaction score on a 0 – 1 scale.'
      Percentile 'p50 / p75 / p90 / p95 / p99 latency …'
      MQ         'Count on the left axis, latency on the right.'
      Top 20     'Layer-wide ranking. Switch tabs to re-rank …'
      Instance top widgets similarly.
    
    mq_combined: visibleWhen removed so the widget always renders, even
    when the service doesn't emit MQ metrics. (Was hiding the whole card
    when the predicate failed.)
---
 apps/bff/src/layers/config/general.json          | 13 +++---
 apps/bff/src/oap/menu-routes.ts                  |  1 +
 apps/ui/src/composables/useLandingOrder.ts       |  4 +-
 apps/ui/src/stores/setup.ts                      | 52 +++++++++++++++++++-----
 apps/ui/src/views/layer/LayerDashboardsView.vue  |  2 +-
 apps/ui/src/views/layer/LayerShell.vue           |  2 +-
 apps/ui/src/views/overview/LayerKpiStripCard.vue |  2 +-
 apps/ui/src/views/overview/LayerKpiTile.vue      |  2 +-
 apps/ui/src/views/overview/LayerLandingCard.vue  |  2 +-
 apps/ui/src/views/setup/LayerSetupCard.vue       |  2 +-
 apps/ui/src/views/setup/SetupView.vue            |  2 +-
 packages/api-client/src/index.ts                 |  9 +++-
 packages/api-client/src/menu.ts                  | 30 ++++++++++++++
 13 files changed, 95 insertions(+), 28 deletions(-)

diff --git a/apps/bff/src/layers/config/general.json 
b/apps/bff/src/layers/config/general.json
index 8109e0a..a4b236f 100644
--- a/apps/bff/src/layers/config/general.json
+++ b/apps/bff/src/layers/config/general.json
@@ -34,7 +34,7 @@
       {
         "id": "top_apis",
         "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",
+        "tip": "Layer-wide ranking. Switch tabs to re-rank by traffic, 
response time, or worst success rate.",
         "type": "top",
         "expressions": [
           "top_n(endpoint_cpm,20,des)",
@@ -59,7 +59,7 @@
       {
         "id": "mq_combined",
         "title": "MQ Consume rate + latency",
-        "tip": "Dual y-axis: service_mq_consume_count (left) + 
service_mq_consume_latency (right).",
+        "tip": "Count on the left axis, latency on the right.",
         "type": "line",
         "expressions": [
           "service_mq_consume_count",
@@ -68,14 +68,13 @@
         "expressionLabels": ["count", "latency"],
         "expressionUnits": ["/min", "ms"],
         "expressionAxes": [0, 1],
-        "visibleWhen": "service_mq_consume_count has value",
         "span": 3,
         "rowSpan": 2
       },
       {
         "id": "apdex_line",
         "title": "Apdex",
-        "tip": "service_apdex/10000 — 0 to 1 satisfaction score.",
+        "tip": "User-satisfaction score on a 0 – 1 scale.",
         "type": "line",
         "expressions": ["service_apdex/10000"],
         "span": 3,
@@ -84,7 +83,7 @@
       {
         "id": "percentile_line",
         "title": "Response Time Percentile",
-        "tip": "Single MQE: 
relabels(service_percentile{p='50,75,90,95,99'},...,percentile='50,...').",
+        "tip": "p50 / p75 / p90 / p95 / p99 latency — the tail of the 
response-time distribution.",
         "type": "line",
         "unit": "ms",
         "expressions": [
@@ -114,7 +113,7 @@
       {
         "id": "top_instance_load",
         "title": "Top 10 instances by load",
-        "tip": "top_n(service_instance_cpm,10,des) — for the selected 
service.",
+        "tip": "Ranked by traffic across the selected service's instances.",
         "type": "top",
         "unit": "rpm",
         "expressions": ["top_n(service_instance_cpm,10,des)"],
@@ -124,7 +123,7 @@
       {
         "id": "top_instance_slow",
         "title": "Top 10 slowest instances",
-        "tip": "top_n(service_instance_resp_time,10,des) — for the selected 
service.",
+        "tip": "Ranked by average response time across the selected service's 
instances.",
         "type": "top",
         "unit": "ms",
         "expressions": ["top_n(service_instance_resp_time,10,des)"],
diff --git a/apps/bff/src/oap/menu-routes.ts b/apps/bff/src/oap/menu-routes.ts
index 13f745d..93914d0 100644
--- a/apps/bff/src/oap/menu-routes.ts
+++ b/apps/bff/src/oap/menu-routes.ts
@@ -179,6 +179,7 @@ function deriveLayer(
       documentLink: tpl.documentLink ?? item?.documentLink ?? undefined,
       slots: tpl.slots,
       caps: componentsToCaps(tpl.components),
+      metrics: tpl.metrics,
     };
   }
   const def = LAYER_DEFAULTS[rawKey] ?? DEFAULT_FOR_UNKNOWN_LAYER;
diff --git a/apps/ui/src/composables/useLandingOrder.ts 
b/apps/ui/src/composables/useLandingOrder.ts
index d111c57..3883b95 100644
--- a/apps/ui/src/composables/useLandingOrder.ts
+++ b/apps/ui/src/composables/useLandingOrder.ts
@@ -32,8 +32,8 @@ export function useLandingOrder(layers: ComputedRef<readonly 
LayerDef[]>) {
   const store = useSetupStore();
   return computed<LayerDef[]>(() => {
     return [...layers.value].sort((a, b) => {
-      const pa = store.ensure(a.key, { slots: a.slots, caps: a.caps 
}).landing.priority;
-      const pb = store.ensure(b.key, { slots: b.slots, caps: b.caps 
}).landing.priority;
+      const pa = store.ensure(a.key, { slots: a.slots, caps: a.caps, metrics: 
a.metrics }).landing.priority;
+      const pb = store.ensure(b.key, { slots: b.slots, caps: b.caps, metrics: 
b.metrics }).landing.priority;
       if (pa !== pb) return pa - pb;
       return 0; // preserve incoming catalog order
     });
diff --git a/apps/ui/src/stores/setup.ts b/apps/ui/src/stores/setup.ts
index 79d82c4..7da8fec 100644
--- a/apps/ui/src/stores/setup.ts
+++ b/apps/ui/src/stores/setup.ts
@@ -22,6 +22,7 @@ import type {
   LandingConfig,
   LayerCaps,
   LayerConfig,
+  LayerMetricsConfig,
   LayerSlots,
 } from '@skywalking-horizon-ui/api-client';
 import { bffClient } from '@/api/client';
@@ -69,7 +70,42 @@ function defaultAggregationFor(metricKey: string): 
AggregationKind {
   return 'avg';
 }
 
-export function defaultLandingFor(layerKey: string): LandingConfig {
+/**
+ * Build the initial LandingConfig for a layer. When the BFF
+ * surfaces a `metrics` block from the JSON template, prefer it as the
+ * source of truth — that's what the operator edits in
+ * `apps/bff/src/layers/config/<layer>.json` / via the admin page.
+ * Falls back to the static metric-catalog defaults when no template
+ * metrics arrived (e.g. layers without a JSON config file).
+ */
+export function defaultLandingFor(layerKey: string, fromTemplate?: 
LayerMetricsConfig): LandingConfig {
+  if (fromTemplate?.columns && fromTemplate.columns.length > 0) {
+    const cols = fromTemplate.columns.map((c) => ({
+      metric: c.metric,
+      label: c.label,
+      ...(c.unit ? { unit: c.unit } : {}),
+      ...(c.mqe ? { mqe: c.mqe } : {}),
+      aggregation: c.aggregation ?? defaultAggregationFor(c.metric),
+      ...(c.scale !== undefined ? { scale: c.scale } : {}),
+      ...(c.precision !== undefined ? { precision: c.precision } : {}),
+    }));
+    const orderBy = fromTemplate.orderBy ?? cols[0].metric;
+    const throughputMetric = fromTemplate.throughput ?? orderBy;
+    const sparkMetric = fromTemplate.spark ?? throughputMetric;
+    return {
+      priority: defaultPriority(layerKey),
+      topN: 5,
+      orderBy,
+      columns: cols,
+      spark: { metric: sparkMetric, height: 28 },
+      throughput: {
+        metric: throughputMetric,
+        aggregation: defaultAggregationFor(throughputMetric),
+      },
+      style: 'table',
+    };
+  }
+  // Static fallback for layers with no JSON template.
   const cols = defaultColumnsForLayer(layerKey).map((c) => ({
     ...c,
     aggregation: defaultAggregationFor(c.metric),
@@ -81,8 +117,6 @@ export function defaultLandingFor(layerKey: string): 
LandingConfig {
     orderBy: defaultOrderByForLayer(layerKey),
     columns: cols,
     spark: { metric: sparkMetric, height: 28 },
-    // Throughput tile defaults to the orderBy metric — operator can
-    // override or remove via Setup. `sum` matches whole-layer traffic.
     throughput: {
       metric: defaultOrderByForLayer(layerKey),
       aggregation: defaultAggregationFor(defaultOrderByForLayer(layerKey)),
@@ -93,12 +127,12 @@ export function defaultLandingFor(layerKey: string): 
LandingConfig {
 
 export function defaultLayerConfig(
   layerKey: string,
-  defaults: { slots: LayerSlots; caps: LayerCaps },
+  defaults: { slots: LayerSlots; caps: LayerCaps; metrics?: LayerMetricsConfig 
},
 ): LayerConfig {
   return {
     slots: { ...defaults.slots },
     caps: { ...defaults.caps },
-    landing: defaultLandingFor(layerKey),
+    landing: defaultLandingFor(layerKey, defaults.metrics),
   };
 }
 
@@ -162,23 +196,19 @@ export const useSetupStore = defineStore('setup', () => {
    */
   function ensure(
     layerKey: string,
-    defaults: { slots: LayerSlots; caps: LayerCaps },
+    defaults: { slots: LayerSlots; caps: LayerCaps; metrics?: 
LayerMetricsConfig },
   ): LayerConfig {
     let cfg = configs[layerKey];
     if (!cfg) {
       cfg = defaultLayerConfig(layerKey, defaults);
       configs[layerKey] = cfg;
-      // Newly-created defaults aren't "dirty" — only explicit edits should
-      // turn the Save button on. We track that by leaving `dirty` alone
-      // here and relying on form-field bindings to flip it via deep proxy
-      // watchers below.
     }
     return cfg;
   }
 
   function reset(
     layerKey: string,
-    defaults: { slots: LayerSlots; caps: LayerCaps },
+    defaults: { slots: LayerSlots; caps: LayerCaps; metrics?: 
LayerMetricsConfig },
   ): void {
     configs[layerKey] = defaultLayerConfig(layerKey, defaults);
     markDirty();
diff --git a/apps/ui/src/views/layer/LayerDashboardsView.vue 
b/apps/ui/src/views/layer/LayerDashboardsView.vue
index 7ca6319..b3ef1fc 100644
--- a/apps/ui/src/views/layer/LayerDashboardsView.vue
+++ b/apps/ui/src/views/layer/LayerDashboardsView.vue
@@ -63,7 +63,7 @@ const safeLayer = computed<LayerDef>(() => layer.value ?? {
 });
 const safeCfg = computed(() => {
   if (!layer.value) return { priority: 99, topN: 5, orderBy: 'cpm', columns: 
[], style: 'table' as const };
-  return store.ensure(layer.value.key, { slots: layer.value.slots, caps: 
layer.value.caps }).landing;
+  return store.ensure(layer.value.key, { slots: layer.value.slots, caps: 
layer.value.caps, metrics: layer.value.metrics }).landing;
 });
 const landing = useLayerLanding(safeLayer, safeCfg);
 const serviceName = computed<string | null>(() => {
diff --git a/apps/ui/src/views/layer/LayerShell.vue 
b/apps/ui/src/views/layer/LayerShell.vue
index 51a3fc2..1cbb2cf 100644
--- a/apps/ui/src/views/layer/LayerShell.vue
+++ b/apps/ui/src/views/layer/LayerShell.vue
@@ -50,7 +50,7 @@ const layer = computed<LayerDef | null>(() => {
 const store = useSetupStore();
 const cfg = computed(() => {
   if (!layer.value) return null;
-  return store.ensure(layer.value.key, { slots: layer.value.slots, caps: 
layer.value.caps });
+  return store.ensure(layer.value.key, { slots: layer.value.slots, caps: 
layer.value.caps, metrics: layer.value.metrics });
 });
 
 // Build a non-null LayerDef ref for the landing composable.
diff --git a/apps/ui/src/views/overview/LayerKpiStripCard.vue 
b/apps/ui/src/views/overview/LayerKpiStripCard.vue
index 16a7906..32c15ea 100644
--- a/apps/ui/src/views/overview/LayerKpiStripCard.vue
+++ b/apps/ui/src/views/overview/LayerKpiStripCard.vue
@@ -36,7 +36,7 @@ import Sparkline from '@/components/charts/Sparkline.vue';
 const props = defineProps<{ layer: LayerDef }>();
 const store = useSetupStore();
 const cfg = computed(() =>
-  store.ensure(props.layer.key, { slots: props.layer.slots, caps: 
props.layer.caps }),
+  store.ensure(props.layer.key, { slots: props.layer.slots, caps: 
props.layer.caps, metrics: props.layer.metrics }),
 );
 const landingCfg = computed(() => cfg.value.landing);
 const layerRef = toRef(props, 'layer');
diff --git a/apps/ui/src/views/overview/LayerKpiTile.vue 
b/apps/ui/src/views/overview/LayerKpiTile.vue
index 76dd4c7..62a82f9 100644
--- a/apps/ui/src/views/overview/LayerKpiTile.vue
+++ b/apps/ui/src/views/overview/LayerKpiTile.vue
@@ -37,7 +37,7 @@ import Sparkline from '@/components/charts/Sparkline.vue';
 const props = defineProps<{ layer: LayerDef }>();
 const store = useSetupStore();
 const cfg = computed(() =>
-  store.ensure(props.layer.key, { slots: props.layer.slots, caps: 
props.layer.caps }),
+  store.ensure(props.layer.key, { slots: props.layer.slots, caps: 
props.layer.caps, metrics: props.layer.metrics }),
 );
 const landingCfg = computed(() => cfg.value.landing);
 const layerRef = toRef(props, 'layer');
diff --git a/apps/ui/src/views/overview/LayerLandingCard.vue 
b/apps/ui/src/views/overview/LayerLandingCard.vue
index 73d2696..795938f 100644
--- a/apps/ui/src/views/overview/LayerLandingCard.vue
+++ b/apps/ui/src/views/overview/LayerLandingCard.vue
@@ -27,7 +27,7 @@ import { fmtMetric } from '@/utils/formatters';
 
 const props = defineProps<{ layer: LayerDef }>();
 const store = useSetupStore();
-const cfg = computed(() => store.ensure(props.layer.key, { slots: 
props.layer.slots, caps: props.layer.caps }));
+const cfg = computed(() => store.ensure(props.layer.key, { slots: 
props.layer.slots, caps: props.layer.caps, metrics: props.layer.metrics }));
 const slotName = computed(() => cfg.value.slots.services ?? 'Services');
 const detailHref = computed(() => `/layer/${props.layer.key}/services`);
 
diff --git a/apps/ui/src/views/setup/LayerSetupCard.vue 
b/apps/ui/src/views/setup/LayerSetupCard.vue
index dd09c3e..ec3c67d 100644
--- a/apps/ui/src/views/setup/LayerSetupCard.vue
+++ b/apps/ui/src/views/setup/LayerSetupCard.vue
@@ -47,7 +47,7 @@ const props = defineProps<{ layer: LayerDef; expanded?: 
boolean }>();
 const emit = defineEmits<{ (e: 'toggle'): void }>();
 
 const store = useSetupStore();
-const cfg = computed(() => store.ensure(props.layer.key, { slots: 
props.layer.slots, caps: props.layer.caps }));
+const cfg = computed(() => store.ensure(props.layer.key, { slots: 
props.layer.slots, caps: props.layer.caps, metrics: props.layer.metrics }));
 
 const open = ref(props.expanded ?? false);
 function toggle(): void {
diff --git a/apps/ui/src/views/setup/SetupView.vue 
b/apps/ui/src/views/setup/SetupView.vue
index ce7f560..584496e 100644
--- a/apps/ui/src/views/setup/SetupView.vue
+++ b/apps/ui/src/views/setup/SetupView.vue
@@ -124,7 +124,7 @@ const visibleLayers = computed(() => {
           <strong>{{ orderedLayers.length }}</strong> layer(s) in this 
deployment,
           rendered on the Overview in priority order:
           <span v-for="(L, i) in orderedLayers.slice(0, 8)" :key="L.key" 
class="chip-name">
-            {{ L.name }} ({{ store.ensure(L.key, { slots: L.slots, caps: 
L.caps }).landing.priority }})<span
+            {{ L.name }} ({{ store.ensure(L.key, { slots: L.slots, caps: 
L.caps, metrics: L.metrics }).landing.priority }})<span
               v-if="i < Math.min(orderedLayers.length, 8) - 1"
             >,</span>
           </span>
diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts
index c10f15c..9d4309b 100644
--- a/packages/api-client/src/index.ts
+++ b/packages/api-client/src/index.ts
@@ -16,7 +16,14 @@
  */
 
 export * from './types.js';
-export type { LayerSlots, LayerCaps, LayerDef, MenuResponse } from './menu.js';
+export type {
+  LayerSlots,
+  LayerCaps,
+  LayerDef,
+  LayerMetricsColumn,
+  LayerMetricsConfig,
+  MenuResponse,
+} from './menu.js';
 export type {
   AggregationKind,
   LandingColumn,
diff --git a/packages/api-client/src/menu.ts b/packages/api-client/src/menu.ts
index a8cfec2..5d6937c 100644
--- a/packages/api-client/src/menu.ts
+++ b/packages/api-client/src/menu.ts
@@ -50,6 +50,32 @@ export interface LayerCaps {
   events?: boolean;
 }
 
+/**
+ * One metric column for the layer's landing / summary KPI strip.
+ * Mirrors `dashboards.<layer>.json:metrics.columns[]` so the SPA can
+ * render the operator-edited labels / MQEs / aggregations instead of
+ * static catalog defaults.
+ */
+export interface LayerMetricsColumn {
+  metric: string;
+  label: string;
+  unit?: string;
+  mqe?: string;
+  aggregation?: 'sum' | 'avg';
+  scale?: number;
+  precision?: number;
+}
+
+export interface LayerMetricsConfig {
+  /** Default `topN` ranking metric. */
+  orderBy?: string;
+  /** Headline metric for the Overview per-layer KPI tile. */
+  throughput?: string;
+  /** Sparkline metric (defaults to `throughput` when omitted). */
+  spark?: string;
+  columns?: LayerMetricsColumn[];
+}
+
 export interface LayerDef {
   key: string;
   /** Display name from OAP `getMenuItems.title` (preserving casing). */
@@ -66,6 +92,10 @@ export interface LayerDef {
   documentLink?: string;
   slots: LayerSlots;
   caps: LayerCaps;
+  /** Per-layer metric config from the JSON template; UI uses it as
+   *  the source of truth for KPI columns + throughput / spark when
+   *  present. Falls back to static catalog defaults when absent. */
+  metrics?: LayerMetricsConfig;
 }
 
 export interface MenuResponse {

Reply via email to