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 6f04f3f ui: live layer data via useLayers composable replacing static
stub
6f04f3f is described below
commit 6f04f3fbec49a1d94ef4dab5e30625bc561f125a
Author: Wu Sheng <[email protected]>
AuthorDate: Tue May 12 14:08:38 2026 +0800
ui: live layer data via useLayers composable replacing static stub
- packages/api-client re-exports LayerDef / LayerSlots / LayerCaps /
MenuResponse so both apps share the wire shape
- apps/ui/composables/useLayers.ts: vue-query hook over /api/menu with
30s staleTime, 60s refetchInterval; exposes layers, activeLayers,
oapReachable, oapError, findLayer, hasTopology
- AppSidebar consumes useLayers(); inactive layers render dim with a
'no data' badge; an 'OAP unreachable' banner appears at the top of the
Layers section when the BFF soft-failed
- Static apps/ui/src/components/shell/layers.ts deleted; router no
longer needs static layer metadata (placeholder titles fall back to
the raw key humanised)
---
apps/ui/src/api/client.ts | 9 ++
apps/ui/src/components/shell/AppSidebar.vue | 56 +++++++--
apps/ui/src/components/shell/layers.ts | 183 ----------------------------
apps/ui/src/composables/useLayers.ts | 70 +++++++++++
apps/ui/src/router/index.ts | 83 +++++--------
5 files changed, 155 insertions(+), 246 deletions(-)
diff --git a/apps/ui/src/api/client.ts b/apps/ui/src/api/client.ts
index fbc2427..39e7fc4 100644
--- a/apps/ui/src/api/client.ts
+++ b/apps/ui/src/api/client.ts
@@ -15,6 +15,10 @@
* limitations under the License.
*/
+import type { MenuResponse } from '@skywalking-horizon-ui/api-client';
+
+export type { MenuResponse, LayerDef, LayerCaps, LayerSlots } from
'@skywalking-horizon-ui/api-client';
+
export interface MeResponse {
username: string;
roles: string[];
@@ -86,6 +90,11 @@ export class BffClient {
return this.request<MeResponse>('GET', '/api/auth/me');
}
+ // ── menu / layers ────────────────────────────────────────────────────
+ menu(): Promise<MenuResponse> {
+ return this.request<MenuResponse>('GET', '/api/menu');
+ }
+
// ── cluster / preflight ──────────────────────────────────────────────
preflight(): Promise<unknown> {
return this.request('GET', '/api/preflight');
diff --git a/apps/ui/src/components/shell/AppSidebar.vue
b/apps/ui/src/components/shell/AppSidebar.vue
index bc2d198..77210e5 100644
--- a/apps/ui/src/components/shell/AppSidebar.vue
+++ b/apps/ui/src/components/shell/AppSidebar.vue
@@ -15,12 +15,12 @@
limitations under the License.
-->
<script setup lang="ts">
-import { ref } from 'vue';
+import { ref, watch } from 'vue';
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, hasTopology } from './layers';
+import { useLayers } from '@/composables/useLayers';
const auth = useAuthStore();
const router = useRouter();
@@ -29,7 +29,25 @@ async function signOut(): Promise<void> {
await router.push({ name: 'login' });
}
-const expandedLayer = ref<string | null>('general');
+const { layers, oapReachable, oapError, hasTopology } = useLayers();
+
+// Default-open the first active layer once data arrives; user clicks
+// thereafter take over.
+const expandedLayer = ref<string | null>(null);
+let userTouched = false;
+watch(
+ layers,
+ (rows) => {
+ if (userTouched || expandedLayer.value) return;
+ const first = rows.find((L) => L.active) ?? rows[0];
+ if (first) expandedLayer.value = first.key;
+ },
+ { immediate: true },
+);
+function toggleLayer(key: string): void {
+ userTouched = true;
+ expandedLayer.value = expandedLayer.value === key ? null : key;
+}
const route = useRoute();
function isActive(path: string): boolean {
@@ -122,17 +140,21 @@ const sections: NavSection[] = [
<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">
+ <div v-if="!oapReachable && oapError" class="oap-banner"
:title="oapError">
+ OAP unreachable
+ </div>
+ <template v-for="L in layers" :key="L.key">
<div
class="sw-nav-item"
- :class="{ 'is-active': expandedLayer === L.key }"
- @click="expandedLayer = expandedLayer === L.key ? null : L.key"
+ :class="{ 'is-active': expandedLayer === L.key, 'is-inactive':
!L.active }"
+ @click="toggleLayer(L.key)"
>
<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.serviceCount
}}</span>
+ <span v-if="L.serviceCount >= 0" class="sw-badge"
style="margin-left: auto">{{ L.serviceCount }}</span>
+ <span v-else-if="!L.active" class="sw-badge" style="margin-left:
auto">no data</span>
<span class="caret" :class="{ open: expandedLayer === L.key }">
<Icon name="caret" :size="10" />
</span>
@@ -174,7 +196,7 @@ const sections: NavSection[] = [
</RouterLink>
<RouterLink
- v-if="hasTopology(L.caps)"
+ v-if="hasTopology(L)"
:to="`/layer/${L.key}/topology`"
class="sw-nav-item"
:class="{ 'is-active': isActive(`/layer/${L.key}/topology`) }"
@@ -328,4 +350,20 @@ const sections: NavSection[] = [
.sw-nav-item.lead {
margin-top: 4px;
}
+.sw-nav-item.is-inactive .layer-dot {
+ opacity: 0.4;
+}
+.sw-nav-item.is-inactive > span:nth-child(2) {
+ color: var(--sw-fg-3);
+}
+.oap-banner {
+ margin: 4px 10px 8px;
+ padding: 6px 8px;
+ font-size: 10.5px;
+ color: #f87171;
+ background: var(--sw-err-soft);
+ border: 1px solid rgba(239, 68, 68, 0.3);
+ border-radius: 4px;
+ letter-spacing: 0.02em;
+}
</style>
diff --git a/apps/ui/src/components/shell/layers.ts
b/apps/ui/src/components/shell/layers.ts
deleted file mode 100644
index 9bc9f5a..0000000
--- a/apps/ui/src/components/shell/layers.ts
+++ /dev/null
@@ -1,183 +0,0 @@
-/*
- * 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.
-//
-// Aliases (`slots.*`) are a GLOBAL term presenter — the same alias is used
-// in the sidebar, breadcrumbs, table headers, dashboard titles, drill-down
-// labels, etc. "Endpoint" → "API" (General), "API → API" (the resulting
-// endpoint-relation feature) → "API dependency".
-//
-// `caps` is a pickable feature set per layer. Setting `caps.services =
-// false` hides the services slot entirely (e.g. a layer with only a single
-// virtual service can disable `instances` and `endpoints` but keep
-// `services`).
-//
-// Term aliases AND cap toggles are both editable from the Phase 7 admin UI
-// (Layer config) and persisted in the BFF JSON store. The values below are
-// the shipped defaults for each known layer.
-
-export interface LayerSlots {
- /** Renamed service-equivalent (functions / workloads / clusters / apps /
databases / virtual service / …). */
- services?: string;
- /** Renamed instance-equivalent (versions / pods / brokers / sessions /
nodes / …). */
- instances?: string;
- /** Renamed endpoint-equivalent — e.g. "API" for General, "Topics" for MQ,
"Pages" for Browser. */
- endpoints?: string;
- /** Label for the endpoint-to-endpoint dependency feature. Defaults to
`${endpoints} dependency`. */
- endpointDependency?: string;
-}
-
-export interface LayerCaps {
- /** Per-layer landing page with KPIs / constellation / health. */
- overview?: boolean;
- /** Service map (service topology). */
- serviceMap?: boolean;
- /** Endpoint-to-endpoint dependency (a.k.a. "API dependency" when aliased).
*/
- endpointDependency?: boolean;
- /** Instance / pod / broker topology. */
- instanceTopology?: boolean;
- /** Process topology (eBPF / rover sourced). */
- processTopology?: 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;
-}
-
-/** Convenience: `caps.serviceMap || caps.instanceTopology ||
caps.processTopology`. */
-export function hasTopology(caps: LayerCaps): boolean {
- return Boolean(caps.serviceMap || caps.instanceTopology ||
caps.processTopology);
-}
-
-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: 'API',
endpointDependency: 'API dependency' },
- caps: {
- overview: true,
- serviceMap: true,
- endpointDependency: true,
- instanceTopology: true,
- processTopology: 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,
- serviceMap: true,
- endpointDependency: true,
- instanceTopology: 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, serviceMap: true, instanceTopology: 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,
- serviceMap: true,
- endpointDependency: 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/composables/useLayers.ts
b/apps/ui/src/composables/useLayers.ts
new file mode 100644
index 0000000..4e7aa80
--- /dev/null
+++ b/apps/ui/src/composables/useLayers.ts
@@ -0,0 +1,70 @@
+/*
+ * 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.
+ */
+
+import { computed } from 'vue';
+import { useQuery } from '@tanstack/vue-query';
+import type { LayerDef } from '@skywalking-horizon-ui/api-client';
+import { bffClient } from '@/api/client';
+
+/**
+ * Live OAP-driven layer + menu state. Fed by `GET /api/menu`. Refetches on
+ * window focus + every 60s.
+ *
+ * `data.value` is `null` while loading. `oapReachable` is false when the
+ * BFF returned a soft-fail body (OAP down); the UI should render a banner
+ * but otherwise keep the empty layer list rendered without crashing.
+ */
+export function useLayers() {
+ const q = useQuery({
+ queryKey: ['menu'],
+ queryFn: () => bffClient.menu(),
+ staleTime: 30_000,
+ refetchInterval: 60_000,
+ refetchOnWindowFocus: true,
+ });
+
+ const layers = computed<LayerDef[]>(() => q.data.value?.layers ?? []);
+ const activeLayers = computed<LayerDef[]>(() => layers.value.filter((L) =>
L.active));
+ const oapReachable = computed<boolean>(() => q.data.value?.oap.reachable ??
false);
+ const oapError = computed<string | undefined>(() => q.data.value?.oap.error);
+
+ function findLayer(key: string | undefined): LayerDef | undefined {
+ if (!key) return undefined;
+ return layers.value.find((L) => L.key === key);
+ }
+
+ /**
+ * `caps.serviceMap || caps.instanceTopology || caps.processTopology`.
+ * Pulled out of `layers.ts` so the sidebar can stay UI-only.
+ */
+ function hasTopology(L: LayerDef | undefined): boolean {
+ if (!L) return false;
+ return Boolean(L.caps.serviceMap || L.caps.instanceTopology ||
L.caps.processTopology);
+ }
+
+ return {
+ isLoading: q.isLoading,
+ isError: q.isError,
+ layers,
+ activeLayers,
+ oapReachable,
+ oapError,
+ findLayer,
+ hasTopology,
+ refetch: q.refetch,
+ };
+}
diff --git a/apps/ui/src/router/index.ts b/apps/ui/src/router/index.ts
index 094512d..79fdcd5 100644
--- a/apps/ui/src/router/index.ts
+++ b/apps/ui/src/router/index.ts
@@ -15,77 +15,52 @@
* limitations under the License.
*/
import { createRouter, createWebHistory, type RouteRecordRaw } from
'vue-router';
-import { findLayer } from '@/components/shell/layers';
import { useAuthStore } from '@/stores/auth';
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 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[] = [];
-
sub.push({
path: 'layer/:layerKey',
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.',
- };
- },
+ props: (r) => ({
+ title: `${humanKey(String(r.params.layerKey))} · Overview`,
+ phase: 'Phase 2',
+ note: 'Per-layer landing: KPIs, throughput, service constellation,
services table.',
+ }),
});
- 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',
- };
- },
- });
- }
-
- const layerFeatures: { path: string; label: string; phase: string;
capCheck?: (caps: NonNullable<ReturnType<typeof findLayer>>['caps']) => boolean
}[] = [
- {
- path: 'topology',
- label: 'Topology',
- phase: 'Phase 4',
- capCheck: (c) => Boolean(c.serviceMap || c.instanceTopology ||
c.processTopology),
- },
- { path: 'dependency', label: 'API dependency', phase: 'Phase 4', capCheck:
(c) => Boolean(c.endpointDependency) },
- { path: 'dashboards', label: 'Dashboards', phase: 'Phase 3', capCheck: (c)
=> Boolean(c.dashboards) },
- { path: 'traces', label: 'Traces', phase: 'Phase 5', capCheck: (c) =>
Boolean(c.traces) },
- { path: 'logs', label: 'Logs', phase: 'Phase 5', capCheck: (c) =>
Boolean(c.logs) },
- { path: 'profiling', label: 'Profiling', phase: 'Phase 8', capCheck: (c)
=> Boolean(c.profiling) },
- { path: 'events', label: 'Events', phase: 'Phase 5', capCheck: (c) =>
Boolean(c.events) },
+ const features: { path: string; label: string; phase: string }[] = [
+ { path: 'services', label: 'Services', phase: 'Phase 2 / 3' },
+ { path: 'instances', label: 'Instances', phase: 'Phase 2 / 3' },
+ { path: 'endpoints', label: 'Endpoints', phase: 'Phase 2 / 3' },
+ { path: 'topology', label: 'Topology', phase: 'Phase 4' },
+ { path: 'dependency', label: 'API dependency', phase: 'Phase 4' },
+ { path: 'dashboards', label: 'Dashboards', phase: 'Phase 3' },
+ { path: 'traces', label: 'Traces', phase: 'Phase 5' },
+ { path: 'logs', label: 'Logs', phase: 'Phase 5' },
+ { path: 'profiling', label: 'Profiling', phase: 'Phase 8' },
+ { path: 'events', label: 'Events', phase: 'Phase 5' },
];
- for (const f of layerFeatures) {
+ for (const f of features) {
sub.push({
path: `layer/:layerKey/${f.path}`,
component: placeholder,
- props: (r) => {
- const L = findLayer(String(r.params.layerKey));
- const supported = L && (!f.capCheck || f.capCheck(L.caps));
- return {
- title: L ? `${L.name} · ${f.label}` : `Layer · ${f.label}`,
- phase: f.phase,
- note: L && !supported ? `${L.name} doesn't expose
${f.label.toLowerCase()}.` : undefined,
- };
- },
+ props: (r) => ({
+ title: `${humanKey(String(r.params.layerKey))} · ${f.label}`,
+ phase: f.phase,
+ }),
});
}
-
return sub;
}