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 e5c1a8b  overview: top per-layer KPI strip + flatten single-feature 
layers in sidebar
e5c1a8b is described below

commit e5c1a8b4f31ae373b673c927bf776b0b75e6b677
Author: Wu Sheng <[email protected]>
AuthorDate: Tue May 12 16:04:10 2026 +0800

    overview: top per-layer KPI strip + flatten single-feature layers in sidebar
    
    KPI strip mirrors the design's first-row style (landing.jsx:30-38) but
    rebinds each card to one of the top-6 detected layers — service count
    as the big value, throughput value + sparkline below. Sits above the
    2/5+3/5+rail detail grid; on narrow viewports the strip wraps to 3 then
    2 columns before everything stacks. Each card is a router-link to its
    layer's services page.
    
    Sidebar: layers with no per-feature pages worth expanding into (virtual
    DB / cache / MQ, native MySQL / Postgres / etc. — anywhere caps reduce
    to dashboards-only and slots reduce to services-only) now render as a
    direct router-link instead of an expander. Operators get one click to
    the dashboard view rather than expand-then-click on a row that has at
    most one sibling child.
---
 apps/ui/src/components/shell/AppSidebar.vue      |  40 ++++-
 apps/ui/src/views/overview/LayerKpiStripCard.vue | 189 +++++++++++++++++++++++
 apps/ui/src/views/overview/OverviewView.vue      |  65 +++++---
 3 files changed, 273 insertions(+), 21 deletions(-)

diff --git a/apps/ui/src/components/shell/AppSidebar.vue 
b/apps/ui/src/components/shell/AppSidebar.vue
index b207468..f8214b4 100644
--- a/apps/ui/src/components/shell/AppSidebar.vue
+++ b/apps/ui/src/components/shell/AppSidebar.vue
@@ -34,6 +34,23 @@ const { availableLayers, oapReachable, oapError, hasTopology 
} = useLayers();
 // Sidebar shares the landing's priority order so the two views stay in sync.
 const orderedLayers = useLandingOrder(availableLayers);
 
+/* A layer counts as "single-feature" when its caps + slots only support
+ * the basic services view + maybe a dashboards tab — no traces, logs,
+ * topology, profiling, events, instances, or endpoints. For these,
+ * expanding the row would reveal at most one or two sibling links, so
+ * we skip the expander and make the row a direct link to the services
+ * page (which IS the dashboard for virtual / cache / database / MQ
+ * scopes). */
+type SidebarLayer = (typeof orderedLayers.value)[number];
+function isSingleFeatureLayer(L: SidebarLayer): boolean {
+  if (L.slots.instances || L.slots.endpoints) return false;
+  if (hasTopology(L)) return false;
+  const c = L.caps;
+  if (c.traces || c.logs || c.profiling || c.events) return false;
+  if (c.endpointDependency || c.serviceMap || c.instanceTopology || 
c.processTopology) return false;
+  return true;
+}
+
 // Default-open the first available layer once data arrives; user clicks
 // thereafter take over.
 const expandedLayer = ref<string | null>(null);
@@ -157,7 +174,25 @@ const sections: NavSection[] = [
         </RouterLink>
       </div>
       <template v-for="L in orderedLayers" :key="L.key">
+        <!-- Single-feature layer (virtual_database, virtual_cache, etc.):
+             render as a direct link — no expander, no children. The
+             services page is the layer's effective dashboard. -->
+        <RouterLink
+          v-if="isSingleFeatureLayer(L)"
+          :to="`/layer/${L.key}/services`"
+          class="layer-row direct"
+          :class="{ 'is-active': isActive(`/layer/${L.key}`) }"
+        >
+          <span class="layer-dot" :style="{ background: L.color }" />
+          <span class="layer-name">{{ L.name }}</span>
+          <span class="layer-count" :title="`${L.serviceCount} 
service${L.serviceCount === 1 ? '' : 's'} reporting`">
+            {{ L.serviceCount }}
+          </span>
+        </RouterLink>
+
+        <!-- Multi-feature layer: expander row + children. -->
         <div
+          v-else
           class="layer-row"
           :class="{ 'is-active': expandedLayer === L.key }"
           @click="toggleLayer(L.key)"
@@ -173,7 +208,7 @@ const sections: NavSection[] = [
             <Icon name="caret" :size="10" />
           </span>
         </div>
-        <div v-if="expandedLayer === L.key" class="layer-children">
+        <div v-if="!isSingleFeatureLayer(L) && expandedLayer === L.key" 
class="layer-children">
           <RouterLink
             v-if="L.slots.services"
             :to="`/layer/${L.key}/services`"
@@ -368,6 +403,9 @@ const sections: NavSection[] = [
   background: var(--sw-accent-soft);
   border-color: var(--sw-accent-line);
 }
+.layer-row.direct {
+  text-decoration: none;
+}
 .empty-layers {
   margin: 4px 10px 8px;
   padding: 6px 8px;
diff --git a/apps/ui/src/views/overview/LayerKpiStripCard.vue 
b/apps/ui/src/views/overview/LayerKpiStripCard.vue
new file mode 100644
index 0000000..4bf3bf4
--- /dev/null
+++ b/apps/ui/src/views/overview/LayerKpiStripCard.vue
@@ -0,0 +1,189 @@
+<!--
+  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.
+-->
+<!--
+  One tile in the per-layer KPI strip at the top of the Overview. Adopts
+  the design's `KPI` card style (label · big value · sparkline) but
+  binds to a single layer's service count + throughput rather than a
+  global rollup.
+
+  Visually denser than LayerKpiTile so 6 fit in a single horizontal row.
+  Clicking the tile drills into the layer's services page.
+-->
+<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 serviceCount = computed(() => aggregates.value?.serviceCount ?? 
props.layer.serviceCount);
+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);
+const detailHref = computed(() => `/layer/${props.layer.key}/services`);
+const slotName = computed(() => cfg.value.slots.services ?? 'services');
+</script>
+
+<template>
+  <RouterLink class="sw-card strip-card" :to="detailHref">
+    <header class="head">
+      <span class="dot" :style="{ background: layer.color }" />
+      <span class="name">{{ cfg.displayName || layer.name }}</span>
+    </header>
+
+    <div class="value-row">
+      <span class="count" :class="{ muted: serviceCount < 0 }">{{
+        serviceCount >= 0 ? fmtMetric(serviceCount) : '—'
+      }}</span>
+      <span class="count-unit">{{ slotName.toLowerCase() }}</span>
+    </div>
+
+    <div class="traffic-row" 
:title="`${throughputMeta.longLabel}\n\n${throughputMeta.tip}`">
+      <span class="traffic-label">{{ throughputMeta.label }}</span>
+      <span class="traffic-value" :class="{ muted: throughputValue == null }">
+        {{ fmtMetric(throughputValue) }}<span v-if="throughputMeta.unit" 
class="unit">{{ throughputMeta.unit }}</span>
+      </span>
+    </div>
+
+    <div class="spark-row">
+      <Sparkline
+        v-if="throughputSeries && throughputSeries.length > 1"
+        :values="throughputSeries"
+        :width="120"
+        :height="18"
+        :color="layer.color"
+        :stroke="1.25"
+      />
+      <span v-else class="spark-empty">—</span>
+    </div>
+  </RouterLink>
+</template>
+
+<style scoped>
+.strip-card {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  padding: 9px 11px;
+  text-decoration: none;
+  min-width: 0;
+  transition: border-color 0.12s, background 0.12s;
+}
+.strip-card:hover {
+  border-color: var(--sw-line-3);
+  background: var(--sw-bg-1);
+}
+.head {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  font-size: 10.5px;
+  color: var(--sw-fg-2);
+  min-width: 0;
+}
+.head .dot {
+  width: 6px;
+  height: 6px;
+  border-radius: 50%;
+  flex: 0 0 6px;
+}
+.head .name {
+  color: var(--sw-fg-1);
+  font-weight: 600;
+  letter-spacing: -0.01em;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  flex: 1;
+  min-width: 0;
+}
+.value-row {
+  display: flex;
+  align-items: baseline;
+  gap: 4px;
+  margin-top: 1px;
+}
+.count {
+  font-size: 19px;
+  font-weight: 600;
+  color: var(--sw-fg-0);
+  font-variant-numeric: tabular-nums;
+  letter-spacing: -0.02em;
+}
+.count.muted {
+  color: var(--sw-fg-3);
+}
+.count-unit {
+  font-size: 10.5px;
+  color: var(--sw-fg-3);
+}
+.traffic-row {
+  display: flex;
+  align-items: baseline;
+  justify-content: space-between;
+  gap: 6px;
+  font-size: 10.5px;
+}
+.traffic-label {
+  color: var(--sw-fg-3);
+  text-transform: uppercase;
+  letter-spacing: 0.06em;
+  font-size: 9.5px;
+}
+.traffic-value {
+  color: var(--sw-fg-0);
+  font-variant-numeric: tabular-nums;
+  font-weight: 500;
+}
+.traffic-value.muted {
+  color: var(--sw-fg-3);
+}
+.traffic-value .unit {
+  color: var(--sw-fg-3);
+  font-size: 9.5px;
+  margin-left: 1px;
+}
+.spark-row {
+  display: flex;
+  align-items: center;
+  margin-top: 2px;
+  min-height: 18px;
+}
+.spark-empty {
+  color: var(--sw-fg-3);
+  font-size: 10px;
+}
+</style>
diff --git a/apps/ui/src/views/overview/OverviewView.vue 
b/apps/ui/src/views/overview/OverviewView.vue
index b6eccc3..b655e58 100644
--- a/apps/ui/src/views/overview/OverviewView.vue
+++ b/apps/ui/src/views/overview/OverviewView.vue
@@ -20,6 +20,7 @@ import { RouterLink } from 'vue-router';
 import { useLayers } from '@/composables/useLayers';
 import { useLandingOrder } from '@/composables/useLandingOrder';
 import AlarmsPanel from './AlarmsPanel.vue';
+import LayerKpiStripCard from './LayerKpiStripCard.vue';
 import LayerKpiTile from './LayerKpiTile.vue';
 import LayerLandingCard from './LayerLandingCard.vue';
 
@@ -72,29 +73,33 @@ const empty = computed(() => !isLoading.value && 
orderedLayers.value.length ===
       </div>
     </div>
 
-    <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" />
+    <template v-else>
+      <!-- Top KPI strip: 6 equal-width per-layer cards (service count +
+           throughput value + sparkline). Adopts the design's KPI-strip
+           style at landing.jsx:30-38. -->
+      <div class="kpi-strip" :style="{ '--kpi-count': topSix.length }">
+        <LayerKpiStripCard v-for="L in topSix" :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.
+      <!-- Detail grid: 2 featured cards + 4 compact tiles + alarms rail. -->
+      <div class="overview-grid">
+        <LayerLandingCard
+          v-for="L in featured"
+          :key="L.key"
+          :layer="L"
+          :class="`featured featured-${featured.indexOf(L) + 1}`"
+        />
+        <AlarmsPanel class="alarms-rail" />
+        <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>
   </div>
 </template>
 
@@ -174,6 +179,26 @@ const empty = computed(() => !isLoading.value && 
orderedLayers.value.length ===
   text-decoration: none;
 }
 
+/* Top per-layer KPI strip — 6 equal columns (or fewer if fewer than 6
+ * layers are reporting). `--kpi-count` is set from the template so the
+ * grid never goes wider than necessary. */
+.kpi-strip {
+  display: grid;
+  grid-template-columns: repeat(var(--kpi-count, 6), 1fr);
+  gap: 12px;
+  margin-bottom: 14px;
+}
+@media (max-width: 1100px) {
+  .kpi-strip {
+    grid-template-columns: repeat(3, 1fr);
+  }
+}
+@media (max-width: 720px) {
+  .kpi-strip {
+    grid-template-columns: repeat(2, 1fr);
+  }
+}
+
 /* 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)

Reply via email to