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
