This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a commit to branch refactor/overview-kpi-mqe-aggregate
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git

commit 252ce2f0e4a9752b26d0f0fdad801b40e97cd373
Author: Wu Sheng <[email protected]>
AuthorDate: Tue Jul 7 19:34:12 2026 +0800

    refactor(overview): aggregate KPI tiles server-side + retire dead template 
config
    
    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. The tiles now roll up the ENTIRE 
layer server-side via sum/avg(top_n(...)) — throughput summed, latency/SLA 
averaged — across General, Mesh, and the Virtual Database / Cache / MQ / GenAI 
layers, in one query per tile instead of a per-service fan-out.
    
    The K8s-cluster and Istio-pilot composites stay page-side (their 
latest()/labeled metrics can't be top_n-rolled) behind an explicit 
`aggregateOnPage` widget flag; the top_n window is bounded by a new 
HORIZON_QUERY_OVERVIEW_TOPN knob (default 100). Mesh's second tile is now 
Latency (avg response time) in place of the old P95.
    
    Also retires the now-dead legacy per-layer-overview machinery 
(LayerOverviewConfig / headerColumns / overviewGroups / overviewMetrics) — the 
standalone Overview dashboards replaced it — plus a sweep of verified-dead 
template config (serviceCountTile, the LayerDef.header twin, 
LogConfig.defaultTags, LandingConfig.style, LandingColumn.tip, 
OverviewWidgetResult, the BFF LayerMetricsConfig alias) and dead loader 
migrations. No layer-template or OAP wire-contract change; the tested `widget 
[...]
---
 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 |   9 +-
 .../src/bundled_templates/overviews/services.json  |  31 ++---
 apps/bff/src/config/schema.ts                      |  10 +-
 apps/bff/src/http/admin/overview-templates.ts      |   1 +
 apps/bff/src/http/query/landing.ts                 |  65 ++++++++-
 apps/bff/src/http/query/menu.ts                    |   6 -
 apps/bff/src/logic/layers/loader.ts                | 149 +--------------------
 apps/bff/src/logic/overview/loader.ts              |   1 +
 apps/ui/src/api/client.ts                          |   5 -
 apps/ui/src/api/scopes/layer.test.ts               |   3 -
 .../overview-templates/OverviewWidgetDrawer.vue    |  16 +++
 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 |  35 ++++-
 apps/ui/src/shell/layerFromTemplate.ts             |   3 -
 apps/ui/src/shell/useLandingOrder.ts               |   4 +-
 apps/ui/src/state/setup.ts                         |  71 +---------
 docs/customization/overview-templates.md           |  30 +++--
 horizon.yaml                                       |   2 +
 packages/api-client/src/index.ts                   |   4 -
 packages/api-client/src/menu.ts                    |  84 +-----------
 packages/api-client/src/overview.ts                |  69 +++++-----
 packages/api-client/src/setup.ts                   |  36 ++---
 38 files changed, 251 insertions(+), 444 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index e9695de..3b57a43 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 opt into **page-side aggregation** (a new 
`aggregateOnPage` toggle 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). The self-aggre [...]
+
 ### 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..ac4dc0c 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,8 @@
       "tip": "xDS push count + reject errors + the layer's service count.",
       "type": "metric-composite",
       "layer": "MESH_CP",
+      "aggregateOnPage": true,
+      "limit": 5,
       "span": 8,
       "rowSpan": 2,
       "kpis": [
@@ -84,6 +86,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..76ee338 100644
--- a/apps/bff/src/http/admin/overview-templates.ts
+++ b/apps/bff/src/http/admin/overview-templates.ts
@@ -93,6 +93,7 @@ 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(),
   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/landing.ts 
b/apps/bff/src/http/query/landing.ts
index 9814976..04733c6 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,20 @@ 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. */
+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 +351,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 +502,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 +517,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..bd6824a 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,101 +409,16 @@ 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. Internal callers
+    // read `template.header`; we mirror it 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;
     if (headerSrc && !parsed.header) parsed.header = headerSrc;
-    if (!parsed.header && parsed.metrics) parsed.header = parsed.metrics;
     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..48e65fb 100644
--- a/apps/bff/src/logic/overview/loader.ts
+++ b/apps/bff/src/logic/overview/loader.ts
@@ -153,6 +153,7 @@ 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,
       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/OverviewWidgetDrawer.vue 
b/apps/ui/src/features/admin/overview-templates/OverviewWidgetDrawer.vue
index ebc2e6d..e4ecb69 100644
--- a/apps/ui/src/features/admin/overview-templates/OverviewWidgetDrawer.vue
+++ b/apps/ui/src/features/admin/overview-templates/OverviewWidgetDrawer.vue
@@ -228,6 +228,22 @@ function onKpiStyleChange(k: OverviewKpi): void {
             <input type="checkbox" v-model="w.showCount" />
           </label>
         </div>
+        <!-- Aggregation mode. Off (default): each KPI's MQE self-aggregates
+             the layer server-side — write `sum|avg(top_n(<metric>,{{topn}},
+             DES[,attr0='<layer>']))` and the BFF fires it once. On: the KPIs
+             are plain per-service metrics and the BFF fans out + rolls up the
+             top-N services page-side (for series that can't be top_n-wrapped:
+             cluster/meter metrics, latest(...), ratios). -->
+        <div class="ot__row">
+          <label class="ot__field">
+            <span>Aggregate on page (fan-out)</span>
+            <input type="checkbox" v-model="w.aggregateOnPage" />
+          </label>
+          <label v-if="w.aggregateOnPage" class="ot__field">
+            <span>Top-N services</span>
+            <input v-model.number="w.limit" type="number" min="1" max="8" 
class="ot__in ot__in--num" />
+          </label>
+        </div>
         <div class="ot__kpis">
           <div class="ot__kpis-head">
             <span>KPI rows ({{ (w.kpis ?? []).length }})</span>
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..30bfee6 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
@@ -63,6 +68,10 @@ function groupByLayer(widgets: OverviewWidget[]): 
Map<string, MqeRequest[]> {
     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);
     if (w.type === 'metric' && w.mqe) {
       const reqs = out.get(layer) ?? [];
       reqs.push({
@@ -70,6 +79,7 @@ function groupByLayer(widgets: OverviewWidget[]): Map<string, 
MqeRequest[]> {
         mqe: w.mqe,
         aggregation: w.aggregation ?? 'avg',
         unit: w.unit,
+        selfAggregate,
       });
       out.set(layer, reqs);
       continue;
@@ -85,6 +95,7 @@ function groupByLayer(widgets: OverviewWidget[]): Map<string, 
MqeRequest[]> {
           aggregation: k.aggregation ?? 'avg',
           unit: k.unit,
           isServiceCount: isCount,
+          selfAggregate: selfAggregate && !isCount,
         });
       }
       out.set(layer, reqs);
@@ -153,20 +164,38 @@ export function useOverviewDashboard(idRef: Ref<string>) {
            * 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);
+          // Page-side aggregation window for this layer's fan-out columns
+          // (aggregateOnPage widgets): sum/avg over the top-`limit` services.
+          // Self-aggregating columns ignore topN — they roll up server-side.
+          // Composites default to 1 (single-entity, e.g. a K8s cluster); a
+          // multi-replica control plane sets a higher `limit` (istiod → 5).
+          // Clamp to the route's 1..8 bound. alarms/topology `limit` is
+          // unrelated, so scope to aggregateOnPage widgets only.
+          const topN = Math.min(
+            8,
+            Math.max(
+              1,
+              ...widgets.value
+                .filter((w) => w.layer === layer && w.aggregateOnPage)
+                .map((w) => w.limit ?? 1),
+            ),
+          );
           // 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',
+            topN,
+            // orderBy keys the ranking/sort by COLUMN id (`w_<idx>`), not the
+            // raw MQE — the BFF stores per-row metrics under the column key.
+            orderBy: 'w_0',
             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,
             })),
           };
           return bffClient.layer.landing(layer, cfg, range).then((res) => ({
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/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..75fe427 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/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/overview.ts 
b/packages/api-client/src/overview.ts
index 0578658..4f938f0 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,7 +135,11 @@ 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;
   /** Grid span in 12-col grid. */
   span?: number;
@@ -182,15 +197,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