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 64ca0d6  landing: top-6 layout + KPI tiles + alarms rail + setup 
config for aggregation/MQE/precision
64ca0d6 is described below

commit 64ca0d6553b3a5b81fe888edadafc769bc4c03b8
Author: Wu Sheng <[email protected]>
AuthorDate: Tue May 12 15:55:24 2026 +0800

    landing: top-6 layout + KPI tiles + alarms rail + setup config for 
aggregation/MQE/precision
    
    Overview shifts from a uniform card grid to the design's 2+4+rail layout:
      Row 1: top-2 layers as full LayerLandingCards (2/5 width each)
      Row 2: layers 3-6 as compact LayerKpiTile (2x2 in 3/5 width)
      Rail:  Alarms panel (1/5 row 1 → 2/5 row 2, rowspans full height)
    
    Anything beyond the top 6 surfaces a one-line overflow link to Setup
    so operators can re-prioritize what shows up. Below 1100px the rail
    falls under the cards; below 720px everything stacks single-column.
    
    Setup gains four new column-level fields + a per-layer throughput
    section:
      - aggregation: 'sum' | 'avg'  (sum default for cpm / msg-rate / qps /
        pv / invocations / tokens / req / slow-queries / js-err / cold-start
        / restart; avg for everything else)
      - mqe: paste a custom MQE expression to override the catalog mapping
      - scale: multiplier applied BFF-side after MQE returns (0.01 unscrambles
        SkyWalking's integer-times-100 SLA values)
      - precision: decimal rounding before display
      - throughput: optional headline metric for the KPI tile, with its own
        aggregation / scale / precision / MQE
    
    BFF route flips GET → POST so the richer column payload fits cleanly
    in the body. New /api/layer/:key/landing response carries an aggregates
    block (serviceCount + per-column rollup + throughput value + aggregated
    spark series) the KPI tile consumes directly — no UI-side aggregation
    work needed.
    
    AlarmsPanel is a structural placeholder until Phase 5 wires getAlarm;
    the design lede + 3 stub rows communicate the panel's shape and the
    read-only-by-design intent.
---
 apps/bff/src/oap/landing-routes.ts          | 381 ++++++++++++++++++----------
 apps/bff/src/setup/routes.ts                |  19 ++
 apps/ui/src/api/client.ts                   |  33 +--
 apps/ui/src/composables/useLayerLanding.ts  |  22 +-
 apps/ui/src/stores/setup.ts                 |  42 ++-
 apps/ui/src/views/overview/AlarmsPanel.vue  | 171 +++++++++++++
 apps/ui/src/views/overview/LayerKpiTile.vue | 262 +++++++++++++++++++
 apps/ui/src/views/overview/OverviewView.vue | 127 ++++++++--
 apps/ui/src/views/setup/LayerSetupCard.vue  | 245 +++++++++++++++++-
 packages/api-client/src/index.ts            |   4 +-
 packages/api-client/src/landing.ts          |  24 ++
 packages/api-client/src/setup.ts            |  58 ++++-
 12 files changed, 1206 insertions(+), 182 deletions(-)

diff --git a/apps/bff/src/oap/landing-routes.ts 
b/apps/bff/src/oap/landing-routes.ts
index 8a85df1..dd1ea22 100644
--- a/apps/bff/src/oap/landing-routes.ts
+++ b/apps/bff/src/oap/landing-routes.ts
@@ -16,24 +16,32 @@
  */
 
 /**
- * `GET /api/layer/:key/landing` — top-N services for a layer with their
- * configured column metrics, ready for the Overview landing card.
+ * `POST /api/layer/:key/landing` — top-N services for a layer with their
+ * configured column metrics + whole-layer aggregates for the Overview
+ * KPI strip tile.
  *
- * One round-trip to OAP for `listServices(layer)`, then a second
- * round-trip that batches `execExpression(...)` aliases — one per
- * service × column. We sort the result by `orderBy desc` and slice to
- * `topN`. MQE failures are soft — the affected cell becomes `null` so
- * the rest of the card still renders.
+ * Body shape (subset of `LandingConfig` from the setup wire types):
+ * ```
+ *  {
+ *    topN, orderBy, columns: LandingColumn[],
+ *    spark?: { metric, height },
+ *    throughput?: ThroughputConfig,
+ *  }
+ * ```
  *
- * The duration window is always a fixed look-back (default 15 min,
- * MINUTE step) anchored to the BFF clock. Phase 2.7's per-layer detail
- * page introduces a proper time-range picker; Overview cards stay on
- * the cheap default.
+ * One GraphQL trip lists services, a second batches per-service column
+ * MQE values (one alias per service × column), a third optional trip
+ * fetches the sparkline + throughput series for the surviving topN
+ * rows. Errors anywhere in the MQE batch are local — failing cells
+ * become `null`, the rest of the response stands.
  */
 
 import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
+import { z } from 'zod';
 import type {
+  AggregationKind,
   FetchLike,
+  LandingAggregates,
   LandingResponse,
   LandingServiceRow,
 } from '@skywalking-horizon-ui/api-client';
@@ -41,7 +49,7 @@ import type { ConfigSource } from '../config/loader.js';
 import type { SessionStore } from '../auth/sessions.js';
 import { requireAuth } from '../auth/middleware.js';
 import { graphqlPost } from './graphql-client.js';
-import { expressionForServiceMetric, resolveColumnExpressions } from 
'./mqe-catalog.js';
+import { expressionForServiceMetric } from './mqe-catalog.js';
 
 export interface LandingRouteDeps {
   config: ConfigSource;
@@ -51,7 +59,7 @@ export interface LandingRouteDeps {
 
 interface ListServicesRow {
   id: string;
-  value: string; // alias for name
+  value: string;
   shortName?: string | null;
   group?: string | null;
   normal?: boolean | null;
@@ -79,28 +87,44 @@ const LIST_SERVICES_QUERY = /* GraphQL */ `
   }
 `;
 
-/** Default look-back window for the landing card. Kept small + cheap —
- *  matches what booster-ui's KPI tiles use under the global Overview. */
 const DEFAULT_WINDOW_MIN = 15;
-
-/** Cap how many services we'll spread MQE queries over. listServices
- *  can return hundreds for big deployments; querying all of them on
- *  every Overview render is wasteful since only top-N are rendered. We
- *  sort client-side after the queries, so this cap is a sampling
- *  ceiling rather than the true top-N.
- *
- *  In Phase 3 we'll move to MQE's `top_n(...)` aggregator which OAP can
- *  resolve server-side. Until then, 25 is the breakpoint where one
- *  request stays sub-second on a typical dev OAP. */
 const SERVICE_QUERY_CAP = 25;
 
+const aggSchema = z.enum(['sum', 'avg']);
+const columnSchema = z.object({
+  metric: z.string().min(1),
+  label: z.string().min(1),
+  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(),
+});
+const throughputSchema = z.object({
+  metric: z.string().min(1),
+  label: 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(),
+});
+const bodySchema = z.object({
+  topN: z.number().int().min(1).max(8),
+  orderBy: z.string().min(1),
+  columns: z.array(columnSchema).max(5),
+  spark: z
+    .object({ metric: z.string().min(1), height: z.number().int().positive() })
+    .optional(),
+  throughput: throughputSchema.optional(),
+});
+
 interface Window {
   start: string;
   end: string;
   step: 'MINUTE';
 }
 
-/** Format a Date in OAP's MINUTE step format: `yyyy-MM-dd HHmm`. */
 function fmtMinute(d: Date): string {
   const yyyy = d.getUTCFullYear();
   const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
@@ -110,38 +134,34 @@ function fmtMinute(d: Date): string {
   return `${yyyy}-${mm}-${dd} ${hh}${mi}`;
 }
 
-/**
- * Build the default look-back window. Anchored to the BFF UTC clock for
- * now — replacing this with the OAP-side `getTimeInfo` cache is a
- * follow-up so the duration aligns with the storage TZ. The
- * MQE-resolver tolerates a few minutes of skew either way.
- */
 function defaultWindow(): Window {
   const end = new Date();
-  // Round to the previous minute boundary so consecutive calls hit
-  // the same OAP bucket — improves cache locality on the server side.
   end.setUTCSeconds(0, 0);
   const start = new Date(end.getTime() - DEFAULT_WINDOW_MIN * 60_000);
   return { start: fmtMinute(start), end: fmtMinute(end), step: 'MINUTE' };
 }
 
-/** GraphQL aliases must be valid identifiers. Index-based prefix. */
-function alias(serviceIdx: number, columnIdx: number): string {
-  return `r${serviceIdx}_c${columnIdx}`;
+/** Pick the expression to fire for `(metric, layer)`. Honors the operator's
+ *  explicit `mqe` override when set. */
+function resolveMqe(metric: string, mqe: string | undefined, layerKey: 
string): string | null {
+  if (mqe && mqe.trim().length > 0) return mqe.trim();
+  return expressionForServiceMetric(metric, layerKey);
+}
+
+/** Apply optional scale + precision to a raw MQE value. */
+function postProcess(v: number | null, scale: number | undefined, precision: 
number | undefined): number | null {
+  if (v === null || !Number.isFinite(v)) return null;
+  let out = scale ? v * scale : v;
+  if (precision !== undefined) {
+    const factor = Math.pow(10, precision);
+    out = Math.round(out * factor) / factor;
+  }
+  return out;
 }
 
 /**
- * Convert a SINGLE_VALUE MQE result to a number. OAP returns each value
- * as a stringified number (or `null`). For SINGLE_VALUE the `values`
- * array has one entry; for TIME_SERIES_VALUES it has many — we use the
- * `avg` of the non-null entries as the cell value, matching what
- * booster-ui does on its KPI tiles when an expression isn't wrapped in
- * `avg(...)` already.
- */
-/**
- * Convert a TIME_SERIES_VALUES MQE result into an ordered series, one
- * bucket per `step` slot. Non-numeric / null values become `null` so
- * the SPA can render a gap in the sparkline.
+ * Collapse a TIME_SERIES_VALUES MQE result to an ordered series, one
+ * bucket per `step` slot. Non-numeric / null values become `null`.
  */
 function collapseToSeries(r: MqeResultShape | undefined): Array<number | null> 
| null {
   if (!r || r.error) return null;
@@ -154,23 +174,73 @@ function collapseToSeries(r: MqeResultShape | undefined): 
Array<number | null> |
   });
 }
 
+/**
+ * Collapse to a single scalar (avg of non-null bucket values). MQE with
+ * `step: MINUTE` over 15m typically returns ~15 buckets — averaging
+ * matches what booster-ui's KPI tiles do.
+ */
 function collapseToScalar(r: MqeResultShape | undefined): number | null {
-  if (!r || r.error) return null;
-  const values = r.results?.[0]?.values ?? [];
-  const parsed: number[] = [];
-  for (const v of values) {
-    if (v.value === null || v.value === undefined) continue;
-    const n = Number(v.value);
-    if (Number.isFinite(n)) parsed.push(n);
+  const series = collapseToSeries(r);
+  if (!series) return null;
+  const ns = series.filter((x): x is number => x !== null);
+  if (ns.length === 0) return null;
+  return ns.reduce((a, b) => a + b, 0) / ns.length;
+}
+
+/** Apply the operator's chosen aggregation across the topN rows for one 
metric. */
+function aggregate(values: Array<number | null>, kind: AggregationKind): 
number | null {
+  const finite = values.filter((v): v is number => v !== null && 
Number.isFinite(v));
+  if (finite.length === 0) return null;
+  const sum = finite.reduce((a, b) => a + b, 0);
+  return kind === 'avg' ? sum / finite.length : sum;
+}
+
+/** Same idea but point-by-point across multiple sparkline series. */
+function aggregateSeries(
+  serieses: Array<Array<number | null> | undefined>,
+  kind: AggregationKind,
+): Array<number | null> | null {
+  const real = serieses.filter((s): s is Array<number | null> => 
Array.isArray(s) && s.length > 0);
+  if (real.length === 0) return null;
+  const len = Math.max(...real.map((s) => s.length));
+  const out: Array<number | null> = [];
+  for (let i = 0; i < len; i++) {
+    const pts = real
+      .map((s) => s[i])
+      .filter((v): v is number => v !== null && v !== undefined && 
Number.isFinite(v));
+    if (pts.length === 0) {
+      out.push(null);
+    } else {
+      const sum = pts.reduce((a, b) => a + b, 0);
+      out.push(kind === 'avg' ? sum / pts.length : sum);
+    }
   }
-  if (parsed.length === 0) return null;
-  const sum = parsed.reduce((a, b) => a + b, 0);
-  return sum / parsed.length;
+  return out;
+}
+
+function alias(serviceIdx: number, columnIdx: number): string {
+  return `r${serviceIdx}_c${columnIdx}`;
+}
+
+interface MqeRequest {
+  expression: string;
+  serviceName: string;
+  normal: boolean;
+}
+
+function buildMqeFragment(aliasName: string, req: MqeRequest, w: Window): 
string {
+  return (
+    `${aliasName}: execExpression(\n` +
+    `      expression: ${JSON.stringify(req.expression)},\n` +
+    `      entity: { scope: Service, serviceName: 
${JSON.stringify(req.serviceName)}, normal: ${req.normal ? 'true' : 'false'} 
},\n` +
+    `      duration: { start: ${JSON.stringify(w.start)}, end: 
${JSON.stringify(w.end)}, step: MINUTE }\n` +
+    `    ) { type error results { values { value } } }`
+  );
 }
 
 export function registerLandingRoute(app: FastifyInstance, deps: 
LandingRouteDeps): void {
   const auth = requireAuth(deps);
-  app.get(
+  app.post(
     '/api/layer/:key/landing',
     { preHandler: auth },
     async (req: FastifyRequest, reply: FastifyReply) => {
@@ -179,28 +249,16 @@ export function registerLandingRoute(app: 
FastifyInstance, deps: LandingRouteDep
       if (!layerKey || !/^[a-z0-9_]+$/i.test(layerKey)) {
         return reply.code(400).send({ error: 'invalid_layer_key' });
       }
-
-      const q = req.query as Record<string, string | undefined>;
-      const topNRaw = Number(q.topN ?? 5);
-      const topN = Math.max(1, Math.min(8, Number.isFinite(topNRaw) ? topNRaw 
: 5));
-      const columnsRaw = (q.columns ?? '').split(',').filter(Boolean);
-      const orderBy = q.orderBy ?? columnsRaw[0] ?? 'cpm';
-      const labels = (q.labels ?? '').split('|');
-      const units = (q.units ?? '').split('|');
-      const columns = columnsRaw.map((metric, i) => ({
-        metric,
-        label: labels[i] || metric,
-        ...(units[i] ? { unit: units[i] } : {}),
-      }));
-      const sparkMetric = q.spark && q.spark.length > 0 ? q.spark : null;
-
-      // OAP enum is upper-case (`GENERAL`, `VIRTUAL_MQ`, …); the SPA
-      // sends lower-case route keys.
+      const parsed = bodySchema.safeParse(req.body);
+      if (!parsed.success) {
+        return reply.code(400).send({ error: 'invalid_body', detail: 
parsed.error.flatten() });
+      }
+      const cfg = parsed.data;
       const oapLayer = layerKey.toUpperCase();
-      const cfg = deps.config.current;
+      const cfgCurrent = deps.config.current;
       const opts = {
-        statusUrl: cfg.oap.statusUrl,
-        timeoutMs: cfg.oap.timeoutMs,
+        statusUrl: cfgCurrent.oap.statusUrl,
+        timeoutMs: cfgCurrent.oap.timeoutMs,
         fetch: deps.fetch,
       };
       const window = defaultWindow();
@@ -217,59 +275,54 @@ export function registerLandingRoute(app: 
FastifyInstance, deps: LandingRouteDep
       } catch (err) {
         const body: LandingResponse = {
           layer: layerKey,
-          topN,
-          orderBy,
+          topN: cfg.topN,
+          orderBy: cfg.orderBy,
           generatedAt: Date.now(),
           step: 'MINUTE',
           durationStart: window.start,
           durationEnd: window.end,
           rows: [],
+          aggregates: { serviceCount: 0, metrics: {} },
           reachable: false,
           error: err instanceof Error ? err.message : String(err),
         };
         return reply.send(body);
       }
 
-      // Empty layer is fine — no error, no rows.
-      if (services.length === 0 || columns.length === 0) {
+      const totalServiceCount = services.length;
+      if (services.length === 0 || cfg.columns.length === 0) {
         const body: LandingResponse = {
           layer: layerKey,
-          topN,
-          orderBy,
+          topN: cfg.topN,
+          orderBy: cfg.orderBy,
           generatedAt: Date.now(),
           step: 'MINUTE',
           durationStart: window.start,
           durationEnd: window.end,
           rows: [],
+          aggregates: { serviceCount: totalServiceCount, metrics: {} },
           reachable: true,
         };
         return reply.send(body);
       }
 
-      // Step 2 — batched MQE queries, one alias per (service, column).
+      // Step 2 — resolve MQE expressions per column.
       const sampled = services.slice(0, SERVICE_QUERY_CAP);
-      const resolved = resolveColumnExpressions(columns, layerKey);
-      // Trip is cheaper than 25× round-trips: one query with all aliases.
+      const resolved = cfg.columns.map((c) => ({
+        column: c,
+        expression: resolveMqe(c.metric, c.mqe, layerKey),
+      }));
+
       const fragments: string[] = [];
-      const aliasMap = new Map<string, { sIdx: number; cIdx: number }>();
       sampled.forEach((svc, sIdx) => {
         resolved.forEach(({ expression }, cIdx) => {
           if (!expression) return;
-          const a = alias(sIdx, cIdx);
-          aliasMap.set(a, { sIdx, cIdx });
-          // Inline JSON.stringify keeps expressions safely quoted even
-          // when they contain `{p='99'}` label selectors.
-          const exprLit = JSON.stringify(expression);
-          const svcLit = JSON.stringify(svc.value);
-          const isNormal = svc.normal === false ? 'false' : 'true';
-          const startLit = JSON.stringify(window.start);
-          const endLit = JSON.stringify(window.end);
           fragments.push(
-            `${a}: execExpression(\n` +
-              `      expression: ${exprLit},\n` +
-              `      entity: { scope: Service, serviceName: ${svcLit}, normal: 
${isNormal} },\n` +
-              `      duration: { start: ${startLit}, end: ${endLit}, step: 
MINUTE }\n` +
-              `    ) { type error results { values { value } } }`,
+            buildMqeFragment(
+              alias(sIdx, cIdx),
+              { expression, serviceName: svc.value, normal: svc.normal !== 
false },
+              window,
+            ),
           );
         });
       });
@@ -280,17 +333,16 @@ export function registerLandingRoute(app: 
FastifyInstance, deps: LandingRouteDep
         try {
           mqeData = await graphqlPost<Record<string, MqeResultShape>>(opts, 
batchQuery);
         } catch {
-          // Soft-fail: leave mqeData empty, all cells render as null.
           mqeData = {};
         }
       }
 
-      // Step 3 — assemble rows.
+      // Step 3 — assemble per-row metrics (pre-aggregation, post-scale).
       const rows: LandingServiceRow[] = sampled.map((svc, sIdx) => {
         const metrics: Record<string, number | null> = {};
         resolved.forEach(({ column }, cIdx) => {
-          const a = alias(sIdx, cIdx);
-          metrics[column.metric] = collapseToScalar(mqeData[a]);
+          const raw = collapseToScalar(mqeData[alias(sIdx, cIdx)]);
+          metrics[column.metric] = postProcess(raw, column.scale, 
column.precision);
         });
         return {
           serviceId: svc.id,
@@ -301,62 +353,127 @@ export function registerLandingRoute(app: 
FastifyInstance, deps: LandingRouteDep
         };
       });
 
-      // Step 4 — sort by orderBy desc, with nulls last; slice topN.
+      // Step 4 — sort + slice.
       rows.sort((a, b) => {
-        const av = a.metrics[orderBy];
-        const bv = b.metrics[orderBy];
+        const av = a.metrics[cfg.orderBy];
+        const bv = b.metrics[cfg.orderBy];
         if (av == null && bv == null) return 
a.serviceName.localeCompare(b.serviceName);
         if (av == null) return 1;
         if (bv == null) return -1;
         return bv - av;
       });
+      const topRows = rows.slice(0, cfg.topN);
 
-      const topRows = rows.slice(0, topN);
-
-      // Step 5 — sparkline series for the surviving topN only.
-      // Skipped when no spark metric was requested or its expression
-      // can't be resolved for this layer.
-      const sparkExpr = sparkMetric
-        ? expressionForServiceMetric(sparkMetric, layerKey)
+      // Step 5 — sparkline + throughput series for the surviving topN.
+      const sparkExpr = cfg.spark ? resolveMqe(cfg.spark.metric, undefined, 
layerKey) : null;
+      const throughputCol = cfg.throughput;
+      const throughputExpr = throughputCol
+        ? resolveMqe(throughputCol.metric, throughputCol.mqe, layerKey)
         : null;
-      if (sparkExpr && topRows.length > 0) {
+
+      // The throughput tile reuses one MQE call per surviving row — but
+      // only when it's a different expression than the column already
+      // fetched. Most setups will pick throughput = orderBy, so we just
+      // reuse `rows` values in that case.
+      const sparkSeriesByRow = new Map<number, Array<number | null>>();
+      const throughputSeriesByRow = new Map<number, Array<number | null>>();
+
+      if ((sparkExpr || throughputExpr) && topRows.length > 0) {
         const sparkFragments: string[] = [];
-        const sparkAliasFor = (i: number) => `s${i}`;
         topRows.forEach((row, i) => {
           const svc = sampled.find((s) => s.id === row.serviceId);
           if (!svc) return;
-          const isNormal = svc.normal === false ? 'false' : 'true';
-          sparkFragments.push(
-            `${sparkAliasFor(i)}: execExpression(\n` +
-              `      expression: ${JSON.stringify(sparkExpr)},\n` +
-              `      entity: { scope: Service, serviceName: 
${JSON.stringify(svc.value)}, normal: ${isNormal} },\n` +
-              `      duration: { start: ${JSON.stringify(window.start)}, end: 
${JSON.stringify(window.end)}, step: MINUTE }\n` +
-              `    ) { type error results { values { value } } }`,
-          );
+          const r: MqeRequest = { expression: '', serviceName: svc.value, 
normal: svc.normal !== false };
+          if (sparkExpr) {
+            sparkFragments.push(buildMqeFragment(`s${i}`, { ...r, expression: 
sparkExpr }, window));
+          }
+          if (throughputExpr && throughputExpr !== sparkExpr) {
+            sparkFragments.push(buildMqeFragment(`t${i}`, { ...r, expression: 
throughputExpr }, window));
+          }
         });
         if (sparkFragments.length > 0) {
           const sparkQuery = `query LandingSpark { ${sparkFragments.join('\n   
 ')} }`;
           try {
             const sparkData = await graphqlPost<Record<string, 
MqeResultShape>>(opts, sparkQuery);
             topRows.forEach((row, i) => {
-              const series = collapseToSeries(sparkData[sparkAliasFor(i)]);
-              if (series) row.spark = series;
+              const sk = sparkExpr ? collapseToSeries(sparkData[`s${i}`]) : 
null;
+              if (sk && cfg.spark) {
+                // Spark inherits the orderBy column's scale/precision if
+                // we have a matching column; otherwise raw.
+                const matchedCol = cfg.columns.find((c) => c.metric === 
cfg.spark!.metric);
+                const scaled = sk.map((v) =>
+                  postProcess(v, matchedCol?.scale, matchedCol?.precision),
+                );
+                row.spark = scaled;
+                sparkSeriesByRow.set(i, scaled);
+              }
+              if (throughputExpr) {
+                const series =
+                  throughputExpr === sparkExpr
+                    ? sk
+                    : collapseToSeries(sparkData[`t${i}`]);
+                if (series) {
+                  const scaled = series.map((v) =>
+                    postProcess(v, throughputCol?.scale, 
throughputCol?.precision),
+                  );
+                  throughputSeriesByRow.set(i, scaled);
+                }
+              }
             });
           } catch {
-            // Soft-fail: card renders without sparkline column data.
+            // Soft-fail: leave spark / throughput-spark empty.
           }
         }
       }
 
+      // Step 6 — aggregates for the KPI tile.
+      const aggregates: LandingAggregates = {
+        serviceCount: totalServiceCount,
+        metrics: {},
+      };
+      for (const col of cfg.columns) {
+        const kind: AggregationKind = col.aggregation ?? 'avg';
+        aggregates.metrics[col.metric] = aggregate(
+          topRows.map((r) => r.metrics[col.metric] ?? null),
+          kind,
+        );
+      }
+      if (throughputCol) {
+        const kind: AggregationKind = throughputCol.aggregation ?? 'sum';
+        // Value: either reuse the per-row column value (when throughput
+        // matches a column) or compute it now from the throughput series.
+        const matchingCol = cfg.columns.find((c) => c.metric === 
throughputCol.metric);
+        const values = matchingCol
+          ? topRows.map((r) => r.metrics[throughputCol.metric] ?? null)
+          : topRows.map((_, i) => {
+              const series = throughputSeriesByRow.get(i);
+              if (!series) return null;
+              const finite = series.filter((v): v is number => v !== null);
+              if (finite.length === 0) return null;
+              return finite.reduce((a, b) => a + b, 0) / finite.length;
+            });
+        aggregates.throughputMetric = throughputCol.metric;
+        aggregates.throughputValue = aggregate(values, kind);
+        const seriesList = topRows.map((_, i) => throughputSeriesByRow.get(i));
+        aggregates.spark = aggregateSeries(seriesList, kind);
+      } else if (cfg.spark) {
+        // No throughput configured — surface the spark metric's aggregated
+        // series as a fallback so the KPI tile still has a trend line.
+        const kind: AggregationKind = 'avg';
+        const seriesList = topRows.map((_, i) => sparkSeriesByRow.get(i));
+        aggregates.spark = aggregateSeries(seriesList, kind);
+      }
+
       const body: LandingResponse = {
         layer: layerKey,
-        topN,
-        orderBy,
+        topN: cfg.topN,
+        orderBy: cfg.orderBy,
         generatedAt: Date.now(),
         step: 'MINUTE',
         durationStart: window.start,
         durationEnd: window.end,
         rows: topRows,
+        aggregates,
         reachable: true,
       };
       return reply.send(body);
diff --git a/apps/bff/src/setup/routes.ts b/apps/bff/src/setup/routes.ts
index df0e5a5..965a384 100644
--- a/apps/bff/src/setup/routes.ts
+++ b/apps/bff/src/setup/routes.ts
@@ -32,11 +32,29 @@ export interface SetupRouteDeps {
   store: SetupStore;
 }
 
+const aggregationSchema = z.enum(['sum', 'avg']);
+
 const landingColumnSchema = z
   .object({
     metric: z.string().min(1),
     label: z.string().min(1),
     unit: z.string().optional(),
+    mqe: z.string().optional(),
+    aggregation: aggregationSchema.optional(),
+    scale: z.number().finite().optional(),
+    precision: z.number().int().min(0).max(6).optional(),
+  })
+  .strict();
+
+const throughputSchema = z
+  .object({
+    metric: z.string().min(1),
+    label: z.string().optional(),
+    unit: z.string().optional(),
+    mqe: z.string().optional(),
+    aggregation: aggregationSchema.optional(),
+    scale: z.number().finite().optional(),
+    precision: z.number().int().min(0).max(6).optional(),
   })
   .strict();
 
@@ -53,6 +71,7 @@ const landingSchema = z
       })
       .strict()
       .optional(),
+    throughput: throughputSchema.optional(),
     style: z.enum(['table', 'bar', 'mini-topology']),
   })
   .strict();
diff --git a/apps/ui/src/api/client.ts b/apps/ui/src/api/client.ts
index 7516966..d84c055 100644
--- a/apps/ui/src/api/client.ts
+++ b/apps/ui/src/api/client.ts
@@ -16,6 +16,7 @@
  */
 
 import type {
+  LandingConfig,
   LandingResponse,
   MenuResponse,
   OapInfo,
@@ -38,14 +39,6 @@ export type {
   LandingServiceRow,
 } from '@skywalking-horizon-ui/api-client';
 
-/** Params accepted by `GET /api/layer/:key/landing`. */
-export interface LandingQuery {
-  topN: number;
-  orderBy: string;
-  columns: ReadonlyArray<{ metric: string; label: string; unit?: string }>;
-  /** Optional sparkline metric — not yet rendered server-side. */
-  spark?: string;
-}
 
 export interface MeResponse {
   username: string;
@@ -137,18 +130,20 @@ export class BffClient {
   }
 
   // ── landing (per-layer top-N) ────────────────────────────────────────
-  layerLanding(layerKey: string, q: LandingQuery): Promise<LandingResponse> {
-    const params = new URLSearchParams({
-      topN: String(q.topN),
-      orderBy: q.orderBy,
-      columns: q.columns.map((c) => c.metric).join(','),
-      labels: q.columns.map((c) => c.label).join('|'),
-      units: q.columns.map((c) => c.unit ?? '').join('|'),
-    });
-    if (q.spark) params.set('spark', q.spark);
+  layerLanding(layerKey: string, cfg: LandingConfig): Promise<LandingResponse> 
{
+    // The wire payload mirrors LandingConfig minus the priority/style
+    // bits the BFF doesn't care about.
+    const body = {
+      topN: cfg.topN,
+      orderBy: cfg.orderBy,
+      columns: cfg.columns,
+      ...(cfg.spark ? { spark: cfg.spark } : {}),
+      ...(cfg.throughput ? { throughput: cfg.throughput } : {}),
+    };
     return this.request<LandingResponse>(
-      'GET',
-      
`/api/layer/${encodeURIComponent(layerKey)}/landing?${params.toString()}`,
+      'POST',
+      `/api/layer/${encodeURIComponent(layerKey)}/landing`,
+      body,
     );
   }
 
diff --git a/apps/ui/src/composables/useLayerLanding.ts 
b/apps/ui/src/composables/useLayerLanding.ts
index 0b83c44..e1fe790 100644
--- a/apps/ui/src/composables/useLayerLanding.ts
+++ b/apps/ui/src/composables/useLayerLanding.ts
@@ -34,21 +34,23 @@ export function useLayerLanding(
   cfg: Ref<LandingConfig>,
 ) {
   const layerKey = computed(() => layer.value.key);
-  const columnsKey = computed(() => cfg.value.columns.map((c) => 
c.metric).join(','));
-  const sparkKey = computed(() => cfg.value.spark?.metric ?? '');
+  // Cache key reflects every field that changes the server response —
+  // when an operator edits aggregation / MQE override / scale via setup,
+  // vue-query re-fetches.
+  const cfgHash = computed(() => JSON.stringify({
+    topN: cfg.value.topN,
+    orderBy: cfg.value.orderBy,
+    columns: cfg.value.columns,
+    spark: cfg.value.spark,
+    throughput: cfg.value.throughput,
+  }));
 
   const q = useQuery({
-    queryKey: ['layer-landing', layerKey, computed(() => cfg.value.topN), 
computed(() => cfg.value.orderBy), columnsKey, sparkKey],
-    queryFn: () => bffClient.layerLanding(layerKey.value, {
-      topN: cfg.value.topN,
-      orderBy: cfg.value.orderBy,
-      columns: cfg.value.columns,
-      ...(cfg.value.spark ? { spark: cfg.value.spark.metric } : {}),
-    }),
+    queryKey: ['layer-landing', layerKey, cfgHash],
+    queryFn: () => bffClient.layerLanding(layerKey.value, cfg.value),
     staleTime: 45_000,
     refetchInterval: 60_000,
     refetchOnWindowFocus: true,
-    // Don't pound the server on a known-broken layer key.
     retry: 1,
   });
 
diff --git a/apps/ui/src/stores/setup.ts b/apps/ui/src/stores/setup.ts
index 0a885c5..79d82c4 100644
--- a/apps/ui/src/stores/setup.ts
+++ b/apps/ui/src/stores/setup.ts
@@ -18,6 +18,7 @@
 import { defineStore } from 'pinia';
 import { computed, reactive, ref } from 'vue';
 import type {
+  AggregationKind,
   LandingConfig,
   LayerCaps,
   LayerConfig,
@@ -42,13 +43,50 @@ function defaultPriority(layerKey: string): number {
   return 99;
 }
 
+/**
+ * Sensible default aggregation per metric. Throughput-shaped keys (cpm,
+ * msg-rate, qps, pv, invocations, tokens, page views, requests) default
+ * to `sum` so the layer-wide KPI tile reflects whole-layer traffic.
+ * Latency / SLA / percentile / error / apdex default to `avg`.
+ */
+function defaultAggregationFor(metricKey: string): AggregationKind {
+  const k = metricKey.toLowerCase();
+  if (
+    k === 'cpm' ||
+    k.endsWith('.msg-rate') ||
+    k.endsWith('.qps') ||
+    k.endsWith('.pv') ||
+    k.endsWith('.invocations') ||
+    k.endsWith('.tokens') ||
+    k.endsWith('.req') ||
+    k.endsWith('.slow-queries') ||
+    k.endsWith('.js-err') ||
+    k.endsWith('.cold-start') ||
+    k.endsWith('.restart')
+  ) {
+    return 'sum';
+  }
+  return 'avg';
+}
+
 export function defaultLandingFor(layerKey: string): LandingConfig {
+  const cols = defaultColumnsForLayer(layerKey).map((c) => ({
+    ...c,
+    aggregation: defaultAggregationFor(c.metric),
+  }));
+  const sparkMetric = defaultSparkForLayer(layerKey);
   return {
     priority: defaultPriority(layerKey),
     topN: 5,
     orderBy: defaultOrderByForLayer(layerKey),
-    columns: defaultColumnsForLayer(layerKey),
-    spark: { metric: defaultSparkForLayer(layerKey), height: 28 },
+    columns: cols,
+    spark: { metric: sparkMetric, height: 28 },
+    // Throughput tile defaults to the orderBy metric — operator can
+    // override or remove via Setup. `sum` matches whole-layer traffic.
+    throughput: {
+      metric: defaultOrderByForLayer(layerKey),
+      aggregation: defaultAggregationFor(defaultOrderByForLayer(layerKey)),
+    },
     style: 'table',
   };
 }
diff --git a/apps/ui/src/views/overview/AlarmsPanel.vue 
b/apps/ui/src/views/overview/AlarmsPanel.vue
new file mode 100644
index 0000000..df03c3b
--- /dev/null
+++ b/apps/ui/src/views/overview/AlarmsPanel.vue
@@ -0,0 +1,171 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one or more
+  contributor license agreements.  See the NOTICE file distributed with
+  this work for additional information regarding copyright ownership.
+  The ASF licenses this file to You under the Apache License, Version 2.0
+  (the "License"); you may not use this file except in compliance with
+  the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+-->
+<!--
+  Active-alarms panel on the Overview. Phase 5 wires this to OAP's
+  `getAlarm` GraphQL query; for now it renders a structural placeholder
+  so the Overview layout matches the design spec ahead of the data
+  plumbing.
+
+  Alarms are read-only per the project's operator-model decision —
+  recovery is OAP-side automatic, no acknowledge / close / silence here.
+-->
+<script setup lang="ts">
+import { RouterLink } from 'vue-router';
+import Icon from '@/components/icons/Icon.vue';
+
+// Static seed rows from the design prototype — visually communicates
+// the panel's shape until the Phase 5 OAP query is wired in. Kept
+// internal so removing them later is a one-file change.
+const placeholderRows = [
+  { sev: 'err', rule: 'Service SLA below 98%', scope: '— · awaiting OAP 
getAlarm', since: '—' },
+  { sev: 'warn', rule: 'JVM Old GC > 5s/min', scope: '— · awaiting OAP 
getAlarm', since: '—' },
+  { sev: 'info', rule: 'Deployment detected', scope: '— · awaiting OAP 
getAlarm', since: '—' },
+];
+</script>
+
+<template>
+  <section class="sw-card alarms-panel">
+    <header class="head">
+      <h4>Active alarms</h4>
+      <span class="sw-badge">Phase 5</span>
+      <RouterLink class="all-link" to="/alarms">
+        <span>View all</span>
+        <Icon name="chev" :size="10" />
+      </RouterLink>
+    </header>
+    <div class="body">
+      <p class="lede">
+        Read-only — alarms recover backend-side. This panel will surface live 
entries from
+        <code>getAlarm</code> once Phase 5 lands. The layout below is a 
placeholder.
+      </p>
+      <div class="rows">
+        <div v-for="(a, i) in placeholderRows" :key="i" class="alarm-row">
+          <span class="sw-badge dot" :class="a.sev">{{ a.sev }}</span>
+          <div class="alarm-text">
+            <div class="rule">{{ a.rule }}</div>
+            <div class="scope">{{ a.scope }}</div>
+          </div>
+          <span class="since">{{ a.since }}</span>
+        </div>
+      </div>
+    </div>
+  </section>
+</template>
+
+<style scoped>
+.alarms-panel {
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
+}
+.head {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 10px 12px;
+  border-bottom: 1px solid var(--sw-line);
+}
+.head h4 {
+  margin: 0;
+  font-size: 12px;
+  font-weight: 600;
+  color: var(--sw-fg-0);
+  letter-spacing: -0.01em;
+}
+.all-link {
+  margin-left: auto;
+  font-size: 11px;
+  color: var(--sw-fg-2);
+  text-decoration: none;
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+}
+.all-link:hover {
+  color: var(--sw-accent-2);
+}
+.body {
+  padding: 10px 12px 12px;
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+  flex: 1;
+}
+.lede {
+  margin: 0;
+  font-size: 10.5px;
+  color: var(--sw-fg-3);
+  line-height: 1.5;
+}
+.lede code {
+  font-family: var(--sw-mono);
+  font-size: 10px;
+  color: var(--sw-fg-2);
+  background: var(--sw-bg-2);
+  padding: 1px 4px;
+  border-radius: 3px;
+}
+.rows {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+.alarm-row {
+  display: flex;
+  align-items: flex-start;
+  gap: 8px;
+  padding: 6px 0;
+  border-top: 1px solid var(--sw-line);
+  font-size: 11px;
+}
+.alarm-row:first-child {
+  border-top: none;
+}
+.sw-badge.dot::before {
+  content: '';
+  width: 5px;
+  height: 5px;
+  border-radius: 50%;
+  background: currentColor;
+  display: inline-block;
+  margin-right: 3px;
+}
+.alarm-text {
+  flex: 1;
+  min-width: 0;
+}
+.alarm-text .rule {
+  color: var(--sw-fg-1);
+  font-weight: 500;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.alarm-text .scope {
+  font-family: var(--sw-mono);
+  color: var(--sw-fg-3);
+  font-size: 10px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.since {
+  color: var(--sw-fg-3);
+  font-size: 10px;
+  flex: 0 0 auto;
+}
+</style>
diff --git a/apps/ui/src/views/overview/LayerKpiTile.vue 
b/apps/ui/src/views/overview/LayerKpiTile.vue
new file mode 100644
index 0000000..76dd4c7
--- /dev/null
+++ b/apps/ui/src/views/overview/LayerKpiTile.vue
@@ -0,0 +1,262 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one or more
+  contributor license agreements.  See the NOTICE file distributed with
+  this work for additional information regarding copyright ownership.
+  The ASF licenses this file to You under the Apache License, Version 2.0
+  (the "License"); you may not use this file except in compliance with
+  the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+-->
+<!--
+  Compact per-layer KPI tile used for the lower 4 layers on the Overview
+  grid. Shows the layer's aggregated throughput value + sparkline + a
+  short row of inline aggregated metrics. No top-N service table — for
+  that the operator clicks through to the layer detail page (which is
+  also the title link).
+
+  Aggregations come from /api/layer/:key/landing.aggregates, which the
+  BFF computes using the per-column setup config.
+-->
+<script setup lang="ts">
+import { computed, toRef } from 'vue';
+import { RouterLink } from 'vue-router';
+import type { LayerDef } from '@skywalking-horizon-ui/api-client';
+import { metricMeta } from '@/composables/metricCatalog';
+import { useLayerLanding } from '@/composables/useLayerLanding';
+import { useSetupStore } from '@/stores/setup';
+import { fmtMetric } from '@/utils/formatters';
+import Sparkline from '@/components/charts/Sparkline.vue';
+
+const props = defineProps<{ layer: LayerDef }>();
+const store = useSetupStore();
+const cfg = computed(() =>
+  store.ensure(props.layer.key, { slots: props.layer.slots, caps: 
props.layer.caps }),
+);
+const landingCfg = computed(() => cfg.value.landing);
+const layerRef = toRef(props, 'layer');
+const landing = useLayerLanding(layerRef, landingCfg);
+
+const aggregates = computed(() => landing.data.value?.aggregates ?? null);
+const throughputKey = computed(() => aggregates.value?.throughputMetric ?? 
cfg.value.landing.orderBy);
+const throughputValue = computed(() => {
+  const a = aggregates.value;
+  if (!a) return null;
+  if (a.throughputValue !== undefined && a.throughputValue !== null) return 
a.throughputValue;
+  return a.metrics?.[throughputKey.value] ?? null;
+});
+const throughputMeta = computed(() => metricMeta(throughputKey.value));
+const throughputSeries = computed(() => aggregates.value?.spark ?? null);
+
+// Inline mini-row: up to 3 secondary metrics, skipping the throughput
+// one so we don't double-count it on a narrow tile.
+const secondaryMetrics = computed(() =>
+  cfg.value.landing.columns
+    .filter((c) => c.metric !== throughputKey.value)
+    .slice(0, 3),
+);
+const serviceCount = computed(() => aggregates.value?.serviceCount ?? 
props.layer.serviceCount);
+const detailHref = computed(() => `/layer/${props.layer.key}/services`);
+const isLoading = computed(() => landing.isLoading.value && !aggregates.value);
+const hasError = computed(() => !!landing.error.value);
+</script>
+
+<template>
+  <section class="sw-card kpi-tile" :class="{ loading: isLoading }">
+    <header class="head">
+      <span class="dot" :style="{ background: layer.color }" />
+      <RouterLink class="title" :to="detailHref">{{ cfg.displayName || 
layer.name }}</RouterLink>
+      <span class="svc-count">{{ serviceCount >= 0 ? `${serviceCount} svc` : 
'—' }}</span>
+    </header>
+
+    <div class="primary">
+      <div class="primary-stack">
+        <div class="primary-label" :title="throughputMeta.tip">
+          {{ aggregates?.throughputMetric ? 'Throughput' : 
throughputMeta.label }}
+        </div>
+        <div class="primary-value">
+          <span class="num" :class="{ muted: throughputValue == null }">
+            {{ fmtMetric(throughputValue) }}
+          </span>
+          <span v-if="throughputMeta.unit" class="unit">{{ throughputMeta.unit 
}}</span>
+        </div>
+        <div class="primary-sub">
+          {{ throughputMeta.longLabel }} ·
+          <span class="kicker">avg of top {{ landing.rows.value.length || 
cfg.landing.topN }}</span>
+        </div>
+      </div>
+      <div class="primary-spark">
+        <Sparkline
+          v-if="throughputSeries && throughputSeries.length > 1"
+          :values="throughputSeries"
+          :width="84"
+          :height="26"
+          :color="layer.color"
+        />
+        <span v-else class="empty-spark">—</span>
+      </div>
+    </div>
+
+    <div class="secondary">
+      <div
+        v-for="m in secondaryMetrics"
+        :key="m.metric"
+        class="metric-pill"
+        
:title="`${metricMeta(m.metric).longLabel}\n\n${metricMeta(m.metric).tip}`"
+      >
+        <span class="metric-label">{{ m.label }}</span>
+        <span class="metric-value" :class="{ muted: 
aggregates?.metrics?.[m.metric] == null }">
+          {{ fmtMetric(aggregates?.metrics?.[m.metric]) }}<span v-if="m.unit" 
class="unit">{{ m.unit }}</span>
+        </span>
+      </div>
+      <span v-if="hasError" class="err-chip" 
:title="landing.error.value">err</span>
+    </div>
+  </section>
+</template>
+
+<style scoped>
+.kpi-tile {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  padding: 10px 12px;
+  min-width: 0;
+}
+.kpi-tile.loading {
+  opacity: 0.75;
+}
+.head {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-size: 11.5px;
+}
+.head .dot {
+  width: 7px;
+  height: 7px;
+  border-radius: 50%;
+  flex: 0 0 7px;
+}
+.head .title {
+  color: var(--sw-fg-0);
+  font-weight: 600;
+  text-decoration: none;
+  letter-spacing: -0.01em;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  flex: 1;
+  min-width: 0;
+}
+.head .title:hover {
+  color: var(--sw-accent-2);
+}
+.svc-count {
+  font-size: 10px;
+  color: var(--sw-fg-3);
+  font-variant-numeric: tabular-nums;
+}
+.primary {
+  display: flex;
+  align-items: flex-end;
+  gap: 8px;
+  border-top: 1px solid var(--sw-line);
+  padding-top: 8px;
+  min-height: 50px;
+}
+.primary-stack {
+  flex: 1;
+  min-width: 0;
+}
+.primary-label {
+  font-size: 9.5px;
+  text-transform: uppercase;
+  letter-spacing: 0.08em;
+  color: var(--sw-fg-3);
+}
+.primary-value {
+  display: flex;
+  align-items: baseline;
+  gap: 3px;
+  margin-top: 2px;
+}
+.primary-value .num {
+  font-size: 18px;
+  font-weight: 600;
+  color: var(--sw-fg-0);
+  font-variant-numeric: tabular-nums;
+  letter-spacing: -0.02em;
+}
+.primary-value .num.muted {
+  color: var(--sw-fg-3);
+}
+.primary-value .unit {
+  color: var(--sw-fg-3);
+  font-size: 10.5px;
+}
+.primary-sub {
+  font-size: 10px;
+  color: var(--sw-fg-3);
+  margin-top: 1px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.primary-sub .kicker {
+  color: var(--sw-fg-3);
+}
+.primary-spark {
+  flex: 0 0 auto;
+  display: flex;
+  align-items: center;
+}
+.empty-spark {
+  color: var(--sw-fg-3);
+  font-size: 10px;
+  width: 84px;
+  text-align: center;
+}
+.secondary {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 6px;
+  border-top: 1px dashed var(--sw-line);
+  padding-top: 6px;
+}
+.metric-pill {
+  display: inline-flex;
+  align-items: baseline;
+  gap: 4px;
+  font-size: 10.5px;
+  padding: 2px 6px;
+  background: var(--sw-bg-2);
+  border-radius: 4px;
+}
+.metric-label {
+  color: var(--sw-fg-3);
+}
+.metric-value {
+  color: var(--sw-fg-0);
+  font-variant-numeric: tabular-nums;
+  font-weight: 500;
+}
+.metric-value.muted {
+  color: var(--sw-fg-3);
+}
+.metric-value .unit {
+  color: var(--sw-fg-3);
+  font-size: 9.5px;
+  margin-left: 1px;
+}
+.err-chip {
+  margin-left: auto;
+  font-size: 10px;
+  color: var(--sw-warn);
+}
+</style>
diff --git a/apps/ui/src/views/overview/OverviewView.vue 
b/apps/ui/src/views/overview/OverviewView.vue
index ecf88c6..b6eccc3 100644
--- a/apps/ui/src/views/overview/OverviewView.vue
+++ b/apps/ui/src/views/overview/OverviewView.vue
@@ -19,13 +19,25 @@ import { computed } from 'vue';
 import { RouterLink } from 'vue-router';
 import { useLayers } from '@/composables/useLayers';
 import { useLandingOrder } from '@/composables/useLandingOrder';
+import AlarmsPanel from './AlarmsPanel.vue';
+import LayerKpiTile from './LayerKpiTile.vue';
 import LayerLandingCard from './LayerLandingCard.vue';
 
 const { availableLayers, oapReachable, oapError, isLoading } = useLayers();
 const orderedLayers = useLandingOrder(availableLayers);
 
-// Empty only when no layer is reporting services. Otherwise every
-// available layer auto-renders a card per its setup config.
+/* Top 6 only — beyond six the page gets noisy and the user has to
+ * scroll past low-priority layers to reach the Alarms / Throughput
+ * panels. Operators with >6 reporting layers can re-order via setup
+ * to surface the ones that matter on the Overview. */
+const topSix = computed(() => orderedLayers.value.slice(0, 6));
+/* Featured pair = top-2 by priority. These get the full LayerLandingCard
+ * with the topN service table. */
+const featured = computed(() => topSix.value.slice(0, 2));
+/* The next 4 render as compact LayerKpiTile (KPI + sparkline + inline
+ * aggregates, no service table). 4 fits a 2×2 grid neatly. */
+const compact = computed(() => topSix.value.slice(2, 6));
+const overflow = computed(() => Math.max(0, orderedLayers.value.length - 6));
 const empty = computed(() => !isLoading.value && orderedLayers.value.length 
=== 0);
 </script>
 
@@ -36,9 +48,8 @@ const empty = computed(() => !isLoading.value && 
orderedLayers.value.length ===
         <div class="kicker">Overview</div>
         <h1>Cross-layer landing</h1>
         <p class="lede">
-          Every layer reporting services renders a card here, in the order 
each layer's priority
-          defines. Each card shows the top services for that layer with its 
configured metrics —
-          adjust per-layer priority, top-N, and columns in
+          The top 2 layers (by priority) get full top-N cards; the next 4 
render as compact KPI
+          tiles. Adjust priority, columns, aggregation, and the throughput 
metric in
           <RouterLink to="/setup">Overview setup</RouterLink>.
         </p>
       </div>
@@ -57,14 +68,32 @@ const empty = computed(() => !isLoading.value && 
orderedLayers.value.length ===
           ordered by the priority you assign in
           <RouterLink to="/setup">Overview setup</RouterLink>.
         </p>
-        <RouterLink class="sw-btn is-primary" to="/setup">
-          Open Overview setup
-        </RouterLink>
+        <RouterLink class="sw-btn is-primary" to="/setup">Open Overview 
setup</RouterLink>
       </div>
     </div>
 
-    <div v-else class="cards">
-      <LayerLandingCard v-for="L in orderedLayers" :key="L.key" :layer="L" />
+    <div v-else class="overview-grid">
+      <!-- Top 2 featured cards: side-by-side, each 2/5 of the page width. -->
+      <LayerLandingCard
+        v-for="L in featured"
+        :key="L.key"
+        :layer="L"
+        :class="`featured featured-${featured.indexOf(L) + 1}`"
+      />
+
+      <!-- Alarms rail: pinned to the right column, rowspans all visible rows. 
-->
+      <AlarmsPanel class="alarms-rail" />
+
+      <!-- Compact tiles: 2x2 grid filling 3/5 of the page width across 2 
rows. -->
+      <div class="compact-grid">
+        <LayerKpiTile v-for="L in compact" :key="L.key" :layer="L" />
+      </div>
+
+      <div v-if="overflow > 0" class="overflow-note">
+        {{ overflow }} more layer{{ overflow === 1 ? '' : 's' }} not shown.
+        <RouterLink to="/setup">Re-order via setup</RouterLink>
+        to surface them.
+      </div>
     </div>
   </div>
 </template>
@@ -144,13 +173,81 @@ const empty = computed(() => !isLoading.value && 
orderedLayers.value.length ===
   color: var(--sw-accent-2);
   text-decoration: none;
 }
-.cards {
-  /* Auto-flow grid: as wide as 4 columns when there's room (>~1380 px),
-   * collapses to 3 / 2 / 1 as the viewport narrows. Each card holds its
-   * minmax-floor so the table stays legible. */
+
+/* Layout — 5fr grid:
+ *  Row 1: featured-1 (2/5) · featured-2 (2/5) · alarms-rail (1/5)
+ *  Row 2: compact-grid (4 tiles, 2x2 — 3/5 wide) ·   alarms-rail (continues, 
2/5)
+ *
+ * The alarms rail widens from 1/5 in row 1 to 2/5 in row 2 — driven by
+ * the compact-grid only occupying 3/5 of the row. This matches the
+ * "top 2 = 2/5 each, other 4 = 3/5" allocation in the operator brief.
+ */
+.overview-grid {
   display: grid;
-  grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
+  grid-template-columns: 2fr 2fr 1fr;
+  grid-template-rows: auto auto;
+  grid-template-areas:
+    'feat1 feat2 alarms'
+    'compact compact alarms';
   gap: 14px;
   align-items: start;
 }
+.featured-1 {
+  grid-area: feat1;
+  min-width: 0;
+}
+.featured-2 {
+  grid-area: feat2;
+  min-width: 0;
+}
+.alarms-rail {
+  grid-area: alarms;
+  min-width: 0;
+  align-self: stretch;
+}
+.compact-grid {
+  grid-area: compact;
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 14px;
+  min-width: 0;
+}
+.overflow-note {
+  grid-column: 1 / -1;
+  font-size: 11px;
+  color: var(--sw-fg-3);
+  padding: 4px 8px;
+}
+.overflow-note a {
+  color: var(--sw-accent-2);
+  text-decoration: none;
+}
+
+/* Below ~1100px the rail crowds the featured cards — fall back to a
+ * single column. The Alarms panel still has utility at narrow widths. */
+@media (max-width: 1100px) {
+  .overview-grid {
+    grid-template-columns: 1fr 1fr;
+    grid-template-areas:
+      'feat1 feat2'
+      'compact compact'
+      'alarms alarms';
+  }
+  .compact-grid {
+    grid-template-columns: 1fr 1fr;
+  }
+}
+@media (max-width: 720px) {
+  .overview-grid {
+    grid-template-columns: 1fr;
+    grid-template-areas:
+      'feat1'
+      'feat2'
+      'compact'
+      'alarms';
+  }
+  .compact-grid {
+    grid-template-columns: 1fr;
+  }
+}
 </style>
diff --git a/apps/ui/src/views/setup/LayerSetupCard.vue 
b/apps/ui/src/views/setup/LayerSetupCard.vue
index cb2b04e..dd09c3e 100644
--- a/apps/ui/src/views/setup/LayerSetupCard.vue
+++ b/apps/ui/src/views/setup/LayerSetupCard.vue
@@ -16,11 +16,33 @@
 -->
 <script setup lang="ts">
 import { computed, ref } from 'vue';
-import type { LayerDef } from '@skywalking-horizon-ui/api-client';
+import type { AggregationKind, LayerDef } from 
'@skywalking-horizon-ui/api-client';
 import Icon from '@/components/icons/Icon.vue';
 import { METRICS, metricsForLayer } from '@/composables/metricCatalog';
 import { useSetupStore, defaultLandingFor } from '@/stores/setup';
 
+/** Mirror of the setup-store's defaultAggregationFor — kept inline so the
+ *  setup UI seeds new columns with the same defaults the store uses. */
+function defaultAgg(metricKey: string): AggregationKind {
+  const k = metricKey.toLowerCase();
+  if (
+    k === 'cpm' ||
+    k.endsWith('.msg-rate') ||
+    k.endsWith('.qps') ||
+    k.endsWith('.pv') ||
+    k.endsWith('.invocations') ||
+    k.endsWith('.tokens') ||
+    k.endsWith('.req') ||
+    k.endsWith('.slow-queries') ||
+    k.endsWith('.js-err') ||
+    k.endsWith('.cold-start') ||
+    k.endsWith('.restart')
+  ) {
+    return 'sum';
+  }
+  return 'avg';
+}
+
 const props = defineProps<{ layer: LayerDef; expanded?: boolean }>();
 const emit = defineEmits<{ (e: 'toggle'): void }>();
 
@@ -104,7 +126,26 @@ function toggleColumn(metric: string, label: string, 
unit?: string): void {
   if (idx >= 0) {
     cols.splice(idx, 1);
   } else if (cols.length < 5) {
-    cols.push({ metric, label, ...(unit ? { unit } : {}) });
+    cols.push({
+      metric,
+      label,
+      ...(unit ? { unit } : {}),
+      aggregation: defaultAgg(metric),
+    });
+  }
+  onEdit();
+}
+
+const showAdvanced = ref(false);
+function toggleThroughput(): void {
+  if (cfg.value.landing.throughput) {
+    cfg.value.landing.throughput = undefined;
+  } else {
+    const m = cfg.value.landing.orderBy;
+    cfg.value.landing.throughput = {
+      metric: m,
+      aggregation: defaultAgg(m),
+    };
   }
   onEdit();
 }
@@ -257,6 +298,133 @@ const isDefaultLanding = computed(() => {
         </div>
       </section>
 
+      <section v-if="cfg.landing.columns.length > 0">
+        <div class="row-with-toggle">
+          <h4>Column details</h4>
+          <button class="sw-btn ghost small" type="button" 
@click="showAdvanced = !showAdvanced">
+            {{ showAdvanced ? 'Hide advanced' : 'Show advanced (MQE, scale, 
precision)' }}
+          </button>
+        </div>
+        <table class="col-editor">
+          <thead>
+            <tr>
+              <th>Metric</th>
+              <th>Label</th>
+              <th>Unit</th>
+              <th>Aggregate</th>
+              <template v-if="showAdvanced">
+                <th>MQE override</th>
+                <th>Scale</th>
+                <th>Precision</th>
+              </template>
+            </tr>
+          </thead>
+          <tbody>
+            <tr v-for="col in cfg.landing.columns" :key="col.metric">
+              <td class="metric-key">{{ col.metric }}</td>
+              <td><input class="ctl" v-model="col.label" @input="onEdit" 
/></td>
+              <td><input class="ctl narrow" v-model="col.unit" placeholder="—" 
@input="onEdit" /></td>
+              <td>
+                <select class="ctl narrow" v-model="col.aggregation" 
@change="onEdit">
+                  <option value="avg">avg</option>
+                  <option value="sum">sum</option>
+                </select>
+              </td>
+              <template v-if="showAdvanced">
+                <td>
+                  <input
+                    class="ctl mono"
+                    v-model="col.mqe"
+                    placeholder="catalog default"
+                    title="Paste a custom MQE expression to override the 
built-in mapping (e.g. avg(service_sla)/100)."
+                    @input="onEdit"
+                  />
+                </td>
+                <td>
+                  <input
+                    class="ctl narrow"
+                    type="number"
+                    step="any"
+                    v-model.number="col.scale"
+                    placeholder="1"
+                    title="Multiplier applied to the raw MQE value. Use 0.01 
to convert SkyWalking SLA (9923 → 99.23)."
+                    @input="onEdit"
+                  />
+                </td>
+                <td>
+                  <input
+                    class="ctl narrow"
+                    type="number"
+                    min="0"
+                    max="6"
+                    v-model.number="col.precision"
+                    placeholder="auto"
+                    title="Decimal places to round to before display."
+                    @input="onEdit"
+                  />
+                </td>
+              </template>
+            </tr>
+          </tbody>
+        </table>
+      </section>
+
+      <section>
+        <div class="row-with-toggle">
+          <h4>Throughput KPI</h4>
+          <button class="sw-btn ghost small" type="button" 
@click="toggleThroughput">
+            {{ cfg.landing.throughput ? 'Remove' : 'Add' }}
+          </button>
+        </div>
+        <p class="hint subtle">
+          The headline metric on this layer's Overview tile. Aggregation 
defaults to
+          <code>sum</code> (whole-layer traffic) when off; switch to 
<code>avg</code> for
+          ratio-shaped metrics.
+        </p>
+        <div v-if="cfg.landing.throughput" class="field-grid landing">
+          <label>
+            <span>Metric</span>
+            <select v-model="cfg.landing.throughput.metric" @change="onEdit">
+              <option v-for="c in availableColumns" :key="c.metric" 
:value="c.metric" :title="c.tip">
+                {{ c.longLabel }}
+              </option>
+            </select>
+          </label>
+          <label>
+            <span>Aggregation</span>
+            <select v-model="cfg.landing.throughput.aggregation" 
@change="onEdit">
+              <option value="sum">sum</option>
+              <option value="avg">avg</option>
+            </select>
+          </label>
+          <label>
+            <span>Label (optional)</span>
+            <input v-model="cfg.landing.throughput.label" 
placeholder="Throughput" @input="onEdit" />
+          </label>
+          <label>
+            <span>Unit (optional)</span>
+            <input v-model="cfg.landing.throughput.unit" placeholder="—" 
@input="onEdit" />
+          </label>
+          <label class="wide-2">
+            <span>MQE override (optional)</span>
+            <input
+              class="mono"
+              v-model="cfg.landing.throughput.mqe"
+              placeholder="catalog default"
+              @input="onEdit"
+            />
+          </label>
+          <label>
+            <span>Scale</span>
+            <input type="number" step="any" 
v-model.number="cfg.landing.throughput.scale" placeholder="1" @input="onEdit" />
+          </label>
+          <label>
+            <span>Precision</span>
+            <input type="number" min="0" max="6" 
v-model.number="cfg.landing.throughput.precision" placeholder="auto" 
@input="onEdit" />
+          </label>
+        </div>
+      </section>
+
       <div class="actions">
         <button class="sw-btn" type="button" @click="resetThisLayer">Reset to 
defaults</button>
         <span class="hint">Changes are local until persisted via /api/setup 
(Stage 2.4).</span>
@@ -444,4 +612,77 @@ const isDefaultLanding = computed(() => {
   font-size: 10.5px;
   color: var(--sw-fg-3);
 }
+.row-with-toggle {
+  display: flex;
+  align-items: baseline;
+  gap: 10px;
+  margin-bottom: 6px;
+}
+.row-with-toggle h4 {
+  margin: 0;
+}
+.row-with-toggle .sw-btn {
+  margin-left: auto;
+  height: 22px;
+  font-size: 10.5px;
+  padding: 0 8px;
+}
+.hint.subtle {
+  margin: -2px 0 8px;
+  font-size: 10.5px;
+  color: var(--sw-fg-3);
+  line-height: 1.5;
+}
+.hint.subtle code {
+  font-family: var(--sw-mono);
+  font-size: 10px;
+  background: var(--sw-bg-2);
+  padding: 1px 4px;
+  border-radius: 3px;
+}
+.col-editor {
+  width: 100%;
+  border-collapse: collapse;
+  font-size: 11px;
+}
+.col-editor th {
+  text-align: left;
+  font-weight: 500;
+  font-size: 10px;
+  color: var(--sw-fg-3);
+  letter-spacing: 0.04em;
+  padding: 4px 6px 6px;
+  border-bottom: 1px solid var(--sw-line);
+}
+.col-editor td {
+  padding: 4px 6px;
+  vertical-align: middle;
+}
+.col-editor .metric-key {
+  font-family: var(--sw-mono);
+  font-size: 10.5px;
+  color: var(--sw-fg-2);
+}
+.col-editor .ctl {
+  width: 100%;
+  height: 22px;
+  padding: 0 6px;
+  background: var(--sw-bg-2);
+  border: 1px solid var(--sw-line-2);
+  border-radius: 3px;
+  color: var(--sw-fg-0);
+  font: inherit;
+  font-size: 11px;
+}
+.col-editor .ctl.narrow {
+  max-width: 70px;
+}
+.col-editor .ctl.mono,
+.field-grid .mono {
+  font-family: var(--sw-mono);
+  font-size: 10.5px;
+}
+.field-grid label.wide-2 {
+  grid-column: span 2;
+}
 </style>
diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts
index d59458b..a75bc18 100644
--- a/packages/api-client/src/index.ts
+++ b/packages/api-client/src/index.ts
@@ -18,13 +18,15 @@
 export * from './types.js';
 export type { LayerSlots, LayerCaps, LayerDef, MenuResponse } from './menu.js';
 export type {
+  AggregationKind,
   LandingColumn,
   LandingConfig,
   LayerConfig,
   SetupResponse,
   SetupSavePayload,
+  ThroughputConfig,
 } from './setup.js';
-export type { LandingResponse, LandingServiceRow } from './landing.js';
+export type { LandingAggregates, LandingResponse, LandingServiceRow } from 
'./landing.js';
 export type { OapInfo } from './oap-info.js';
 export { parseOapTimezoneMinutes } from './oap-info.js';
 export {
diff --git a/packages/api-client/src/landing.ts 
b/packages/api-client/src/landing.ts
index a2618a9..b1d8c12 100644
--- a/packages/api-client/src/landing.ts
+++ b/packages/api-client/src/landing.ts
@@ -42,6 +42,28 @@ export interface LandingServiceRow {
   spark?: Array<number | null>;
 }
 
+/**
+ * Layer-wide rollup numbers — used by the compact KPI tile on the
+ * Overview page. Aggregations are computed BFF-side using the per-column
+ * `aggregation` field from setup; UI doesn't have to recompute.
+ */
+export interface LandingAggregates {
+  /** Whole-layer service count (pre-topN slice). */
+  serviceCount: number;
+  /** Aggregated value per column metric, keyed by metric short key.
+   *  `null` when the column has no MQE mapping or every cell failed. */
+  metrics: Record<string, number | null>;
+  /** Aggregated sparkline series for the `throughput.metric` (or `spark`)
+   *  using the throughput aggregation. `null` when not configured. */
+  spark?: Array<number | null> | null;
+  /** Echo of the throughput metric key the spark series was computed
+   *  against (so the UI can label the tile). */
+  throughputMetric?: string;
+  /** Value of the throughput metric across the layer (null when
+   *  unconfigured or unmapped). */
+  throughputValue?: number | null;
+}
+
 export interface LandingResponse {
   layer: string;
   topN: number;
@@ -54,6 +76,8 @@ export interface LandingResponse {
   durationStart: string;
   durationEnd: string;
   rows: LandingServiceRow[];
+  /** Whole-layer rollup KPIs for the Overview strip tile. */
+  aggregates: LandingAggregates;
   /**
    * True when the BFF reached OAP and got a service list back. Per-metric
    * MQE errors don't flip this — only `listServices` failures do.
diff --git a/packages/api-client/src/setup.ts b/packages/api-client/src/setup.ts
index d9b988a..3620fd1 100644
--- a/packages/api-client/src/setup.ts
+++ b/packages/api-client/src/setup.ts
@@ -26,13 +26,67 @@
 
 import type { LayerCaps, LayerSlots } from './menu.js';
 
+/**
+ * How to roll up a metric's per-service values into a single layer-wide
+ * KPI. `sum` matches "throughput / counting" semantics (cpm, msg/s,
+ * invocations) — adding service rates gives whole-layer traffic. `avg`
+ * matches "ratio / latency" semantics (sla, p99, err%) — averaging is
+ * the only meaningful collapse.
+ */
+export type AggregationKind = 'sum' | 'avg';
+
 export interface LandingColumn {
-  /** MQE-result key. */
+  /** Short metric key — looked up in the UI catalog for label/unit/tip. */
   metric: string;
   /** Short header label (e.g. `cpm`). */
   label: string;
   /** Suffix unit (`%`, `ms`, etc.). */
   unit?: string;
+  /**
+   * MQE expression override. When set, the BFF passes this verbatim to
+   * `execExpression(...)` instead of looking up the built-in catalog
+   * mapping. Use this when the built-in is wrong, or when the layer
+   * has a custom metric the catalog doesn't know about.
+   */
+  mqe?: string;
+  /**
+   * 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.
+   */
+  aggregation?: AggregationKind;
+  /**
+   * Multiplier applied BFF-side after MQE returns. Use for unit
+   * normalization — e.g. SkyWalking's `service_sla` is integer
+   * percent-times-100 (`9923` for 99.23%), so a `scale: 0.01` brings
+   * it into a familiar `99.23%` range. Default `1` (no-op).
+   */
+  scale?: number;
+  /**
+   * Suggested decimal precision for display. The UI formatter honors
+   * this when present, otherwise picks a sensible default from the
+   * value's magnitude.
+   */
+  precision?: number;
+}
+
+/**
+ * Headline throughput metric for the per-layer KPI strip tile. Optional —
+ * when omitted, the strip falls back to the `orderBy` column's value
+ * (also aggregated per the column's `aggregation` field).
+ */
+export interface ThroughputConfig {
+  /** Short metric key (must match a column or stand alone). */
+  metric: string;
+  /** Display label override (default falls through to the metric catalog). */
+  label?: string;
+  unit?: string;
+  /** MQE override — same semantics as `LandingColumn.mqe`. */
+  mqe?: string;
+  /** Aggregation across services (defaults to `sum`). */
+  aggregation?: AggregationKind;
+  scale?: number;
+  precision?: number;
 }
 
 export interface LandingConfig {
@@ -45,6 +99,8 @@ export interface LandingConfig {
   columns: LandingColumn[];
   /** Optional sparkline column. */
   spark?: { metric: string; height: number };
+  /** Optional headline metric for the per-layer KPI strip tile. */
+  throughput?: ThroughputConfig;
   style: 'table' | 'bar' | 'mini-topology';
 }
 

Reply via email to