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 4402793 menu: catalog order, legacy enum aliases, real service
counts; sidebar shows only layers with services
4402793 is described below
commit 4402793bc64e1cb3c7b9c596ca5712a9ccf8a602
Author: Wu Sheng <[email protected]>
AuthorDate: Tue May 12 14:26:15 2026 +0800
menu: catalog order, legacy enum aliases, real service counts; sidebar
shows only layers with services
BFF /api/menu:
- Collapses legacy enum values into modern aliases (CACHE→VIRTUAL_CACHE,
DATABASE→VIRTUAL_DATABASE, MQ→VIRTUAL_MQ, GENAI→VIRTUAL_GENAI). Removes
the duplicate sidebar rows.
- Preserves the order from getMenuItems so the sidebar follows the OAP
menu catalog (mirrors menu.yaml) instead of alphabetising.
- Fetches per-layer service counts via a single batched GraphQL request
with aliased listServices(layer) — gives a real number per row.
UI useLayers + sidebar:
- availableLayers (serviceCount > 0) drives the sidebar; activeLayers
(listLayers-only) stays around for the Setup page which shows every
registered layer.
- Layer rows render as widget-style rails with a prominent monospace
count chip; the expanded row tints the chip with the accent.
- Empty state ('no service reporting yet — set up a layer') links to /.
---
apps/bff/src/oap/menu-routes.ts | 114 ++++++++++++++++++++++------
apps/ui/src/components/shell/AppSidebar.vue | 86 ++++++++++++++++++---
apps/ui/src/composables/useLayers.ts | 15 ++++
3 files changed, 179 insertions(+), 36 deletions(-)
diff --git a/apps/bff/src/oap/menu-routes.ts b/apps/bff/src/oap/menu-routes.ts
index 46647b4..4d19cee 100644
--- a/apps/bff/src/oap/menu-routes.ts
+++ b/apps/bff/src/oap/menu-routes.ts
@@ -54,6 +54,21 @@ const MENU_QUERY = /* GraphQL */ `
}
`;
+/**
+ * Legacy enum values OAP keeps for backward compatibility — collapse to the
+ * modern equivalent so the sidebar shows one row per logical layer.
+ */
+const LAYER_ALIAS: Record<string, string> = {
+ CACHE: 'VIRTUAL_CACHE',
+ DATABASE: 'VIRTUAL_DATABASE',
+ MQ: 'VIRTUAL_MQ',
+ GENAI: 'VIRTUAL_GENAI',
+};
+
+function canonical(layer: string): string {
+ return LAYER_ALIAS[layer] ?? layer;
+}
+
interface MenuRaw {
layers: string[];
items: Array<{
@@ -124,15 +139,16 @@ function deriveLayer(
rawKey: string,
active: boolean,
level: number | null,
+ serviceCount: number,
items: MenuRaw['items'],
): LayerDef {
- const item = items.find((i) => i.layer === rawKey);
+ const item = items.find((i) => canonical(i.layer) === rawKey);
const def = LAYER_DEFAULTS[rawKey] ?? DEFAULT_FOR_UNKNOWN_LAYER;
return {
key: rawKey.toLowerCase(),
name: item?.title?.trim() || rawKey.replace(/_/g, '
').toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase()),
color: def.color,
- serviceCount: -1, // Phase 2.x will fold in `listServices(layer)` counts.
+ serviceCount,
active,
level,
documentLink: item?.documentLink ?? undefined,
@@ -141,35 +157,85 @@ function deriveLayer(
};
}
+/**
+ * Fetch per-layer service counts in a single GraphQL request with aliased
+ * `listServices(layer)` queries (one alias per active layer). Returns a
+ * map keyed by the layer's RAW (pre-canonical) name.
+ */
+async function fetchCountsForLayers(
+ layers: readonly string[],
+ opts: { statusUrl: string; timeoutMs: number; fetch?: FetchLike },
+): Promise<Map<string, number>> {
+ const map = new Map<string, number>();
+ if (layers.length === 0) return map;
+ // GraphQL aliases must be valid identifiers — index-keyed.
+ const aliased = layers
+ .map((l, i) => `_${i}: listServices(layer: ${JSON.stringify(l)}) { id }`)
+ .join('\n');
+ const query = `query HorizonCounts { ${aliased} }`;
+ try {
+ const data = await graphqlPostShim<Record<string, Array<{ id: string
}>>>(opts, query);
+ layers.forEach((l, i) => map.set(l, (data[`_${i}`] ?? []).length));
+ } catch {
+ // Soft-fail: leave the map empty so deriveLayer falls back to -1.
+ }
+ return map;
+}
+
+// Local re-import to avoid a circular dep — graphqlPost is in the same dir.
+import { graphqlPost as graphqlPostShim } from './graphql-client.js';
+
export function registerMenuRoute(app: FastifyInstance, deps: MenuRouteDeps):
void {
const auth = requireAuth(deps);
app.get('/api/menu', { preHandler: auth }, async (_req: FastifyRequest,
reply: FastifyReply) => {
const cfg = deps.config.current;
const statusUrl = cfg.oap.statusUrl;
+ const opts = { statusUrl, timeoutMs: cfg.oap.timeoutMs, fetch: deps.fetch
};
try {
- const raw = await graphqlPost<MenuRaw>(
- { statusUrl, timeoutMs: cfg.oap.timeoutMs, fetch: deps.fetch },
- MENU_QUERY,
+ const raw = await graphqlPost<MenuRaw>(opts, MENU_QUERY);
+
+ // Active list collapsed by alias (CACHE → VIRTUAL_CACHE, etc.).
+ const activeCanonical = new Set(raw.layers.map(canonical));
+ const levelByCanonical = new Map(raw.levels.map((l) =>
[canonical(l.layer), l.level]));
+
+ // Service-count batch — uses the RAW layer names from OAP since the
+ // alias collapse is only a presentation concern.
+ const counts = await fetchCountsForLayers(raw.layers, opts);
+ const countByCanonical = new Map<string, number>();
+ for (const rawLayer of raw.layers) {
+ const key = canonical(rawLayer);
+ countByCanonical.set(key, (countByCanonical.get(key) ?? 0) +
(counts.get(rawLayer) ?? 0));
+ }
+
+ // Catalog order = the order OAP returned `getMenuItems` (mirrors
+ // menu.yaml). Active-only keys not in the catalog get appended at
+ // the end so nothing disappears.
+ const ordered: string[] = [];
+ const seen = new Set<string>();
+ for (const item of raw.items) {
+ if (!item.layer) continue;
+ const k = canonical(item.layer);
+ if (seen.has(k)) continue;
+ seen.add(k);
+ ordered.push(k);
+ }
+ for (const rawLayer of raw.layers) {
+ const k = canonical(rawLayer);
+ if (seen.has(k)) continue;
+ seen.add(k);
+ ordered.push(k);
+ }
+
+ const layers = ordered.map((key) =>
+ deriveLayer(
+ key,
+ activeCanonical.has(key),
+ levelByCanonical.has(key) ? (levelByCanonical.get(key) ?? null) :
null,
+ countByCanonical.get(key) ?? (activeCanonical.has(key) ? 0 : -1),
+ raw.items,
+ ),
);
- const levelByLayer = new Map(raw.levels.map((l) => [l.layer, l.level]));
- const allKeys = new Set<string>([
- ...raw.layers,
- ...raw.items.map((i) => i.layer),
- ]);
- const layers = [...allKeys]
- .map((key) =>
- deriveLayer(
- key,
- raw.layers.includes(key),
- levelByLayer.has(key) ? (levelByLayer.get(key) ?? null) : null,
- raw.items,
- ),
- )
- .sort((a, b) => {
- // Active layers first, then by name. UI re-sorts as needed.
- if (a.active !== b.active) return a.active ? -1 : 1;
- return a.name.localeCompare(b.name);
- });
+
const body: MenuResponse = {
layers,
generatedAt: Date.now(),
diff --git a/apps/ui/src/components/shell/AppSidebar.vue
b/apps/ui/src/components/shell/AppSidebar.vue
index 77210e5..d09d05e 100644
--- a/apps/ui/src/components/shell/AppSidebar.vue
+++ b/apps/ui/src/components/shell/AppSidebar.vue
@@ -29,18 +29,17 @@ async function signOut(): Promise<void> {
await router.push({ name: 'login' });
}
-const { layers, oapReachable, oapError, hasTopology } = useLayers();
+const { availableLayers, oapReachable, oapError, hasTopology } = useLayers();
-// Default-open the first active layer once data arrives; user clicks
+// Default-open the first available layer once data arrives; user clicks
// thereafter take over.
const expandedLayer = ref<string | null>(null);
let userTouched = false;
watch(
- layers,
+ availableLayers,
(rows) => {
if (userTouched || expandedLayer.value) return;
- const first = rows.find((L) => L.active) ?? rows[0];
- if (first) expandedLayer.value = first.key;
+ if (rows.length > 0) expandedLayer.value = rows[0].key;
},
{ immediate: true },
);
@@ -140,21 +139,32 @@ 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">
+ {{ availableLayers.length }} with services
+ </span>
</div>
<div v-if="!oapReachable && oapError" class="oap-banner"
:title="oapError">
OAP unreachable
</div>
- <template v-for="L in layers" :key="L.key">
+ <div v-else-if="availableLayers.length === 0" class="empty-layers">
+ no service reporting yet —
+ <RouterLink to="/" style="color: var(--sw-accent-2); text-decoration:
none">
+ set up a layer
+ </RouterLink>
+ </div>
+ <template v-for="L in availableLayers" :key="L.key">
<div
- class="sw-nav-item"
- :class="{ 'is-active': expandedLayer === L.key, 'is-inactive':
!L.active }"
+ class="layer-row"
+ :class="{ 'is-active': expandedLayer === L.key }"
@click="toggleLayer(L.key)"
>
<span class="layer-dot" :style="{ background: L.color }" />
- <span :style="{ fontWeight: expandedLayer === L.key ? 600 : 500
}">{{ L.name }}</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="layer-name" :style="{ fontWeight: expandedLayer ===
L.key ? 600 : 500 }">
+ {{ L.name }}
+ </span>
+ <span class="layer-count" :title="`${L.serviceCount}
service${L.serviceCount === 1 ? '' : 's'} reporting`">
+ {{ L.serviceCount }}
+ </span>
<span class="caret" :class="{ open: expandedLayer === L.key }">
<Icon name="caret" :size="10" />
</span>
@@ -318,6 +328,58 @@ const sections: NavSection[] = [
margin-left: 2px;
letter-spacing: 0.02em;
}
+.layer-row {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ padding: 7px 10px;
+ border-radius: 6px;
+ color: var(--sw-fg-1);
+ font-size: 12px;
+ cursor: pointer;
+ user-select: none;
+ position: relative;
+}
+.layer-row:hover {
+ background: var(--sw-bg-2);
+ color: var(--sw-fg-0);
+}
+.layer-row.is-active {
+ background: var(--sw-bg-3);
+ color: var(--sw-fg-0);
+ box-shadow: inset 2px 0 0 var(--sw-accent);
+}
+.layer-row .layer-name {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.layer-row .layer-count {
+ font-family: var(--sw-mono);
+ font-size: 10.5px;
+ font-variant-numeric: tabular-nums;
+ color: var(--sw-fg-1);
+ background: var(--sw-bg-2);
+ border: 1px solid var(--sw-line-2);
+ border-radius: 4px;
+ padding: 1px 6px;
+ min-width: 24px;
+ text-align: center;
+}
+.layer-row.is-active .layer-count {
+ color: var(--sw-accent-2);
+ background: var(--sw-accent-soft);
+ border-color: var(--sw-accent-line);
+}
+.empty-layers {
+ margin: 4px 10px 8px;
+ padding: 6px 8px;
+ font-size: 10.5px;
+ color: var(--sw-fg-3);
+ font-style: italic;
+}
.layer-dot {
width: 7px;
height: 7px;
diff --git a/apps/ui/src/composables/useLayers.ts
b/apps/ui/src/composables/useLayers.ts
index 4e7aa80..e4b315d 100644
--- a/apps/ui/src/composables/useLayers.ts
+++ b/apps/ui/src/composables/useLayers.ts
@@ -38,7 +38,21 @@ export function useLayers() {
});
const layers = computed<LayerDef[]>(() => q.data.value?.layers ?? []);
+ /**
+ * Layers known to OAP (returned by `listLayers`). May include stale
+ * registry entries — receivers that ever ingested but currently have no
+ * services. Use this for the Setup page; users want to see and configure
+ * every layer they could enable.
+ */
const activeLayers = computed<LayerDef[]>(() => layers.value.filter((L) =>
L.active));
+ /**
+ * Layers with at least one currently-reporting service (`listServices`
+ * count > 0). The sidebar uses this so the user doesn't see ghost
+ * entries that no longer carry data.
+ */
+ const availableLayers = computed<LayerDef[]>(() =>
+ layers.value.filter((L) => L.serviceCount > 0),
+ );
const oapReachable = computed<boolean>(() => q.data.value?.oap.reachable ??
false);
const oapError = computed<string | undefined>(() => q.data.value?.oap.error);
@@ -61,6 +75,7 @@ export function useLayers() {
isError: q.isError,
layers,
activeLayers,
+ availableLayers,
oapReachable,
oapError,
findLayer,