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 7e0968b menu/setup: layer metrics from JSON template drive UI
columns; clean tips
7e0968b is described below
commit 7e0968b01f7b0e125dad5f29541f4d9c88e1407a
Author: Wu Sheng <[email protected]>
AuthorDate: Tue May 12 22:11:27 2026 +0800
menu/setup: layer metrics from JSON template drive UI columns; clean tips
Architectural fix: the UI's KPI columns (Overview KPI strip + per-layer
header summary + selected-service row) read from the JSON layer
template's metrics.columns now, not the static metricCatalog defaults.
The user's edits to apps/bff/src/layers/config/general.json finally
land in the UI.
Wire types: LayerDef gains LayerMetricsConfig (columns / orderBy /
throughput / spark). BFF menu route populates it from
getLayerTemplate(rawKey).metrics. UI setupStore.defaultLandingFor
accepts an optional template-metrics arg and prefers it over the
static catalog when columns are defined. setupStore.ensure / reset
signatures gain an optional metrics field on the defaults arg; every
caller (LayerShell, LayerKpiTile, LayerKpiStripCard, LayerLandingCard,
LayerDashboardsView, LayerSetupCard, SetupView, useLandingOrder) now
forwards layer.metrics through.
For General this means the UI now shows RPM / Apdex / Error Rate
columns (from general.json) instead of cpm / p99 / sla / err (from
the static catalog).
Tips cleaned to drop embedded MQE — operators see expressions in the
admin/edit form, not in the hover tip:
Apdex 'User-satisfaction score on a 0 – 1 scale.'
Percentile 'p50 / p75 / p90 / p95 / p99 latency …'
MQ 'Count on the left axis, latency on the right.'
Top 20 'Layer-wide ranking. Switch tabs to re-rank …'
Instance top widgets similarly.
mq_combined: visibleWhen removed so the widget always renders, even
when the service doesn't emit MQ metrics. (Was hiding the whole card
when the predicate failed.)
---
apps/bff/src/layers/config/general.json | 13 +++---
apps/bff/src/oap/menu-routes.ts | 1 +
apps/ui/src/composables/useLandingOrder.ts | 4 +-
apps/ui/src/stores/setup.ts | 52 +++++++++++++++++++-----
apps/ui/src/views/layer/LayerDashboardsView.vue | 2 +-
apps/ui/src/views/layer/LayerShell.vue | 2 +-
apps/ui/src/views/overview/LayerKpiStripCard.vue | 2 +-
apps/ui/src/views/overview/LayerKpiTile.vue | 2 +-
apps/ui/src/views/overview/LayerLandingCard.vue | 2 +-
apps/ui/src/views/setup/LayerSetupCard.vue | 2 +-
apps/ui/src/views/setup/SetupView.vue | 2 +-
packages/api-client/src/index.ts | 9 +++-
packages/api-client/src/menu.ts | 30 ++++++++++++++
13 files changed, 95 insertions(+), 28 deletions(-)
diff --git a/apps/bff/src/layers/config/general.json
b/apps/bff/src/layers/config/general.json
index 8109e0a..a4b236f 100644
--- a/apps/bff/src/layers/config/general.json
+++ b/apps/bff/src/layers/config/general.json
@@ -34,7 +34,7 @@
{
"id": "top_apis",
"title": "Top 20 APIs",
- "tip": "Layer-wide ranking. Switch tabs to re-rank by:\n Traffic
top_n(endpoint_cpm,20,des)\n Slow response
top_n(endpoint_resp_time,20,des)\n Worst SR
top_n(endpoint_sla,20,asc)/100",
+ "tip": "Layer-wide ranking. Switch tabs to re-rank by traffic,
response time, or worst success rate.",
"type": "top",
"expressions": [
"top_n(endpoint_cpm,20,des)",
@@ -59,7 +59,7 @@
{
"id": "mq_combined",
"title": "MQ Consume rate + latency",
- "tip": "Dual y-axis: service_mq_consume_count (left) +
service_mq_consume_latency (right).",
+ "tip": "Count on the left axis, latency on the right.",
"type": "line",
"expressions": [
"service_mq_consume_count",
@@ -68,14 +68,13 @@
"expressionLabels": ["count", "latency"],
"expressionUnits": ["/min", "ms"],
"expressionAxes": [0, 1],
- "visibleWhen": "service_mq_consume_count has value",
"span": 3,
"rowSpan": 2
},
{
"id": "apdex_line",
"title": "Apdex",
- "tip": "service_apdex/10000 — 0 to 1 satisfaction score.",
+ "tip": "User-satisfaction score on a 0 – 1 scale.",
"type": "line",
"expressions": ["service_apdex/10000"],
"span": 3,
@@ -84,7 +83,7 @@
{
"id": "percentile_line",
"title": "Response Time Percentile",
- "tip": "Single MQE:
relabels(service_percentile{p='50,75,90,95,99'},...,percentile='50,...').",
+ "tip": "p50 / p75 / p90 / p95 / p99 latency — the tail of the
response-time distribution.",
"type": "line",
"unit": "ms",
"expressions": [
@@ -114,7 +113,7 @@
{
"id": "top_instance_load",
"title": "Top 10 instances by load",
- "tip": "top_n(service_instance_cpm,10,des) — for the selected
service.",
+ "tip": "Ranked by traffic across the selected service's instances.",
"type": "top",
"unit": "rpm",
"expressions": ["top_n(service_instance_cpm,10,des)"],
@@ -124,7 +123,7 @@
{
"id": "top_instance_slow",
"title": "Top 10 slowest instances",
- "tip": "top_n(service_instance_resp_time,10,des) — for the selected
service.",
+ "tip": "Ranked by average response time across the selected service's
instances.",
"type": "top",
"unit": "ms",
"expressions": ["top_n(service_instance_resp_time,10,des)"],
diff --git a/apps/bff/src/oap/menu-routes.ts b/apps/bff/src/oap/menu-routes.ts
index 13f745d..93914d0 100644
--- a/apps/bff/src/oap/menu-routes.ts
+++ b/apps/bff/src/oap/menu-routes.ts
@@ -179,6 +179,7 @@ function deriveLayer(
documentLink: tpl.documentLink ?? item?.documentLink ?? undefined,
slots: tpl.slots,
caps: componentsToCaps(tpl.components),
+ metrics: tpl.metrics,
};
}
const def = LAYER_DEFAULTS[rawKey] ?? DEFAULT_FOR_UNKNOWN_LAYER;
diff --git a/apps/ui/src/composables/useLandingOrder.ts
b/apps/ui/src/composables/useLandingOrder.ts
index d111c57..3883b95 100644
--- a/apps/ui/src/composables/useLandingOrder.ts
+++ b/apps/ui/src/composables/useLandingOrder.ts
@@ -32,8 +32,8 @@ export function useLandingOrder(layers: ComputedRef<readonly
LayerDef[]>) {
const store = useSetupStore();
return computed<LayerDef[]>(() => {
return [...layers.value].sort((a, b) => {
- const pa = store.ensure(a.key, { slots: a.slots, caps: a.caps
}).landing.priority;
- const pb = store.ensure(b.key, { slots: b.slots, caps: b.caps
}).landing.priority;
+ const pa = store.ensure(a.key, { slots: a.slots, caps: a.caps, metrics:
a.metrics }).landing.priority;
+ const pb = store.ensure(b.key, { slots: b.slots, caps: b.caps, metrics:
b.metrics }).landing.priority;
if (pa !== pb) return pa - pb;
return 0; // preserve incoming catalog order
});
diff --git a/apps/ui/src/stores/setup.ts b/apps/ui/src/stores/setup.ts
index 79d82c4..7da8fec 100644
--- a/apps/ui/src/stores/setup.ts
+++ b/apps/ui/src/stores/setup.ts
@@ -22,6 +22,7 @@ import type {
LandingConfig,
LayerCaps,
LayerConfig,
+ LayerMetricsConfig,
LayerSlots,
} from '@skywalking-horizon-ui/api-client';
import { bffClient } from '@/api/client';
@@ -69,7 +70,42 @@ function defaultAggregationFor(metricKey: string):
AggregationKind {
return 'avg';
}
-export function defaultLandingFor(layerKey: string): LandingConfig {
+/**
+ * Build the initial LandingConfig for a layer. When the BFF
+ * surfaces a `metrics` block from the JSON template, prefer it as the
+ * source of truth — that's what the operator edits in
+ * `apps/bff/src/layers/config/<layer>.json` / via the admin page.
+ * Falls back to the static metric-catalog defaults when no template
+ * metrics arrived (e.g. layers without a JSON config file).
+ */
+export function defaultLandingFor(layerKey: string, fromTemplate?:
LayerMetricsConfig): LandingConfig {
+ if (fromTemplate?.columns && fromTemplate.columns.length > 0) {
+ const cols = fromTemplate.columns.map((c) => ({
+ metric: c.metric,
+ label: c.label,
+ ...(c.unit ? { unit: c.unit } : {}),
+ ...(c.mqe ? { mqe: c.mqe } : {}),
+ aggregation: c.aggregation ?? defaultAggregationFor(c.metric),
+ ...(c.scale !== undefined ? { scale: c.scale } : {}),
+ ...(c.precision !== undefined ? { precision: c.precision } : {}),
+ }));
+ const orderBy = fromTemplate.orderBy ?? cols[0].metric;
+ const throughputMetric = fromTemplate.throughput ?? orderBy;
+ const sparkMetric = fromTemplate.spark ?? throughputMetric;
+ return {
+ priority: defaultPriority(layerKey),
+ topN: 5,
+ orderBy,
+ columns: cols,
+ spark: { metric: sparkMetric, height: 28 },
+ throughput: {
+ metric: throughputMetric,
+ aggregation: defaultAggregationFor(throughputMetric),
+ },
+ style: 'table',
+ };
+ }
+ // Static fallback for layers with no JSON template.
const cols = defaultColumnsForLayer(layerKey).map((c) => ({
...c,
aggregation: defaultAggregationFor(c.metric),
@@ -81,8 +117,6 @@ export function defaultLandingFor(layerKey: string):
LandingConfig {
orderBy: defaultOrderByForLayer(layerKey),
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)),
@@ -93,12 +127,12 @@ export function defaultLandingFor(layerKey: string):
LandingConfig {
export function defaultLayerConfig(
layerKey: string,
- defaults: { slots: LayerSlots; caps: LayerCaps },
+ defaults: { slots: LayerSlots; caps: LayerCaps; metrics?: LayerMetricsConfig
},
): LayerConfig {
return {
slots: { ...defaults.slots },
caps: { ...defaults.caps },
- landing: defaultLandingFor(layerKey),
+ landing: defaultLandingFor(layerKey, defaults.metrics),
};
}
@@ -162,23 +196,19 @@ export const useSetupStore = defineStore('setup', () => {
*/
function ensure(
layerKey: string,
- defaults: { slots: LayerSlots; caps: LayerCaps },
+ defaults: { slots: LayerSlots; caps: LayerCaps; metrics?:
LayerMetricsConfig },
): LayerConfig {
let cfg = configs[layerKey];
if (!cfg) {
cfg = defaultLayerConfig(layerKey, defaults);
configs[layerKey] = cfg;
- // Newly-created defaults aren't "dirty" — only explicit edits should
- // turn the Save button on. We track that by leaving `dirty` alone
- // here and relying on form-field bindings to flip it via deep proxy
- // watchers below.
}
return cfg;
}
function reset(
layerKey: string,
- defaults: { slots: LayerSlots; caps: LayerCaps },
+ defaults: { slots: LayerSlots; caps: LayerCaps; metrics?:
LayerMetricsConfig },
): void {
configs[layerKey] = defaultLayerConfig(layerKey, defaults);
markDirty();
diff --git a/apps/ui/src/views/layer/LayerDashboardsView.vue
b/apps/ui/src/views/layer/LayerDashboardsView.vue
index 7ca6319..b3ef1fc 100644
--- a/apps/ui/src/views/layer/LayerDashboardsView.vue
+++ b/apps/ui/src/views/layer/LayerDashboardsView.vue
@@ -63,7 +63,7 @@ const safeLayer = computed<LayerDef>(() => layer.value ?? {
});
const safeCfg = computed(() => {
if (!layer.value) return { priority: 99, topN: 5, orderBy: 'cpm', columns:
[], style: 'table' as const };
- return store.ensure(layer.value.key, { slots: layer.value.slots, caps:
layer.value.caps }).landing;
+ return store.ensure(layer.value.key, { slots: layer.value.slots, caps:
layer.value.caps, metrics: layer.value.metrics }).landing;
});
const landing = useLayerLanding(safeLayer, safeCfg);
const serviceName = computed<string | null>(() => {
diff --git a/apps/ui/src/views/layer/LayerShell.vue
b/apps/ui/src/views/layer/LayerShell.vue
index 51a3fc2..1cbb2cf 100644
--- a/apps/ui/src/views/layer/LayerShell.vue
+++ b/apps/ui/src/views/layer/LayerShell.vue
@@ -50,7 +50,7 @@ const layer = computed<LayerDef | null>(() => {
const store = useSetupStore();
const cfg = computed(() => {
if (!layer.value) return null;
- return store.ensure(layer.value.key, { slots: layer.value.slots, caps:
layer.value.caps });
+ return store.ensure(layer.value.key, { slots: layer.value.slots, caps:
layer.value.caps, metrics: layer.value.metrics });
});
// Build a non-null LayerDef ref for the landing composable.
diff --git a/apps/ui/src/views/overview/LayerKpiStripCard.vue
b/apps/ui/src/views/overview/LayerKpiStripCard.vue
index 16a7906..32c15ea 100644
--- a/apps/ui/src/views/overview/LayerKpiStripCard.vue
+++ b/apps/ui/src/views/overview/LayerKpiStripCard.vue
@@ -36,7 +36,7 @@ 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 }),
+ store.ensure(props.layer.key, { slots: props.layer.slots, caps:
props.layer.caps, metrics: props.layer.metrics }),
);
const landingCfg = computed(() => cfg.value.landing);
const layerRef = toRef(props, 'layer');
diff --git a/apps/ui/src/views/overview/LayerKpiTile.vue
b/apps/ui/src/views/overview/LayerKpiTile.vue
index 76dd4c7..62a82f9 100644
--- a/apps/ui/src/views/overview/LayerKpiTile.vue
+++ b/apps/ui/src/views/overview/LayerKpiTile.vue
@@ -37,7 +37,7 @@ 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 }),
+ store.ensure(props.layer.key, { slots: props.layer.slots, caps:
props.layer.caps, metrics: props.layer.metrics }),
);
const landingCfg = computed(() => cfg.value.landing);
const layerRef = toRef(props, 'layer');
diff --git a/apps/ui/src/views/overview/LayerLandingCard.vue
b/apps/ui/src/views/overview/LayerLandingCard.vue
index 73d2696..795938f 100644
--- a/apps/ui/src/views/overview/LayerLandingCard.vue
+++ b/apps/ui/src/views/overview/LayerLandingCard.vue
@@ -27,7 +27,7 @@ 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 cfg = computed(() => store.ensure(props.layer.key, { slots:
props.layer.slots, caps: props.layer.caps, metrics: props.layer.metrics }));
const slotName = computed(() => cfg.value.slots.services ?? 'Services');
const detailHref = computed(() => `/layer/${props.layer.key}/services`);
diff --git a/apps/ui/src/views/setup/LayerSetupCard.vue
b/apps/ui/src/views/setup/LayerSetupCard.vue
index dd09c3e..ec3c67d 100644
--- a/apps/ui/src/views/setup/LayerSetupCard.vue
+++ b/apps/ui/src/views/setup/LayerSetupCard.vue
@@ -47,7 +47,7 @@ const props = defineProps<{ layer: LayerDef; expanded?:
boolean }>();
const emit = defineEmits<{ (e: 'toggle'): void }>();
const store = useSetupStore();
-const cfg = computed(() => store.ensure(props.layer.key, { slots:
props.layer.slots, caps: props.layer.caps }));
+const cfg = computed(() => store.ensure(props.layer.key, { slots:
props.layer.slots, caps: props.layer.caps, metrics: props.layer.metrics }));
const open = ref(props.expanded ?? false);
function toggle(): void {
diff --git a/apps/ui/src/views/setup/SetupView.vue
b/apps/ui/src/views/setup/SetupView.vue
index ce7f560..584496e 100644
--- a/apps/ui/src/views/setup/SetupView.vue
+++ b/apps/ui/src/views/setup/SetupView.vue
@@ -124,7 +124,7 @@ const visibleLayers = computed(() => {
<strong>{{ orderedLayers.length }}</strong> layer(s) in this
deployment,
rendered on the Overview in priority order:
<span v-for="(L, i) in orderedLayers.slice(0, 8)" :key="L.key"
class="chip-name">
- {{ L.name }} ({{ store.ensure(L.key, { slots: L.slots, caps:
L.caps }).landing.priority }})<span
+ {{ L.name }} ({{ store.ensure(L.key, { slots: L.slots, caps:
L.caps, metrics: L.metrics }).landing.priority }})<span
v-if="i < Math.min(orderedLayers.length, 8) - 1"
>,</span>
</span>
diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts
index c10f15c..9d4309b 100644
--- a/packages/api-client/src/index.ts
+++ b/packages/api-client/src/index.ts
@@ -16,7 +16,14 @@
*/
export * from './types.js';
-export type { LayerSlots, LayerCaps, LayerDef, MenuResponse } from './menu.js';
+export type {
+ LayerSlots,
+ LayerCaps,
+ LayerDef,
+ LayerMetricsColumn,
+ LayerMetricsConfig,
+ MenuResponse,
+} from './menu.js';
export type {
AggregationKind,
LandingColumn,
diff --git a/packages/api-client/src/menu.ts b/packages/api-client/src/menu.ts
index a8cfec2..5d6937c 100644
--- a/packages/api-client/src/menu.ts
+++ b/packages/api-client/src/menu.ts
@@ -50,6 +50,32 @@ export interface LayerCaps {
events?: boolean;
}
+/**
+ * One metric column for the layer's landing / summary KPI strip.
+ * Mirrors `dashboards.<layer>.json:metrics.columns[]` so the SPA can
+ * render the operator-edited labels / MQEs / aggregations instead of
+ * static catalog defaults.
+ */
+export interface LayerMetricsColumn {
+ metric: string;
+ label: string;
+ unit?: string;
+ mqe?: string;
+ aggregation?: 'sum' | 'avg';
+ scale?: number;
+ precision?: number;
+}
+
+export interface LayerMetricsConfig {
+ /** Default `topN` ranking metric. */
+ orderBy?: string;
+ /** Headline metric for the Overview per-layer KPI tile. */
+ throughput?: string;
+ /** Sparkline metric (defaults to `throughput` when omitted). */
+ spark?: string;
+ columns?: LayerMetricsColumn[];
+}
+
export interface LayerDef {
key: string;
/** Display name from OAP `getMenuItems.title` (preserving casing). */
@@ -66,6 +92,10 @@ export interface LayerDef {
documentLink?: string;
slots: LayerSlots;
caps: LayerCaps;
+ /** Per-layer metric config from the JSON template; UI uses it as
+ * the source of truth for KPI columns + throughput / spark when
+ * present. Falls back to static catalog defaults when absent. */
+ metrics?: LayerMetricsConfig;
}
export interface MenuResponse {