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