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;
+}