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
commit c58212a6e10a03d935543f6678a78d6a65c6df00 Author: Wu Sheng <[email protected]> AuthorDate: Tue May 12 10:31:43 2026 +0800 ui: per-layer menu replaces global telemetry section --- apps/ui/src/components/shell/AppSidebar.vue | 153 ++++++++++++++++++++-------- apps/ui/src/components/shell/layers.ts | 129 +++++++++++++++++++++++ apps/ui/src/router/index.ts | 137 +++++++++++++------------ 3 files changed, 314 insertions(+), 105 deletions(-) diff --git a/apps/ui/src/components/shell/AppSidebar.vue b/apps/ui/src/components/shell/AppSidebar.vue index 6281ec0..948e983 100644 --- a/apps/ui/src/components/shell/AppSidebar.vue +++ b/apps/ui/src/components/shell/AppSidebar.vue @@ -20,6 +20,7 @@ import { RouterLink, useRoute, useRouter } from 'vue-router'; import Icon, { type IconName } from '@/components/icons/Icon.vue'; import logoSw from '@/assets/icons/logo-sw.svg?raw'; import { useAuthStore } from '@/stores/auth'; +import { LAYERS } from './layers'; const auth = useAuthStore(); const router = useRouter(); @@ -28,17 +29,6 @@ async function signOut(): Promise<void> { await router.push({ name: 'login' }); } -// Phase 2 will replace this stub with real getMenuItems / listLayers data. -const layers = ref([ - { key: 'general', name: 'General Service', svc: 84, color: 'var(--sw-accent)' }, - { key: 'mesh', name: 'Service Mesh', svc: 22, color: 'var(--sw-info)' }, - { key: 'k8s', name: 'Kubernetes', svc: 62, color: 'var(--sw-purple)' }, - { key: 'rum', name: 'Browser (RUM)', svc: 8, color: 'var(--sw-cyan)' }, - { key: 'mq', name: 'Virtual MQ', svc: 6, color: 'var(--sw-ok)' }, - { key: 'db', name: 'Virtual Database', svc: 6, color: 'var(--sw-warn)' }, - { key: 'otel', name: 'OpenTelemetry', svc: 18, color: 'var(--sw-purple)' }, - { key: 'faas', name: 'FaaS', svc: 3, color: 'var(--sw-err)' }, -]); const expandedLayer = ref<string | null>('general'); const route = useRoute(); @@ -53,15 +43,10 @@ interface NavRow { badge?: { text: string; kind?: 'ok' | 'warn' | 'err' | 'info' }; } -const telemetry: NavRow[] = [ - { icon: 'metric', label: 'Dashboards', to: '/dashboards' }, - { icon: 'trace', label: 'Traces', to: '/operate/traces' }, - { icon: 'log', label: 'Logs', to: '/operate/logs' }, - { icon: 'prof', label: 'Profiling', to: '/profiling' }, - { icon: 'event', label: 'Events', to: '/operate/events' }, -]; +// Cross-layer operations stay global. const operate: NavRow[] = [ { icon: 'alert', label: 'Alarms', to: '/operate/alarms', badge: { text: '7', kind: 'err' } }, + { icon: 'trace', label: 'Trace search', to: '/operate/traces' }, ]; const admin: NavRow[] = [ { icon: 'svc', label: 'Cluster status', to: '/cluster' }, @@ -81,9 +66,9 @@ const admin: NavRow[] = [ <nav class="sw-nav"> <div class="sw-nav-section sw-row" style="justify-content: space-between"> <span>Layers</span> - <span style="color: var(--sw-fg-3); font-weight: 400">{{ layers.length }} layers</span> + <span style="color: var(--sw-fg-3); font-weight: 400">{{ LAYERS.length }} layers</span> </div> - <template v-for="L in layers" :key="L.key"> + <template v-for="L in LAYERS" :key="L.key"> <div class="sw-nav-item" :class="{ 'is-active': expandedLayer === L.key }" @@ -91,42 +76,120 @@ const admin: NavRow[] = [ > <span class="layer-dot" :style="{ background: L.color }" /> <span :style="{ fontWeight: expandedLayer === L.key ? 600 : 500 }">{{ L.name }}</span> - <span class="sw-badge" style="margin-left: auto">{{ L.svc }}</span> - <span class="caret" :class="{ open: expandedLayer === L.key }"><Icon name="caret" :size="10" /></span> + <span class="sw-badge" style="margin-left: auto">{{ L.serviceCount }}</span> + <span class="caret" :class="{ open: expandedLayer === L.key }"> + <Icon name="caret" :size="10" /> + </span> </div> <div v-if="expandedLayer === L.key" class="layer-children"> - <RouterLink :to="`/layer/${L.key}`" class="sw-nav-item" :class="{ 'is-active': isActive(`/layer/${L.key}`) }"> - <Icon name="dash" /><span>Layer overview</span> + <RouterLink + v-if="L.caps.overview" + :to="`/layer/${L.key}`" + class="sw-nav-item" + :class="{ 'is-active': isActive(`/layer/${L.key}`) && route.path === `/layer/${L.key}` }" + > + <Icon name="dash" /><span>Overview</span> </RouterLink> - <RouterLink :to="`/layer/${L.key}/services`" class="sw-nav-item" :class="{ 'is-active': isActive(`/layer/${L.key}/services`) }"> - <Icon name="svc" /><span>Services</span><span class="sw-badge" style="margin-left: auto">{{ L.svc }}</span> + + <RouterLink + v-if="L.slots.services" + :to="`/layer/${L.key}/services`" + class="sw-nav-item" + :class="{ 'is-active': isActive(`/layer/${L.key}/services`) }" + > + <Icon name="svc" /><span>{{ L.slots.services }}</span> + <span class="sw-badge" style="margin-left: auto">{{ L.serviceCount }}</span> </RouterLink> - <RouterLink :to="`/layer/${L.key}/instances`" class="sw-nav-item" :class="{ 'is-active': isActive(`/layer/${L.key}/instances`) }"> - <Icon name="prof" /><span>Instances</span> + <RouterLink + v-if="L.slots.instances" + :to="`/layer/${L.key}/instances`" + class="sw-nav-item" + :class="{ 'is-active': isActive(`/layer/${L.key}/instances`) }" + > + <Icon name="prof" /><span>{{ L.slots.instances }}</span> </RouterLink> - <RouterLink :to="`/layer/${L.key}/endpoints`" class="sw-nav-item" :class="{ 'is-active': isActive(`/layer/${L.key}/endpoints`) }"> - <Icon name="ep" /><span>Endpoints</span> + <RouterLink + v-if="L.slots.endpoints" + :to="`/layer/${L.key}/endpoints`" + class="sw-nav-item" + :class="{ 'is-active': isActive(`/layer/${L.key}/endpoints`) }" + > + <Icon name="ep" /><span>{{ L.slots.endpoints }}</span> </RouterLink> - <RouterLink :to="`/layer/${L.key}/topology`" class="sw-nav-item" :class="{ 'is-active': isActive(`/layer/${L.key}/topology`) }"> + + <RouterLink + v-if="L.caps.topology" + :to="`/layer/${L.key}/topology`" + class="sw-nav-item" + :class="{ 'is-active': isActive(`/layer/${L.key}/topology`) }" + > <Icon name="topo" /><span>Topology</span> </RouterLink> + <RouterLink + v-if="L.caps.dashboards" + :to="`/layer/${L.key}/dashboards`" + class="sw-nav-item" + :class="{ 'is-active': isActive(`/layer/${L.key}/dashboards`) }" + > + <Icon name="metric" /><span>Dashboards</span> + </RouterLink> + <RouterLink + v-if="L.caps.traces" + :to="`/layer/${L.key}/traces`" + class="sw-nav-item" + :class="{ 'is-active': isActive(`/layer/${L.key}/traces`) }" + > + <Icon name="trace" /><span>Traces</span> + </RouterLink> + <RouterLink + v-if="L.caps.logs" + :to="`/layer/${L.key}/logs`" + class="sw-nav-item" + :class="{ 'is-active': isActive(`/layer/${L.key}/logs`) }" + > + <Icon name="log" /><span>Logs</span> + </RouterLink> + <RouterLink + v-if="L.caps.profiling" + :to="`/layer/${L.key}/profiling`" + class="sw-nav-item" + :class="{ 'is-active': isActive(`/layer/${L.key}/profiling`) }" + > + <Icon name="flame" /><span>Profiling</span> + </RouterLink> + <RouterLink + v-if="L.caps.events" + :to="`/layer/${L.key}/events`" + class="sw-nav-item" + :class="{ 'is-active': isActive(`/layer/${L.key}/events`) }" + > + <Icon name="event" /><span>Events</span> + </RouterLink> </div> </template> - <div class="sw-nav-section">Telemetry</div> - <RouterLink v-for="row in telemetry" :key="row.to" :to="row.to" class="sw-nav-item" :class="{ 'is-active': isActive(row.to) }"> - <Icon :name="row.icon" /><span>{{ row.label }}</span> - <span v-if="row.badge" class="sw-badge" :class="row.badge.kind" style="margin-left: auto">{{ row.badge.text }}</span> - </RouterLink> - <div class="sw-nav-section">Operate</div> - <RouterLink v-for="row in operate" :key="row.to" :to="row.to" class="sw-nav-item" :class="{ 'is-active': isActive(row.to) }"> + <RouterLink + v-for="row in operate" + :key="row.to" + :to="row.to" + class="sw-nav-item" + :class="{ 'is-active': isActive(row.to) }" + > <Icon :name="row.icon" /><span>{{ row.label }}</span> - <span v-if="row.badge" class="sw-badge" :class="row.badge.kind" style="margin-left: auto">{{ row.badge.text }}</span> + <span v-if="row.badge" class="sw-badge" :class="row.badge.kind" style="margin-left: auto"> + {{ row.badge.text }} + </span> </RouterLink> <div class="sw-nav-section">Admin</div> - <RouterLink v-for="row in admin" :key="row.to" :to="row.to" class="sw-nav-item" :class="{ 'is-active': isActive(row.to) }"> + <RouterLink + v-for="row in admin" + :key="row.to" + :to="row.to" + class="sw-nav-item" + :class="{ 'is-active': isActive(row.to) }" + > <Icon :name="row.icon" /><span>{{ row.label }}</span> </RouterLink> </nav> @@ -136,7 +199,15 @@ const admin: NavRow[] = [ {{ auth.user?.username ? auth.user.username.slice(0, 2).toUpperCase() : '?' }} </div> <div style="line-height: 1.2; flex: 1; min-width: 0; overflow: hidden"> - <div style="color: var(--sw-fg-0); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap"> + <div + style=" + color: var(--sw-fg-0); + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + " + > {{ auth.user?.username ?? 'guest' }} </div> <div>{{ auth.user?.roles?.join(' · ') ?? 'not signed in' }}</div> diff --git a/apps/ui/src/components/shell/layers.ts b/apps/ui/src/components/shell/layers.ts new file mode 100644 index 0000000..6556a34 --- /dev/null +++ b/apps/ui/src/components/shell/layers.ts @@ -0,0 +1,129 @@ +/* + * 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. + */ + +// Phase 2 will replace this static stub with real getMenuItems / listLayers +// data + per-layer overrides from the BFF dashboard-template bundle. The +// shape is what the sidebar and router will consume regardless. + +export interface LayerSlots { + /** Renamed service-equivalent (functions / workloads / clusters / apps / databases / …). */ + services?: string; + /** Renamed instance-equivalent (versions / pods / brokers / sessions / nodes / …). */ + instances?: string; + /** Renamed endpoint-equivalent (invocations / topics / pages / queries / …). */ + endpoints?: string; +} + +export interface LayerCaps { + /** Per-layer landing page with KPIs / constellation / health. */ + overview?: boolean; + /** Topology graph. */ + topology?: boolean; + /** Per-scope dashboards (Service / Instance / Endpoint / Glance). */ + dashboards?: boolean; + /** Trace explorer (SkyWalking native or Zipkin sources). */ + traces?: boolean; + /** Log explorer. */ + logs?: boolean; + /** Any of the profiling subsystems (sampled / async-profiler / eBPF / pprof). */ + profiling?: boolean; + /** Event timeline. */ + events?: boolean; +} + +export interface LayerDef { + key: string; + name: string; + /** CSS color (token var or hex). */ + color: string; + /** Stub count — Phase 2 pulls the real number from listServices(layer). */ + serviceCount: number; + slots: LayerSlots; + caps: LayerCaps; +} + +export const LAYERS: readonly LayerDef[] = [ + { + key: 'general', + name: 'General Service', + color: 'var(--sw-accent)', + serviceCount: 84, + slots: { services: 'Services', instances: 'Instances', endpoints: 'Endpoints' }, + caps: { overview: true, topology: true, dashboards: true, traces: true, logs: true, profiling: true, events: true }, + }, + { + key: 'mesh', + name: 'Service Mesh', + color: 'var(--sw-info)', + serviceCount: 22, + slots: { services: 'Services', instances: 'Sidecars', endpoints: 'Endpoints' }, + caps: { overview: true, topology: true, dashboards: true, traces: true, logs: true, events: true }, + }, + { + key: 'k8s', + name: 'Kubernetes', + color: 'var(--sw-purple)', + serviceCount: 62, + slots: { services: 'Workloads', instances: 'Pods' }, + caps: { overview: true, topology: true, dashboards: true, events: true }, + }, + { + key: 'rum', + name: 'Browser (RUM)', + color: 'var(--sw-cyan)', + serviceCount: 8, + slots: { services: 'Applications', instances: 'Sessions', endpoints: 'Pages' }, + caps: { overview: true, dashboards: true, traces: true, logs: true }, + }, + { + key: 'mq', + name: 'Virtual MQ', + color: 'var(--sw-ok)', + serviceCount: 6, + slots: { services: 'Clusters', instances: 'Brokers', endpoints: 'Topics' }, + caps: { overview: true, dashboards: true }, + }, + { + key: 'db', + name: 'Virtual Database', + color: 'var(--sw-warn)', + serviceCount: 6, + slots: { services: 'Databases', instances: 'Nodes' }, + caps: { overview: true, dashboards: true }, + }, + { + key: 'otel', + name: 'OpenTelemetry', + color: 'var(--sw-purple)', + serviceCount: 18, + slots: { services: 'Services', instances: 'Instances', endpoints: 'Endpoints' }, + caps: { overview: true, topology: true, dashboards: true, traces: true, logs: true }, + }, + { + key: 'faas', + name: 'FaaS', + color: 'var(--sw-err)', + serviceCount: 3, + slots: { services: 'Functions', instances: 'Versions', endpoints: 'Invocations' }, + caps: { overview: true, dashboards: true, traces: true }, + }, +]; + +export function findLayer(key: string | undefined): LayerDef | undefined { + if (!key) return undefined; + return LAYERS.find((L) => L.key === key); +} diff --git a/apps/ui/src/router/index.ts b/apps/ui/src/router/index.ts index a597c20..41c949a 100644 --- a/apps/ui/src/router/index.ts +++ b/apps/ui/src/router/index.ts @@ -15,75 +15,85 @@ * limitations under the License. */ import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'; +import { findLayer } from '@/components/shell/layers'; import { useAuthStore } from '@/stores/auth'; -const shellRoutes: RouteRecordRaw[] = [ - { - path: '', - name: 'home', - component: () => import('@/views/landing/LandingView.vue'), - }, - // Layer drill-down stubs - { +const placeholder = () => import('@/views/PlaceholderView.vue'); + +// Build a per-layer route bundle from the layer feature config. Each cap that +// the layer declares becomes a sub-route under /layer/:layerKey/... +// Unknown layer keys fall back to a generic "not found" via the catch-all. +function layerSubRoutes(): RouteRecordRaw[] { + const sub: RouteRecordRaw[] = []; + + sub.push({ path: 'layer/:layerKey', - name: 'layer-overview', - component: () => import('@/views/PlaceholderView.vue'), - props: (route) => ({ - title: `Layer · ${route.params.layerKey}`, - phase: 'Phase 2', - note: 'Layer overview · KPIs, throughput, services table, constellation.', - }), - }, - { - path: 'layer/:layerKey/services', - component: () => import('@/views/PlaceholderView.vue'), - props: (route) => ({ - title: `${route.params.layerKey} · Services`, - phase: 'Phase 2', - }), - }, - { - path: 'layer/:layerKey/instances', - component: () => import('@/views/PlaceholderView.vue'), - props: (route) => ({ - title: `${route.params.layerKey} · Instances`, - phase: 'Phase 3', - }), - }, - { - path: 'layer/:layerKey/endpoints', - component: () => import('@/views/PlaceholderView.vue'), - props: (route) => ({ - title: `${route.params.layerKey} · Endpoints`, - phase: 'Phase 3', - }), - }, - { - path: 'layer/:layerKey/topology', - component: () => import('@/views/PlaceholderView.vue'), - props: (route) => ({ - title: `${route.params.layerKey} · Topology`, - phase: 'Phase 4', - note: 'Three variants: force-directed, hierarchical DAG, hex/honeycomb.', - }), - }, + component: placeholder, + props: (r) => { + const L = findLayer(String(r.params.layerKey)); + return { + title: L ? `${L.name} · Overview` : `Layer · ${r.params.layerKey}`, + phase: 'Phase 2', + note: L + ? 'Per-layer landing: KPIs, throughput, service constellation, services table.' + : 'Unknown layer key.', + }; + }, + }); - // Telemetry - { path: 'dashboards', component: () => import('@/views/PlaceholderView.vue'), props: { title: 'Dashboards', phase: 'Phase 3', note: 'Widget grid, per-scope templates, MQE editor.' } }, - { path: 'operate/traces', component: () => import('@/views/PlaceholderView.vue'), props: { title: 'Trace explorer', phase: 'Phase 5', note: 'Native (v2/v1) + Zipkin Lens, switchable.' } }, - { path: 'operate/traces/:traceId', component: () => import('@/views/PlaceholderView.vue'), props: (r) => ({ title: `Trace · ${r.params.traceId}`, phase: 'Phase 5' }) }, - { path: 'operate/logs', component: () => import('@/views/PlaceholderView.vue'), props: { title: 'Log explorer', phase: 'Phase 5' } }, - { path: 'profiling', component: () => import('@/views/PlaceholderView.vue'), props: { title: 'Profiling', phase: 'Phase 8', note: 'Sampled · async-profiler · eBPF · Go pprof — unified flame graph.' } }, - { path: 'operate/events', component: () => import('@/views/PlaceholderView.vue'), props: { title: 'Events', phase: 'Phase 5' } }, + for (const slot of ['services', 'instances', 'endpoints'] as const) { + sub.push({ + path: `layer/:layerKey/${slot}`, + component: placeholder, + props: (r) => { + const L = findLayer(String(r.params.layerKey)); + const label = L?.slots[slot] ?? slot; + return { + title: L ? `${L.name} · ${label}` : `Layer · ${slot}`, + phase: 'Phase 2 / 3', + }; + }, + }); + } - // Operate - { path: 'operate/alarms', component: () => import('@/views/PlaceholderView.vue'), props: { title: 'Alarms', phase: 'Phase 5', note: 'Read-only; recovery is backend-auto. Live debug card via admin REST.' } }, + const caps: { key: keyof NonNullable<ReturnType<typeof findLayer>>['caps']; label: string; phase: string }[] = [ + { key: 'topology', label: 'Topology', phase: 'Phase 4' }, + { key: 'dashboards', label: 'Dashboards', phase: 'Phase 3' }, + { key: 'traces', label: 'Traces', phase: 'Phase 5' }, + { key: 'logs', label: 'Logs', phase: 'Phase 5' }, + { key: 'profiling', label: 'Profiling', phase: 'Phase 8' }, + { key: 'events', label: 'Events', phase: 'Phase 5' }, + ]; + for (const c of caps) { + sub.push({ + path: `layer/:layerKey/${c.key}`, + component: placeholder, + props: (r) => { + const L = findLayer(String(r.params.layerKey)); + return { + title: L ? `${L.name} · ${c.label}` : `Layer · ${c.label}`, + phase: c.phase, + note: L && !L.caps[c.key] ? `${L.name} doesn't expose ${c.label.toLowerCase()}.` : undefined, + }; + }, + }); + } + + return sub; +} +const shellRoutes: RouteRecordRaw[] = [ + { path: '', name: 'home', component: () => import('@/views/landing/LandingView.vue') }, + ...layerSubRoutes(), + // Cross-layer operate + { path: 'operate/alarms', component: placeholder, props: { title: 'Alarms', phase: 'Phase 5', note: 'Read-only; recovery is backend-auto.' } }, + { path: 'operate/traces', component: placeholder, props: { title: 'Trace search', phase: 'Phase 5', note: 'Cross-layer trace search. Per-layer trace explorers live under /layer/:key/traces.' } }, + { path: 'operate/traces/:traceId', component: placeholder, props: (r) => ({ title: `Trace · ${r.params.traceId}`, phase: 'Phase 5' }) }, // Admin - { path: 'cluster', component: () => import('@/views/PlaceholderView.vue'), props: { title: 'Cluster status', phase: 'Phase 6 / 7', note: 'Module activity matrix · storage health · receiver activity · effective config tree · TTL grid.' } }, - { path: 'admin/users', component: () => import('@/views/PlaceholderView.vue'), props: { title: 'Users', phase: 'Phase 7' } }, - { path: 'admin/roles', component: () => import('@/views/PlaceholderView.vue'), props: { title: 'Roles & permissions', phase: 'Phase 7' } }, - { path: 'admin/audit', component: () => import('@/views/PlaceholderView.vue'), props: { title: 'Audit log', phase: 'Phase 7' } }, + { path: 'cluster', component: placeholder, props: { title: 'Cluster status', phase: 'Phase 6 / 7', note: 'Module activity matrix · storage health · receiver activity · effective config tree · TTL grid.' } }, + { path: 'admin/users', component: placeholder, props: { title: 'Users', phase: 'Phase 7' } }, + { path: 'admin/roles', component: placeholder, props: { title: 'Roles & permissions', phase: 'Phase 7' } }, + { path: 'admin/audit', component: placeholder, props: { title: 'Audit log', phase: 'Phase 7' } }, ]; const router = createRouter({ @@ -102,14 +112,13 @@ const router = createRouter({ }, { path: '/:catchAll(.*)*', - component: () => import('@/views/PlaceholderView.vue'), + component: placeholder, props: { title: 'Not found', phase: 'never', note: 'No route matches.' }, }, ], }); let bootstrapped = false; - router.beforeEach(async (to) => { const auth = useAuthStore(); if (!bootstrapped) {
