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)