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 eaebf40  config: split metrics/overview blocks; admin gains separate 
Overview tile card
eaebf40 is described below

commit eaebf401241ad102db64ad93984f849195e9e004
Author: Wu Sheng <[email protected]>
AuthorDate: Tue May 12 22:48:16 2026 +0800

    config: split metrics/overview blocks; admin gains separate Overview tile 
card
    
    Schema split: the layer template's metrics block used to bundle the
    service-list config (orderBy, columns) with Overview-page settings
    (throughput, spark). Two pages, two concerns — they now live in
    separate blocks:
    
      'metrics':  orderBy + columns       ← drives the service list
      'overview': throughput + spark      ← drives the Overview tile
    
    Loader migrates legacy JSONs at load: metrics.throughput / .spark
    move to overview.throughput / .spark transparently, so old templates
    don't break.
    
    All five bundled layer JSONs (general / mesh / k8s_service /
    virtual_database / virtual_cache / virtual_mq) restructured to the
    new shape — slots also renamed to aliases everywhere.
    
    UI wire types + setup store updated:
      - LayerDef gains optional .overview alongside .metrics.
      - defaultLandingFor accepts both blocks; reads throughput/spark
        from overview when present.
      - ensure() callers (LayerShell, LayerKpiStripCard, LayerKpiTile,
        LayerLandingCard, LayerDashboardsView, LayerSetupCard, SetupView,
        useLandingOrder) forward layer.overview through.
    
    Admin / Layer dashboards UI split:
      - 'Service list metrics' card: orderBy dropdown + columns editor.
      - 'Overview tile' card (new): throughput + spark dropdowns.
    Both reference the same columns array — operators can't pick a
    metric that isn't a defined column.
    
    BFF admin-save zod schema accepts the new shape (overview block
    optional; legacy metrics.throughput / .spark rejected, but loader
    still migrates incoming JSON files at startup).
---
 apps/bff/src/dashboard/routes.ts                 |  9 ++-
 apps/bff/src/layers/config/general.json          |  6 +-
 apps/bff/src/layers/config/k8s_service.json      | 72 ++++++++++++++----
 apps/bff/src/layers/config/mesh.json             | 81 +++++++++++++++++----
 apps/bff/src/layers/config/virtual_cache.json    | 55 +++++++++++---
 apps/bff/src/layers/config/virtual_database.json | 76 +++++++++++++++----
 apps/bff/src/layers/config/virtual_mq.json       | 54 +++++++++++---
 apps/bff/src/layers/loader.ts                    | 36 ++++++++-
 apps/bff/src/oap/menu-routes.ts                  |  1 +
 apps/ui/src/api/client.ts                        |  6 +-
 apps/ui/src/composables/useLandingOrder.ts       |  4 +-
 apps/ui/src/stores/setup.ts                      | 34 +++++++--
 apps/ui/src/views/admin/LayerDashboardsAdmin.vue | 93 +++++++++++++++++-------
 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                 |  1 +
 packages/api-client/src/menu.ts                  | 33 +++++++--
 22 files changed, 447 insertions(+), 128 deletions(-)

diff --git a/apps/bff/src/dashboard/routes.ts b/apps/bff/src/dashboard/routes.ts
index 9e005a7..778b5c9 100644
--- a/apps/bff/src/dashboard/routes.ts
+++ b/apps/bff/src/dashboard/routes.ts
@@ -530,8 +530,6 @@ export function registerDashboardRoute(app: 
FastifyInstance, deps: DashboardRout
     metrics: z
       .object({
         orderBy: z.string().optional(),
-        throughput: z.string().optional(),
-        spark: z.string().optional(),
         columns: z
           .array(
             z.object({
@@ -548,6 +546,13 @@ export function registerDashboardRoute(app: 
FastifyInstance, deps: DashboardRout
           .optional(),
       })
       .strict(),
+    overview: z
+      .object({
+        throughput: z.string().optional(),
+        spark: z.string().optional(),
+      })
+      .strict()
+      .optional(),
     dashboards: z
       .object({
         service: z.array(widgetSchema).max(40).optional(),
diff --git a/apps/bff/src/layers/config/general.json 
b/apps/bff/src/layers/config/general.json
index 07911c3..38b6189 100644
--- a/apps/bff/src/layers/config/general.json
+++ b/apps/bff/src/layers/config/general.json
@@ -20,8 +20,6 @@
     "profiling": true
   },
   "metrics": {
-    "throughput": "cpm",
-    "spark": "cpm",
     "orderBy": "cpm",
     "columns": [
       { "metric": "cpm", "label": "RPM", "mqe": "service_cpm", "aggregation": 
"sum" },
@@ -29,6 +27,10 @@
       { "metric": "err", "label": "Error Rate", "unit": "%", "mqe": "100 - 
service_sla/100", "aggregation": "avg" }
     ]
   },
+  "overview": {
+    "throughput": "cpm",
+    "spark": "cpm"
+  },
   "dashboards": {
     "service": [
       {
diff --git a/apps/bff/src/layers/config/k8s_service.json 
b/apps/bff/src/layers/config/k8s_service.json
index 875e220..0ea174d 100644
--- a/apps/bff/src/layers/config/k8s_service.json
+++ b/apps/bff/src/layers/config/k8s_service.json
@@ -2,7 +2,7 @@
   "key": "K8S_SERVICE",
   "alias": "K8s Service",
   "color": "var(--sw-purple)",
-  "slots": {
+  "aliases": {
     "services": "K8s services",
     "instances": "Pods"
   },
@@ -14,40 +14,81 @@
     "logs": true
   },
   "metrics": {
-    "throughput": "cpm",
-    "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": "sla", "label": "SLA", "unit": "%", "mqe": 
"service_sla/100", "aggregation": "avg" },
-      { "metric": "err", "label": "err", "unit": "%", "mqe": "100 - 
service_sla/100", "aggregation": "avg" }
+      {
+        "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": "sla",
+        "label": "SLA",
+        "unit": "%",
+        "mqe": "service_sla/100",
+        "aggregation": "avg"
+      },
+      {
+        "metric": "err",
+        "label": "err",
+        "unit": "%",
+        "mqe": "100 - service_sla/100",
+        "aggregation": "avg"
+      }
     ]
   },
+  "overview": {
+    "throughput": "cpm",
+    "spark": "cpm"
+  },
   "widgets": [
     {
       "id": "traffic",
       "title": "Traffic",
       "type": "card",
       "unit": "rpm",
-      "expressions": ["avg(service_cpm)"],
-      "x": 0, "y": 0, "w": 12, "h": 5
+      "expressions": [
+        "avg(service_cpm)"
+      ],
+      "x": 0,
+      "y": 0,
+      "w": 12,
+      "h": 5
     },
     {
       "id": "sla",
       "title": "Success Rate",
       "type": "card",
       "unit": "%",
-      "expressions": ["avg(service_sla)/100"],
-      "x": 12, "y": 0, "w": 12, "h": 5
+      "expressions": [
+        "avg(service_sla)/100"
+      ],
+      "x": 12,
+      "y": 0,
+      "w": 12,
+      "h": 5
     },
     {
       "id": "traffic_line",
       "title": "Traffic",
       "type": "line",
       "unit": "rpm",
-      "expressions": ["service_cpm"],
-      "x": 0, "y": 5, "w": 12, "h": 12
+      "expressions": [
+        "service_cpm"
+      ],
+      "x": 0,
+      "y": 5,
+      "w": 12,
+      "h": 12
     },
     {
       "id": "percentile",
@@ -59,7 +100,10 @@
         "service_percentile{p='95'}",
         "service_percentile{p='99'}"
       ],
-      "x": 12, "y": 5, "w": 12, "h": 12
+      "x": 12,
+      "y": 5,
+      "w": 12,
+      "h": 12
     }
   ]
 }
diff --git a/apps/bff/src/layers/config/mesh.json 
b/apps/bff/src/layers/config/mesh.json
index 47c6cef..dffd4ff 100644
--- a/apps/bff/src/layers/config/mesh.json
+++ b/apps/bff/src/layers/config/mesh.json
@@ -2,7 +2,7 @@
   "key": "MESH",
   "alias": "Service Mesh",
   "color": "var(--sw-info)",
-  "slots": {
+  "aliases": {
     "services": "Services",
     "instances": "Sidecars",
     "endpoints": "Endpoints"
@@ -17,48 +17,94 @@
     "logs": true
   },
   "metrics": {
-    "throughput": "cpm",
-    "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": "sla", "label": "SLA", "unit": "%", "mqe": 
"service_sla/100", "aggregation": "avg" },
-      { "metric": "err", "label": "err", "unit": "%", "mqe": "100 - 
service_sla/100", "aggregation": "avg" }
+      {
+        "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": "sla",
+        "label": "SLA",
+        "unit": "%",
+        "mqe": "service_sla/100",
+        "aggregation": "avg"
+      },
+      {
+        "metric": "err",
+        "label": "err",
+        "unit": "%",
+        "mqe": "100 - service_sla/100",
+        "aggregation": "avg"
+      }
     ]
   },
+  "overview": {
+    "throughput": "cpm",
+    "spark": "cpm"
+  },
   "widgets": [
     {
       "id": "sla",
       "title": "Success Rate",
       "type": "card",
       "unit": "%",
-      "expressions": ["avg(service_sla)/100"],
-      "x": 0, "y": 0, "w": 8, "h": 5
+      "expressions": [
+        "avg(service_sla)/100"
+      ],
+      "x": 0,
+      "y": 0,
+      "w": 8,
+      "h": 5
     },
     {
       "id": "traffic",
       "title": "Traffic",
       "type": "card",
       "unit": "rpm",
-      "expressions": ["avg(service_cpm)"],
-      "x": 8, "y": 0, "w": 8, "h": 5
+      "expressions": [
+        "avg(service_cpm)"
+      ],
+      "x": 8,
+      "y": 0,
+      "w": 8,
+      "h": 5
     },
     {
       "id": "p99",
       "title": "p99 latency",
       "type": "card",
       "unit": "ms",
-      "expressions": ["service_percentile{p='99'}"],
-      "x": 16, "y": 0, "w": 8, "h": 5
+      "expressions": [
+        "service_percentile{p='99'}"
+      ],
+      "x": 16,
+      "y": 0,
+      "w": 8,
+      "h": 5
     },
     {
       "id": "traffic_line",
       "title": "Traffic",
       "type": "line",
       "unit": "rpm",
-      "expressions": ["service_cpm"],
-      "x": 0, "y": 5, "w": 12, "h": 12
+      "expressions": [
+        "service_cpm"
+      ],
+      "x": 0,
+      "y": 5,
+      "w": 12,
+      "h": 12
     },
     {
       "id": "percentile",
@@ -70,7 +116,10 @@
         "service_percentile{p='95'}",
         "service_percentile{p='99'}"
       ],
-      "x": 12, "y": 5, "w": 12, "h": 12
+      "x": 12,
+      "y": 5,
+      "w": 12,
+      "h": 12
     }
   ]
 }
diff --git a/apps/bff/src/layers/config/virtual_cache.json 
b/apps/bff/src/layers/config/virtual_cache.json
index 048535e..a6c3bde 100644
--- a/apps/bff/src/layers/config/virtual_cache.json
+++ b/apps/bff/src/layers/config/virtual_cache.json
@@ -2,43 +2,74 @@
   "key": "VIRTUAL_CACHE",
   "alias": "Virtual Cache",
   "color": "var(--sw-warn)",
-  "slots": {
+  "aliases": {
     "services": "Caches"
   },
-  "components": { "service": true },
+  "components": {
+    "service": true
+  },
   "metrics": {
-    "throughput": "cpm",
-    "spark": "cpm",
     "orderBy": "cpm",
     "columns": [
-      { "metric": "cpm", "label": "Traffic", "unit": "rpm", "mqe": 
"service_cpm", "aggregation": "sum" },
-      { "metric": "resp", "label": "resp", "unit": "ms", "mqe": 
"service_resp_time", "aggregation": "avg" }
+      {
+        "metric": "cpm",
+        "label": "Traffic",
+        "unit": "rpm",
+        "mqe": "service_cpm",
+        "aggregation": "sum"
+      },
+      {
+        "metric": "resp",
+        "label": "resp",
+        "unit": "ms",
+        "mqe": "service_resp_time",
+        "aggregation": "avg"
+      }
     ]
   },
+  "overview": {
+    "throughput": "cpm",
+    "spark": "cpm"
+  },
   "widgets": [
     {
       "id": "traffic",
       "title": "Traffic",
       "type": "card",
       "unit": "rpm",
-      "expressions": ["avg(service_cpm)"],
-      "x": 0, "y": 0, "w": 12, "h": 5
+      "expressions": [
+        "avg(service_cpm)"
+      ],
+      "x": 0,
+      "y": 0,
+      "w": 12,
+      "h": 5
     },
     {
       "id": "resp",
       "title": "Avg Response Time",
       "type": "card",
       "unit": "ms",
-      "expressions": ["avg(service_resp_time)"],
-      "x": 12, "y": 0, "w": 12, "h": 5
+      "expressions": [
+        "avg(service_resp_time)"
+      ],
+      "x": 12,
+      "y": 0,
+      "w": 12,
+      "h": 5
     },
     {
       "id": "traffic_line",
       "title": "Traffic",
       "type": "line",
       "unit": "rpm",
-      "expressions": ["service_cpm"],
-      "x": 0, "y": 5, "w": 24, "h": 12
+      "expressions": [
+        "service_cpm"
+      ],
+      "x": 0,
+      "y": 5,
+      "w": 24,
+      "h": 12
     }
   ]
 }
diff --git a/apps/bff/src/layers/config/virtual_database.json 
b/apps/bff/src/layers/config/virtual_database.json
index 0b186b4..cf16f42 100644
--- a/apps/bff/src/layers/config/virtual_database.json
+++ b/apps/bff/src/layers/config/virtual_database.json
@@ -2,55 +2,101 @@
   "key": "VIRTUAL_DATABASE",
   "alias": "Virtual Database",
   "color": "var(--sw-warn)",
-  "slots": {
+  "aliases": {
     "services": "Databases"
   },
   "components": {
     "service": true
   },
   "metrics": {
-    "throughput": "cpm",
-    "spark": "cpm",
     "orderBy": "cpm",
     "columns": [
-      { "metric": "cpm", "label": "Traffic", "unit": "rpm", "mqe": 
"service_cpm", "aggregation": "sum" },
-      { "metric": "resp", "label": "resp", "unit": "ms", "mqe": 
"service_resp_time", "aggregation": "avg" },
-      { "metric": "p99", "label": "p99", "unit": "ms", "mqe": 
"service_percentile{p='99'}", "aggregation": "avg" },
-      { "metric": "err", "label": "err", "unit": "%", "mqe": "100 - 
service_sla/100", "aggregation": "avg" }
+      {
+        "metric": "cpm",
+        "label": "Traffic",
+        "unit": "rpm",
+        "mqe": "service_cpm",
+        "aggregation": "sum"
+      },
+      {
+        "metric": "resp",
+        "label": "resp",
+        "unit": "ms",
+        "mqe": "service_resp_time",
+        "aggregation": "avg"
+      },
+      {
+        "metric": "p99",
+        "label": "p99",
+        "unit": "ms",
+        "mqe": "service_percentile{p='99'}",
+        "aggregation": "avg"
+      },
+      {
+        "metric": "err",
+        "label": "err",
+        "unit": "%",
+        "mqe": "100 - service_sla/100",
+        "aggregation": "avg"
+      }
     ]
   },
+  "overview": {
+    "throughput": "cpm",
+    "spark": "cpm"
+  },
   "widgets": [
     {
       "id": "traffic",
       "title": "Traffic",
       "type": "card",
       "unit": "rpm",
-      "expressions": ["avg(service_cpm)"],
-      "x": 0, "y": 0, "w": 8, "h": 5
+      "expressions": [
+        "avg(service_cpm)"
+      ],
+      "x": 0,
+      "y": 0,
+      "w": 8,
+      "h": 5
     },
     {
       "id": "resp",
       "title": "Avg Response Time",
       "type": "card",
       "unit": "ms",
-      "expressions": ["avg(service_resp_time)"],
-      "x": 8, "y": 0, "w": 8, "h": 5
+      "expressions": [
+        "avg(service_resp_time)"
+      ],
+      "x": 8,
+      "y": 0,
+      "w": 8,
+      "h": 5
     },
     {
       "id": "sla",
       "title": "Success Rate",
       "type": "card",
       "unit": "%",
-      "expressions": ["avg(service_sla)/100"],
-      "x": 16, "y": 0, "w": 8, "h": 5
+      "expressions": [
+        "avg(service_sla)/100"
+      ],
+      "x": 16,
+      "y": 0,
+      "w": 8,
+      "h": 5
     },
     {
       "id": "traffic_line",
       "title": "Traffic",
       "type": "line",
       "unit": "rpm",
-      "expressions": ["service_cpm"],
-      "x": 0, "y": 5, "w": 24, "h": 12
+      "expressions": [
+        "service_cpm"
+      ],
+      "x": 0,
+      "y": 5,
+      "w": 24,
+      "h": 12
     }
   ]
 }
diff --git a/apps/bff/src/layers/config/virtual_mq.json 
b/apps/bff/src/layers/config/virtual_mq.json
index 5bd061b..21bef7c 100644
--- a/apps/bff/src/layers/config/virtual_mq.json
+++ b/apps/bff/src/layers/config/virtual_mq.json
@@ -2,43 +2,73 @@
   "key": "VIRTUAL_MQ",
   "alias": "Virtual MQ",
   "color": "var(--sw-ok)",
-  "slots": {
+  "aliases": {
     "services": "Queues"
   },
-  "components": { "service": true },
+  "components": {
+    "service": true
+  },
   "metrics": {
-    "throughput": "cpm",
-    "spark": "cpm",
     "orderBy": "cpm",
     "columns": [
-      { "metric": "cpm", "label": "msg/s", "mqe": "service_cpm", 
"aggregation": "sum" },
-      { "metric": "resp", "label": "consume", "unit": "ms", "mqe": 
"service_resp_time", "aggregation": "avg" }
+      {
+        "metric": "cpm",
+        "label": "msg/s",
+        "mqe": "service_cpm",
+        "aggregation": "sum"
+      },
+      {
+        "metric": "resp",
+        "label": "consume",
+        "unit": "ms",
+        "mqe": "service_resp_time",
+        "aggregation": "avg"
+      }
     ]
   },
+  "overview": {
+    "throughput": "cpm",
+    "spark": "cpm"
+  },
   "widgets": [
     {
       "id": "traffic",
       "title": "Message rate",
       "type": "card",
       "unit": "msg / min",
-      "expressions": ["avg(service_cpm)"],
-      "x": 0, "y": 0, "w": 12, "h": 5
+      "expressions": [
+        "avg(service_cpm)"
+      ],
+      "x": 0,
+      "y": 0,
+      "w": 12,
+      "h": 5
     },
     {
       "id": "resp",
       "title": "Avg Consume Latency",
       "type": "card",
       "unit": "ms",
-      "expressions": ["avg(service_resp_time)"],
-      "x": 12, "y": 0, "w": 12, "h": 5
+      "expressions": [
+        "avg(service_resp_time)"
+      ],
+      "x": 12,
+      "y": 0,
+      "w": 12,
+      "h": 5
     },
     {
       "id": "traffic_line",
       "title": "Message rate",
       "type": "line",
       "unit": "msg / min",
-      "expressions": ["service_cpm"],
-      "x": 0, "y": 5, "w": 24, "h": 12
+      "expressions": [
+        "service_cpm"
+      ],
+      "x": 0,
+      "y": 5,
+      "w": 24,
+      "h": 12
     }
   ]
 }
diff --git a/apps/bff/src/layers/loader.ts b/apps/bff/src/layers/loader.ts
index bf69aa0..4a8b93d 100644
--- a/apps/bff/src/layers/loader.ts
+++ b/apps/bff/src/layers/loader.ts
@@ -66,13 +66,22 @@ export interface LayerMetricColumn {
 }
 
 export interface LayerMetricsConfig {
-  /** Default order-by metric key for the topN service ranking. */
+  /** Default sort metric for the service list. */
   orderBy?: string;
-  /** Throughput metric key (drives the per-layer KPI tile + spark). */
+  columns?: LayerMetricColumn[];
+}
+
+/**
+ * Overview-tile-only settings. Headline + trend metrics on the
+ * per-layer compact tile in the Overview's top strip. Separated
+ * from `LayerMetricsConfig` because these settings affect ONLY the
+ * Overview page; the service list / per-layer pages don't read them.
+ */
+export interface LayerOverviewConfig {
+  /** Metric key for the Overview tile's big headline value. */
   throughput?: string;
-  /** Spark metric key (defaults to `throughput` when omitted). */
+  /** Metric key for the Overview tile's trend line. */
   spark?: string;
-  columns?: LayerMetricColumn[];
 }
 
 /**
@@ -102,6 +111,10 @@ export interface LayerTemplate {
   slots: LayerSlotsConfig;
   components: LayerComponentFlags;
   metrics: LayerMetricsConfig;
+  /** Overview-tile only — headline + trend metrics for the Overview's
+   *  per-layer compact tile. Optional; falls back to `metrics.orderBy`
+   *  for the headline when omitted. */
+  overview?: LayerOverviewConfig;
   /** Per-scope widget sets. `service` is the layer's primary landing. */
   dashboards?: LayerDashboards;
   /** Legacy single widget list — treated as `dashboards.service`. */
@@ -145,6 +158,21 @@ function load(): Map<string, LayerTemplate> {
     if (parsed.widgets && (!parsed.dashboards || !parsed.dashboards.service)) {
       parsed.dashboards = { ...parsed.dashboards, service: parsed.widgets };
     }
+    // Migrate legacy `metrics.throughput` + `metrics.spark` → top-level
+    // `overview` block. Overview-page settings used to live alongside
+    // the service-list metric config; they're now split since they
+    // affect different pages.
+    const legacyMetrics = parsed.metrics as
+      | (LayerMetricsConfig & { throughput?: string; spark?: string })
+      | undefined;
+    if (legacyMetrics && (legacyMetrics.throughput || legacyMetrics.spark)) {
+      const ov: LayerOverviewConfig = { ...(parsed.overview ?? {}) };
+      if (!ov.throughput && legacyMetrics.throughput) ov.throughput = 
legacyMetrics.throughput;
+      if (!ov.spark && legacyMetrics.spark) ov.spark = legacyMetrics.spark;
+      parsed.overview = ov;
+      delete legacyMetrics.throughput;
+      delete legacyMetrics.spark;
+    }
     out.set(parsed.key.toUpperCase(), parsed);
   }
   return out;
diff --git a/apps/bff/src/oap/menu-routes.ts b/apps/bff/src/oap/menu-routes.ts
index 93914d0..80389cf 100644
--- a/apps/bff/src/oap/menu-routes.ts
+++ b/apps/bff/src/oap/menu-routes.ts
@@ -180,6 +180,7 @@ function deriveLayer(
       slots: tpl.slots,
       caps: componentsToCaps(tpl.components),
       metrics: tpl.metrics,
+      overview: tpl.overview,
     };
   }
   const def = LAYER_DEFAULTS[rawKey] ?? DEFAULT_FOR_UNKNOWN_LAYER;
diff --git a/apps/ui/src/api/client.ts b/apps/ui/src/api/client.ts
index 932acca..03e78d6 100644
--- a/apps/ui/src/api/client.ts
+++ b/apps/ui/src/api/client.ts
@@ -72,8 +72,6 @@ export interface AdminLayerTemplate {
   };
   metrics: {
     orderBy?: string;
-    throughput?: string;
-    spark?: string;
     columns?: Array<{
       metric: string;
       label: string;
@@ -84,6 +82,10 @@ export interface AdminLayerTemplate {
       precision?: number;
     }>;
   };
+  overview?: {
+    throughput?: string;
+    spark?: string;
+  };
   widgets: DashboardWidget[];
 }
 
diff --git a/apps/ui/src/composables/useLandingOrder.ts 
b/apps/ui/src/composables/useLandingOrder.ts
index 3883b95..5a1fafe 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, metrics: 
a.metrics }).landing.priority;
-      const pb = store.ensure(b.key, { slots: b.slots, caps: b.caps, metrics: 
b.metrics }).landing.priority;
+      const pa = store.ensure(a.key, { slots: a.slots, caps: a.caps, metrics: 
a.metrics, overview: a.overview }).landing.priority;
+      const pb = store.ensure(b.key, { slots: b.slots, caps: b.caps, metrics: 
b.metrics, overview: b.overview }).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 7da8fec..b204ccc 100644
--- a/apps/ui/src/stores/setup.ts
+++ b/apps/ui/src/stores/setup.ts
@@ -23,6 +23,7 @@ import type {
   LayerCaps,
   LayerConfig,
   LayerMetricsConfig,
+  LayerOverviewConfig,
   LayerSlots,
 } from '@skywalking-horizon-ui/api-client';
 import { bffClient } from '@/api/client';
@@ -78,7 +79,11 @@ function defaultAggregationFor(metricKey: string): 
AggregationKind {
  * 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 {
+export function defaultLandingFor(
+  layerKey: string,
+  fromTemplate?: LayerMetricsConfig,
+  fromOverview?: LayerOverviewConfig,
+): LandingConfig {
   if (fromTemplate?.columns && fromTemplate.columns.length > 0) {
     const cols = fromTemplate.columns.map((c) => ({
       metric: c.metric,
@@ -90,8 +95,8 @@ export function defaultLandingFor(layerKey: string, 
fromTemplate?: LayerMetricsC
       ...(c.precision !== undefined ? { precision: c.precision } : {}),
     }));
     const orderBy = fromTemplate.orderBy ?? cols[0].metric;
-    const throughputMetric = fromTemplate.throughput ?? orderBy;
-    const sparkMetric = fromTemplate.spark ?? throughputMetric;
+    const throughputMetric = fromOverview?.throughput ?? orderBy;
+    const sparkMetric = fromOverview?.spark ?? throughputMetric;
     return {
       priority: defaultPriority(layerKey),
       topN: 5,
@@ -127,12 +132,17 @@ export function defaultLandingFor(layerKey: string, 
fromTemplate?: LayerMetricsC
 
 export function defaultLayerConfig(
   layerKey: string,
-  defaults: { slots: LayerSlots; caps: LayerCaps; metrics?: LayerMetricsConfig 
},
+  defaults: {
+    slots: LayerSlots;
+    caps: LayerCaps;
+    metrics?: LayerMetricsConfig;
+    overview?: LayerOverviewConfig;
+  },
 ): LayerConfig {
   return {
     slots: { ...defaults.slots },
     caps: { ...defaults.caps },
-    landing: defaultLandingFor(layerKey, defaults.metrics),
+    landing: defaultLandingFor(layerKey, defaults.metrics, defaults.overview),
   };
 }
 
@@ -196,7 +206,12 @@ export const useSetupStore = defineStore('setup', () => {
    */
   function ensure(
     layerKey: string,
-    defaults: { slots: LayerSlots; caps: LayerCaps; metrics?: 
LayerMetricsConfig },
+    defaults: {
+      slots: LayerSlots;
+      caps: LayerCaps;
+      metrics?: LayerMetricsConfig;
+      overview?: LayerOverviewConfig;
+    },
   ): LayerConfig {
     let cfg = configs[layerKey];
     if (!cfg) {
@@ -208,7 +223,12 @@ export const useSetupStore = defineStore('setup', () => {
 
   function reset(
     layerKey: string,
-    defaults: { slots: LayerSlots; caps: LayerCaps; metrics?: 
LayerMetricsConfig },
+    defaults: {
+      slots: LayerSlots;
+      caps: LayerCaps;
+      metrics?: LayerMetricsConfig;
+      overview?: LayerOverviewConfig;
+    },
   ): void {
     configs[layerKey] = defaultLayerConfig(layerKey, defaults);
     markDirty();
diff --git a/apps/ui/src/views/admin/LayerDashboardsAdmin.vue 
b/apps/ui/src/views/admin/LayerDashboardsAdmin.vue
index f789761..34f3860 100644
--- a/apps/ui/src/views/admin/LayerDashboardsAdmin.vue
+++ b/apps/ui/src/views/admin/LayerDashboardsAdmin.vue
@@ -169,11 +169,9 @@ const selectedTpl = computed(() => draft.template);
 const currentWidgets = computed(() => widgetsFor(activeScope.value));
 
 /**
- * Metrics-block editor. The metrics block lives directly on the
- * draft template; mutations flow through Vue's reactive proxy so the
- * dirty diff picks them up and Save enables. We ensure the
- * `metrics.columns` array exists before binding so the template can
- * safely v-model into it.
+ * Metrics block editor — drives the service-list columns + default
+ * sort. Overview-only fields (throughput, spark) live in a separate
+ * block, so they're edited in their own card.
  */
 function ensureMetrics(): NonNullable<AdminLayerTemplate['metrics']> {
   if (!draft.template) throw new Error('no template selected');
@@ -182,12 +180,23 @@ function ensureMetrics(): 
NonNullable<AdminLayerTemplate['metrics']> {
   }
   return draft.template.metrics as NonNullable<AdminLayerTemplate['metrics']>;
 }
+function ensureOverview(): NonNullable<AdminLayerTemplate['overview']> {
+  if (!draft.template) throw new Error('no template selected');
+  if (!draft.template.overview) {
+    (draft.template as AdminLayerTemplate).overview = {};
+  }
+  return draft.template.overview as 
NonNullable<AdminLayerTemplate['overview']>;
+}
 const metricsModel = computed(() => {
   if (!draft.template) return null;
-  // Touch ensureMetrics on read so the keys are present for v-model.
   ensureMetrics();
   return draft.template.metrics as NonNullable<AdminLayerTemplate['metrics']>;
 });
+const overviewModel = computed(() => {
+  if (!draft.template) return null;
+  ensureOverview();
+  return draft.template.overview as 
NonNullable<AdminLayerTemplate['overview']>;
+});
 const metricsColumns = computed(() => {
   if (!draft.template) return [];
   const m = ensureMetrics();
@@ -335,37 +344,23 @@ function toggleComponent(key: ComponentKey): void {
           </div>
         </section>
 
-        <!-- Metrics editor (the layer's summary KPI columns + the
-             orderBy / throughput / spark selectors). These drive the
-             Overview KPI tile and the per-layer header summary. -->
+        <!-- Service-list metrics: the columns shown in the picker
+             zone's services table + the default sort. Used across
+             the per-layer page. -->
         <section class="sw-card metrics-card">
           <div class="card-head">
-            <h4>Summary metrics</h4>
-            <span class="sub">columns shown on the Overview KPI tile + 
per-layer header</span>
+            <h4>Service list metrics</h4>
+            <span class="sub">columns + default sort for the service list 
(picker zone)</span>
             <button class="sw-btn add" type="button" 
@click="addMetricColumn">+ Add column</button>
           </div>
           <div v-if="metricsModel" class="metrics-keys">
             <label>
-              <span>orderBy</span>
+              <span>Default sort (orderBy)</span>
               <select v-model="metricsModel.orderBy">
                 <option :value="undefined">(first column)</option>
                 <option v-for="c in metricsColumns" :key="c.metric" 
:value="c.metric">{{ c.metric }}</option>
               </select>
             </label>
-            <label>
-              <span>throughput</span>
-              <select v-model="metricsModel.throughput">
-                <option :value="undefined">(orderBy)</option>
-                <option v-for="c in metricsColumns" :key="c.metric" 
:value="c.metric">{{ c.metric }}</option>
-              </select>
-            </label>
-            <label>
-              <span>spark</span>
-              <select v-model="metricsModel.spark">
-                <option :value="undefined">(throughput)</option>
-                <option v-for="c in metricsColumns" :key="c.metric" 
:value="c.metric">{{ c.metric }}</option>
-              </select>
-            </label>
           </div>
           <div v-if="metricsColumns.length === 0" class="empty inset">
             No metric columns defined. Click "Add column" to start.
@@ -405,6 +400,32 @@ function toggleComponent(key: ComponentKey): void {
           </table>
         </section>
 
+        <!-- Overview-tile settings: the per-layer compact tile on the
+             Overview's top strip. Only these two settings live here;
+             they reference metric keys from the service-list columns. -->
+        <section v-if="overviewModel" class="sw-card overview-card">
+          <div class="card-head">
+            <h4>Overview tile</h4>
+            <span class="sub">per-layer compact tile on the Overview's top 
strip</span>
+          </div>
+          <div class="metrics-keys">
+            <label>
+              <span>Headline (throughput)</span>
+              <select v-model="overviewModel.throughput">
+                <option :value="undefined">(orderBy)</option>
+                <option v-for="c in metricsColumns" :key="c.metric" 
:value="c.metric">{{ c.metric }}</option>
+              </select>
+            </label>
+            <label>
+              <span>Trend line (spark)</span>
+              <select v-model="overviewModel.spark">
+                <option :value="undefined">(throughput)</option>
+                <option v-for="c in metricsColumns" :key="c.metric" 
:value="c.metric">{{ c.metric }}</option>
+              </select>
+            </label>
+          </div>
+        </section>
+
         <!-- Scope tabs -->
         <nav class="scope-tabs sw-card">
           <button
@@ -773,7 +794,25 @@ function toggleComponent(key: ComponentKey): void {
   font-weight: 500;
 }
 
-.metrics-card { padding: 0; }
+.metrics-card,
+.overview-card { padding: 0; }
+.overview-card .card-head {
+  display: flex;
+  align-items: baseline;
+  gap: 10px;
+  padding: 10px 14px;
+  border-bottom: 1px solid var(--sw-line);
+}
+.overview-card .card-head h4 {
+  margin: 0;
+  font-size: 12px;
+  font-weight: 600;
+  color: var(--sw-fg-0);
+}
+.overview-card .card-head .sub {
+  font-size: 10.5px;
+  color: var(--sw-fg-3);
+}
 .metrics-card .card-head .add {
   margin-left: auto;
   font-size: 11.5px;
diff --git a/apps/ui/src/views/layer/LayerDashboardsView.vue 
b/apps/ui/src/views/layer/LayerDashboardsView.vue
index b3ef1fc..deb2ec5 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, metrics: layer.value.metrics }).landing;
+  return store.ensure(layer.value.key, { slots: layer.value.slots, caps: 
layer.value.caps, metrics: layer.value.metrics, overview: layer.value.overview 
}).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 1cbb2cf..f5f8fe4 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, metrics: layer.value.metrics });
+  return store.ensure(layer.value.key, { slots: layer.value.slots, caps: 
layer.value.caps, metrics: layer.value.metrics, overview: layer.value.overview 
});
 });
 
 // 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 32c15ea..77f0373 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, metrics: props.layer.metrics }),
+  store.ensure(props.layer.key, { slots: props.layer.slots, caps: 
props.layer.caps, metrics: props.layer.metrics, overview: props.layer.overview 
}),
 );
 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 62a82f9..0847210 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, metrics: props.layer.metrics }),
+  store.ensure(props.layer.key, { slots: props.layer.slots, caps: 
props.layer.caps, metrics: props.layer.metrics, overview: props.layer.overview 
}),
 );
 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 795938f..c69eaca 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, metrics: props.layer.metrics }));
+const cfg = computed(() => store.ensure(props.layer.key, { slots: 
props.layer.slots, caps: props.layer.caps, metrics: props.layer.metrics, 
overview: props.layer.overview }));
 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 ec3c67d..5869564 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, metrics: props.layer.metrics }));
+const cfg = computed(() => store.ensure(props.layer.key, { slots: 
props.layer.slots, caps: props.layer.caps, metrics: props.layer.metrics, 
overview: props.layer.overview }));
 
 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 584496e..2cd55a8 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, metrics: L.metrics }).landing.priority }})<span
+            {{ L.name }} ({{ store.ensure(L.key, { slots: L.slots, caps: 
L.caps, metrics: L.metrics, overview: L.overview }).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 9d4309b..99a65ec 100644
--- a/packages/api-client/src/index.ts
+++ b/packages/api-client/src/index.ts
@@ -22,6 +22,7 @@ export type {
   LayerDef,
   LayerMetricsColumn,
   LayerMetricsConfig,
+  LayerOverviewConfig,
   MenuResponse,
 } from './menu.js';
 export type {
diff --git a/packages/api-client/src/menu.ts b/packages/api-client/src/menu.ts
index 5d6937c..988df89 100644
--- a/packages/api-client/src/menu.ts
+++ b/packages/api-client/src/menu.ts
@@ -66,14 +66,31 @@ export interface LayerMetricsColumn {
   precision?: number;
 }
 
+/**
+ * Metrics block on a layer template — defines the columns used
+ * across the service list, plus the default sort. Overview-tile
+ * settings (headline metric, trend metric) live separately on
+ * `LayerOverviewConfig` since they're only used by the Overview
+ * page; this block drives the service list table on the per-layer
+ * page.
+ */
 export interface LayerMetricsConfig {
-  /** Default `topN` ranking metric. */
+  /** Default sort metric for the service list. */
   orderBy?: string;
-  /** Headline metric for the Overview per-layer KPI tile. */
+  columns?: LayerMetricsColumn[];
+}
+
+/**
+ * Overview-page-only settings. The per-layer compact tile on the
+ * Overview's top strip picks its big headline value and its
+ * trend-line metric from here. Both reference metric keys present
+ * in `LayerMetricsConfig.columns`.
+ */
+export interface LayerOverviewConfig {
+  /** Metric key for the Overview tile's big headline value. */
   throughput?: string;
-  /** Sparkline metric (defaults to `throughput` when omitted). */
+  /** Metric key for the Overview tile's trend line. */
   spark?: string;
-  columns?: LayerMetricsColumn[];
 }
 
 export interface LayerDef {
@@ -93,9 +110,13 @@ export interface LayerDef {
   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. */
+   *  the source of truth for the service-list columns + default
+   *  sort. Falls back to static catalog defaults when absent. */
   metrics?: LayerMetricsConfig;
+  /** Overview-tile settings — the headline metric + trend metric on
+   *  the per-layer compact tile in the Overview's top strip. Empty
+   *  when the layer template omits the `overview` block. */
+  overview?: LayerOverviewConfig;
 }
 
 export interface MenuResponse {


Reply via email to