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 d303215  refactor(overview): aggregate KPI tiles server-side + retire 
dead template config (#98)
d303215 is described below

commit d30321509e49dcde2f76023f9b6dbef4948f8589
Author: 吴晟 Wu Sheng <[email protected]>
AuthorDate: Tue Jul 7 22:47:41 2026 +0800

    refactor(overview): aggregate KPI tiles server-side + retire dead template 
config (#98)
    
    refactor(overview): whole-layer KPI aggregation + per-widget page-side 
control (#98)
    
    Overview KPI tiles (e.g. "General services · RPM / Latency / SLA") 
aggregated over only a single service instead of the whole layer (a hardcoded 
topN:1), so a busy layer read far too low. Reported in apache/skywalking#13934.
    
    Overview KPI aggregation
    - Multi-service tiles (General / Mesh / Virtual Database·Cache·MQ·GenAI) 
now roll up the entire layer server-side via 
sum/avg(top_n(<metric>,{{topn}},DES[,attr0='<layer>'])) — one query per tile 
instead of a per-service fan-out. Bounded by a new HORIZON_QUERY_OVERVIEW_TOPN 
setting (default 100).
    - K8s-cluster and Istio-pilot composites stay page-side (their latest() 
snapshots + label-split metrics can't be top_n-rolled). Mesh's second tile is 
now Latency (avg response time) in place of P95.
    
    Page-side aggregation as a first-class, per-widget choice
    - Explicit aggregateOnPage flag (Server-side vs Page-side) with its own 
top-N limit, plus a new rankBy — rank the top-N services by one of the widget's 
KPIs (default the first) or by a separate ranking metric ({ kpi: n } | { mqe: 
"…" }). Fixes a latent bug where a page-side widget's first KPI being a 
LABELED_VALUE metric skewed the ranking; pilot_summary now ranks by xDS 
connections.
    - Each page-side widget gets its own landing call (own limit + ranking); 
self-aggregating tiles batch by layer.
    
    Editor UX
    - {{topn}} explained inline via a WidgetTip popover showing its live 
resolved value (surfaced through /api/oap/info); redesigned aggregation 
controls (mode radio + nested Top-N / Rank-by); auto-growing 
dashboard-description textarea; removed a duplicate "mqe" option in the KPI 
Source selector.
    
    Cleanup (no behavior change)
    - Retired the dead legacy per-layer-overview machinery (LayerOverviewConfig 
/ headerColumns / overviewGroups / overviewMetrics) and a sweep of 
verified-dead template config (serviceCountTile, the LayerDef.header twin, 
LogConfig.defaultTags, LandingConfig.style, LandingColumn.tip, 
OverviewWidgetResult, the BFF LayerMetricsConfig alias) + dead loader 
migrations.
    
    No layer-template or OAP wire-contract change — only the two Overview 
dashboard templates (services.json, mesh.json) + their i18n overlays changed. 
Overviews are served remote-only from OAP, so existing deployments need an 
admin re-sync of the bundled Overview templates (Overview-templates admin → 
push-bundled); fresh installs get it via boot-seed.
---
 CHANGELOG.md                                       |   5 +
 .../bundled_templates/overviews/mesh.i18n.de.json  |   2 +-
 .../bundled_templates/overviews/mesh.i18n.es.json  |   2 +-
 .../bundled_templates/overviews/mesh.i18n.fr.json  |   2 +-
 .../bundled_templates/overviews/mesh.i18n.ja.json  |   2 +-
 .../bundled_templates/overviews/mesh.i18n.ko.json  |   2 +-
 .../bundled_templates/overviews/mesh.i18n.pt.json  |   2 +-
 .../overviews/mesh.i18n.zh-CN.json                 |   2 +-
 apps/bff/src/bundled_templates/overviews/mesh.json |  10 +-
 .../src/bundled_templates/overviews/services.json  |  31 ++---
 apps/bff/src/config/schema.ts                      |  10 +-
 apps/bff/src/http/admin/overview-templates.ts      |   4 +
 apps/bff/src/http/query/info.ts                    |   2 +
 apps/bff/src/http/query/landing.ts                 |  73 +++++++++-
 apps/bff/src/http/query/menu.ts                    |   6 -
 apps/bff/src/logic/layers/loader.ts                | 150 +--------------------
 apps/bff/src/logic/overview/loader.ts              |  13 ++
 apps/ui/src/api/client.ts                          |   5 -
 apps/ui/src/api/scopes/layer.test.ts               |   3 -
 .../NewOverviewDashboardModal.vue                  |   3 +-
 .../overview-templates/OverviewWidgetDrawer.vue    | 121 ++++++++++++++++-
 apps/ui/src/layer/LayerShell.vue                   |  11 +-
 .../browser-errors/LayerBrowserErrorsView.vue      |   3 +-
 .../LayerEndpointDependencyView.vue                |   5 +-
 apps/ui/src/layer/logs/LayerLogsView.vue           |   4 +-
 .../src/layer/profiling/LayerEBPFProfilingView.vue |   3 +-
 .../layer/profiling/LayerTraceProfilingView.vue    |   3 +-
 .../src/layer/service-map/LayerServiceMapView.vue  |   5 +-
 apps/ui/src/layer/traces/LayerTracesView.vue       |   4 +-
 .../render/layer-dashboard/LayerDashboardsView.vue |   4 +-
 .../ui/src/render/overview/useOverviewDashboard.ts | 139 +++++++++++++------
 apps/ui/src/shell/layerFromTemplate.ts             |   3 -
 apps/ui/src/shell/useLandingOrder.ts               |   4 +-
 apps/ui/src/shell/useOapInfo.ts                    |   6 +
 apps/ui/src/state/setup.ts                         |  71 +---------
 docs/customization/overview-templates.md           |  30 +++--
 docs/setup/container-image.md                      |   1 +
 horizon.yaml                                       |   2 +
 packages/api-client/src/index.ts                   |   4 -
 packages/api-client/src/menu.ts                    |  84 +-----------
 packages/api-client/src/oap-info.ts                |   6 +
 packages/api-client/src/overview.ts                |  80 ++++++-----
 packages/api-client/src/setup.ts                   |  36 ++---
 43 files changed, 469 insertions(+), 489 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index e9695de..12c0c25 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -96,6 +96,11 @@ The version line is shared by every package in the monorepo 
(apps + shared packa
 
 - **Satellite event and queue widgets break out per pipeline.** The 
SO11Y_SATELLITE Receive Events, Fetch Events, Queue Input / Output, and Queue 
Used widgets now label each series by its Satellite pipeline (`tracingpipe`, 
`jvmpipe`, `logpipe`, …) instead of collapsing every line onto a single `all`, 
so you can see which collection pipeline drives the rate.
 
+### Overview dashboards
+
+- **The per-layer KPI tiles now report the whole layer, not a single 
service.** A tile such as "General services · RPM / Latency / SLA" was 
aggregating just one service (an internal top-1 cap), so a busy layer read far 
too low. Each tile now rolls up **every** service in its layer — throughput 
summed, latency and SLA averaged — for General, Mesh, and the Virtual Database 
/ Cache / MQ / GenAI layers. Mesh's second tile is now **Latency** (average 
response time) in place of the old P95.
+- **New per-widget aggregation control for Overview composites.** An Overview 
data widget defaults to a self-aggregating expression that OAP rolls up 
server-side; a widget can instead pick **page-side aggregation** (an 
*Aggregation* mode choice in the Overview templates admin, with a top-`limit` 
services window) for metrics that can't be expressed that way — the Kubernetes 
cluster-capacity and Istio pilot composites use it (pilot over up to 5 
control-plane services). A page-side widget a [...]
+
 ### Performance & behavior tuning
 
 - **New `performance` section in `horizon.yaml`.** Tune how hard the BFF fans 
metric queries out to OAP — per-route bulk (request) sizes and concurrency for 
the topology, 3D-map, landing, and dashboard fan-outs — plus protective caps: 
the service-map render valve (`topologyMaxNodes` / `topologyMaxEdges`) and 
per-request record caps for traces / logs / browser logs. Operational, 
hot-reloaded, per-deployment; defaults match the previous built-in values, so 
the whole block is optional. Rais [...]
diff --git a/apps/bff/src/bundled_templates/overviews/mesh.i18n.de.json 
b/apps/bff/src/bundled_templates/overviews/mesh.i18n.de.json
index 7fad9f3..90967fc 100644
--- a/apps/bff/src/bundled_templates/overviews/mesh.i18n.de.json
+++ b/apps/bff/src/bundled_templates/overviews/mesh.i18n.de.json
@@ -13,7 +13,7 @@
           "label": "RPM"
         },
         {
-          "label": "P95"
+          "label": "Latenz"
         },
         {
           "label": "SLA"
diff --git a/apps/bff/src/bundled_templates/overviews/mesh.i18n.es.json 
b/apps/bff/src/bundled_templates/overviews/mesh.i18n.es.json
index f51de1f..ec55aa5 100644
--- a/apps/bff/src/bundled_templates/overviews/mesh.i18n.es.json
+++ b/apps/bff/src/bundled_templates/overviews/mesh.i18n.es.json
@@ -13,7 +13,7 @@
           "label": "RPM"
         },
         {
-          "label": "P95"
+          "label": "Latencia"
         },
         {
           "label": "SLA"
diff --git a/apps/bff/src/bundled_templates/overviews/mesh.i18n.fr.json 
b/apps/bff/src/bundled_templates/overviews/mesh.i18n.fr.json
index 274e830..eeff461 100644
--- a/apps/bff/src/bundled_templates/overviews/mesh.i18n.fr.json
+++ b/apps/bff/src/bundled_templates/overviews/mesh.i18n.fr.json
@@ -13,7 +13,7 @@
           "label": "RPM"
         },
         {
-          "label": "P95"
+          "label": "Latence"
         },
         {
           "label": "SLA"
diff --git a/apps/bff/src/bundled_templates/overviews/mesh.i18n.ja.json 
b/apps/bff/src/bundled_templates/overviews/mesh.i18n.ja.json
index b928c4b..8dae983 100644
--- a/apps/bff/src/bundled_templates/overviews/mesh.i18n.ja.json
+++ b/apps/bff/src/bundled_templates/overviews/mesh.i18n.ja.json
@@ -13,7 +13,7 @@
           "label": "RPM"
         },
         {
-          "label": "P95"
+          "label": "レイテンシ"
         },
         {
           "label": "SLA"
diff --git a/apps/bff/src/bundled_templates/overviews/mesh.i18n.ko.json 
b/apps/bff/src/bundled_templates/overviews/mesh.i18n.ko.json
index 2540799..6a9c1e9 100644
--- a/apps/bff/src/bundled_templates/overviews/mesh.i18n.ko.json
+++ b/apps/bff/src/bundled_templates/overviews/mesh.i18n.ko.json
@@ -13,7 +13,7 @@
           "label": "RPM"
         },
         {
-          "label": "P95"
+          "label": "지연 시간"
         },
         {
           "label": "SLA"
diff --git a/apps/bff/src/bundled_templates/overviews/mesh.i18n.pt.json 
b/apps/bff/src/bundled_templates/overviews/mesh.i18n.pt.json
index 1da2330..29b5925 100644
--- a/apps/bff/src/bundled_templates/overviews/mesh.i18n.pt.json
+++ b/apps/bff/src/bundled_templates/overviews/mesh.i18n.pt.json
@@ -13,7 +13,7 @@
           "label": "RPM"
         },
         {
-          "label": "P95"
+          "label": "Latência"
         },
         {
           "label": "SLA"
diff --git a/apps/bff/src/bundled_templates/overviews/mesh.i18n.zh-CN.json 
b/apps/bff/src/bundled_templates/overviews/mesh.i18n.zh-CN.json
index 4dea766..6e64f1d 100644
--- a/apps/bff/src/bundled_templates/overviews/mesh.i18n.zh-CN.json
+++ b/apps/bff/src/bundled_templates/overviews/mesh.i18n.zh-CN.json
@@ -13,7 +13,7 @@
           "label": "RPM"
         },
         {
-          "label": "P95"
+          "label": "延迟"
         },
         {
           "label": "SLA"
diff --git a/apps/bff/src/bundled_templates/overviews/mesh.json 
b/apps/bff/src/bundled_templates/overviews/mesh.json
index bc2c26c..b613d06 100644
--- a/apps/bff/src/bundled_templates/overviews/mesh.json
+++ b/apps/bff/src/bundled_templates/overviews/mesh.json
@@ -21,9 +21,9 @@
       "layer": "MESH",
       "showCount": true,
       "kpis": [
-        { "label": "RPM", "mqe": "service_cpm", "unit": "rpm", "aggregation": 
"sum" },
-        { "label": "P95", "mqe": "service_percentile{p='95'}", "unit": "ms", 
"aggregation": "avg" },
-        { "label": "SLA", "mqe": "service_sla/100", "unit": "%", 
"aggregation": "avg" }
+        { "label": "RPM", "mqe": 
"sum(top_n(service_cpm,{{topn}},DES,attr0='MESH'))", "unit": "rpm" },
+        { "label": "Latency", "mqe": 
"avg(top_n(service_resp_time,{{topn}},DES,attr0='MESH'))", "unit": "ms" },
+        { "label": "SLA", "mqe": 
"avg(top_n(service_sla,{{topn}},DES,attr0='MESH'))/100", "unit": "%" }
       ],
       "span": 4,
       "rowSpan": 2
@@ -34,6 +34,9 @@
       "tip": "xDS push count + reject errors + the layer's service count.",
       "type": "metric-composite",
       "layer": "MESH_CP",
+      "aggregateOnPage": true,
+      "limit": 5,
+      "rankBy": { "kpi": 1 },
       "span": 8,
       "rowSpan": 2,
       "kpis": [
@@ -84,6 +87,7 @@
       "tip": "Nodes / namespaces / workloads + cluster-wide CPU, memory, and 
disk usage.",
       "type": "metric-composite",
       "layer": "K8S",
+      "aggregateOnPage": true,
       "span": 12,
       "rowSpan": 3,
       "kpis": [
diff --git a/apps/bff/src/bundled_templates/overviews/services.json 
b/apps/bff/src/bundled_templates/overviews/services.json
index 7fcf0c0..cdddcd9 100644
--- a/apps/bff/src/bundled_templates/overviews/services.json
+++ b/apps/bff/src/bundled_templates/overviews/services.json
@@ -28,9 +28,9 @@
       "layer": "GENERAL",
       "showCount": true,
       "kpis": [
-        { "label": "RPM", "mqe": "service_cpm", "unit": "rpm", "aggregation": 
"sum" },
-        { "label": "Latency", "mqe": "service_resp_time", "unit": "ms", 
"aggregation": "avg" },
-        { "label": "SLA", "mqe": "service_sla/100", "unit": "%", 
"aggregation": "avg" }
+        { "label": "RPM", "mqe": 
"sum(top_n(service_cpm,{{topn}},DES,attr0='GENERAL'))", "unit": "rpm" },
+        { "label": "Latency", "mqe": 
"avg(top_n(service_resp_time,{{topn}},DES,attr0='GENERAL'))", "unit": "ms" },
+        { "label": "SLA", "mqe": 
"avg(top_n(service_sla,{{topn}},DES,attr0='GENERAL'))/100", "unit": "%" }
       ],
       "span": 1,
       "rowSpan": 2
@@ -43,9 +43,9 @@
       "layer": "VIRTUAL_DATABASE",
       "showCount": true,
       "kpis": [
-        { "label": "RPM", "mqe": "database_access_cpm", "unit": "rpm", 
"aggregation": "sum" },
-        { "label": "Latency", "mqe": "database_access_resp_time", "unit": 
"ms", "aggregation": "avg" },
-        { "label": "SLA", "mqe": "database_access_sla/100", "unit": "%", 
"aggregation": "avg" }
+        { "label": "RPM", "mqe": 
"sum(top_n(database_access_cpm,{{topn}},DES))", "unit": "rpm" },
+        { "label": "Latency", "mqe": 
"avg(top_n(database_access_resp_time,{{topn}},DES))", "unit": "ms" },
+        { "label": "SLA", "mqe": 
"avg(top_n(database_access_sla,{{topn}},DES))/100", "unit": "%" }
       ],
       "span": 1,
       "rowSpan": 2
@@ -58,9 +58,9 @@
       "layer": "VIRTUAL_CACHE",
       "showCount": true,
       "kpis": [
-        { "label": "RPM", "mqe": "cache_access_cpm", "unit": "rpm", 
"aggregation": "sum" },
-        { "label": "Latency", "mqe": "cache_access_resp_time", "unit": "ms", 
"aggregation": "avg" },
-        { "label": "SLA", "mqe": "cache_access_sla/100", "unit": "%", 
"aggregation": "avg" }
+        { "label": "RPM", "mqe": "sum(top_n(cache_access_cpm,{{topn}},DES))", 
"unit": "rpm" },
+        { "label": "Latency", "mqe": 
"avg(top_n(cache_access_resp_time,{{topn}},DES))", "unit": "ms" },
+        { "label": "SLA", "mqe": 
"avg(top_n(cache_access_sla,{{topn}},DES))/100", "unit": "%" }
       ],
       "span": 1,
       "rowSpan": 2
@@ -73,9 +73,9 @@
       "layer": "VIRTUAL_MQ",
       "showCount": true,
       "kpis": [
-        { "label": "Consume", "mqe": "mq_service_consume_cpm", "unit": "rpm", 
"aggregation": "sum" },
-        { "label": "Produce", "mqe": "mq_service_produce_cpm", "unit": "rpm", 
"aggregation": "sum" },
-        { "label": "Consume latency", "mqe": "mq_service_consume_latency", 
"unit": "ms", "aggregation": "avg" }
+        { "label": "Consume", "mqe": 
"sum(top_n(mq_service_consume_cpm,{{topn}},DES))", "unit": "rpm" },
+        { "label": "Produce", "mqe": 
"sum(top_n(mq_service_produce_cpm,{{topn}},DES))", "unit": "rpm" },
+        { "label": "Consume latency", "mqe": 
"avg(top_n(mq_service_consume_latency,{{topn}},DES))", "unit": "ms" }
       ],
       "span": 1,
       "rowSpan": 2
@@ -88,9 +88,9 @@
       "layer": "VIRTUAL_GENAI",
       "showCount": true,
       "kpis": [
-        { "label": "RPM", "mqe": "gen_ai_provider_cpm", "unit": "rpm", 
"aggregation": "sum" },
-        { "label": "Latency", "mqe": "gen_ai_provider_resp_time", "unit": 
"ms", "aggregation": "avg" },
-        { "label": "SLA", "mqe": "gen_ai_provider_sla/100", "unit": "%", 
"aggregation": "avg" }
+        { "label": "RPM", "mqe": 
"sum(top_n(gen_ai_provider_cpm,{{topn}},DES))", "unit": "rpm" },
+        { "label": "Latency", "mqe": 
"avg(top_n(gen_ai_provider_resp_time,{{topn}},DES))", "unit": "ms" },
+        { "label": "SLA", "mqe": 
"avg(top_n(gen_ai_provider_sla,{{topn}},DES))/100", "unit": "%" }
       ],
       "span": 1,
       "rowSpan": 2
@@ -132,6 +132,7 @@
       "tip": "Nodes / namespaces / workloads + cluster-wide CPU, memory, and 
disk usage.",
       "type": "metric-composite",
       "layer": "K8S",
+      "aggregateOnPage": true,
       "span": 12,
       "rowSpan": 3,
       "kpis": [
diff --git a/apps/bff/src/config/schema.ts b/apps/bff/src/config/schema.ts
index 6454506..0e48617 100644
--- a/apps/bff/src/config/schema.ts
+++ b/apps/bff/src/config/schema.ts
@@ -312,9 +312,17 @@ const querySchema = z
      *  storage backend can take the larger fan-out; lower it to protect a
      *  modest deployment. Default 100. */
     landingServiceCap: z.number().int().positive().default(100),
+    /** N for the Overview KPI tiles' self-aggregating MQE. Each tile column
+     *  is `sum|avg(top_n(<metric>,{{topn}},DES[,attr0='<layer>']))` — the
+     *  layer-wide rollup happens SERVER-SIDE via OAP's `top_n`, so the BFF
+     *  substitutes this into the `{{topn}}` placeholder before firing (one
+     *  global query per tile, no per-service fan-out). 100 covers every
+     *  layer on a normal deployment; raise it only if a single layer holds
+     *  more than 100 services and the tail matters to the aggregate. */
+    overviewTopN: z.number().int().positive().default(100),
   })
   .strict()
-  .default({ landingServiceCap: 100 });
+  .default({ landingServiceCap: 100, overviewTopN: 100 });
 
 // JS source maps for de-obfuscating BROWSER-layer error stacks (#6784).
 // Maps live in the BFF process heap — there is NO OAP-side storage — so
diff --git a/apps/bff/src/http/admin/overview-templates.ts 
b/apps/bff/src/http/admin/overview-templates.ts
index 0f0a464..20ca82f 100644
--- a/apps/bff/src/http/admin/overview-templates.ts
+++ b/apps/bff/src/http/admin/overview-templates.ts
@@ -93,7 +93,11 @@ const widgetSchema = z.object({
   cols: z.number().int().optional(),
   kpis: z.array(kpiSchema).optional(),
   showCount: z.boolean().optional(),
+  aggregateOnPage: z.boolean().optional(),
   limit: z.number().int().min(1).max(100).optional(),
+  rankBy: z
+    .object({ kpi: z.number().int().min(0).optional(), mqe: 
z.string().optional() })
+    .optional(),
   span: z.number().int().min(1).max(12).optional(),
   rowSpan: z.number().int().min(1).max(12).optional(),
 });
diff --git a/apps/bff/src/http/query/info.ts b/apps/bff/src/http/query/info.ts
index ce24104..f360cc4 100644
--- a/apps/bff/src/http/query/info.ts
+++ b/apps/bff/src/http/query/info.ts
@@ -126,6 +126,7 @@ export function registerOapInfoRoute(app: FastifyInstance, 
deps: InfoRouteDeps):
         zipkinUrl,
         zipkinReachable: zipkin.reachable,
         zipkinError: zipkin.error,
+        overviewTopN: cfg.query.overviewTopN,
       };
       return reply.send(body);
     } catch (err) {
@@ -137,6 +138,7 @@ export function registerOapInfoRoute(app: FastifyInstance, 
deps: InfoRouteDeps):
         zipkinUrl,
         zipkinReachable: zipkin.reachable,
         zipkinError: zipkin.error,
+        overviewTopN: cfg.query.overviewTopN,
       };
       return reply.status(200).send(body);
     }
diff --git a/apps/bff/src/http/query/landing.ts 
b/apps/bff/src/http/query/landing.ts
index 9814976..92979b3 100644
--- a/apps/bff/src/http/query/landing.ts
+++ b/apps/bff/src/http/query/landing.ts
@@ -117,12 +117,14 @@ const aggSchema = z.enum(['sum', 'avg']);
 const columnSchema = z.object({
   metric: z.string().min(1),
   label: z.string().min(1),
-  tip: z.string().optional(),
   unit: z.string().optional(),
   mqe: z.string().optional(),
   aggregation: aggSchema.optional(),
   scale: z.number().finite().optional(),
   precision: z.number().int().min(0).max(6).optional(),
+  // Self-aggregating column: the `mqe` folds the layer to one scalar
+  // server-side, so the BFF fires it once (no per-service fan-out).
+  selfAggregate: z.boolean().optional(),
 });
 const bodySchema = z.object({
   topN: z.number().int().min(1).max(8),
@@ -247,6 +249,28 @@ function buildMqeFragment(aliasName: string, m: 
MqeRequest, w: Window, coldStage
   );
 }
 
+/** Fragment for a self-aggregating column — the MQE (`sum|avg(top_n(...))`)
+ *  already rolls the whole layer up server-side, so the entity carries no
+ *  `serviceName`: OAP's `top_n` ranks across every service of the scope.
+ *
+ *  `normal: true` is safe here even for the VIRTUAL_* layers whose services
+ *  are `normal: false`: `top_n` is a cross-entity scan over the METRIC's own
+ *  entities (`database_access_*` etc. belong only to virtual services), and
+ *  it ignores the query entity's `normal` flag. Verified against the demo —
+ *  `sum(top_n(database_access_cpm,100,DES))` returns the same value with
+ *  `normal: true` and `normal: false`. (The flag only matters for a
+ *  single-entity metric query that names one service.) */
+function buildAggFragment(aliasName: string, expression: string, w: Window, 
coldStage: boolean): string {
+  const coldFrag = coldStage ? ', coldStage: true' : '';
+  return (
+    `${aliasName}: execExpression(\n` +
+    `      expression: ${JSON.stringify(expression)},\n` +
+    `      entity: { scope: Service, normal: true },\n` +
+    `      duration: { start: ${JSON.stringify(w.start)}, end: 
${JSON.stringify(w.end)}, step: ${w.step}${coldFrag} }\n` +
+    `    ) { type error results { values { value } } }`
+  );
+}
+
 export function registerLandingRoute(app: FastifyInstance, deps: 
LandingRouteDeps): void {
   const auth = requireAuth(deps);
   app.post(
@@ -335,11 +359,29 @@ export function registerLandingRoute(app: 
FastifyInstance, deps: LandingRouteDep
         return reply.send(body);
       }
 
-      const resolved = cfg.columns.map((c) => ({
+      const coldStage = !!req.coldStage;
+
+      // Split header columns by the caller's explicit `selfAggregate` flag.
+      //  - self-aggregating columns fold the whole layer to one scalar
+      //    server-side (`sum|avg(top_n(<metric>,{{topn}},DES[,attr0=…]))`);
+      //    the BFF fires each ONCE globally. A per-service fan-out here would
+      //    re-aggregate an already-aggregated number (the Overview `topN:1`
+      //    bug). `{{topn}}` is substituted with `query.overviewTopN`.
+      //  - every other column keeps the per-service fan-out + page-side topN
+      //    rollup below (composite KPIs, the per-layer landing header). The
+      //    flag is opt-in, so legacy callers stay on the fan-out path.
+      const overviewTopN = deps.config.current.query.overviewTopN;
+      const allResolved = cfg.columns.map((c) => ({
         column: c,
         expression: resolveMqe(c.metric, c.mqe, layerKey),
       }));
-      const coldStage = !!req.coldStage;
+      const aggResolved = allResolved
+        .filter((r) => r.column.selfAggregate === true && r.expression !== 
null)
+        .map((r) => ({
+          column: r.column,
+          expression: (r.expression as string).replace(/\{\{\s*topn\s*\}\}/g, 
String(overviewTopN)),
+        }));
+      const resolved = allResolved.filter((r) => r.column.selfAggregate !== 
true);
 
       // Probe `cols` for every service in `svcList`, chunked into
       // per-request batches and drained through the bounded pool. Keyed by
@@ -468,7 +510,7 @@ export function registerLandingRoute(app: FastifyInstance, 
deps: LandingRouteDep
         metrics: {},
         seriesByMetric: {},
       };
-      for (const col of cfg.columns) {
+      for (const { column: col } of resolved) {
         const kind: AggregationKind = col.aggregation ?? 'avg';
         aggregates.metrics[col.metric] = aggregate(
           topRows.map((r) => r.metrics[col.metric] ?? null),
@@ -483,6 +525,29 @@ export function registerLandingRoute(app: FastifyInstance, 
deps: LandingRouteDep
         if (agg) aggregates.seriesByMetric[col.metric] = agg;
       }
 
+      // Self-aggregating columns — one global execExpression each. The MQE
+      // collapses to a SINGLE_VALUE, so the lone scalar IS the KPI: no rows,
+      // no page-side rollup. Batched in one GraphQL trip; a batch failure
+      // leaves those KPIs null (the plain-column aggregates still stand).
+      if (aggResolved.length > 0) {
+        const back = aggResolved.map((r, i) => ({ a: `agg${i}`, column: 
r.column, expression: r.expression }));
+        try {
+          const data = await graphqlPost<Record<string, MqeResultShape>>(
+            opts,
+            `query LandingAggMqe { ${back.map((b) => buildAggFragment(b.a, 
b.expression, window, coldStage)).join('\n    ')} }`,
+          );
+          for (const { a, column } of back) {
+            aggregates.metrics[column.metric] = postProcess(
+              collapseToScalar(data[a]),
+              column.scale,
+              column.precision,
+            );
+          }
+        } catch {
+          for (const { column } of back) aggregates.metrics[column.metric] = 
null;
+        }
+      }
+
       const body: LandingResponse = {
         layer: layerKey,
         topN: cfg.topN,
diff --git a/apps/bff/src/http/query/menu.ts b/apps/bff/src/http/query/menu.ts
index 2429a0b..5d59258 100644
--- a/apps/bff/src/http/query/menu.ts
+++ b/apps/bff/src/http/query/menu.ts
@@ -70,10 +70,6 @@ function componentsToCaps(components: LayerComponentFlags): 
LayerCaps {
     pprofProfiling: !!components.pprofProfiling,
     podLogs: !!components.podLogs,
     events: false,
-    // Bundled service-count tile defaults on — every layer benefits
-    // from the headline count, and operators can opt out per-layer
-    // from the setup card's Features section.
-    serviceCountTile: true,
   };
 }
 
@@ -310,9 +306,7 @@ function deriveLayer(
           c.deployment && !!rawTpl?.deployment;
         return c;
       })(),
-      header: tpl.header,
       metrics: tpl.metrics,
-      overview: tpl.overview,
       log: tpl.log,
       traces: tpl.traces,
       naming: tpl.naming,
diff --git a/apps/bff/src/logic/layers/loader.ts 
b/apps/bff/src/logic/layers/loader.ts
index 923d22c..f25a6a3 100644
--- a/apps/bff/src/logic/layers/loader.ts
+++ b/apps/bff/src/logic/layers/loader.ts
@@ -117,52 +117,6 @@ export interface LayerHeaderConfig {
   columns?: LayerMetricColumn[];
 }
 
-/** @deprecated alias kept for callers — same shape as LayerHeaderConfig. */
-export type LayerMetricsConfig = LayerHeaderConfig;
-
-/**
- * One Overview-tile metric. Self-contained: carries its own MQE +
- * presentation hints (label / tip / unit / aggregation / scale /
- * precision). The Overview tile no longer cross-references the
- * per-layer header columns.
- *
- * `id` is auto-assigned by the loader (`ov_0`, `ov_1`, …) when the
- * source JSON omits it, so the SPA + BFF always have a stable key to
- * thread requests + results on.
- */
-export interface OverviewMetric {
-  id?: string;
-  label: string;
-  mqe: string;
-  tip?: string;
-  unit?: string;
-  aggregation?: 'sum' | 'avg';
-  scale?: number;
-  precision?: number;
-}
-
-/**
- * One Overview-tile group — a layer can have N groups, each rendered
- * as its own tile in the Overview strip with the group's `title` in
- * the header. `size: square` is the dense-fleet variant and should
- * carry exactly 1 metric.
- */
-export interface OverviewGroup {
-  title: string;
-  size: 'auto' | 'square';
-  metrics: OverviewMetric[];
-}
-
-/**
- * Overview-tile config. `groups` is the canonical shape; legacy
- * `metrics` is migrated to a single auto-size group at load time.
- */
-export interface LayerOverviewConfig {
-  groups?: OverviewGroup[];
-  /** @deprecated — wrapped into a single auto-size group on load. */
-  metrics?: OverviewMetric[];
-}
-
 /**
  * Per-scope dashboards bundled with a layer template. Each scope is an
  * independent widget set; the SPA picks one based on the active route
@@ -214,8 +168,6 @@ export interface LayerTemplate {
   /** @deprecated alias for `header` — populated by the loader so old
    *  callers reading `template.metrics` keep working. */
   metrics: LayerHeaderConfig;
-  /** Overview-tile config. Self-contained metric entries. */
-  overview?: LayerOverviewConfig;
   /** Per-scope widget sets. `service` is the layer's primary landing. */
   dashboards?: LayerDashboards;
   /** Legacy single widget list — treated as `dashboards.service`. */
@@ -272,9 +224,6 @@ export interface LogConfig {
    *     selector is shown alongside.
    *  Matches booster-ui's ConditionTags routing (`EntityType[1|2|3]`). */
   scope?: 'service' | 'instance' | 'endpoint';
-  /** Default tag filters appended to every log query. Operators can
-   *  add more on the page via the conditions bar. */
-  defaultTags?: Array<{ key: string; value: string }>;
 }
 
 /**
@@ -424,7 +373,7 @@ function load(): Map<string, LayerTemplate> {
     // i18n store, not by the layer loader.
     if (isOverlayFilename(file)) continue;
     const raw = readFileSync(join(CONFIG_DIR, file), 'utf-8');
-    let parsed: LayerTemplate & { alias_terms?: LayerSlotsConfig; alias?: 
LayerSlotsConfig | string };
+    let parsed: LayerTemplate;
     try {
       parsed = JSON.parse(raw);
     } catch (err) {
@@ -446,11 +395,6 @@ function load(): Map<string, LayerTemplate> {
     if (!parsed.slots && aliases) {
       parsed.slots = aliases;
     }
-    // Migrate legacy `widgets` (flat array) → `dashboards.service` so
-    // the rest of the codebase only needs to know about the new shape.
-    if (parsed.widgets && (!parsed.dashboards || !parsed.dashboards.service)) {
-      parsed.dashboards = { ...parsed.dashboards, service: parsed.widgets };
-    }
     // Profiling split: the old shape had a single `profiling` component
     // / dashboards bucket; we now split into Trace / eBPF / Async
     // profiling. Older JSONs are migrated by promoting the legacy
@@ -465,19 +409,11 @@ function load(): Map<string, LayerTemplate> {
       }
       delete legacyComponents.profiling;
     }
-    const legacyDashboards = parsed.dashboards as
-      | (LayerDashboards & { profiling?: DashboardWidget[] })
-      | undefined;
-    if (legacyDashboards && legacyDashboards.profiling) {
-      if (!legacyDashboards.traceProfiling) {
-        legacyDashboards.traceProfiling = legacyDashboards.profiling;
-      }
-      delete legacyDashboards.profiling;
-    }
-    // Header block: accept the new `layer-header` JSON key (preferred)
-    // and the legacy `metrics` alias. Internal callers read
-    // `template.header`; we also populate `template.metrics` so older
-    // code paths keep working without churn.
+    // Header block: read the `layer-header` JSON key, falling back to the
+    // legacy top-level `metrics` alias (custom / older templates still ship
+    // it — see the `LayerDef.metrics` doc). Internal callers read
+    // `template.header`; we mirror it back to `template.metrics` so callers
+    // reading the old field name keep working.
     const headerSrc = (parsed as unknown as Record<string, 
unknown>)['layer-header'] as
       | LayerHeaderConfig
       | undefined;
@@ -486,80 +422,6 @@ function load(): Map<string, LayerTemplate> {
     if (!parsed.header) parsed.header = { columns: [] };
     parsed.metrics = parsed.header;
 
-    // Overview tile schema: self-contained `OverviewMetric[]`. Support
-    // two input shapes and normalize to the new one:
-    //   1. `metrics: OverviewMetric[]`   ← new shape, pass through.
-    //   2. `metrics: string[]`           ← previous shape (key refs
-    //      into the header columns); resolve each ref to a full entry.
-    if (parsed.overview) {
-      const ov = parsed.overview as LayerOverviewConfig & {
-        metrics?: unknown;
-      };
-      const columns = parsed.header?.columns ?? [];
-      const findCol = (key: string): LayerMetricColumn | undefined =>
-        columns.find((c) => c.metric === key);
-      const fromRef = (key: string, fallbackLabel?: string): OverviewMetric => 
{
-        const col = findCol(key);
-        // mqe falls back to the bare metric key — the BFF landing
-        // route resolves unknown keys through the metric catalog, so
-        // legacy short keys like `cpm` keep working without an
-        // explicit expression in the JSON.
-        return {
-          id: key,
-          label: col?.label ?? fallbackLabel ?? key,
-          mqe: col?.mqe ?? key,
-          ...(col?.unit ? { unit: col.unit } : {}),
-          ...(col?.aggregation ? { aggregation: col.aggregation } : {}),
-          ...(col?.scale !== undefined ? { scale: col.scale } : {}),
-          ...(col?.precision !== undefined ? { precision: col.precision } : 
{}),
-        };
-      };
-      let resolved: OverviewMetric[] = [];
-      if (Array.isArray(ov.metrics)) {
-        for (const m of ov.metrics as Array<OverviewMetric | string>) {
-          if (typeof m === 'string') {
-            resolved.push(fromRef(m));
-          } else if (m && typeof m === 'object' && 'mqe' in m) {
-            resolved.push(m);
-          } else if (m && typeof m === 'object' && 'label' in m) {
-            // Object without an explicit mqe — treat the label as a
-            // metric-key ref so older JSONs writing { label: "cpm" }
-            // keep working.
-            resolved.push(fromRef((m as { label: string }).label, (m as { 
label: string }).label));
-          }
-        }
-      }
-      // Assign auto-ids to any unkeyed entry. The id is what the SPA
-      // threads through the landing query as the synthetic column key.
-      resolved = resolved.map((m, i) => ({ id: m.id ?? `ov_${i}`, ...m }));
-
-      // Groups migration: if the JSON didn't supply `groups`, wrap the
-      // resolved metric list into a single auto-size group so older
-      // configs still light up. JSON authors writing `groups` directly
-      // win.
-      const ovGroups = (ov as { groups?: OverviewGroup[] }).groups;
-      if (!ovGroups || ovGroups.length === 0) {
-        ov.groups = resolved.length > 0
-          ? [{ title: '', size: 'auto', metrics: resolved }]
-          : [];
-      } else {
-        // For author-supplied groups, also assign ov_* ids to any
-        // unkeyed entries so the SPA has a stable synthetic column key
-        // to query through.
-        let counter = 0;
-        ov.groups = ovGroups.map((g) => ({
-          title: g.title ?? '',
-          size: g.size === 'square' ? 'square' : 'auto',
-          metrics: (g.metrics ?? []).map((m) => ({
-            id: m.id ?? `ov_${counter++}`,
-            ...m,
-          })),
-        }));
-      }
-      // Keep the legacy `metrics` array in sync with the flattened
-      // groups so any caller still reading the old field keeps working.
-      ov.metrics = (ov.groups ?? []).flatMap((g) => g.metrics);
-    }
     out.set(parsed.key.toUpperCase(), parsed);
   }
   return out;
diff --git a/apps/bff/src/logic/overview/loader.ts 
b/apps/bff/src/logic/overview/loader.ts
index 760161d..af13186 100644
--- a/apps/bff/src/logic/overview/loader.ts
+++ b/apps/bff/src/logic/overview/loader.ts
@@ -108,6 +108,17 @@ function parseKpis(raw: unknown): OverviewKpi[] | 
undefined {
   return out.length > 0 ? out : undefined;
 }
 
+/** Page-side ranking override — `{ kpi?: number, mqe?: string }`. Dropped
+ *  when neither is present so absent stays absent. */
+function parseRankBy(raw: unknown): { kpi?: number; mqe?: string } | undefined 
{
+  if (!raw || typeof raw !== 'object') return undefined;
+  const r = raw as Record<string, unknown>;
+  const kpi = typeof r.kpi === 'number' && Number.isInteger(r.kpi) && r.kpi >= 
0 ? r.kpi : undefined;
+  const mqe = isString(r.mqe) ? r.mqe : undefined;
+  if (kpi === undefined && mqe === undefined) return undefined;
+  return { ...(kpi !== undefined ? { kpi } : {}), ...(mqe ? { mqe } : {}) };
+}
+
 function validate(raw: unknown, file: string): OverviewDashboard | null {
   if (!raw || typeof raw !== 'object') {
     console.warn(`overview/${file}: not an object, skipped`);
@@ -153,7 +164,9 @@ function validate(raw: unknown, file: string): 
OverviewDashboard | null {
       cols: typeof w.cols === 'number' ? w.cols : undefined,
       kpis: parseKpis(w.kpis),
       showCount: w.showCount === true ? true : undefined,
+      aggregateOnPage: w.aggregateOnPage === true ? true : undefined,
       limit: typeof w.limit === 'number' ? w.limit : undefined,
+      rankBy: parseRankBy(w.rankBy),
       span: typeof w.span === 'number' ? w.span : undefined,
       rowSpan: typeof w.rowSpan === 'number' ? w.rowSpan : undefined,
     })),
diff --git a/apps/ui/src/api/client.ts b/apps/ui/src/api/client.ts
index 010a2a8..399d27c 100644
--- a/apps/ui/src/api/client.ts
+++ b/apps/ui/src/api/client.ts
@@ -37,7 +37,6 @@ import type {
   DashboardWidget,
   DslDebuggingStatus,
   EndpointDependencyConfig,
-  LayerOverviewConfig,
   LocalState,
   MetricRow,
   ProcessTopologyConfig,
@@ -333,10 +332,6 @@ export interface AdminLayerTemplate {
       precision?: number;
     }>;
   };
-  /** Overview-tile config (group list). Edited on the Overview-templates
-   *  admin, not here — surfaced for the translation preview + so a
-   *  round-trip save preserves it. */
-  overview?: LayerOverviewConfig;
   widgets: DashboardWidget[];
   /** Per-scope widget sets (keyed by AdminScope); `widgets` is the legacy
    *  service-scope fallback. */
diff --git a/apps/ui/src/api/scopes/layer.test.ts 
b/apps/ui/src/api/scopes/layer.test.ts
index 80f3f18..84d8e9e 100644
--- a/apps/ui/src/api/scopes/layer.test.ts
+++ b/apps/ui/src/api/scopes/layer.test.ts
@@ -47,7 +47,6 @@ describe('LayerApi.landing', () => {
         topN: 5,
         orderBy: 'cpm',
         columns: [{ metric: 'cpm', label: 'CPM', mqe: 'service_cpm', 
aggregation: 'sum' }],
-        style: 'table',
       },
       { step: 'MINUTE', startMs: 1, endMs: 2 },
     );
@@ -71,7 +70,6 @@ describe('LayerApi.landing', () => {
       topN: 5,
       orderBy: 'cpm',
       columns: [],
-      style: 'table',
     });
     const body = calls[0][2] as Record<string, unknown>;
     expect(body.step).toBeUndefined();
@@ -86,7 +84,6 @@ describe('LayerApi.landing', () => {
       topN: 5,
       orderBy: 'cpm',
       columns: [],
-      style: 'table',
     });
     expect(calls[0][1]).toBe('/api/layer/aws_eks/landing');
   });
diff --git 
a/apps/ui/src/features/admin/overview-templates/NewOverviewDashboardModal.vue 
b/apps/ui/src/features/admin/overview-templates/NewOverviewDashboardModal.vue
index 731b167..0e8a0b3 100644
--- 
a/apps/ui/src/features/admin/overview-templates/NewOverviewDashboardModal.vue
+++ 
b/apps/ui/src/features/admin/overview-templates/NewOverviewDashboardModal.vue
@@ -24,6 +24,7 @@
 <script setup lang="ts">
 import { ref, watch } from 'vue';
 import Modal from '@/features/operate/_shared/Modal.vue';
+import { vAutosize } from '@/utils/autosize';
 
 const props = defineProps<{
   open: boolean;
@@ -71,7 +72,7 @@ function submit(): void {
       </label>
       <label class="nod__field">
         <span>Description (optional)</span>
-        <textarea v-model="description" class="nod__in nod__in--ta" rows="2" 
placeholder="Short, one-paragraph description shown under the dashboard title." 
/>
+        <textarea v-autosize="description" v-model="description" 
class="nod__in nod__in--ta" rows="2" placeholder="Short, one-paragraph 
description shown under the dashboard title." />
       </label>
       <div v-if="error" class="nod__err">{{ error }}</div>
     </div>
diff --git 
a/apps/ui/src/features/admin/overview-templates/OverviewWidgetDrawer.vue 
b/apps/ui/src/features/admin/overview-templates/OverviewWidgetDrawer.vue
index ebc2e6d..d7bc411 100644
--- a/apps/ui/src/features/admin/overview-templates/OverviewWidgetDrawer.vue
+++ b/apps/ui/src/features/admin/overview-templates/OverviewWidgetDrawer.vue
@@ -27,9 +27,13 @@
   description) instead of a real one.
 -->
 <script setup lang="ts">
+import { computed } from 'vue';
 import type { OverviewDashboard, OverviewKpi, OverviewWidget } from 
'@skywalking-horizon-ui/api-client';
 import MqeExpressionInput from 
'@/features/admin/_shared/MqeExpressionInput.vue';
+import WidgetTip from '@/components/primitives/WidgetTip.vue';
+import { vAutosize } from '@/utils/autosize';
 import { useEscapeToClose } from '@/components/primitives/useEscapeToClose';
+import { useOapInfo } from '@/shell/useOapInfo';
 import { META_SEL } from './constants';
 
 defineProps<{
@@ -49,6 +53,58 @@ const emit = defineEmits<{
 
 useEscapeToClose(() => true, () => emit('close'));
 
+// `{{topn}}` is the only MQE variable — the layer-wide top-N window the BFF
+// substitutes at query time. Surface its live value so authors know what the
+// raw placeholder means (and that it IS intentional, not a typo).
+const { overviewTopN } = useOapInfo();
+const topnHint = computed(
+  () =>
+    `{{topn}} → the layer-wide top-N window, replaced at query time with ` +
+    `HORIZON_QUERY_OVERVIEW_TOPN (currently ${overviewTopN.value}). ` +
+    `Use in a self-aggregating MQE, e.g. 
sum(top_n(service_cpm,{{topn}},DES,attr0='GENERAL')).`,
+);
+const aggOnPageHint =
+  'On: Horizon queries the metric per service and sums/averages the top-N of 
them here — for ' +
+  "metrics a server-side top_n() can't wrap (cluster / meter series, 
latest(...), ratios). " +
+  "Off (default): the KPI's own MQE self-aggregates the whole layer 
server-side (one top_n(...) expression).";
+const topNServicesHint =
+  'How many of the layer’s services feed the page-side aggregate, 
highest-ranked first (see “Rank by”). ' +
+  'A single-entity layer (one cluster / one control plane) is unaffected; 
raise it for a multi-instance layer.';
+const rankByHint =
+  'Which metric ranks the top-N services (the BFF sorts the per-service rows, 
highest first). ' +
+  'Pick a KPI — the first one is the default; use a REGULAR_VALUE metric, a 
LABELED_VALUE ranks poorly — ' +
+  'or “a separate metric” for a ranking expression not shown as a KPI. Only 
matters when the layer has >1 service.';
+
+function setAggMode(w: OverviewWidget, pageSide: boolean): void {
+  w.aggregateOnPage = pageSide;
+  if (!pageSide) w.rankBy = undefined; // ranking only applies to the 
page-side path
+}
+function firstMqeKpi(w: OverviewWidget): number {
+  const i = (w.kpis ?? []).findIndex((k) => (k.source ?? 'mqe') === 'mqe');
+  return i < 0 ? 0 : i;
+}
+/** Current "Rank by" selection: a KPI index (string) or the `__mqe` escape. */
+function rankSel(w: OverviewWidget): string {
+  if (w.rankBy?.mqe != null) return '__mqe';
+  return String(w.rankBy?.kpi ?? firstMqeKpi(w));
+}
+function setRankSel(w: OverviewWidget, v: string): void {
+  if (v === '__mqe') {
+    w.rankBy = { mqe: '' };
+    return;
+  }
+  const idx = Number(v);
+  // Absent rankBy === rank by the first KPI, so only store an explicit index
+  // when it isn't the default — keeps the template clean.
+  w.rankBy = idx === firstMqeKpi(w) ? undefined : { kpi: idx };
+}
+function rankMqe(w: OverviewWidget): string {
+  return w.rankBy?.mqe ?? '';
+}
+function setRankMqe(w: OverviewWidget, v: string): void {
+  w.rankBy = { mqe: v };
+}
+
 function widgetKindLabel(type: OverviewWidget['type']): string {
   switch (type) {
     case 'metric-composite': return 'Composite metrics';
@@ -110,6 +166,7 @@ function onKpiStyleChange(k: OverviewKpi): void {
       <label class="ot__field ot__field--wide">
         <span>Description</span>
         <textarea
+          v-autosize="draft.description"
           :value="draft.description"
           class="ot__in ot__in--ta"
           rows="3"
@@ -197,7 +254,7 @@ function onKpiStyleChange(k: OverviewKpi): void {
 
       <div v-if="w.type === 'metric'" class="ot__row">
         <label class="ot__field ot__field--wide">
-          <span>MQE</span>
+          <span>MQE <WidgetTip :tip="topnHint" /></span>
           <MqeExpressionInput v-model="w.mqe" placeholder="service_cpm" 
title="Widget MQE" />
         </label>
         <label class="ot__field">
@@ -223,10 +280,49 @@ function onKpiStyleChange(k: OverviewKpi): void {
 
       <template v-if="w.type === 'kpi-tile' || w.type === 'metric-composite'">
         <div v-if="w.type === 'kpi-tile'" class="ot__row">
-          <label class="ot__field">
-            <span>Show service count</span>
+          <label class="ot__field ot__field--check">
             <input type="checkbox" v-model="w.showCount" />
+            <span>Show service count</span>
+          </label>
+        </div>
+
+        <!-- Aggregation method. Server-side (default): each KPI's MQE
+             self-aggregates the layer via top_n(). Page-side: fan out per
+             service + roll up the top-N (for series top_n can't wrap —
+             cluster/meter metrics, latest(...), ratios). -->
+        <div class="ot__agg">
+          <div class="ot__agg-head">Aggregation <WidgetTip 
:tip="aggOnPageHint" /></div>
+          <label class="ot__agg-opt">
+            <input type="radio" :name="`agg-${w.id}`" 
:checked="!w.aggregateOnPage" @change="setAggMode(w, false)" />
+            <span><strong>Server-side</strong> — each KPI's MQE rolls up the 
layer itself (<code>top_n</code>)</span>
           </label>
+          <label class="ot__agg-opt">
+            <input type="radio" :name="`agg-${w.id}`" 
:checked="!!w.aggregateOnPage" @change="setAggMode(w, true)" />
+            <span><strong>Page-side</strong> — rank the layer's services here, 
aggregate the top-N</span>
+          </label>
+          <div v-if="w.aggregateOnPage" class="ot__agg-params">
+            <label class="ot__field">
+              <span>Top-N services <WidgetTip :tip="topNServicesHint" /></span>
+              <input v-model.number="w.limit" type="number" min="1" max="8" 
class="ot__in ot__in--num" />
+            </label>
+            <label class="ot__field ot__field--wide">
+              <span>Rank by <WidgetTip :tip="rankByHint" /></span>
+              <select
+                :value="rankSel(w)"
+                class="ot__in"
+                @change="setRankSel(w, ($event.target as 
HTMLSelectElement).value)"
+              >
+                <template v-for="(k, i) in (w.kpis ?? [])" :key="i">
+                  <option v-if="(k.source ?? 'mqe') === 'mqe'" 
:value="String(i)">{{ i }} · {{ k.label }}</option>
+                </template>
+                <option value="__mqe">A separate metric…</option>
+              </select>
+            </label>
+            <label v-if="w.rankBy?.mqe != null" class="ot__field 
ot__field--wide">
+              <span>Rank MQE</span>
+              <MqeExpressionInput :model-value="rankMqe(w)" title="Ranking 
MQE" @update:model-value="setRankMqe(w, $event)" />
+            </label>
+          </div>
         </div>
         <div class="ot__kpis">
           <div class="ot__kpis-head">
@@ -267,14 +363,17 @@ function onKpiStyleChange(k: OverviewKpi): void {
               </label>
               <label class="ot__field ot__field--full">
                 <span>Source</span>
-                <select v-model="k.source" class="ot__in">
-                  <option :value="undefined">mqe</option>
+                <select
+                  :value="k.source ?? 'mqe'"
+                  class="ot__in"
+                  @change="k.source = ($event.target as 
HTMLSelectElement).value as OverviewKpi['source']"
+                >
                   <option value="mqe">mqe</option>
                   <option value="service-count">service-count</option>
                 </select>
               </label>
               <label v-if="(k.source ?? 'mqe') === 'mqe'" class="ot__field 
ot__field--full">
-                <span>MQE</span>
+                <span>MQE <WidgetTip :tip="topnHint" /></span>
                 <MqeExpressionInput v-model="k.mqe" title="KPI MQE" />
               </label>
               <p v-else class="ot__none">Value comes from the service count 
(listServices) — no MQE.</p>
@@ -392,6 +491,16 @@ function onKpiStyleChange(k: OverviewKpi): void {
   font-size: 12px;
 }
 .ot__field input[type='checkbox'] { width: 14px; height: 14px; margin: 4px 0 
0; cursor: pointer; }
+/* Checkbox + label on one line (checkbox first, then the words). */
+.ot__field--check { flex-direction: row; align-items: center; gap: 7px; 
min-width: 0; cursor: pointer; }
+.ot__field--check input[type='checkbox'] { margin: 0; }
+/* Aggregation method block: mode radios + nested page-side params. */
+.ot__agg { margin-top: 8px; padding: 8px 10px; border: 1px solid 
var(--sw-line); border-radius: 6px; }
+.ot__agg-head { font-size: 9.5px; text-transform: uppercase; letter-spacing: 
0.08em; color: var(--sw-fg-3); font-weight: 600; margin-bottom: 6px; }
+.ot__agg-opt { display: flex; align-items: flex-start; gap: 7px; padding: 3px 
0; font-size: 11.5px; color: var(--sw-fg-1); cursor: pointer; }
+.ot__agg-opt input[type='radio'] { margin: 2px 0 0; cursor: pointer; flex: 0 0 
auto; }
+.ot__agg-opt code { font-family: var(--sw-mono); font-size: 10.5px; color: 
var(--sw-fg-2); }
+.ot__agg-params { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 6px; 
padding-top: 8px; border-top: 1px solid var(--sw-line); }
 .ot__none { color: var(--sw-fg-3); font-size: 11px; }
 
 .ot__meta {
diff --git a/apps/ui/src/layer/LayerShell.vue b/apps/ui/src/layer/LayerShell.vue
index 12bb523..d312ae9 100644
--- a/apps/ui/src/layer/LayerShell.vue
+++ b/apps/ui/src/layer/LayerShell.vue
@@ -212,7 +212,7 @@ watch(
 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, overview: layer.value.overview 
});
+  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.
@@ -231,7 +231,6 @@ const safeCfg = computed(() => cfg.value?.landing ?? {
   topN: 5,
   orderBy: 'cpm',
   columns: [],
-  style: 'table' as const,
 });
 // Header KPIs honor the topbar time picker so they line up with what
 // the dashboard widgets below are showing — without this, the header
@@ -452,11 +451,7 @@ const layerKpis = computed<HeaderKpi[]>(() => {
   if (!c) return [];
   const a = aggregates.value;
   const out: HeaderKpi[] = [];
-  // Prefer the operator-defined header set (`headerColumns`) over the
-  // combined `columns` array — the combined set carries overview
-  // promotions that aren't real service-level KPIs. Falls back to
-  // `columns` for legacy persisted configs that pre-date this field.
-  const headerCols = c.landing.headerColumns ?? c.landing.columns;
+  const headerCols = c.landing.columns;
   for (const col of headerCols.slice(0, 5)) {
     const m = metricMeta(col.metric);
     out.push({
@@ -487,7 +482,7 @@ const serviceKpis = computed<HeaderKpi[]>(() => {
   const row = selectedRow.value;
   if (!row) return [];
   const out: HeaderKpi[] = [];
-  const headerCols = c.landing.headerColumns ?? c.landing.columns;
+  const headerCols = c.landing.columns;
   for (const col of headerCols.slice(0, 5)) {
     const m = metricMeta(col.metric);
     out.push({
diff --git a/apps/ui/src/layer/browser-errors/LayerBrowserErrorsView.vue 
b/apps/ui/src/layer/browser-errors/LayerBrowserErrorsView.vue
index 47fb2c9..395ee29 100644
--- a/apps/ui/src/layer/browser-errors/LayerBrowserErrorsView.vue
+++ b/apps/ui/src/layer/browser-errors/LayerBrowserErrorsView.vue
@@ -75,12 +75,11 @@ const safeLayer = computed<LayerDef>(
     },
 );
 const safeCfg = computed(() => {
-  if (!layer.value) return { priority: 99, topN: 5, orderBy: 'cpm', columns: 
[], style: 'table' as const };
+  if (!layer.value) return { priority: 99, topN: 5, orderBy: 'cpm', columns: 
[] };
   return setup.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);
diff --git 
a/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue 
b/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue
index f6679b8..818cf0a 100644
--- a/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue
+++ b/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue
@@ -100,9 +100,9 @@ const safeLayer = computed<LayerDef>(() => layer.value ?? {
   serviceCount: -1, active: false, level: null, slots: {}, caps: {},
 });
 const safeCfg = computed(() => {
-  if (!layer.value) return { priority: 99, topN: 5, orderBy: 'cpm', columns: 
[], style: 'table' as const };
+  if (!layer.value) return { priority: 99, topN: 5, orderBy: 'cpm', columns: 
[] };
   return store.ensure(layer.value.key, {
-    slots: layer.value.slots, caps: layer.value.caps, metrics: 
layer.value.metrics, overview: layer.value.overview,
+    slots: layer.value.slots, caps: layer.value.caps, metrics: 
layer.value.metrics,
   }).landing;
 });
 // Layer-aware identity resolver — mirrors the topology view. Endpoint
@@ -234,7 +234,6 @@ const setupCfg = computed(() => 
store.ensure(layerKey.value, {
   slots: safeLayer.value.slots,
   caps: safeLayer.value.caps,
   metrics: safeLayer.value.metrics,
-  overview: safeLayer.value.overview,
 }));
 function mergeThresholdOverride(def: TopologyMetricDef): TopologyMetricDef {
   const ov = 
setupCfg.value.landing.thresholdOverrides?.[`dependency.${def.id}`];
diff --git a/apps/ui/src/layer/logs/LayerLogsView.vue 
b/apps/ui/src/layer/logs/LayerLogsView.vue
index 49b1488..63a52d2 100644
--- a/apps/ui/src/layer/logs/LayerLogsView.vue
+++ b/apps/ui/src/layer/logs/LayerLogsView.vue
@@ -73,9 +73,9 @@ const safeLayer = computed<LayerDef>(() => layer.value ?? {
   serviceCount: -1, active: false, level: null, slots: {}, caps: {},
 });
 const safeCfg = computed(() => {
-  if (!layer.value) return { priority: 99, topN: 5, orderBy: 'cpm', columns: 
[], style: 'table' as const };
+  if (!layer.value) return { priority: 99, topN: 5, orderBy: 'cpm', columns: 
[] };
   return store.ensure(layer.value.key, {
-    slots: layer.value.slots, caps: layer.value.caps, metrics: 
layer.value.metrics, overview: layer.value.overview,
+    slots: layer.value.slots, caps: layer.value.caps, metrics: 
layer.value.metrics,
   }).landing;
 });
 const landing = useLayerLanding(safeLayer, safeCfg);
diff --git a/apps/ui/src/layer/profiling/LayerEBPFProfilingView.vue 
b/apps/ui/src/layer/profiling/LayerEBPFProfilingView.vue
index 23acec7..83ba1e4 100644
--- a/apps/ui/src/layer/profiling/LayerEBPFProfilingView.vue
+++ b/apps/ui/src/layer/profiling/LayerEBPFProfilingView.vue
@@ -75,12 +75,11 @@ const safeLayer = computed<LayerDef>(
 const setup = useSetupStore();
 const safeCfg = computed(() => {
   if (!layer.value)
-    return { priority: 99, topN: 5, orderBy: 'cpm', columns: [], style: 
'table' as const };
+    return { priority: 99, topN: 5, orderBy: 'cpm', columns: [] };
   return setup.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);
diff --git a/apps/ui/src/layer/profiling/LayerTraceProfilingView.vue 
b/apps/ui/src/layer/profiling/LayerTraceProfilingView.vue
index 423f2d8..5e03398 100644
--- a/apps/ui/src/layer/profiling/LayerTraceProfilingView.vue
+++ b/apps/ui/src/layer/profiling/LayerTraceProfilingView.vue
@@ -87,12 +87,11 @@ const safeLayer = computed<LayerDef>(
 const store = useSetupStore();
 const safeCfg = computed(() => {
   if (!layer.value)
-    return { priority: 99, topN: 5, orderBy: 'cpm', columns: [], style: 
'table' as const };
+    return { priority: 99, topN: 5, orderBy: 'cpm', columns: [] };
   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);
diff --git a/apps/ui/src/layer/service-map/LayerServiceMapView.vue 
b/apps/ui/src/layer/service-map/LayerServiceMapView.vue
index 0c829b1..24ddd41 100644
--- a/apps/ui/src/layer/service-map/LayerServiceMapView.vue
+++ b/apps/ui/src/layer/service-map/LayerServiceMapView.vue
@@ -140,9 +140,9 @@ function identity(name: string | null | undefined): 
ServiceIdentity {
   return resolveServiceIdentity(name, namingRule.value);
 }
 const safeCfg = computed(() => {
-  if (!layer.value) return { priority: 99, topN: 5, orderBy: 'cpm', columns: 
[], style: 'table' as const };
+  if (!layer.value) return { priority: 99, topN: 5, orderBy: 'cpm', columns: 
[] };
   return store.ensure(layer.value.key, {
-    slots: layer.value.slots, caps: layer.value.caps, metrics: 
layer.value.metrics, overview: layer.value.overview,
+    slots: layer.value.slots, caps: layer.value.caps, metrics: 
layer.value.metrics,
   }).landing;
 });
 const landing = useLayerLanding(safeLayer, safeCfg);
@@ -250,7 +250,6 @@ const setupCfg = computed(() => 
store.ensure(layerKey.value, {
   slots: safeLayer.value.slots,
   caps: safeLayer.value.caps,
   metrics: safeLayer.value.metrics,
-  overview: safeLayer.value.overview,
 }));
 function mergeThresholdOverride(def: TopologyMetricDef, scope: 'topology' | 
'dependency'): TopologyMetricDef {
   const ov = setupCfg.value.landing.thresholdOverrides?.[`${scope}.${def.id}`];
diff --git a/apps/ui/src/layer/traces/LayerTracesView.vue 
b/apps/ui/src/layer/traces/LayerTracesView.vue
index f66779b..6d3f409 100644
--- a/apps/ui/src/layer/traces/LayerTracesView.vue
+++ b/apps/ui/src/layer/traces/LayerTracesView.vue
@@ -85,9 +85,9 @@ const safeLayer = computed<LayerDef>(() => layer.value ?? {
   serviceCount: -1, active: false, level: null, slots: {}, caps: {},
 });
 const safeCfg = computed(() => {
-  if (!layer.value) return { priority: 99, topN: 5, orderBy: 'cpm', columns: 
[], style: 'table' as const };
+  if (!layer.value) return { priority: 99, topN: 5, orderBy: 'cpm', columns: 
[] };
   return store.ensure(layer.value.key, {
-    slots: layer.value.slots, caps: layer.value.caps, metrics: 
layer.value.metrics, overview: layer.value.overview,
+    slots: layer.value.slots, caps: layer.value.caps, metrics: 
layer.value.metrics,
   }).landing;
 });
 const landing = useLayerLanding(safeLayer, safeCfg);
diff --git a/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue 
b/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue
index 49c19cb..6e46af0 100644
--- a/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue
+++ b/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue
@@ -99,8 +99,8 @@ function cardText(w: { id: string; format?: MetricFormat; 
valueMap?: Record<stri
   return fmtMetricAs(v, w.format);
 }
 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, overview: layer.value.overview 
}).landing;
+  if (!layer.value) return { priority: 99, topN: 5, orderBy: 'cpm', columns: 
[] };
+  return store.ensure(layer.value.key, { slots: layer.value.slots, caps: 
layer.value.caps, metrics: layer.value.metrics }).landing;
 });
 // Global time-range — picker change refires the landing rollup
 // AND the widget batch via queryKey. Each downstream control
diff --git a/apps/ui/src/render/overview/useOverviewDashboard.ts 
b/apps/ui/src/render/overview/useOverviewDashboard.ts
index c344ba0..98d7641 100644
--- a/apps/ui/src/render/overview/useOverviewDashboard.ts
+++ b/apps/ui/src/render/overview/useOverviewDashboard.ts
@@ -45,6 +45,11 @@ interface MqeRequest {
   mqe: string;
   aggregation: 'sum' | 'avg';
   unit?: string;
+  /** When true, the `mqe` self-aggregates the layer server-side and the
+   *  BFF fires it once (no per-service fan-out). Derived from the widget's
+   *  `aggregateOnPage`: self-aggregating unless the widget opts into
+   *  page-side aggregation. */
+  selfAggregate: boolean;
   /** When set, the widget+kpi expects the layer's service count (from
    *  the landing aggregate's `serviceCount`) instead of an MQE
    *  result. The `mqe` field is filled with a placeholder so the
@@ -53,29 +58,65 @@ interface MqeRequest {
   isServiceCount?: boolean;
 }
 
+/** One landing call's worth of requests. */
+interface LayerGroup {
+  layer: string;
+  /** Page-side top-N window (the `topN` sent to the landing route). Self-
+   *  aggregating groups ignore it (their columns fire once). */
+  limit: number;
+  reqs: MqeRequest[];
+  /** Page-side ranking basis. `column` = sort by an existing column index;
+   *  `mqe` = a standalone ranking metric (probed per service, sorted on,
+   *  NOT displayed). Absent → rank by the first column. */
+  rank?: { column?: number; mqe?: string };
+}
+
+/** Resolve a page-side widget's `rankBy` to a landing-column reference. An
+ *  `mqe` passes straight through; a KPI index is mapped to its position
+ *  among the widget's MQE (non-service-count) KPIs — a `service-count` KPI
+ *  can't be ranked by, so that falls back to the first column. */
+function resolveRank(w: OverviewWidget): LayerGroup['rank'] {
+  const rb = w.rankBy;
+  if (!rb) return undefined;
+  if (rb.mqe) return { mqe: rb.mqe };
+  if (rb.kpi != null && w.kpis) {
+    const target = w.kpis[rb.kpi];
+    if (target && (target.source ?? 'mqe') === 'mqe') {
+      const column = w.kpis.slice(0, rb.kpi).filter((k) => (k.source ?? 'mqe') 
=== 'mqe').length;
+      return { column };
+    }
+  }
+  return undefined;
+}
+
 /**
- * Group every data-bound widget by its `layer`. Section-breaks, alarms,
- * and topology widgets are skipped (no aggregate to evaluate).
+ * Group data-bound widgets into landing calls. Self-aggregating columns
+ * batch by layer (they fire once and ignore topN/ranking). Each page-side
+ * (`aggregateOnPage`) widget gets its OWN call — it carries its own `limit`
+ * AND ranking, which a shared per-layer call couldn't honour. Section-
+ * breaks, alarms, and topology widgets are skipped (no aggregate).
  */
-function groupByLayer(widgets: OverviewWidget[]): Map<string, MqeRequest[]> {
-  const out = new Map<string, MqeRequest[]>();
+function groupRequests(widgets: OverviewWidget[]): LayerGroup[] {
+  const selfByLayer = new Map<string, LayerGroup>();
+  const pageGroups: LayerGroup[] = [];
   for (const w of widgets) {
     const layer = w.layer;
     if (!layer) continue;
     if (w.type === 'section-break' || w.type === 'alarms' || w.type === 
'topology') continue;
+    // Self-aggregating unless the widget opts into page-side (fan-out)
+    // aggregation. Service-count rows never fire an MQE, so the flag is
+    // moot for them.
+    const selfAggregate = !(w.aggregateOnPage ?? false);
+    const reqs: MqeRequest[] = [];
     if (w.type === 'metric' && w.mqe) {
-      const reqs = out.get(layer) ?? [];
       reqs.push({
         widgetId: w.id,
         mqe: w.mqe,
         aggregation: w.aggregation ?? 'avg',
         unit: w.unit,
+        selfAggregate,
       });
-      out.set(layer, reqs);
-      continue;
-    }
-    if ((w.type === 'kpi-tile' || w.type === 'metric-composite') && w.kpis) {
-      const reqs = out.get(layer) ?? [];
+    } else if ((w.type === 'kpi-tile' || w.type === 'metric-composite') && 
w.kpis) {
       for (const k of w.kpis) {
         const isCount = k.source === 'service-count';
         reqs.push({
@@ -85,12 +126,20 @@ function groupByLayer(widgets: OverviewWidget[]): 
Map<string, MqeRequest[]> {
           aggregation: k.aggregation ?? 'avg',
           unit: k.unit,
           isServiceCount: isCount,
+          selfAggregate: selfAggregate && !isCount,
         });
       }
-      out.set(layer, reqs);
+    }
+    if (reqs.length === 0) continue;
+    if (w.aggregateOnPage) {
+      pageGroups.push({ layer, limit: Math.min(8, Math.max(1, w.limit ?? 1)), 
reqs, rank: resolveRank(w) });
+    } else {
+      const g = selfByLayer.get(layer) ?? { layer, limit: 1, reqs: [] };
+      g.reqs.push(...reqs);
+      selfByLayer.set(layer, g);
     }
   }
-  return out;
+  return [...selfByLayer.values(), ...pageGroups];
 }
 
 /**
@@ -121,7 +170,7 @@ export function useOverviewDashboard(idRef: Ref<string>) {
   );
 
   const widgets = computed<OverviewWidget[]>(() => dashboard.value?.widgets ?? 
[]);
-  const layerRequests = computed(() => groupByLayer(widgets.value));
+  const layerGroups = computed(() => groupRequests(widgets.value));
 
   // The topbar time picker is part of every overview query so flipping
   // the time / cold pills refires the per-layer landing calls instead
@@ -135,43 +184,49 @@ export function useOverviewDashboard(idRef: Ref<string>) {
     endMs: timeRange.range.endMs,
   }));
 
-  // One landing call per referenced layer. Bundling all that layer's
-  // MQEs in one request keeps the round-trip count to N, where N is the
-  // distinct layer count in the dashboard — usually 1–3.
+  // One landing call per (layer, page-limit) group — usually 1–3 total.
   const layerQueries = useQueries({
     queries: computed(() => {
-      const entries = Array.from(layerRequests.value.entries());
       const range = rangeKey.value;
-      return entries.map(([layer, reqs]) => ({
-        // Include the MQE column set (`reqs`), not just the overview id:
-        // a remote sync or preview edit that keeps the id but changes a
-        // widget's MQE must refire, or the cache serves stale data.
-        queryKey: ['overview-dashboard-data', idRef.value, layer, range, 
JSON.stringify(reqs)],
+      return layerGroups.value.map((g) => ({
+        // Key on the MQE column set (`reqs`) + the group's limit, not just
+        // the overview id: a remote sync or preview edit that keeps the id
+        // but changes a widget's MQE / limit must refire.
+        queryKey: ['overview-dashboard-data', idRef.value, g.layer, g.limit, 
JSON.stringify(g.rank ?? null), range, JSON.stringify(g.reqs)],
         queryFn: () => {
           /* Service-count KPIs read from `aggregates.serviceCount`
            * — strip them from the MQE column list to avoid sending
            * a synthetic MQE upstream. They still ride in `reqs` so
            * the value-pickup pass below can inject the count. */
-          const mqeReqs = reqs.filter((r) => !r.isServiceCount);
-          // priority + style are required by the LandingConfig type
-          // but ignored by the BFF route — the client only forwards
-          // topN/orderBy/columns. Stubbed to satisfy the type.
-          const cfg: LandingConfig = {
-            priority: 0,
-            style: 'table',
-            topN: 1,
-            orderBy: mqeReqs[0]?.mqe ?? 'service_cpm',
-            columns: mqeReqs.map((r, i) => ({
-              metric: `w_${i}`,
-              label: r.kpiLabel ?? r.widgetId,
-              mqe: r.mqe,
-              aggregation: r.aggregation,
-              unit: r.unit,
-            })),
-          };
-          return bffClient.layer.landing(layer, cfg, range).then((res) => ({
-            layer,
-            reqs,
+          const mqeReqs = g.reqs.filter((r) => !r.isServiceCount);
+          // Columns are keyed by COLUMN id (`w_<idx>`), not the raw MQE — the
+          // BFF stores per-row metrics under the column key.
+          const columns: LandingConfig['columns'] = mqeReqs.map((r, i) => ({
+            metric: `w_${i}`,
+            label: r.kpiLabel ?? r.widgetId,
+            mqe: r.mqe,
+            aggregation: r.aggregation,
+            unit: r.unit,
+            selfAggregate: r.selfAggregate,
+          }));
+          // Ranking basis for the page-side top-N slice (default: first
+          // column). `rank.column` sorts by an existing KPI column; `rank.mqe`
+          // appends a ranking-ONLY column — fan-out probed + sorted on, but
+          // never read back (the value pickup only maps w_0..w_{n-1}).
+          let orderBy = 'w_0';
+          if (g.rank?.mqe) {
+            const rankKey = `w_${mqeReqs.length}`;
+            columns.push({ metric: rankKey, label: '__rank', mqe: g.rank.mqe, 
aggregation: 'sum', selfAggregate: false });
+            orderBy = rankKey;
+          } else if (g.rank?.column != null) {
+            orderBy = `w_${g.rank.column}`;
+          }
+          // priority is required by the LandingConfig type but ignored by
+          // the BFF route (it forwards only topN/orderBy/columns).
+          const cfg: LandingConfig = { priority: 0, topN: g.limit, orderBy, 
columns };
+          return bffClient.layer.landing(g.layer, cfg, range).then((res) => ({
+            layer: g.layer,
+            reqs: g.reqs,
             mqeReqs,
             aggregates: res.aggregates,
           }));
diff --git a/apps/ui/src/shell/layerFromTemplate.ts 
b/apps/ui/src/shell/layerFromTemplate.ts
index 08e347f..12738f2 100644
--- a/apps/ui/src/shell/layerFromTemplate.ts
+++ b/apps/ui/src/shell/layerFromTemplate.ts
@@ -36,7 +36,6 @@ export interface LayerTemplateContent {
   components?: Record<string, boolean | undefined>;
   slots?: LayerDef['slots'];
   metrics?: LayerDef['metrics'];
-  overview?: LayerDef['overview'];
   naming?: LayerDef['naming'];
   traces?: LayerDef['traces'];
   /** Only the `instanceTopology` presence is read here, to gate the
@@ -93,9 +92,7 @@ export function layerContentToDef(t: LayerTemplateContent): 
LayerDef {
     documentLink: t.documentLink,
     slots: t.slots ?? {},
     caps: componentsToCaps(t.components, t.topology, t.deployment),
-    header: t.metrics,
     metrics: t.metrics,
-    overview: t.overview,
     naming: t.naming,
     traces: t.traces,
   };
diff --git a/apps/ui/src/shell/useLandingOrder.ts 
b/apps/ui/src/shell/useLandingOrder.ts
index e8516b1..b893650 100644
--- a/apps/ui/src/shell/useLandingOrder.ts
+++ b/apps/ui/src/shell/useLandingOrder.ts
@@ -29,8 +29,8 @@ import { useSetupStore } from '@/state/setup';
  * section (row order) so the two stay in lockstep.
  *
  * IMPORTANT: read priority via `store.priorityFor()` (side-effect-free),
- * never `store.ensure()` — `ensure` writes `configs.<layer>.landing.
- * headerColumns`, and inside a computed that READS the store those writes
+ * never `store.ensure()` — `ensure` writes `configs.<layer>.landing`,
+ * and inside a computed that READS the store those writes
  * invalidate the same computed → "Maximum recursive updates exceeded in
  * component <AppSidebar>", freezing the page on any layer route. Setup
  * reconciliation runs on the admin pages that explicitly call `ensure`.
diff --git a/apps/ui/src/shell/useOapInfo.ts b/apps/ui/src/shell/useOapInfo.ts
index b23a27a..8736531 100644
--- a/apps/ui/src/shell/useOapInfo.ts
+++ b/apps/ui/src/shell/useOapInfo.ts
@@ -94,6 +94,11 @@ export function useOapInfo() {
    *  bootstrap rather than flashing on then disappearing. */
   const backend = computed<OapBackend>(() => info.value?.backend ?? 'unknown');
 
+  /** Resolved `query.overviewTopN` — the value the BFF substitutes for the
+   *  `{{topn}}` placeholder in Overview KPI MQE. Falls back to 100 (the
+   *  config default) until the info call lands. */
+  const overviewTopN = computed<number>(() => info.value?.overviewTopN ?? 100);
+
   /** Health status pill colour: ok / warn / err / unknown. */
   const healthState = computed<'ok' | 'warn' | 'err' | 'unknown'>(() => {
     if (!reachable.value) return 'err';
@@ -114,6 +119,7 @@ export function useOapInfo() {
     healthState,
     capabilities,
     backend,
+    overviewTopN,
     toServerTzString,
     refetch: q.refetch,
   };
diff --git a/apps/ui/src/state/setup.ts b/apps/ui/src/state/setup.ts
index 46b41f9..a8256e3 100644
--- a/apps/ui/src/state/setup.ts
+++ b/apps/ui/src/state/setup.ts
@@ -23,7 +23,6 @@ import type {
   LayerCaps,
   LayerConfig,
   LayerMetricsConfig,
-  LayerOverviewConfig,
   LayerSlots,
 } from '@skywalking-horizon-ui/api-client';
 import {
@@ -80,7 +79,6 @@ function defaultAggregationFor(metricKey: string): 
AggregationKind {
 export function defaultLandingFor(
   layerKey: string,
   fromHeader?: LayerMetricsConfig,
-  fromOverview?: LayerOverviewConfig,
 ): LandingConfig {
   // ---- Per-layer page header (service-list columns + default sort).
   //      Drives the per-layer Service page's picker table. Overview is
@@ -117,74 +115,11 @@ export function defaultLandingFor(
     headerCols[0]?.metric ??
     defaultOrderByForLayer(layerKey);
 
-  // ---- Overview tile groups. Each group becomes one tile on the
-  //      Overview strip with its own title + size. Metrics inside a
-  //      group are still self-contained (mqe / label / aggregation),
-  //      promoted into synthetic landing columns so the BFF batches
-  //      every Overview MQE in the same query. ----
-  let counter = 0;
-  const ovCols: LandingConfig['columns'] = [];
-  const overviewGroups: NonNullable<LandingConfig['overviewGroups']> = [];
-  const sourceGroups = fromOverview?.groups ??
-    (fromOverview?.metrics && fromOverview.metrics.length > 0
-      ? [{ title: '', size: 'auto' as const, metrics: fromOverview.metrics }]
-      : []);
-  for (const g of sourceGroups) {
-    const ids: string[] = [];
-    for (const m of g.metrics) {
-      const id = m.id ?? `ov_${counter++}`;
-      // Dedupe across groups: if two groups reference the same id, the
-      // column lands once and both groups share the result.
-      if (!ovCols.some((c) => c.metric === id)) {
-        ovCols.push({
-          metric: id,
-          label: m.label,
-          ...(m.tip ? { tip: m.tip } : {}),
-          ...(m.unit ? { unit: m.unit } : {}),
-          mqe: m.mqe,
-          aggregation: m.aggregation ?? defaultAggregationFor(id),
-          ...(m.scale !== undefined ? { scale: m.scale } : {}),
-          ...(m.precision !== undefined ? { precision: m.precision } : {}),
-        });
-      }
-      ids.push(id);
-    }
-    overviewGroups.push({
-      title: g.title ?? '',
-      size: g.size === 'square' ? 'square' : 'auto',
-      metricIds: ids,
-    });
-  }
-  const ovIds = ovCols.map((c) => c.metric);
-
-  // Combine: header columns first (so order-by lookups stay stable),
-  // then Overview metrics. Duplicates collapse — Overview wins because
-  // it carries the operator's intent for the tile.
-  const combined: LandingConfig['columns'] = [];
-  for (const c of headerCols) {
-    if (!ovIds.includes(c.metric)) combined.push(c);
-  }
-  for (const c of ovCols) combined.push(c);
-
   return {
     priority: defaultPriority(layerKey),
     topN: 5,
     orderBy,
-    columns: combined,
-    // The original header-column set, preserved here so the LayerShell
-    // KPI strip can render ONLY these (the combined `columns` above
-    // also carries overview-promoted entries which would otherwise
-    // bleed into the header — wrong for so11y_* layers whose overview
-    // metrics are SERVICE_INSTANCE-only and show as `—` on the
-    // Service page header).
-    headerColumns: headerCols,
-    // Flat list of overview metric ids (legacy back-compat for any
-    // code path still reading it). Same set, flattened from groups.
-    overviewMetrics: ovIds.length > 0 ? ovIds : [orderBy],
-    overviewGroups: overviewGroups.length > 0
-      ? overviewGroups
-      : [{ title: '', size: 'auto', metricIds: [orderBy] }],
-    style: 'table',
+    columns: headerCols,
   };
 }
 
@@ -194,13 +129,12 @@ export function defaultLayerConfig(
     slots: LayerSlots;
     caps: LayerCaps;
     metrics?: LayerMetricsConfig;
-    overview?: LayerOverviewConfig;
   },
 ): LayerConfig {
   return {
     slots: { ...defaults.slots },
     caps: { ...defaults.caps },
-    landing: defaultLandingFor(layerKey, defaults.metrics, defaults.overview),
+    landing: defaultLandingFor(layerKey, defaults.metrics),
   };
 }
 
@@ -230,7 +164,6 @@ export const useSetupStore = defineStore('setup', () => {
       slots: LayerSlots;
       caps: LayerCaps;
       metrics?: LayerMetricsConfig;
-      overview?: LayerOverviewConfig;
     },
   ): LayerConfig {
     let cfg = configs[layerKey];
diff --git a/docs/customization/overview-templates.md 
b/docs/customization/overview-templates.md
index 3ff21db..237b9d5 100644
--- a/docs/customization/overview-templates.md
+++ b/docs/customization/overview-templates.md
@@ -82,6 +82,14 @@ The 72 px row height is tuned for KPI tile content; widgets 
that need more verti
 | `mqe`, `unit`, `aggregation` | Metric-specific fields. |
 | `cols` | Section-break column count for following widgets. |
 | `kpis`, `showCount`, `limit` | Type-specific fields described below. |
+| `aggregateOnPage` | Data widgets only. `false` (default): each KPI's 
expression already rolls the whole layer up (see *Aggregation modes*). `true`: 
the KPIs are plain per-service metrics and Horizon aggregates the top-`limit` 
services itself. |
+
+## Aggregation modes
+
+An Overview KPI reports one number for a whole layer (e.g. "General services · 
RPM"). There are two ways to get there, chosen per widget with 
`aggregateOnPage`:
+
+- **Server-side (default, `aggregateOnPage` omitted).** The KPI's `mqe` is 
written to aggregate the layer itself — 
`sum(top_n(<metric>,{{topn}},DES[,attr0='<layer>']))` for a sum, 
`avg(top_n(…))` for an average. `top_n` ranks every service in the layer and 
the outer `sum`/`avg` collapses them to one value; the `{{topn}}` placeholder 
is filled from the `HORIZON_QUERY_OVERVIEW_TOPN` setting (default 100). 
`attr0='<layer>'` narrows a metric that several layers share (e.g. 
`service_cpm` on b [...]
+- **Page-side (`aggregateOnPage: true`, with `limit`).** Horizon evaluates the 
plain metric across the layer's services and sums/averages the top-`limit` of 
them. Use it for metrics that can't be wrapped in `top_n` — single-entity 
cluster or meter series, `latest(...)` totals, and ratios (the Kubernetes 
cluster-capacity and Istio pilot composites). `limit` defaults to `1` (fine for 
a single-entity cluster); raise it for a multi-instance control plane (the 
bundled Istio pilot tile uses `5 [...]
 
 ## `OverviewKpi`
 
@@ -92,7 +100,7 @@ Used by `kpi-tile` and `metric-composite`:
 | `label` | Row label. |
 | `mqe` | Required when `source === 'mqe'` (the default). |
 | `unit` | Unit suffix. |
-| `aggregation` | `sum` for throughput / count; `avg` for ratios and rates. |
+| `aggregation` | Only used by `aggregateOnPage` widgets — `sum` for 
throughput / count, `avg` for ratios and rates. A server-side KPI carries its 
aggregation inside the `mqe` instead. |
 | `style` | `number` (default) or `progress-bar`. |
 | `max` | Required when `style === 'progress-bar'` — the 100% value. |
 | `source` | `mqe` (default) or `service-count` — the latter reads the layer's 
service count from the menu response instead of evaluating MQE. |
@@ -130,23 +138,20 @@ Single scalar tile. The MQE collapses to one number 
(here, `sum` over the time w
   "rowSpan": 3,
   "kpis": [
     {
-      "label": "Apdex",
-      "mqe": "avg(service_apdex/10000)",
-      "aggregation": "avg",
-      "style": "progress-bar",
-      "max": 1
+      "label": "RPM",
+      "mqe": "sum(top_n(service_cpm,{{topn}},DES,attr0='GENERAL'))",
+      "unit": "rpm"
     },
     {
-      "label": "P95",
-      "mqe": "avg(service_percentile{p='95'})",
-      "unit": "ms",
-      "aggregation": "avg"
+      "label": "SLA",
+      "mqe": "avg(top_n(service_sla,{{topn}},DES,attr0='GENERAL'))/100",
+      "unit": "%"
     }
   ]
 }
 ```
 
-`showCount: true` adds a service-count header row above the KPIs.
+`showCount: true` adds a service-count header row above the KPIs. The KPIs are 
server-side aggregated (see *Aggregation modes*): each `mqe` rolls the whole 
`GENERAL` layer up, so the tile shows the layer total / average, not one 
service.
 
 ### `metric-composite` — mixed number + bar grid
 
@@ -156,6 +161,7 @@ Single scalar tile. The MQE collapses to one number (here, 
`sum` over the time w
   "title": "Cluster capacity & utilisation",
   "type": "metric-composite",
   "layer": "K8S",
+  "aggregateOnPage": true,
   "span": 12,
   "rowSpan": 3,
   "kpis": [
@@ -173,6 +179,8 @@ Single scalar tile. The MQE collapses to one number (here, 
`sum` over the time w
 }
 ```
 
+`aggregateOnPage: true` marks these as page-side (see *Aggregation modes*) — 
the cluster / meter series and `latest(...)` totals here can't be wrapped in 
`top_n`, so Horizon aggregates the top-`limit` services itself (`limit` 
defaults to `1`, right for a single cluster).
+
 The widget auto-splits KPIs:
 
 - `number`-style KPIs (Nodes, Pods) go into the count-tile row (auto-fit, min 
100 px).
diff --git a/docs/setup/container-image.md b/docs/setup/container-image.md
index 502656c..9bb9748 100644
--- a/docs/setup/container-image.md
+++ b/docs/setup/container-image.md
@@ -77,6 +77,7 @@ Scalar vars take a plain value; **list / object vars take a 
JSON string** (injec
 | `HORIZON_SESSION_COOKIE_NAME` | `horizon_sid` | string | Session cookie 
name. |
 | `HORIZON_SESSION_COOKIE_SECURE` | `false` | bool | Set `true` behind HTTPS. |
 | `HORIZON_QUERY_LANDING_SERVICE_CAP` | `100` | int | Max services a layer 
landing fetches metrics for per request. |
+| `HORIZON_QUERY_OVERVIEW_TOPN` | `100` | int | Top-N window the Overview KPI 
tiles' `top_n(...)` rollups use (the `{{topn}}` placeholder). Raise it only if 
a single layer holds more than 100 services and the tail matters to the 
aggregate. |
 | `HORIZON_SOURCEMAPS_ENABLED` | `true` | bool | Source-map upload / resolve 
capability. |
 | `HORIZON_SOURCEMAPS_MAX_FILE_BYTES` | `67108864` | int | Reject a `.map` 
larger than this (64 MiB). |
 | `HORIZON_SOURCEMAPS_MAX_TOTAL_BYTES` | `536870912` | int | In-memory map 
budget (512 MiB, LRU-evicted). |
diff --git a/horizon.yaml b/horizon.yaml
index 8cb03c4..af5bf02 100644
--- a/horizon.yaml
+++ b/horizon.yaml
@@ -117,6 +117,8 @@ audit:
 query:
   # Max services a layer landing runs metric MQE for, per request.
   landingServiceCap: ${HORIZON_QUERY_LANDING_SERVICE_CAP:100}
+  # N for the Overview KPI tiles' self-aggregating `top_n(...)` MQE.
+  overviewTopN: ${HORIZON_QUERY_OVERVIEW_TOPN:100}
 
 # JS source maps for the BROWSER-layer "Browser Errors" tab (#6784). Held
 # in BFF process memory only (no OAP storage), per-instance + ephemeral.
diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts
index c5e818e..e2170a3 100644
--- a/packages/api-client/src/index.ts
+++ b/packages/api-client/src/index.ts
@@ -24,10 +24,7 @@ export type {
   LayerMetricsColumn,
   LayerMetricsConfig,
   LayerHeaderConfig,
-  LayerOverviewConfig,
   MenuResponse,
-  OverviewGroup,
-  OverviewMetric,
   ServiceNamingRule,
 } from './menu.js';
 export type {
@@ -231,7 +228,6 @@ export type {
   OverviewDashboard,
   OverviewDashboardListResponse,
   OverviewDashboardResponse,
-  OverviewWidgetResult,
 } from './overview.js';
 export { parseOapTimezoneMinutes } from './oap-info.js';
 export {
diff --git a/packages/api-client/src/menu.ts b/packages/api-client/src/menu.ts
index beb6839..2eaf3d0 100644
--- a/packages/api-client/src/menu.ts
+++ b/packages/api-client/src/menu.ts
@@ -23,8 +23,9 @@
  *
  * `caps` flags reflect what the LAYER supports; the UI hides rows whose
  * cap is false. `slots` carries per-layer term aliases (e.g. General's
- * endpoint → "API"). Layer-level overrides (term aliases, menu mode) live
- * in `horizon.yaml` and per-user state — the BFF merges all three sources.
+ * endpoint → "API"). Layer-level overrides (term aliases, color, caps)
+ * come from OAP-published UI-template rows (remote wins over the in-code
+ * defaults) plus OAP translation overlays.
  */
 
 export interface LayerSlots {
@@ -77,10 +78,6 @@ export interface LayerCaps {
    *  per-layer "Pod Logs" tab; only K8s-deployed layers set it. */
   podLogs?: boolean;
   events?: boolean;
-  /** Bundle a dedicated square tile per layer on the Overview strip,
-   *  showing live service count. When on, regular tiles drop the
-   *  "N services" counter from their header (no duplicate). */
-  serviceCountTile?: boolean;
 }
 
 /**
@@ -106,9 +103,7 @@ export interface LayerMetricsColumn {
  *
  * Renamed from `metrics` in the JSON to `layer-header` to reflect
  * where the data actually surfaces; the old `metrics` key is still
- * accepted by the loader for back-compat. Overview tile config lives
- * separately on `LayerOverviewConfig` with its own self-contained
- * metrics.
+ * accepted by the loader for back-compat.
  */
 export interface LayerHeaderConfig {
   /** Default sort metric for the service list. */
@@ -153,69 +148,6 @@ export interface ServiceNamingRule {
   alias: string;
 }
 
-/**
- * One self-contained metric on the Overview tile. Each carries its own
- * MQE expression + label + presentation hints; the Overview tile does
- * NOT cross-reference the per-layer header columns any more.
- *
- * `id` is optional in source JSON — the loader assigns `ov_0`,
- * `ov_1`, … on load so the SPA has a stable key to thread through
- * the landing query (the BFF treats each as a synthetic column).
- */
-export interface OverviewMetric {
-  id?: string;
-  label: string;
-  mqe: string;
-  /** Hover tip (string only, no markdown). */
-  tip?: string;
-  unit?: string;
-  aggregation?: 'sum' | 'avg';
-  scale?: number;
-  precision?: number;
-}
-
-/**
- * One Overview tile **group** — a layer can have multiple groups, each
- * rendered as its own tile in the Overview strip. The group is the
- * unit of layout decisions:
- *
- *   - `title`  is shown in the tile header, alongside the layer name.
- *     Operators use it to label a group's purpose (e.g. "Throughput",
- *     "Health", "Cache hit rate").
- *   - `size`   = "auto" (full tile with up to 3 metric cells, 5/row)
- *               or "square" (compact 1-metric tile, 8/row).
- *     Square is for at-a-glance density; the recommendation is to put
- *     exactly ONE metric in a square group — the layer's primary
- *     headline (RPM for services, Msg/s for MQ, QPS for DB, …).
- *   - `metrics` are self-contained `OverviewMetric` entries (mqe +
- *     label + tip + unit + aggregation + scale + precision).
- */
-export interface OverviewGroup {
-  title: string;
-  size: 'auto' | 'square';
-  metrics: OverviewMetric[];
-}
-
-/**
- * Overview-page-only settings. A layer's overview is now a list of
- * groups; each group becomes one tile in the Overview strip. Most
- * layers carry a single auto-size group (the headline metrics), and
- * may add additional square groups to surface a primary KPI in dense
- * fleet views.
- *
- * Legacy shapes (migrated at load time):
- *   - `metrics: OverviewMetric[]`   → wrapped into a single group
- *                                     `{title: '', size: 'auto'}`.
- *   - `metrics: string[]` of column-key refs → resolved against
- *                                     `layer-header.columns` then
- *                                     wrapped as above.
- */
-export interface LayerOverviewConfig {
-  groups?: OverviewGroup[];
-  /** @deprecated — wrapped into a single auto-size group on load. */
-  metrics?: OverviewMetric[];
-}
-
 export interface LayerDef {
   key: string;
   /** Display name from OAP `getMenuItems.title` (preserving casing). */
@@ -261,14 +193,7 @@ export interface LayerDef {
    *  JSON template's `layer-header` block (or legacy `metrics`). UI
    *  uses it for the per-layer Service page picker columns. Falls
    *  back to static catalog defaults when absent. */
-  header?: LayerHeaderConfig;
-  /** @deprecated — same data as `header`. Kept for back-compat with
-   *  callers reading the old field name. */
   metrics?: LayerHeaderConfig;
-  /** Overview-tile settings — the 1 – 3 self-contained metric cells
-   *  on the per-layer Overview tile. Empty when the layer template
-   *  omits the `overview` block. */
-  overview?: LayerOverviewConfig;
   /** Logs-tab config. Layers like `MESH_DP` carry per-instance
    *  (sidecar) logs and need an instance picker on the Logs tab; most
    *  agent-traced layers carry per-service logs. Drives the UI scope +
@@ -313,7 +238,6 @@ export interface LogConfig {
    *
    *  The pinned entity is the one the layer's data is indexed by. */
   scope?: 'service' | 'instance' | 'endpoint';
-  defaultTags?: Array<{ key: string; value: string }>;
 }
 
 export interface MenuResponse {
diff --git a/packages/api-client/src/oap-info.ts 
b/packages/api-client/src/oap-info.ts
index d481d97..b0e6d21 100644
--- a/packages/api-client/src/oap-info.ts
+++ b/packages/api-client/src/oap-info.ts
@@ -57,6 +57,12 @@ export interface OapInfo {
    *  (query port can be up while Zipkin is off, and vice versa). */
   zipkinReachable?: boolean;
   zipkinError?: string;
+  /** Horizon's resolved `query.overviewTopN` (`HORIZON_QUERY_OVERVIEW_TOPN`,
+   *  default 100) — the value the BFF substitutes for the `{{topn}}`
+   *  placeholder in Overview KPI MQE. Surfaced so the Overview-templates
+   *  editor can show the live value in its `{{topn}}` hint. Always present
+   *  (a static Horizon config), independent of OAP reachability. */
+  overviewTopN?: number;
 }
 
 export interface OapCapabilities {
diff --git a/packages/api-client/src/overview.ts 
b/packages/api-client/src/overview.ts
index 0578658..8338fa5 100644
--- a/packages/api-client/src/overview.ts
+++ b/packages/api-client/src/overview.ts
@@ -27,25 +27,23 @@
  * dashboard pull metrics from multiple layers (e.g. an overview
  * combining General service metrics with Kubernetes service counts).
  *
- * Widget types:
- *   - `service-count`  — count of services reporting on the layer.
- *   - `metric`         — an MQE expression evaluated layer-wide.
- *   - `topology`       — static service-map snapshot for the layer with
- *                        click-through to the full topology view.
- *   - `section-break`  — visual row header; pure layout, no data.
- *   - `kpi-tile`       — compound tile: optional service count + 1–N
- *                        KPI rows for one layer. Used for the
- *                        per-service-type rows on the Services / Mesh
- *                        dashboards.
- *   - `alarms`         — active-alarm rail. Carries no MQE — the
- *                        renderer queries `getAlarms` directly. Layer
- *                        filter is best-effort (server-side scope tags
- *                        today, no native layer filter).
- *   - `k8s-summary`    — fixed-shape Kubernetes capacity + utilisation
- *                        block. Renderer-driven so the JSON stays
- *                        compact.
- *   - `pilot-summary`  — fixed-shape Istio pilot push / error / push-
- *                        time block. Renderer-driven, see `k8s-summary`.
+ * Widget types (`OverviewWidgetType`):
+ *   - `metric`          — an MQE expression evaluated layer-wide.
+ *   - `topology`        — static service-map snapshot for the layer with
+ *                         click-through to the full topology view.
+ *   - `section-break`   — visual row header; pure layout, no data.
+ *   - `kpi-tile`        — compound tile: optional service count + 1–N
+ *                         KPI rows for one layer. Used for the
+ *                         per-service-type rows on the Services / Mesh
+ *                         dashboards.
+ *   - `alarms`          — active-alarm rail. Carries no MQE — the
+ *                         renderer queries `getAlarms` directly. Layer
+ *                         filter is best-effort (server-side scope tags
+ *                         today, no native layer filter).
+ *   - `metric-composite`— mixed KPI grid (number tiles + progress-bar
+ *                         rows) — the Kubernetes capacity + Istio pilot
+ *                         blocks. `service-count` is a KPI *source*
+ *                         within a tile, not a widget type.
  *
  * The layout uses the same 12-col / `span` + `rowSpan` model as the
  * per-layer dashboard, so the same renderer can place these widgets
@@ -113,8 +111,21 @@ export interface OverviewWidget {
   mqe?: string;
   /** Display unit. */
   unit?: string;
-  /** `sum` for throughput-shaped metrics, `avg` otherwise. */
+  /** `sum` for throughput-shaped metrics, `avg` otherwise. Consulted only
+   *  when `aggregateOnPage` is set (page-side fan-out); a self-aggregating
+   *  tile carries its aggregation inside the MQE. */
   aggregation?: 'sum' | 'avg';
+  /** For data widgets (`kpi-tile` / `metric-composite` / `metric`) — how the
+   *  layer-wide KPI is aggregated:
+   *   - omitted / false (the tile default): each KPI's `mqe` is
+   *     self-aggregating (`sum|avg(top_n(<metric>,{{topn}},DES[,attr0=…]))`)
+   *     and the BFF fires it ONCE — OAP does the rollup server-side.
+   *   - true: the KPIs are plain per-service metrics; the BFF fans out
+   *     across the layer's services and aggregates the top-`limit` of them
+   *     page-side. Use for metrics that can't be `top_n`-wrapped (single-
+   *     entity cluster/meter series, `latest(...)`, ratios) — e.g. the K8s
+   *     cluster + Istio pilot composites. */
+  aggregateOnPage?: boolean;
   /** For `section-break` — overrides the grid column count for widgets
    *  that follow this break, up to the next break. Default 12. Use 5
    *  for "five tiles across", etc. */
@@ -124,8 +135,23 @@ export interface OverviewWidget {
   /** For `kpi-tile` — also render the layer's service count above the
    *  KPIs. Defaults to false. */
   showCount?: boolean;
-  /** For `alarms` — cap the alarm list at this many rows. */
+  /** For `alarms` — cap the alarm list at this many rows.
+   *  For `aggregateOnPage` widgets — the page-side aggregation window: the
+   *  tile sums/averages the layer's top-`limit` services (default 1, which
+   *  suits single-entity composites like a K8s cluster; set higher for
+   *  multi-instance control planes, e.g. 5 for a multi-replica istiod). */
   limit?: number;
+  /** For `aggregateOnPage` widgets — how the top-`limit` services are RANKED
+   *  before the aggregate. Default (absent): by the FIRST KPI. Set it when
+   *  the first KPI is a `LABELED_VALUE` metric (a poor ranking basis) or to
+   *  rank by something not shown as a KPI. `mqe` wins over `kpi`. */
+  rankBy?: {
+    /** Rank by an existing KPI, 0-based index into `kpis` (must be an `mqe`
+     *  KPI, not `service-count`). */
+    kpi?: number;
+    /** Rank by a standalone MQE, not shown as a KPI. */
+    mqe?: string;
+  };
   /** Grid span in 12-col grid. */
   span?: number;
   /** Grid row span. */
@@ -182,15 +208,3 @@ export interface OverviewDashboardResponse {
   error?: string;
 }
 
-/** Wire shape for one resolved widget value when the BFF runs
- *  `POST /api/overview/dashboards/:id/data` (out of scope for the
- *  first cut — the SPA will fetch widget data widget-by-widget). */
-export interface OverviewWidgetResult {
-  id: string;
-  /** For `service-count` and `metric` — single value. */
-  value?: number | null;
-  /** For `topology` — the topology response is returned via the
-   *  existing `/api/layer/:key/topology` route directly. This block
-   *  carries only widget metadata. */
-  error?: string;
-}
diff --git a/packages/api-client/src/setup.ts b/packages/api-client/src/setup.ts
index 8959201..79efae3 100644
--- a/packages/api-client/src/setup.ts
+++ b/packages/api-client/src/setup.ts
@@ -39,10 +39,6 @@ export interface LandingColumn {
   metric: string;
   /** Short header label (e.g. `cpm`). */
   label: string;
-  /** Hover tip (string only, no markdown). Used by the Overview tile
-   *  metric cells; the SPA surfaces it via the cell label's `title`
-   *  attribute. */
-  tip?: string;
   /** Suffix unit (`%`, `ms`, etc.). */
   unit?: string;
   /**
@@ -56,8 +52,20 @@ export interface LandingColumn {
    * Aggregation when collapsing the top-N service values to the
    * per-layer KPI tile. Defaults to `avg` on the UI when unset — the
    * landing card itself (per-service rows) doesn't consult this field.
+   * Consulted only for page-side (fan-out) columns; a `selfAggregate`
+   * column carries its aggregation inside the MQE.
    */
   aggregation?: AggregationKind;
+  /**
+   * When true, the `mqe` already folds the whole layer to one scalar
+   * server-side (`sum|avg(top_n(<metric>,{{topn}},DES[,attr0='<layer>']))`),
+   * so the BFF fires it ONCE globally instead of fanning out per service
+   * and aggregating page-side. `{{topn}}` is substituted with the BFF's
+   * `query.overviewTopN` before firing. Default false (fan-out) — every
+   * legacy caller (the per-layer landing header) stays on the fan-out
+   * path untouched. The Overview KPI tiles set this; composites don't.
+   */
+  selfAggregate?: boolean;
   /**
    * Multiplier applied BFF-side after MQE returns. Use for unit
    * normalization — e.g. SkyWalking's `service_sla` is integer
@@ -81,26 +89,6 @@ export interface LandingConfig {
   /** Metric key used to rank the top-N. */
   orderBy: string;
   columns: LandingColumn[];
-  /** @deprecated kept for back-compat; new code reads `overviewGroups`. */
-  overviewMetrics?: string[];
-  /** Explicit per-layer page header columns — distinct from `columns`
-   *  which mixes header + overview-promoted entries (the BFF batches
-   *  every MQE in one query). The LayerShell KPI strip iterates ONLY
-   *  this list so overview-only metrics don't leak into the header.
-   *  Absent on legacy configs → LayerShell falls back to `columns`. */
-  headerColumns?: LandingColumn[];
-  /** Resolved Overview tile groups. Each group becomes one tile on
-   *  the Overview strip with the group's `title` in the header.
-   *  Metrics are referenced by id — those ids show up as synthetic
-   *  entries in `columns[]` so the BFF batches their MQE in the
-   *  same landing query. */
-  overviewGroups?: Array<{
-    title: string;
-    size: 'auto' | 'square';
-    /** Column-key references into `columns[]`. */
-    metricIds: string[];
-  }>;
-  style: 'table' | 'bar' | 'mini-topology';
   /**
    * Per-user threshold overrides for topology + endpoint-dependency
    * metrics. Keyed by `<scope>.<metricId>` where scope is

Reply via email to