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>

Reply via email to