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 6cdc434 overview: auto-flow card grid; metric catalog with longLabel
+ tooltip
6cdc434 is described below
commit 6cdc434c3af9349d305e671faaafd07230c7dabe
Author: Wu Sheng <[email protected]>
AuthorDate: Tue May 12 14:38:43 2026 +0800
overview: auto-flow card grid; metric catalog with longLabel + tooltip
- OverviewView .cards switches from vertical stack to CSS grid auto-fit
with minmax(320px, 1fr) and a 14px gap. Container max-width bumped to
1440. With 2 reporting layers you get 2 per row, with 4 you get 4 per
row, gracefully collapsing on narrow viewports.
- New metricCatalog.ts: per-metric MetricMeta (key, label, longLabel,
unit, tip, category) cribbed from booster-ui's widget configs in
ui-initialized-templates. Catalog covers cpm, resp, p50/75/95/99, sla,
apdex, err.
- LayerLandingCard column headers get title attributes carrying the
long label + tip explanation. Sparkline column gets the same.
- LayerSetupCard's available-columns chips and selects pull from the
shared catalog so labels/units/tips stay aligned across the UI.
---
apps/ui/src/composables/metricCatalog.ts | 127 ++++++++++++++++++++++++
apps/ui/src/views/overview/LayerLandingCard.vue | 40 ++++++--
apps/ui/src/views/overview/OverviewView.vue | 11 +-
apps/ui/src/views/setup/LayerSetupCard.vue | 33 +++---
4 files changed, 184 insertions(+), 27 deletions(-)
diff --git a/apps/ui/src/composables/metricCatalog.ts
b/apps/ui/src/composables/metricCatalog.ts
new file mode 100644
index 0000000..52ad065
--- /dev/null
+++ b/apps/ui/src/composables/metricCatalog.ts
@@ -0,0 +1,127 @@
+/*
+ * 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.
+ */
+
+/**
+ * Display metadata for the MQE-result keys we surface on cards and widgets.
+ *
+ * Definitions cribbed from booster-ui's widget configs in
+ * `oap-server/.../ui-initialized-templates` — each upstream widget carries
+ * `{name, title, tips}` per expression, which we collapse to one
+ * `MetricMeta` per logical metric. Phase 7's admin UI lets operators
+ * extend/override this catalog per deployment.
+ */
+
+export interface MetricMeta {
+ key: string;
+ /** Short header label (e.g. `cpm`, `p99`). */
+ label: string;
+ /** Full readable name (e.g. `Calls per minute`). */
+ longLabel: string;
+ /** Suffix unit; rendered in subtle tone after the label. */
+ unit?: string;
+ /** Tooltip explanation rendered as `title` on hover. */
+ tip: string;
+ /** Optional category for grouping in the setup UI. */
+ category?: 'throughput' | 'latency' | 'reliability' | 'resource';
+}
+
+export const METRICS: Record<string, MetricMeta> = {
+ cpm: {
+ key: 'cpm',
+ label: 'cpm',
+ longLabel: 'Calls per minute',
+ tip: 'Throughput — average number of requests served per minute over the
time window.',
+ category: 'throughput',
+ },
+ resp: {
+ key: 'resp',
+ label: 'avg resp',
+ longLabel: 'Average response time',
+ unit: 'ms',
+ tip: 'Mean latency across all calls in the time window.',
+ category: 'latency',
+ },
+ p50: {
+ key: 'p50',
+ label: 'p50',
+ longLabel: '50th percentile latency',
+ unit: 'ms',
+ tip: 'Median response time — half of requests complete within this
latency.',
+ category: 'latency',
+ },
+ p75: {
+ key: 'p75',
+ label: 'p75',
+ longLabel: '75th percentile latency',
+ unit: 'ms',
+ tip: '75% of requests complete within this latency.',
+ category: 'latency',
+ },
+ p95: {
+ key: 'p95',
+ label: 'p95',
+ longLabel: '95th percentile latency',
+ unit: 'ms',
+ tip: '95% of requests complete within this latency — useful for the long
tail.',
+ category: 'latency',
+ },
+ p99: {
+ key: 'p99',
+ label: 'p99',
+ longLabel: '99th percentile latency',
+ unit: 'ms',
+ tip: '99% of requests complete within this latency — the slow tail
experienced by 1% of users.',
+ category: 'latency',
+ },
+ sla: {
+ key: 'sla',
+ label: 'SLA',
+ longLabel: 'Service Level Agreement',
+ unit: '%',
+ tip: 'Percentage of successful requests — `(successful / total) * 100`.
Higher is better.',
+ category: 'reliability',
+ },
+ apdex: {
+ key: 'apdex',
+ label: 'apdex',
+ longLabel: 'Application Performance Index',
+ tip: 'User-satisfaction score on a 0–1 scale. Computed from response-time
thresholds.',
+ category: 'reliability',
+ },
+ err: {
+ key: 'err',
+ label: 'err',
+ longLabel: 'Error rate',
+ unit: '%',
+ tip: 'Percentage of failed requests. Lower is better.',
+ category: 'reliability',
+ },
+};
+
+/** Lookup with a graceful fallback so unknown metrics render readable. */
+export function metricMeta(key: string): MetricMeta {
+ return (
+ METRICS[key] ?? {
+ key,
+ label: key,
+ longLabel: key,
+ tip: `Custom metric: ${key}`,
+ }
+ );
+}
+
+export const METRIC_KEYS: ReadonlyArray<string> = Object.keys(METRICS);
diff --git a/apps/ui/src/views/overview/LayerLandingCard.vue
b/apps/ui/src/views/overview/LayerLandingCard.vue
index 9496e2e..43f08d9 100644
--- a/apps/ui/src/views/overview/LayerLandingCard.vue
+++ b/apps/ui/src/views/overview/LayerLandingCard.vue
@@ -19,6 +19,7 @@ import { computed } 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 { useSetupStore } from '@/stores/setup';
const props = defineProps<{ layer: LayerDef }>();
@@ -55,10 +56,21 @@ const detailHref = computed(() =>
`/layer/${props.layer.key}`);
<thead>
<tr>
<th class="svc-col">{{ slotName }}</th>
- <th v-for="c in cfg.landing.columns" :key="c.metric" class="num">
+ <th
+ v-for="c in cfg.landing.columns"
+ :key="c.metric"
+ class="num"
+
:title="`${metricMeta(c.metric).longLabel}\n\n${metricMeta(c.metric).tip}`"
+ >
{{ c.label }}<span v-if="c.unit" class="unit">{{ c.unit }}</span>
</th>
- <th v-if="cfg.landing.spark" class="spark-col">trend</th>
+ <th
+ v-if="cfg.landing.spark"
+ class="spark-col"
+ :title="`Trend of
${metricMeta(cfg.landing.spark.metric).longLabel} over the time window`"
+ >
+ {{ metricMeta(cfg.landing.spark.metric).label }} trend
+ </th>
</tr>
</thead>
<tbody>
@@ -86,13 +98,14 @@ const detailHref = computed(() =>
`/layer/${props.layer.key}`);
<style scoped>
.layer-landing {
- margin-bottom: 12px;
+ /* margin-bottom dropped — parent grid owns the spacing now. */
+ min-width: 0; /* allow the grid track to shrink */
}
.head {
display: flex;
align-items: center;
gap: 10px;
- padding: 12px 14px;
+ padding: 10px 12px;
border-bottom: 1px solid var(--sw-line);
}
.head .dot {
@@ -107,10 +120,13 @@ const detailHref = computed(() =>
`/layer/${props.layer.key}`);
}
h2 {
margin: 0;
- font-size: 14px;
+ font-size: 13px;
font-weight: 600;
color: var(--sw-fg-0);
letter-spacing: -0.01em;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
}
h2 a {
color: inherit;
@@ -120,10 +136,10 @@ h2 a:hover {
color: var(--sw-accent-2);
}
.sub {
- font-size: 11px;
+ font-size: 10.5px;
color: var(--sw-fg-2);
display: flex;
- gap: 6px;
+ gap: 5px;
align-items: center;
flex-wrap: wrap;
margin-top: 2px;
@@ -144,11 +160,15 @@ h2 a:hover {
padding: 4px 0 8px;
}
.svc-col {
- width: 28%;
- min-width: 160px;
+ width: 36%;
+ min-width: 110px;
+ max-width: 160px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
}
.spark-col {
- width: 80px;
+ width: 60px;
}
th .unit {
margin-left: 3px;
diff --git a/apps/ui/src/views/overview/OverviewView.vue
b/apps/ui/src/views/overview/OverviewView.vue
index 4155725..ecf88c6 100644
--- a/apps/ui/src/views/overview/OverviewView.vue
+++ b/apps/ui/src/views/overview/OverviewView.vue
@@ -72,7 +72,7 @@ const empty = computed(() => !isLoading.value &&
orderedLayers.value.length ===
<style scoped>
.overview {
padding: 20px 20px 60px;
- max-width: 1140px;
+ max-width: 1440px;
margin: 0 auto;
}
.page-head {
@@ -145,7 +145,12 @@ const empty = computed(() => !isLoading.value &&
orderedLayers.value.length ===
text-decoration: none;
}
.cards {
- display: flex;
- flex-direction: column;
+ /* 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. */
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
+ gap: 14px;
+ align-items: start;
}
</style>
diff --git a/apps/ui/src/views/setup/LayerSetupCard.vue
b/apps/ui/src/views/setup/LayerSetupCard.vue
index e83a782..e526ed4 100644
--- a/apps/ui/src/views/setup/LayerSetupCard.vue
+++ b/apps/ui/src/views/setup/LayerSetupCard.vue
@@ -18,6 +18,7 @@
import { computed, ref } from 'vue';
import type { LayerDef } from '@skywalking-horizon-ui/api-client';
import Icon from '@/components/icons/Icon.vue';
+import { METRICS } from '@/composables/metricCatalog';
import { useSetupStore, defaultLandingFor } from '@/stores/setup';
const props = defineProps<{ layer: LayerDef; expanded?: boolean }>();
@@ -61,16 +62,15 @@ const capRows: Array<{ key: keyof typeof cfg.value.caps;
label: string }> = [
{ key: 'events', label: 'Events' },
];
-// Columns the operator can enable on the landing card.
-const availableColumns = [
- { metric: 'cpm', label: 'cpm' },
- { metric: 'p99', label: 'p99', unit: 'ms' },
- { metric: 'p95', label: 'p95', unit: 'ms' },
- { metric: 'sla', label: 'SLA', unit: '%' },
- { metric: 'apdex', label: 'apdex' },
- { metric: 'err', label: 'err', unit: '%' },
- { metric: 'resp', label: 'avg resp', unit: 'ms' },
-] as const;
+// Pulled from the shared metric catalog so labels/units/tips stay
+// consistent across the Overview cards and the setup UI.
+const availableColumns = Object.values(METRICS).map((m) => ({
+ metric: m.key,
+ label: m.label,
+ longLabel: m.longLabel,
+ unit: m.unit,
+ tip: m.tip,
+}));
function isColumnSelected(metric: string): boolean {
return cfg.value.landing.columns.some((c) => c.metric === metric);
}
@@ -167,14 +167,18 @@ const isDefaultLanding = computed(() => {
<label>
<span>Order by</span>
<select v-model="cfg.landing.orderBy">
- <option v-for="c in availableColumns" :key="c.metric"
:value="c.metric">{{ c.label }}</option>
+ <option v-for="c in availableColumns" :key="c.metric"
:value="c.metric" :title="c.tip">
+ {{ c.longLabel }}
+ </option>
</select>
</label>
<label>
<span>Sparkline</span>
<select :value="cfg.landing.spark?.metric ?? ''" @change="(e) => {
const v = (e.target as HTMLSelectElement).value; cfg.landing.spark = v ? {
metric: v, height: 28 } : undefined; }">
<option value="">none</option>
- <option v-for="c in availableColumns" :key="c.metric"
:value="c.metric">{{ c.label }}</option>
+ <option v-for="c in availableColumns" :key="c.metric"
:value="c.metric" :title="c.tip">
+ {{ c.longLabel }}
+ </option>
</select>
</label>
<label>
@@ -195,9 +199,10 @@ const isDefaultLanding = computed(() => {
class="chip"
:class="{ on: isColumnSelected(c.metric) }"
type="button"
- @click="toggleColumn(c.metric, c.label, 'unit' in c ? c.unit :
undefined)"
+ :title="`${c.longLabel}\n\n${c.tip}`"
+ @click="toggleColumn(c.metric, c.label, c.unit)"
>
- {{ c.label }}<span v-if="'unit' in c && c.unit" class="unit">{{
c.unit }}</span>
+ {{ c.label }}<span v-if="c.unit" class="unit">{{ c.unit }}</span>
</button>
</div>
</div>