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 a3eb8f5  landing: BFF /api/layer/:key/landing + live data in Overview 
cards
a3eb8f5 is described below

commit a3eb8f5ed5abd8fc725eb01050ce60fdaf2d6669
Author: Wu Sheng <[email protected]>
AuthorDate: Tue May 12 15:30:03 2026 +0800

    landing: BFF /api/layer/:key/landing + live data in Overview cards
    
    New endpoint takes a layer key + landing config (orderBy, columns, topN)
    and returns top-N services with their current MQE values, ranked desc
    on orderBy. Sparkline is wired in the response shape but not yet
    populated — Stage 2.6b will fill the trend series.
    
    Per-layer MQE catalog (bff/src/oap/mqe-catalog.ts) maps short metric
    keys to OAP expressions per layer category. General/mesh/k8s reuse the
    RPC service_* names; browser and DB/MQ get conservative subsets that
    fall through to RPC for missing keys. Unmapped (metric, layer) pairs
    become null cells rendered as a muted em-dash.
    
    LayerLandingCard now consumes useLayerLanding() — real service names
    link to the layer-services detail page, numeric cells use a compact
    SI formatter, and the placeholder shimmer only shows while the first
    fetch is in flight. OAP-unreachable case shows a warn banner instead
    of a generic placeholder.
---
 apps/bff/src/oap/landing-routes.ts              | 311 ++++++++++++++++++++++++
 apps/bff/src/oap/mqe-catalog.ts                 | 158 ++++++++++++
 apps/bff/src/server.ts                          |   2 +
 apps/ui/src/api/client.ts                       |  28 +++
 apps/ui/src/composables/useLayerLanding.ts      |  69 ++++++
 apps/ui/src/utils/formatters.ts                 |  40 +++
 apps/ui/src/views/overview/LayerLandingCard.vue |  78 +++++-
 packages/api-client/src/index.ts                |   1 +
 packages/api-client/src/landing.ts              |  64 +++++
 9 files changed, 744 insertions(+), 7 deletions(-)

diff --git a/apps/bff/src/oap/landing-routes.ts 
b/apps/bff/src/oap/landing-routes.ts
new file mode 100644
index 0000000..c1e3505
--- /dev/null
+++ b/apps/bff/src/oap/landing-routes.ts
@@ -0,0 +1,311 @@
+/*
+ * 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.
+ */
+
+/**
+ * `GET /api/layer/:key/landing` — top-N services for a layer with their
+ * configured column metrics, ready for the Overview landing card.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
+import type {
+  FetchLike,
+  LandingResponse,
+  LandingServiceRow,
+} from '@skywalking-horizon-ui/api-client';
+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 { resolveColumnExpressions } from './mqe-catalog.js';
+
+export interface LandingRouteDeps {
+  config: ConfigSource;
+  sessions: SessionStore;
+  fetch?: FetchLike;
+}
+
+interface ListServicesRow {
+  id: string;
+  value: string; // alias for name
+  shortName?: string | null;
+  group?: string | null;
+  normal?: boolean | null;
+}
+
+interface MqeValuesShape {
+  metric?: { labels?: Array<{ key: string; value: string }> | null };
+  values?: Array<{ id?: string | null; value?: string | null }>;
+}
+interface MqeResultShape {
+  type: string;
+  error?: string | null;
+  results?: MqeValuesShape[];
+}
+
+const LIST_SERVICES_QUERY = /* GraphQL */ `
+  query LandingServices($layer: String!) {
+    services: listServices(layer: $layer) {
+      id
+      value: name
+      shortName
+      group
+      normal
+    }
+  }
+`;
+
+/** 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;
+
+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');
+  const dd = String(d.getUTCDate()).padStart(2, '0');
+  const hh = String(d.getUTCHours()).padStart(2, '0');
+  const mi = String(d.getUTCMinutes()).padStart(2, '0');
+  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}`;
+}
+
+/**
+ * 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.
+ */
+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);
+  }
+  if (parsed.length === 0) return null;
+  const sum = parsed.reduce((a, b) => a + b, 0);
+  return sum / parsed.length;
+}
+
+export function registerLandingRoute(app: FastifyInstance, deps: 
LandingRouteDeps): void {
+  const auth = requireAuth(deps);
+  app.get(
+    '/api/layer/:key/landing',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      const params = req.params as { key: string };
+      const layerKey = params.key;
+      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] } : {}),
+      }));
+
+      // OAP enum is upper-case (`GENERAL`, `VIRTUAL_MQ`, …); the SPA
+      // sends lower-case route keys.
+      const oapLayer = layerKey.toUpperCase();
+      const cfg = deps.config.current;
+      const opts = {
+        statusUrl: cfg.oap.statusUrl,
+        timeoutMs: cfg.oap.timeoutMs,
+        fetch: deps.fetch,
+      };
+      const window = defaultWindow();
+
+      // Step 1 — service list.
+      let services: ListServicesRow[];
+      try {
+        const data = await graphqlPost<{ services: ListServicesRow[] }>(
+          opts,
+          LIST_SERVICES_QUERY,
+          { layer: oapLayer },
+        );
+        services = data.services ?? [];
+      } catch (err) {
+        const body: LandingResponse = {
+          layer: layerKey,
+          topN,
+          orderBy,
+          generatedAt: Date.now(),
+          step: 'MINUTE',
+          durationStart: window.start,
+          durationEnd: window.end,
+          rows: [],
+          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 body: LandingResponse = {
+          layer: layerKey,
+          topN,
+          orderBy,
+          generatedAt: Date.now(),
+          step: 'MINUTE',
+          durationStart: window.start,
+          durationEnd: window.end,
+          rows: [],
+          reachable: true,
+        };
+        return reply.send(body);
+      }
+
+      // Step 2 — batched MQE queries, one alias per (service, 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 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 } } }`,
+          );
+        });
+      });
+
+      let mqeData: Record<string, MqeResultShape> = {};
+      if (fragments.length > 0) {
+        const batchQuery = `query LandingMqe { ${fragments.join('\n    ')} }`;
+        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.
+      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]);
+        });
+        return {
+          serviceId: svc.id,
+          serviceName: svc.value,
+          ...(svc.shortName ? { shortName: svc.shortName } : {}),
+          ...(svc.group ? { group: svc.group } : {}),
+          metrics,
+        };
+      });
+
+      // Step 4 — sort by orderBy desc, with nulls last; slice topN.
+      rows.sort((a, b) => {
+        const av = a.metrics[orderBy];
+        const bv = b.metrics[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 body: LandingResponse = {
+        layer: layerKey,
+        topN,
+        orderBy,
+        generatedAt: Date.now(),
+        step: 'MINUTE',
+        durationStart: window.start,
+        durationEnd: window.end,
+        rows: rows.slice(0, topN),
+        reachable: true,
+      };
+      return reply.send(body);
+    },
+  );
+}
diff --git a/apps/bff/src/oap/mqe-catalog.ts b/apps/bff/src/oap/mqe-catalog.ts
new file mode 100644
index 0000000..e649382
--- /dev/null
+++ b/apps/bff/src/oap/mqe-catalog.ts
@@ -0,0 +1,158 @@
+/*
+ * 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.
+ */
+
+/**
+ * Maps the short metric keys the UI catalog uses (`cpm`, `p99`,
+ * `mq.msg-rate`, …) to the actual MQE expressions OAP understands.
+ *
+ * For SINGLE_VALUE landing cells (current value per service), we wrap
+ * the bare OAP metric name in `avg()` — this is the most defensible
+ * default and matches what every booster-ui widget does for the
+ * service-scope KPI tiles. Percentile metrics are passed through
+ * unwrapped because they're already labeled-value MQE expressions.
+ *
+ * For TIME_SERIES_VALUES (sparkline), the same expression works — OAP
+ * returns one value per bucket when the duration `step` is set.
+ *
+ * Returns `null` when no mapping exists for the (metric, layer) pair —
+ * the BFF then surfaces a `null` value cell and the UI renders an
+ * em-dash. Operators can extend the catalog via the Phase 7 admin
+ * surface; for now we ship a conservative built-in set.
+ */
+
+import type { LandingColumn } from '@skywalking-horizon-ui/api-client';
+
+/**
+ * Logical layer-category bucketing — kept in lockstep with the UI's
+ * `layerCategory()` in metricCatalog.ts. We keep the table local so the
+ * BFF doesn't drag a `@/composables` dep over the package boundary.
+ */
+function layerCategory(layerKey: string): string {
+  const k = layerKey.toLowerCase();
+  if (k === 'general') return 'general';
+  if (k === 'mesh' || k === 'mesh_cp' || k === 'mesh_dp') return 'mesh';
+  if (k === 'k8s' || k === 'k8s_service') return 'k8s';
+  if (k === 'browser') return 'browser';
+  if (k === 'virtual_genai') return 'genai';
+  if (
+    k === 'mysql' || k === 'postgresql' || k === 'mongodb' || k === 
'elasticsearch' ||
+    k === 'redis' || k === 'clickhouse' || k === 'virtual_database' || k === 
'virtual_cache'
+  ) return 'database';
+  if (
+    k === 'kafka' || k === 'pulsar' || k === 'rocketmq' || k === 'rabbitmq' ||
+    k === 'activemq' || k === 'virtual_mq'
+  ) return 'mq';
+  return 'general';
+}
+
+/**
+ * The expression for a generic-RPC metric on the service scope. These
+ * match booster-ui's `general-service.json` widget config (cf. OAP's
+ * official metrics catalog — `service_cpm`, `service_sla`, etc.). Used
+ * for general/mesh/k8s_service layers and as a fallback for unknowns.
+ */
+const RPC_SERVICE: Record<string, string> = {
+  cpm: 'avg(service_cpm)',
+  resp: 'avg(service_resp_time)',
+  p50: 'service_percentile{p=\'50\'}',
+  p75: 'service_percentile{p=\'75\'}',
+  p95: 'service_percentile{p=\'95\'}',
+  p99: 'service_percentile{p=\'99\'}',
+  sla: 'avg(service_sla)/100',
+  apdex: 'avg(service_apdex)/10000',
+  err: '100 - avg(service_sla)/100',
+};
+
+/**
+ * Browser-layer expressions. OAP names live in `browser_app_*` for the
+ * app (~= service) scope. See `browser-app.json`. We surface a subset
+ * relevant to the landing card; the rest can come via admin override.
+ */
+const BROWSER_SERVICE: Record<string, string> = {
+  'browser.pv': 'avg(browser_app_pv)',
+  'browser.js-err': 'avg(browser_app_error_sum)',
+  err: 'avg(browser_app_error_rate)/100',
+  // Page-level metrics aren't service-scope — they bind to endpoint
+  // (page) entities. Operators viewing the service card see app-level
+  // aggregates; deep-dives happen on the per-layer page.
+};
+
+/**
+ * Database virtual-service metrics (`virtual_database`). MQ + native
+ * database (mysql/postgresql/…) layers have richer per-tech catalogs in
+ * OAP — we ship the lowest common denominator here and let admin
+ * override land in Phase 7.
+ */
+const DATABASE_SERVICE: Record<string, string> = {
+  cpm: 'avg(service_cpm)',
+  resp: 'avg(service_resp_time)',
+  p99: 'service_percentile{p=\'99\'}',
+  err: '100 - avg(service_sla)/100',
+};
+
+/**
+ * Virtual-MQ service metrics. Producer/consumer split shows up in
+ * topology rather than the service KPI, so we surface CPM (= messages
+ * routed) + latency + sla on the card.
+ */
+const MQ_SERVICE: Record<string, string> = {
+  cpm: 'avg(service_cpm)',
+  resp: 'avg(service_resp_time)',
+  err: '100 - avg(service_sla)/100',
+};
+
+/** Per-category lookup tables. Falls back to `RPC_SERVICE`. */
+const TABLES: Record<string, Record<string, string>> = {
+  general: RPC_SERVICE,
+  mesh: RPC_SERVICE,
+  k8s: RPC_SERVICE,
+  browser: BROWSER_SERVICE,
+  database: DATABASE_SERVICE,
+  mq: MQ_SERVICE,
+  // genai / faas / others — no first-class mapping yet, fall through to
+  // RPC_SERVICE so cpm / resp / sla still render.
+};
+
+/**
+ * Resolve the MQE expression for `(metricKey, layerKey)` on the service
+ * scope. Returns `null` when neither the layer table nor the RPC
+ * fallback covers the key — callers should treat that as a `null` cell.
+ */
+export function expressionForServiceMetric(
+  metricKey: string,
+  layerKey: string,
+): string | null {
+  const cat = layerCategory(layerKey);
+  const table = TABLES[cat] ?? RPC_SERVICE;
+  return table[metricKey] ?? RPC_SERVICE[metricKey] ?? null;
+}
+
+/**
+ * Convenience helper — resolve a list of landing columns to their MQE
+ * expressions, keeping the same order. Entries with no mapping become
+ * `null`, which the caller maps to a `null` cell rather than firing a
+ * GraphQL query.
+ */
+export function resolveColumnExpressions(
+  columns: ReadonlyArray<LandingColumn>,
+  layerKey: string,
+): Array<{ column: LandingColumn; expression: string | null }> {
+  return columns.map((c) => ({
+    column: c,
+    expression: expressionForServiceMetric(c.metric, layerKey),
+  }));
+}
diff --git a/apps/bff/src/server.ts b/apps/bff/src/server.ts
index d3605ea..f5b171f 100644
--- a/apps/bff/src/server.ts
+++ b/apps/bff/src/server.ts
@@ -22,6 +22,7 @@ import { registerAuthRoutes } from './auth/routes.js';
 import { SessionStore } from './auth/sessions.js';
 import { loadConfig, type ConfigSource } from './config/loader.js';
 import { registerOapInfoRoute } from './oap/info-routes.js';
+import { registerLandingRoute } from './oap/landing-routes.js';
 import { registerMenuRoute } from './oap/menu-routes.js';
 import { registerOapRoutes } from './oap/routes.js';
 import { registerPreflightRoutes } from './oap/preflight-routes.js';
@@ -61,6 +62,7 @@ app.addContentTypeParser('text/plain', { parseAs: 'string' }, 
(_req, body, done)
 registerAuthRoutes(app, source, sessions, audit);
 registerOapInfoRoute(app, { config: source, sessions });
 registerMenuRoute(app, { config: source, sessions });
+registerLandingRoute(app, { config: source, sessions });
 registerSetupRoutes(app, { config: source, sessions, audit, store: setupStore 
});
 registerOapRoutes(app, { config: source, sessions, audit });
 registerPreflightRoutes(app, { config: source, sessions });
diff --git a/apps/ui/src/api/client.ts b/apps/ui/src/api/client.ts
index e99bf63..7516966 100644
--- a/apps/ui/src/api/client.ts
+++ b/apps/ui/src/api/client.ts
@@ -16,6 +16,7 @@
  */
 
 import type {
+  LandingResponse,
   MenuResponse,
   OapInfo,
   SetupResponse,
@@ -33,8 +34,19 @@ export type {
   LayerConfig,
   LandingConfig,
   LandingColumn,
+  LandingResponse,
+  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;
   roles: string[];
@@ -124,6 +136,22 @@ export class BffClient {
     return this.request<SetupResponse>('POST', '/api/setup', payload);
   }
 
+  // ── 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);
+    return this.request<LandingResponse>(
+      'GET',
+      
`/api/layer/${encodeURIComponent(layerKey)}/landing?${params.toString()}`,
+    );
+  }
+
   // ── cluster / preflight ──────────────────────────────────────────────
   preflight(): Promise<unknown> {
     return this.request('GET', '/api/preflight');
diff --git a/apps/ui/src/composables/useLayerLanding.ts 
b/apps/ui/src/composables/useLayerLanding.ts
new file mode 100644
index 0000000..0b83c44
--- /dev/null
+++ b/apps/ui/src/composables/useLayerLanding.ts
@@ -0,0 +1,69 @@
+/*
+ * 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.
+ */
+
+import { computed, type Ref } from 'vue';
+import { useQuery } from '@tanstack/vue-query';
+import type { LandingConfig, LandingResponse, LayerDef } from 
'@skywalking-horizon-ui/api-client';
+import { bffClient } from '@/api/client';
+
+/**
+ * Live top-N service rollup for one Overview landing card. Polls every
+ * 60s (sufficient for a MINUTE-step window) and falls back gracefully
+ * when OAP is unreachable — the BFF surfaces `reachable: false` and the
+ * card keeps the placeholder rows.
+ *
+ * The query key includes the resolved column set so changing a layer's
+ * setup (in Stage 2.3+) re-fetches automatically.
+ */
+export function useLayerLanding(
+  layer: Ref<LayerDef>,
+  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 ?? '');
+
+  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 } : {}),
+    }),
+    staleTime: 45_000,
+    refetchInterval: 60_000,
+    refetchOnWindowFocus: true,
+    // Don't pound the server on a known-broken layer key.
+    retry: 1,
+  });
+
+  const data = computed<LandingResponse | null>(() => q.data.value ?? null);
+  const rows = computed(() => data.value?.rows ?? []);
+  const reachable = computed(() => data.value?.reachable ?? false);
+  const error = computed(() => data.value?.error ?? (q.error.value ? 
String(q.error.value) : undefined));
+
+  return {
+    isLoading: q.isLoading,
+    isFetching: q.isFetching,
+    data,
+    rows,
+    reachable,
+    error,
+    refetch: q.refetch,
+  };
+}
diff --git a/apps/ui/src/utils/formatters.ts b/apps/ui/src/utils/formatters.ts
new file mode 100644
index 0000000..4a2af25
--- /dev/null
+++ b/apps/ui/src/utils/formatters.ts
@@ -0,0 +1,40 @@
+/*
+ * 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-readable formatter for landing-card numeric cells.
+ *
+ * Rules:
+ *   - `null` / `undefined` / NaN → `'—'` (so the column stays aligned).
+ *   - Integers under 10k render bare (`1234`).
+ *   - Larger values use SI suffixes (`12.3k`, `1.2M`).
+ *   - Sub-1 values render at 2 decimals (`0.42`).
+ *   - Everything else uses 1 decimal (`12.3`, `999.0`).
+ *
+ * Matches the booster-ui KPI tile feel without dragging in a date/number
+ * library — the landing card is the only place this is used today.
+ */
+export function fmtMetric(v: number | null | undefined): string {
+  if (v === null || v === undefined || !Number.isFinite(v)) return '—';
+  const abs = Math.abs(v);
+  if (abs >= 1_000_000_000) return `${(v / 1_000_000_000).toFixed(1)}B`;
+  if (abs >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
+  if (abs >= 10_000) return `${(v / 1_000).toFixed(1)}k`;
+  if (abs >= 1) return abs < 100 ? v.toFixed(1) : Math.round(v).toString();
+  if (abs === 0) return '0';
+  return v.toFixed(2);
+}
diff --git a/apps/ui/src/views/overview/LayerLandingCard.vue 
b/apps/ui/src/views/overview/LayerLandingCard.vue
index 308e08a..8ddad5d 100644
--- a/apps/ui/src/views/overview/LayerLandingCard.vue
+++ b/apps/ui/src/views/overview/LayerLandingCard.vue
@@ -15,18 +15,31 @@
   limitations under the License.
 -->
 <script setup lang="ts">
-import { computed } from 'vue';
+import { computed, toRef } from 'vue';
 import { RouterLink } from 'vue-router';
 import type { LayerDef } from '@skywalking-horizon-ui/api-client';
 import Icon from '@/components/icons/Icon.vue';
 import { metricMeta } from '@/composables/metricCatalog';
+import { useLayerLanding } from '@/composables/useLayerLanding';
 import { useSetupStore } from '@/stores/setup';
+import { fmtMetric } from '@/utils/formatters';
 
 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 slotName = computed(() => cfg.value.slots.services ?? 'Services');
 const detailHref = computed(() => `/layer/${props.layer.key}/services`);
+
+// Live top-N rollup — see useLayerLanding for the polling cadence.
+const landingCfg = computed(() => cfg.value.landing);
+const layerRef = toRef(props, 'layer');
+const landing = useLayerLanding(layerRef, landingCfg);
+const hasRows = computed(() => landing.rows.value.length > 0);
+const placeholderCount = computed(() =>
+  Math.max(0, cfg.value.landing.topN - landing.rows.value.length),
+);
+const serviceHref = (serviceId: string): string =>
+  `/layer/${props.layer.key}/services/${encodeURIComponent(serviceId)}`;
 </script>
 
 <template>
@@ -74,22 +87,51 @@ const detailHref = computed(() => 
`/layer/${props.layer.key}/services`);
           </tr>
         </thead>
         <tbody>
-          <tr v-for="i in cfg.landing.topN" :key="i" class="placeholder-row">
+          <tr v-for="row in landing.rows.value" :key="row.serviceId" 
class="data-row">
+            <td class="svc-col" :title="row.serviceName">
+              <RouterLink :to="serviceHref(row.serviceId)">{{ row.shortName || 
row.serviceName }}</RouterLink>
+            </td>
+            <td
+              v-for="c in cfg.landing.columns"
+              :key="c.metric"
+              class="num"
+              :class="{ muted: row.metrics[c.metric] == null }"
+            >
+              {{ fmtMetric(row.metrics[c.metric]) }}
+            </td>
+            <td v-if="cfg.landing.spark" class="spark-col muted">—</td>
+          </tr>
+          <tr
+            v-for="i in placeholderCount"
+            :key="`ph-${i}`"
+            class="placeholder-row"
+          >
             <td class="svc-col">
-              <span class="shim w-name" />
+              <span v-if="landing.isLoading.value && !hasRows" class="shim 
w-name" />
+              <span v-else class="empty-cell">—</span>
             </td>
             <td v-for="c in cfg.landing.columns" :key="c.metric" class="num">
-              <span class="shim w-num" />
+              <span v-if="landing.isLoading.value && !hasRows" class="shim 
w-num" />
+              <span v-else class="empty-cell">—</span>
             </td>
             <td v-if="cfg.landing.spark" class="spark-col">
-              <span class="shim w-spark" />
+              <span v-if="landing.isLoading.value && !hasRows" class="shim 
w-spark" />
+              <span v-else class="empty-cell">—</span>
             </td>
           </tr>
         </tbody>
       </table>
       <div class="card-foot">
-        <span class="placeholder-note">
-          Live service data lands in Stage 2.4 — <RouterLink 
to="/setup">customize this card</RouterLink>.
+        <span v-if="landing.error.value" class="err-note" 
:title="landing.error.value">
+          OAP unreachable — showing last known data.
+        </span>
+        <span v-else-if="!hasRows && !landing.isLoading.value" 
class="placeholder-note">
+          No services reporting on this layer yet —
+          <RouterLink to="/setup">customize this card</RouterLink>.
+        </span>
+        <span v-else class="placeholder-note">
+          Sparkline + live trend land in Stage 2.6b —
+          <RouterLink to="/setup">customize this card</RouterLink>.
         </span>
       </div>
     </div>
@@ -179,6 +221,25 @@ td.num {
   font-variant-numeric: tabular-nums;
   text-align: right;
 }
+.data-row .svc-col a {
+  color: var(--sw-fg-0);
+  text-decoration: none;
+  display: inline-block;
+  max-width: 100%;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  vertical-align: middle;
+}
+.data-row .svc-col a:hover {
+  color: var(--sw-accent-2);
+}
+.num.muted {
+  color: var(--sw-fg-3);
+}
+.empty-cell {
+  color: var(--sw-fg-3);
+}
 .placeholder-row .shim {
   display: inline-block;
   height: 9px;
@@ -205,4 +266,7 @@ td.num {
   color: var(--sw-accent-2);
   text-decoration: none;
 }
+.err-note {
+  color: var(--sw-warn);
+}
 </style>
diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts
index 238411f..d59458b 100644
--- a/packages/api-client/src/index.ts
+++ b/packages/api-client/src/index.ts
@@ -24,6 +24,7 @@ export type {
   SetupResponse,
   SetupSavePayload,
 } from './setup.js';
+export type { 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
new file mode 100644
index 0000000..a2618a9
--- /dev/null
+++ b/packages/api-client/src/landing.ts
@@ -0,0 +1,64 @@
+/*
+ * 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.
+ */
+
+/**
+ * Wire shape for `GET /api/layer/:key/landing` — the per-layer top-N
+ * service rollup the Overview cards render.
+ */
+
+export interface LandingServiceRow {
+  /** OAP service id. */
+  serviceId: string;
+  serviceName: string;
+  /** Short display name (often `serviceName` without the group prefix). */
+  shortName?: string;
+  /** Group prefix if present. */
+  group?: string;
+  /**
+   * metric key → numeric value pulled from MQE. `null` means the catalog
+   * has no MQE mapping for this metric or the query errored. The UI
+   * renders `null` as a muted em-dash.
+   */
+  metrics: Record<string, number | null>;
+  /**
+   * Sparkline series for `cfg.spark.metric`, when configured. Same order
+   * as the `step` buckets returned by OAP — left-to-right oldest-to-newest.
+   * `null` entries mark missing samples.
+   */
+  spark?: Array<number | null>;
+}
+
+export interface LandingResponse {
+  layer: string;
+  topN: number;
+  orderBy: string;
+  /** Server epoch ms when the response was assembled. */
+  generatedAt: number;
+  /** Step the BFF asked OAP to bucket on (`MINUTE` for a 15m window). */
+  step: 'MINUTE' | 'HOUR' | 'DAY';
+  /** Echo of the window the BFF queried — server-TZ formatted. */
+  durationStart: string;
+  durationEnd: string;
+  rows: LandingServiceRow[];
+  /**
+   * True when the BFF reached OAP and got a service list back. Per-metric
+   * MQE errors don't flip this — only `listServices` failures do.
+   */
+  reachable: boolean;
+  /** Set when `reachable === false`. */
+  error?: string;
+}


Reply via email to