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 84a50fd  layer: shared per-layer shell with header KPIs + cap-driven 
tabs
84a50fd is described below

commit 84a50fd793f27d937130148fa3ad54d9bea4ad9c
Author: Wu Sheng <[email protected]>
AuthorDate: Tue May 12 16:07:35 2026 +0800

    layer: shared per-layer shell with header KPIs + cap-driven tabs
    
    LayerShell mirrors design/screens/landing-layer.jsx — a header card
    carrying layer identity (initials tile, name, source/level tag, status
    badge), a KPI strip on the right (service count + the configured
    landing columns' aggregated values), and a tab strip filtered by the
    layer's caps. No 'Overview' tab — Services is the default per the
    project directive.
    
    Router restructured to nest sub-routes under /layer/:layerKey so every
    tab shares the header. Tab body components fill the shell via a nested
    router-view; placeholders render in an 'inset' compact variant so they
    don't fight the shell's outer padding.
    
    Overview strip cards: linear graph (sparkline) bumped to 32px tall and
    full-width below the KPI value — matches the design's KPI component
    where the trend line is the prominent visual, not an afterthought.
---
 apps/ui/src/router/index.ts                      |  51 ++-
 apps/ui/src/views/PlaceholderView.vue            |  20 +-
 apps/ui/src/views/layer/LayerShell.vue           | 390 +++++++++++++++++++++++
 apps/ui/src/views/overview/LayerKpiStripCard.vue |  45 ++-
 4 files changed, 460 insertions(+), 46 deletions(-)

diff --git a/apps/ui/src/router/index.ts b/apps/ui/src/router/index.ts
index 3fc988d..7f3ddff 100644
--- a/apps/ui/src/router/index.ts
+++ b/apps/ui/src/router/index.ts
@@ -23,20 +23,11 @@ function humanKey(k: string): string {
   return k.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
 }
 
-// Layer sub-routes are open-ended — any `:layerKey` is accepted. The real
-// per-layer view (Phase 2.6+) will read live cap data via useLayers() and
-// render a 'doesn't expose' note when the cap is off. The placeholder here
-// only needs the raw key + the feature label.
-function layerSubRoutes(): RouteRecordRaw[] {
-  const sub: RouteRecordRaw[] = [];
-  // Bare /layer/:layerKey redirects to /layer/:layerKey/services — the
-  // default entry point per layer. There is no per-layer 'overview'
-  // (the global Overview at / handles that).
-  sub.push({
-    path: 'layer/:layerKey',
-    redirect: (to) => ({ path: `/layer/${to.params.layerKey}/services` }),
-  });
-
+// Layer sub-routes nest under a single LayerShell route so every tab
+// shares the header KPI strip + cap-driven tab navigation. The shell
+// reads `:layerKey` from the URL and pulls layer config / live data.
+// Sub-route components fill the tab body via a nested router-view.
+function layerRoute(): RouteRecordRaw {
   const features: { path: string; label: string; phase: string }[] = [
     { path: 'services', label: 'Services', phase: 'Phase 2 / 3' },
     { path: 'instances', label: 'Instances', phase: 'Phase 2 / 3' },
@@ -49,23 +40,31 @@ function layerSubRoutes(): RouteRecordRaw[] {
     { path: 'profiling', label: 'Profiling', phase: 'Phase 8' },
     { path: 'events', label: 'Events', phase: 'Phase 5' },
   ];
-  for (const f of features) {
-    sub.push({
-      path: `layer/:layerKey/${f.path}`,
-      component: placeholder,
-      props: (r) => ({
-        title: `${humanKey(String(r.params.layerKey))} · ${f.label}`,
-        phase: f.phase,
-      }),
-    });
-  }
-  return sub;
+  return {
+    path: 'layer/:layerKey',
+    component: () => import('@/views/layer/LayerShell.vue'),
+    children: [
+      // Bare /layer/:layerKey lands on Services — the default entry.
+      { path: '', redirect: (to) => ({ path: 
`/layer/${to.params.layerKey}/services` }) },
+      ...features.map<RouteRecordRaw>((f) => ({
+        path: f.path,
+        component: placeholder,
+        props: (r) => ({
+          title: `${humanKey(String(r.params.layerKey))} · ${f.label}`,
+          phase: f.phase,
+          // The shell renders its own header — placeholder mode for tab
+          // bodies shows just the phase note.
+          inset: true,
+        }),
+      })),
+    ],
+  };
 }
 
 const shellRoutes: RouteRecordRaw[] = [
   { path: '', name: 'overview', component: () => 
import('@/views/overview/OverviewView.vue') },
   { path: 'setup', name: 'setup', component: () => 
import('@/views/setup/SetupView.vue') },
-  ...layerSubRoutes(),
+  layerRoute(),
   // Alerts (user-facing — alarms are observability data, not operator-only)
   { path: 'alarms', component: placeholder, props: { title: 'Alarms', phase: 
'Phase 5', note: 'Read-only; recovery is backend-auto. Live debug card via 
admin REST.' } },
   // Marketplace — all dashboards / templates across layers
diff --git a/apps/ui/src/views/PlaceholderView.vue 
b/apps/ui/src/views/PlaceholderView.vue
index 57094ba..4c40a4e 100644
--- a/apps/ui/src/views/PlaceholderView.vue
+++ b/apps/ui/src/views/PlaceholderView.vue
@@ -15,14 +15,15 @@
   limitations under the License.
 -->
 <script setup lang="ts">
-defineProps<{ title: string; phase: string; note?: string }>();
+defineProps<{ title: string; phase: string; note?: string; inset?: boolean 
}>();
 </script>
 
 <template>
-  <div class="ph">
+  <div class="ph" :class="{ inset }">
     <div class="ph-card">
       <div class="ph-kicker">Coming in {{ phase }}</div>
-      <h1>{{ title }}</h1>
+      <h1 v-if="!inset">{{ title }}</h1>
+      <h2 v-else>{{ title }}</h2>
       <p v-if="note">{{ note }}</p>
     </div>
   </div>
@@ -36,6 +37,19 @@ defineProps<{ title: string; phase: string; note?: string 
}>();
   min-height: 60vh;
   padding: 32px;
 }
+.ph.inset {
+  min-height: 200px;
+  padding: 20px;
+}
+.ph.inset .ph-card {
+  padding: 20px 24px;
+}
+.ph-card h2 {
+  font-size: 14px;
+  font-weight: 600;
+  color: var(--sw-fg-0);
+  margin: 0 0 6px;
+}
 .ph-card {
   background: var(--sw-bg-1);
   border: 1px solid var(--sw-line);
diff --git a/apps/ui/src/views/layer/LayerShell.vue 
b/apps/ui/src/views/layer/LayerShell.vue
new file mode 100644
index 0000000..76f9530
--- /dev/null
+++ b/apps/ui/src/views/layer/LayerShell.vue
@@ -0,0 +1,390 @@
+<!--
+  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.
+-->
+<!--
+  Shared shell for every per-layer page (`/layer/:layerKey/*`). Renders
+  a header card with the layer's identity, KPI strip, and cap-driven
+  tabs, then a router-view outlet for the active sub-route.
+
+  Mirrors design/screens/landing-layer.jsx but with:
+    - Real KPI data sourced from /api/layer/:key/landing.aggregates
+    - Tabs filtered by the layer's caps (no Logs row when caps.logs=false)
+    - No "Overview" tab — per the project directive that Services is the
+      default entry; the cross-layer Overview lives at `/`.
+-->
+<script setup lang="ts">
+import { computed } from 'vue';
+import { RouterLink, RouterView, useRoute } 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 { useLayerLanding } from '@/composables/useLayerLanding';
+import { useLayers } from '@/composables/useLayers';
+import { useSetupStore } from '@/stores/setup';
+import { fmtMetric } from '@/utils/formatters';
+
+const route = useRoute();
+const layerKey = computed(() => String(route.params.layerKey ?? ''));
+const { layers, hasTopology } = useLayers();
+const layer = computed<LayerDef | null>(() => {
+  const found = layers.value.find((l) => l.key === layerKey.value);
+  return found ?? 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 });
+});
+
+// Build a non-null LayerDef ref for the landing composable.
+const safeLayer = computed<LayerDef>(() => layer.value ?? {
+  key: layerKey.value,
+  name: layerKey.value,
+  color: 'var(--sw-fg-2)',
+  serviceCount: -1,
+  active: false,
+  level: null,
+  slots: {},
+  caps: {},
+});
+const safeCfg = computed(() => cfg.value?.landing ?? {
+  priority: 99,
+  topN: 5,
+  orderBy: 'cpm',
+  columns: [],
+  style: 'table' as const,
+});
+const landing = useLayerLanding(safeLayer, safeCfg);
+const aggregates = computed(() => landing.data.value?.aggregates ?? null);
+
+// ── Header identity ──────────────────────────────────────────────────
+function initialsFor(name: string): string {
+  return name
+    .split(/[\s_-]+/)
+    .filter(Boolean)
+    .slice(0, 2)
+    .map((p) => p[0]?.toUpperCase() ?? '')
+    .join('') || '?';
+}
+const displayName = computed(() => cfg.value?.displayName || layer.value?.name 
|| layerKey.value);
+const initials = computed(() => initialsFor(displayName.value));
+
+// ── Tabs ─────────────────────────────────────────────────────────────
+interface Tab {
+  to: string;
+  label: string;
+  icon?: string;
+}
+const tabs = computed<Tab[]>(() => {
+  if (!layer.value) return [];
+  const L = layer.value;
+  const out: Tab[] = [];
+  const base = `/layer/${L.key}`;
+  if (L.slots.services) out.push({ to: `${base}/services`, label: 
cfg.value?.slots.services || L.slots.services || 'Services' });
+  if (L.slots.instances) out.push({ to: `${base}/instances`, label: 
cfg.value?.slots.instances || L.slots.instances });
+  if (L.slots.endpoints) out.push({ to: `${base}/endpoints`, label: 
cfg.value?.slots.endpoints || L.slots.endpoints });
+  if (hasTopology(L)) out.push({ to: `${base}/topology`, label: 'Topology' });
+  if (L.caps.endpointDependency) {
+    out.push({
+      to: `${base}/dependency`,
+      label: cfg.value?.slots.endpointDependency || 
`${cfg.value?.slots.endpoints || 'Endpoint'} dependency`,
+    });
+  }
+  if (L.caps.dashboards) out.push({ to: `${base}/dashboards`, label: 
'Dashboards' });
+  if (L.caps.traces) out.push({ to: `${base}/traces`, label: 'Traces' });
+  if (L.caps.logs) out.push({ to: `${base}/logs`, label: 'Logs' });
+  if (L.caps.profiling) out.push({ to: `${base}/profiling`, label: 'Profiling' 
});
+  if (L.caps.events) out.push({ to: `${base}/events`, label: 'Events' });
+  return out;
+});
+
+function isTabActive(to: string): boolean {
+  return route.path === to || route.path.startsWith(to + '/');
+}
+
+// ── Header KPI strip ─────────────────────────────────────────────────
+// Picks at most 5 metrics from the layer's setup columns; service count
+// always leads. Each KPI is read from /api/layer/:key/landing.aggregates,
+// so it's the same value the Overview tile shows.
+interface HeaderKpi {
+  label: string;
+  value: number | null;
+  unit?: string;
+  color?: string;
+  isService?: boolean;
+}
+const headerKpis = computed<HeaderKpi[]>(() => {
+  const L = layer.value;
+  if (!L) return [];
+  const c = cfg.value;
+  if (!c) return [];
+  const a = aggregates.value;
+  const svcCount = a?.serviceCount ?? L.serviceCount;
+  const out: HeaderKpi[] = [
+    { label: c.slots.services || 'Services', value: svcCount, color: L.color, 
isService: true },
+  ];
+  for (const col of c.landing.columns.slice(0, 5)) {
+    const m = metricMeta(col.metric);
+    out.push({
+      label: col.label || m.label,
+      value: a?.metrics?.[col.metric] ?? null,
+      unit: col.unit || m.unit,
+    });
+  }
+  return out;
+});
+
+// Source/level chip text (mirrors design's "from Java agent · OAP v10.3").
+const sourceText = computed(() => {
+  if (!layer.value) return '';
+  const lvl = layer.value.level;
+  return lvl !== null && lvl !== undefined ? `OAP layer · level ${lvl}` : 'OAP 
layer';
+});
+</script>
+
+<template>
+  <div class="layer-shell">
+    <header v-if="layer" class="sw-card layer-head">
+      <div class="head-row">
+        <div class="identity">
+          <div class="icon-tile" :style="{ background: layer.color }">{{ 
initials }}</div>
+          <div class="identity-text">
+            <div class="title-row">
+              <h1>{{ displayName }}</h1>
+              <span class="sw-tag layer-tag">LAYER</span>
+              <span class="sw-tag">{{ sourceText }}</span>
+              <span v-if="layer.serviceCount === 0" class="sw-badge warn">no 
services</span>
+              <span v-else-if="!layer.active" class="sw-badge">no data</span>
+            </div>
+            <div class="sub">
+              {{ layer.serviceCount >= 0 ? `${layer.serviceCount} 
${(cfg?.slots.services || 'services').toLowerCase()}` : 'no service data' }}
+              <span v-if="layer.documentLink">·
+                <a :href="layer.documentLink" target="_blank" rel="noopener 
noreferrer">docs ↗</a>
+              </span>
+            </div>
+          </div>
+        </div>
+        <div class="kpi-strip">
+          <div v-for="(k, i) in headerKpis" :key="i" class="kpi">
+            <div class="kpi-label">{{ k.label }}</div>
+            <div class="kpi-value" :style="k.color && k.isService ? { color: 
k.color } : undefined">
+              <span :class="{ muted: k.value == null }">{{ fmtMetric(k.value) 
}}</span>
+              <span v-if="k.unit" class="kpi-unit">{{ k.unit }}</span>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <nav class="tab-strip" v-if="tabs.length > 0">
+        <RouterLink
+          v-for="t in tabs"
+          :key="t.to"
+          :to="t.to"
+          class="tab"
+          :class="{ on: isTabActive(t.to) }"
+        >
+          {{ t.label }}
+        </RouterLink>
+      </nav>
+    </header>
+
+    <div v-else class="missing">
+      <div class="sw-card missing-card">
+        <Icon name="alert" :size="18" />
+        <div>
+          <h2>Layer not found</h2>
+          <p>
+            No OAP layer matches <code>{{ layerKey }}</code>. The layer may be 
inactive or unknown.
+            <RouterLink to="/">Back to Overview</RouterLink>.
+          </p>
+        </div>
+      </div>
+    </div>
+
+    <div v-if="layer" class="tab-body">
+      <RouterView />
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.layer-shell {
+  padding: 16px 20px 48px;
+  max-width: 1440px;
+  margin: 0 auto;
+}
+.layer-head {
+  padding: 14px 14px 0;
+  margin-bottom: 14px;
+}
+.head-row {
+  display: flex;
+  align-items: flex-start;
+  gap: 18px;
+  flex-wrap: wrap;
+}
+.identity {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  min-width: 0;
+  flex: 1 1 320px;
+}
+.icon-tile {
+  width: 40px;
+  height: 40px;
+  border-radius: 10px;
+  display: grid;
+  place-items: center;
+  color: #fff;
+  font-weight: 700;
+  font-size: 14px;
+  letter-spacing: -0.02em;
+  flex: 0 0 40px;
+  /* Layer color is intentionally bright; mix with a darker overlay so
+   * white initials stay legible across the palette range. */
+  background-blend-mode: multiply;
+  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
+}
+.identity-text {
+  min-width: 0;
+}
+.title-row {
+  display: flex;
+  align-items: baseline;
+  gap: 8px;
+  flex-wrap: wrap;
+}
+.title-row h1 {
+  margin: 0;
+  font-size: 18px;
+  font-weight: 600;
+  color: var(--sw-fg-0);
+  letter-spacing: -0.02em;
+}
+.layer-tag {
+  background: var(--sw-accent-soft);
+  color: var(--sw-accent-2);
+  border-color: var(--sw-accent-line);
+}
+.sub {
+  margin-top: 4px;
+  font-size: 11.5px;
+  color: var(--sw-fg-3);
+}
+.sub a {
+  color: var(--sw-accent-2);
+  text-decoration: none;
+  margin-left: 4px;
+}
+.kpi-strip {
+  display: flex;
+  gap: 20px;
+  flex-wrap: wrap;
+  align-items: flex-end;
+  margin-left: auto;
+}
+.kpi {
+  text-align: right;
+  min-width: 60px;
+}
+.kpi-label {
+  font-size: 10px;
+  text-transform: uppercase;
+  letter-spacing: 0.08em;
+  color: var(--sw-fg-3);
+  margin-bottom: 2px;
+}
+.kpi-value {
+  font-size: 17px;
+  font-weight: 600;
+  color: var(--sw-fg-0);
+  font-variant-numeric: tabular-nums;
+  letter-spacing: -0.02em;
+}
+.kpi-value .muted {
+  color: var(--sw-fg-3);
+}
+.kpi-unit {
+  font-size: 10px;
+  color: var(--sw-fg-3);
+  margin-left: 2px;
+}
+.tab-strip {
+  display: flex;
+  gap: 2px;
+  margin: 14px -14px 0;
+  padding: 0 14px;
+  border-bottom: 1px solid var(--sw-line);
+  overflow-x: auto;
+}
+.tab {
+  padding: 8px 12px;
+  font-size: 12px;
+  font-weight: 500;
+  color: var(--sw-fg-2);
+  text-decoration: none;
+  border-bottom: 2px solid transparent;
+  margin-bottom: -1px;
+  white-space: nowrap;
+  transition: color 0.12s, border-color 0.12s;
+}
+.tab:hover {
+  color: var(--sw-fg-1);
+}
+.tab.on {
+  color: var(--sw-fg-0);
+  font-weight: 600;
+  border-bottom-color: var(--sw-accent);
+}
+.tab-body {
+  /* Sub-routes own their own internal layout / padding. */
+  min-height: 200px;
+}
+.missing {
+  padding: 40px 0;
+}
+.missing-card {
+  display: flex;
+  align-items: center;
+  gap: 14px;
+  padding: 20px;
+  max-width: 540px;
+  margin: 0 auto;
+}
+.missing-card h2 {
+  margin: 0 0 4px;
+  font-size: 14px;
+  color: var(--sw-fg-0);
+}
+.missing-card p {
+  margin: 0;
+  font-size: 11.5px;
+  color: var(--sw-fg-2);
+  line-height: 1.5;
+}
+.missing-card code {
+  font-family: var(--sw-mono);
+  font-size: 10.5px;
+  background: var(--sw-bg-2);
+  padding: 1px 4px;
+  border-radius: 3px;
+}
+.missing-card a {
+  color: var(--sw-accent-2);
+  text-decoration: none;
+}
+</style>
diff --git a/apps/ui/src/views/overview/LayerKpiStripCard.vue 
b/apps/ui/src/views/overview/LayerKpiStripCard.vue
index 4bf3bf4..16a7906 100644
--- a/apps/ui/src/views/overview/LayerKpiStripCard.vue
+++ b/apps/ui/src/views/overview/LayerKpiStripCard.vue
@@ -71,21 +71,21 @@ const slotName = computed(() => cfg.value.slots.services ?? 
'services');
       <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">
+    <div class="traffic-block" 
:title="`${throughputMeta.longLabel}\n\n${throughputMeta.tip}`">
+      <div class="traffic-meta">
+        <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>
       <Sparkline
         v-if="throughputSeries && throughputSeries.length > 1"
+        class="trend"
         :values="throughputSeries"
-        :width="120"
-        :height="18"
+        :width="160"
+        :height="32"
         :color="layer.color"
-        :stroke="1.25"
+        :stroke="1.5"
       />
       <span v-else class="spark-empty">—</span>
     </div>
@@ -150,7 +150,15 @@ const slotName = computed(() => cfg.value.slots.services 
?? 'services');
   font-size: 10.5px;
   color: var(--sw-fg-3);
 }
-.traffic-row {
+.traffic-block {
+  margin-top: 3px;
+  border-top: 1px dashed var(--sw-line);
+  padding-top: 5px;
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+}
+.traffic-meta {
   display: flex;
   align-items: baseline;
   justify-content: space-between;
@@ -176,14 +184,17 @@ const slotName = computed(() => cfg.value.slots.services 
?? 'services');
   font-size: 9.5px;
   margin-left: 1px;
 }
-.spark-row {
-  display: flex;
-  align-items: center;
-  margin-top: 2px;
-  min-height: 18px;
+.trend {
+  width: 100%;
+  height: 32px;
+  display: block;
 }
 .spark-empty {
   color: var(--sw-fg-3);
   font-size: 10px;
+  min-height: 32px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
 }
 </style>

Reply via email to