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