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>