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 eafb2a9  feat: overview-by-layer visibility, server-global service 
catalog, landing cascade (#10)
eafb2a9 is described below

commit eafb2a9921fc110c752204337848f9ab7a66a402
Author: 吴晟 Wu Sheng <[email protected]>
AuthorDate: Sat May 23 23:01:43 2026 +0800

    feat: overview-by-layer visibility, server-global service catalog, landing 
cascade (#10)
    
    Three connected improvements driven by "show what's relevant, never a
    blank page" + reduce duplicate OAP fan-out.
    
    - Overview dashboards appear in the sidebar only when at least one of
      their layers is reporting services. Visibility is derived from each
      dashboard's widgets (their `layer` field) unioned with its explicit
      `layers[]` list, gated against the live `availableLayers`. User-
      created dashboards from "+ New" inherit this without needing a
      hand-maintained `layers[]`. Updates on the existing 60s menu cadence.
    - New process-global ServiceLayerCatalog (apps/bff/src/logic/services/)
      owns the `listLayers` + aliased `listServices(layer)` fan-out with a
      60s TTL + single-flight dedup. The sidebar menu's per-layer counts
      and the alarms layer-tagger share this one cache instead of each
      running their own poll. OAP sees at most one fan-out per minute
      regardless of how many routes are polling, and the two views can no
      longer drift relative to each other. Replaces alarms-only
      ServiceLayerMap (deleted).
    - Root `/` lander now cascades to a real destination so the user never
      sees a blank page: first available public overview, else first
      layer-with-services, else first bundled layer template, else the new
      `/landing-empty` route. The empty landing has two distinct copies —
      "No data is flowing yet" (no agents reporting) vs "No dashboard
      configured yet" (services exist but no overview) — each with the
      right operations-team handoff and no action buttons (avoids sending
      a viewer to a 403).
    - Debug events panel default flipped to OFF uniformly (was on for
      localhost). Same baseline for operators and developers; sticky
      preference unchanged.
---
 CHANGELOG.md                                       |  29 ++++
 apps/bff/src/http/config/alarms.ts                 |   4 +-
 apps/bff/src/http/query/alarms.ts                  |  13 +-
 apps/bff/src/http/query/menu.ts                    |  63 +++-----
 apps/bff/src/logic/alarms/service-layer-map.ts     | 128 ----------------
 .../src/logic/services/service-layer-catalog.ts    | 164 +++++++++++++++++++++
 apps/bff/src/server.ts                             |  10 +-
 apps/ui/src/controls/debugPanel.ts                 |  17 +--
 apps/ui/src/render/overview/OverviewLanding.vue    | 128 +++++++++++-----
 .../src/render/overview/useOverviewDashboards.ts   |  16 +-
 apps/ui/src/render/widgets/AlarmsWidget.vue        |   4 +-
 apps/ui/src/shell/router/index.ts                  |   7 +
 docs/customization/menu-structure.md               |  21 +++
 docs/customization/overview-templates.md           |  13 +-
 14 files changed, 383 insertions(+), 234 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0539b63..2d0cc63 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -92,6 +92,28 @@ live, shared version is whatever OAP serves.
   regression where the sidebar scrolled to the very bottom on every
   navigation (the "Debug events" toggle's active state was being treated
   as the scroll target).
+- **Overview dashboards appear in the sidebar only when their layers are
+  reporting services.** Visibility is derived from each dashboard's
+  widgets (their `layer` field) ∪ the explicit `layers[]` list, gated
+  against the live `availableLayers`. A dashboard you create via "+ New"
+  inherits this automatically — no need to maintain the `layers[]` field
+  by hand. Polls on the 60s menu cadence + window focus, so entries
+  appear / disappear as services start and stop reporting.
+- **Smarter landing.** Root `/` cascades through a sensible chain so the
+  user never sees a blank page: first available public overview → first
+  layer with services → the **empty landing** (`/landing-empty`). The
+  cascade only lands on destinations that are also in the sidebar — a
+  bundled-but-inactive layer (no services yet) is deliberately not a
+  fallback, since it would put the user on a page they can't navigate
+  back to via the menu. The empty page is also a
+  real bookmarkable route, with two distinct copies — *"No data is
+  flowing yet"* (no agents/receivers reporting) vs *"No dashboard
+  configured yet"* (services exist but no overview is set up) — each
+  with the right operations-team handoff and no action buttons (a
+  viewer's role doesn't include the verbs the old buttons jumped to).
+- **Debug events panel now defaults OFF on every host** (was on for
+  localhost). Same baseline for operators and developers so
+  reproductions match what operators see.
 - **Zipkin trace mode** drops the per-layer service-KPI header — the Zipkin
   explorer is a self-contained, cross-service view.
 
@@ -101,6 +123,13 @@ live, shared version is whatever OAP serves.
   server" message instead of the cryptic "body stream already read" — the
   API client reads each error response body once and surfaces the real
   status/text (or a wrapped network error).
+- **Server-global service-by-layer catalog.** One singleton on the BFF
+  (60s TTL + single-flight) now owns the `listLayers` + aliased
+  `listServices(layer)` fan-out. The sidebar menu's per-layer counts and
+  the alarms layer-tagger share this one cache instead of each running
+  their own poll, so OAP sees at most one fan-out per minute regardless
+  of how many routes are polling — and the two views can no longer drift
+  by 60s relative to each other.
 
 ## 0.5.0
 
diff --git a/apps/bff/src/http/config/alarms.ts 
b/apps/bff/src/http/config/alarms.ts
index 9984192..1b1e5c3 100644
--- a/apps/bff/src/http/config/alarms.ts
+++ b/apps/bff/src/http/config/alarms.ts
@@ -30,7 +30,7 @@ import type { ConfigSource } from '../../config/loader.js';
 import type { SessionStore } from '../../user/sessions.js';
 import type { AuditLogger } from '../../audit/logger.js';
 import { requireAuth } from '../../user/middleware.js';
-import type { ServiceLayerMap } from '../../logic/alarms/service-layer-map.js';
+import type { ServiceLayerCatalog } from 
'../../logic/services/service-layer-catalog.js';
 import {
   ALARMS_WINDOW_OPTIONS,
   OVERVIEW_ALARMS_LIMIT_MAX,
@@ -44,7 +44,7 @@ export interface AlarmsConfigRouteDeps {
   sessions: SessionStore;
   audit: AuditLogger;
   store: AlarmsStore;
-  serviceLayer: ServiceLayerMap;
+  serviceLayer: ServiceLayerCatalog;
 }
 
 const configSaveSchema = z.object({
diff --git a/apps/bff/src/http/query/alarms.ts 
b/apps/bff/src/http/query/alarms.ts
index ce913f1..033775b 100644
--- a/apps/bff/src/http/query/alarms.ts
+++ b/apps/bff/src/http/query/alarms.ts
@@ -54,14 +54,15 @@ import type { SessionStore } from '../../user/sessions.js';
 import { badRequest } from '../../errors.js';
 import { buildOapOpts, graphqlPost } from '../../client/graphql.js';
 import { getOapCapabilities } from '../../logic/oap/capabilities.js';
-import type { ServiceLayerMap } from '../../logic/alarms/service-layer-map.js';
+import type { ServiceLayerCatalog } from 
'../../logic/services/service-layer-catalog.js';
 
 export interface AlarmsQueryRouteDeps {
   config: ConfigSource;
   sessions: SessionStore;
-  /** Shared with config/alarms.ts so a config save can invalidate the
-   *  cache and the next list call picks up the new layers. */
-  serviceLayer: ServiceLayerMap;
+  /** Server-global service-by-layer index (shared with config/alarms.ts +
+   *  the sidebar menu). A config save invalidates it so the next list call
+   *  picks up newly-pinned layers. */
+  serviceLayer: ServiceLayerCatalog;
   fetch?: FetchLike;
 }
 
@@ -117,7 +118,7 @@ export interface AlarmMessage {
   tags: MqeKeyValue[];
   events?: Array<Record<string, unknown>>;
   snapshot: AlarmSnapshot;
-  /** Best-effort layer tag derived from service-layer-map. Null when
+  /** Best-effort layer tag derived from the service-layer catalog. Null when
    *  the entity isn't a known service (instance / endpoint / etc.
    *  fall through if their service prefix doesn't match). */
   layerKey: string | null;
@@ -373,7 +374,7 @@ function buildEntity(q: {
 
 async function tagWithLayer(
   msgsRaw: AlarmMessage[],
-  serviceLayer: ServiceLayerMap,
+  serviceLayer: ServiceLayerCatalog,
 ): Promise<AlarmMessage[]> {
   const layerIdx = await serviceLayer.get();
   return msgsRaw.map((m) => {
diff --git a/apps/bff/src/http/query/menu.ts b/apps/bff/src/http/query/menu.ts
index 39965cd..ce35f44 100644
--- a/apps/bff/src/http/query/menu.ts
+++ b/apps/bff/src/http/query/menu.ts
@@ -27,10 +27,11 @@ import type {
 import type { ConfigSource } from '../../config/loader.js';
 import type { SessionStore } from '../../user/sessions.js';
 import { requireAuth } from '../../user/middleware.js';
-import { buildOapOpts, graphqlPost, type GraphqlOptions } from 
'../../client/graphql.js';
+import { buildOapOpts, graphqlPost } from '../../client/graphql.js';
 import { allLayerTemplates, getLayerTemplate, type LayerComponentFlags } from 
'../../logic/layers/loader.js';
 import { getSyncStatus } from '../../logic/templates/sync.js';
 import { iterateBundledTemplates } from '../../logic/templates/aggregator.js';
+import type { ServiceLayerCatalog } from 
'../../logic/services/service-layer-catalog.js';
 import { logger } from '../../logger.js';
 
 /**
@@ -71,6 +72,10 @@ export interface MenuRouteDeps {
    *  (a layer disabled in the admin disappears from the sidebar). Optional
    *  so tests can omit it; without it no layer is filtered as disabled. */
   uiTemplateClient?: () => UITemplateClient;
+  /** Server-global service-by-layer index. Single source of truth for
+   *  per-layer counts + `normal` flags (shared with the alarms tagger
+   *  and any future surface that needs the service ↔ layer mapping). */
+  serviceCatalog: ServiceLayerCatalog;
 }
 
 /** Canonical layer keys disabled on OAP (`horizon.layer.<KEY>` rows flagged
@@ -231,44 +236,6 @@ 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: GraphqlOptions,
-): Promise<Map<string, { count: number; normal: boolean | null }>> {
-  const map = new Map<string, { count: number; normal: boolean | null }>();
-  if (layers.length === 0) return map;
-  // GraphQL aliases must be valid identifiers — index-keyed. Also pull
-  // the `normal` flag off the first service so callers can pivot the
-  // MQE entity scope (`{ normal: true|false }`) without a separate
-  // listServices roundtrip on every dashboard hit.
-  const aliased = layers
-    .map((l, i) => `_${i}: listServices(layer: ${JSON.stringify(l)}) { id 
normal }`)
-    .join('\n');
-  const query = `query HorizonCounts { ${aliased} }`;
-  try {
-    const data = await graphqlPostShim<
-      Record<string, Array<{ id: string; normal?: boolean | null }>>
-    >(opts, query);
-    layers.forEach((l, i) => {
-      const rows = data[`_${i}`] ?? [];
-      const first = rows[0];
-      const normal = first ? (first.normal === false ? false : first.normal 
=== true ? true : null) : null;
-      map.set(l, { count: rows.length, normal });
-    });
-  } catch {
-    // Soft-fail: leave the map empty so deriveLayer falls back to -1 / null.
-  }
-  return map;
-}
-
-// Local re-import to avoid a circular dep — graphqlPost is in the same dir.
-import { graphqlPost as graphqlPostShim } from '../../client/graphql.js';
-
 export function registerMenuRoute(app: FastifyInstance, deps: MenuRouteDeps): 
void {
   const auth = requireAuth(deps);
   app.get('/api/menu', { preHandler: auth }, async (_req: FastifyRequest, 
reply: FastifyReply) => {
@@ -282,20 +249,24 @@ export function registerMenuRoute(app: FastifyInstance, 
deps: MenuRouteDeps): vo
       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);
+      // Service counts + first-row `normal` flag come from the
+      // server-global catalog (60s TTL, shared with alarms + any other
+      // surface needing the service ↔ layer map). RAW layer names since
+      // the canonical alias collapse is presentation-only.
+      const catalog = await deps.serviceCatalog.get();
       const countByCanonical = new Map<string, number>();
       const normalByCanonical = new Map<string, boolean | null>();
       for (const rawLayer of raw.layers) {
         const key = canonical(rawLayer);
-        const c = counts.get(rawLayer);
-        countByCanonical.set(key, (countByCanonical.get(key) ?? 0) + (c?.count 
?? 0));
+        const rows = catalog.byLayer.get(rawLayer) ?? [];
+        countByCanonical.set(key, (countByCanonical.get(key) ?? 0) + 
rows.length);
         // First non-null `normal` value wins for the canonical key —
         // raw layers that fold into one canonical (e.g. mesh / mesh_cp)
         // share the same `normal` in practice, so collisions are safe.
-        if (c?.normal !== undefined && c.normal !== null && 
!normalByCanonical.has(key)) {
-          normalByCanonical.set(key, c.normal);
+        const first = rows[0];
+        const normal = first ? (first.normal === true ? true : first.normal 
=== false ? false : null) : null;
+        if (normal !== null && !normalByCanonical.has(key)) {
+          normalByCanonical.set(key, normal);
         }
       }
 
diff --git a/apps/bff/src/logic/alarms/service-layer-map.ts 
b/apps/bff/src/logic/alarms/service-layer-map.ts
deleted file mode 100644
index d9b7189..0000000
--- a/apps/bff/src/logic/alarms/service-layer-map.ts
+++ /dev/null
@@ -1,128 +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.
- */
-
-/**
- * Service-name → layer-key index used by the alarms route to tag each
- * AlarmMessage with the layer it belongs to. OAP's alarm wire only
- * carries `scope` (Service / Instance / Endpoint) and `name` (the
- * entity name); the layer is derived from the service-name lookup.
- *
- * Refresh on demand with a 60s TTL — service rosters move slowly
- * enough that staleness for a minute is fine, and it spares OAP from
- * a `listServices` fan-out on every alarms poll.
- */
-
-import { buildOapOpts, graphqlPost } from '../../client/graphql.js';
-import type { ConfigSource } from '../../config/loader.js';
-import type { FetchLike } from '@skywalking-horizon-ui/api-client';
-import { logger } from '../../logger.js';
-
-interface ServiceLayerMapDeps {
-  config: ConfigSource;
-  fetch?: FetchLike;
-}
-
-interface ListResult {
-  layers: string[];
-  /** Lower-case service name → canonical layer key. Multiple layers
-   *  for the same name collapse to the last one seen — operators
-   *  shouldn't be running the same service name across layers, but
-   *  if they do, the layer tag becomes best-effort. */
-  byName: Map<string, string>;
-}
-
-const LAYERS_QUERY = /* GraphQL */ `
-  query HorizonAlarmsLayers {
-    layers: listLayers
-  }
-`;
-
-interface LayersRaw {
-  layers: string[];
-}
-
-function fragment(idx: number, layer: string): string {
-  return `_${idx}: listServices(layer: ${JSON.stringify(layer)}) { name }`;
-}
-
-interface ServiceRow {
-  name: string;
-}
-
-export class ServiceLayerMap {
-  private cached: ListResult | null = null;
-  private lastFetchAt = 0;
-  private inflight: Promise<ListResult> | null = null;
-  /** ms */
-  private readonly ttl = 60_000;
-
-  constructor(private readonly deps: ServiceLayerMapDeps) {}
-
-  async get(): Promise<ListResult> {
-    const now = Date.now();
-    if (this.cached && now - this.lastFetchAt < this.ttl) return this.cached;
-    if (this.inflight) return this.inflight;
-    this.inflight = this.refresh()
-      .then((r) => {
-        this.cached = r;
-        this.lastFetchAt = Date.now();
-        return r;
-      })
-      .finally(() => {
-        this.inflight = null;
-      });
-    return this.inflight;
-  }
-
-  /** Force a refresh on the next `get()`. Called after the alarms-config
-   *  layer list changes — the new layer might not be in the existing
-   *  cache. */
-  invalidate(): void {
-    this.cached = null;
-    this.lastFetchAt = 0;
-  }
-
-  private async refresh(): Promise<ListResult> {
-    const cfg = this.deps.config.current;
-    const opts = buildOapOpts(cfg, this.deps.fetch);
-    let layers: string[];
-    try {
-      const got = await graphqlPost<LayersRaw>(opts, LAYERS_QUERY);
-      layers = Array.isArray(got.layers) ? got.layers : [];
-    } catch (err) {
-      logger.warn({ err }, 'alarms service-layer-map: listLayers failed');
-      return { layers: [], byName: new Map() };
-    }
-    if (layers.length === 0) return { layers, byName: new Map() };
-    const aliased = layers.map((l, i) => fragment(i, l)).join('\n');
-    const query = `query HorizonAlarmsServices { ${aliased} }`;
-    try {
-      const data = await graphqlPost<Record<string, ServiceRow[]>>(opts, 
query);
-      const byName = new Map<string, string>();
-      layers.forEach((layer, i) => {
-        const rows = data[`_${i}`] ?? [];
-        for (const row of rows) {
-          if (row?.name) byName.set(row.name.toLowerCase(), layer);
-        }
-      });
-      return { layers, byName };
-    } catch (err) {
-      logger.warn({ err }, 'alarms service-layer-map: listServices fan-out 
failed');
-      return { layers, byName: new Map() };
-    }
-  }
-}
diff --git a/apps/bff/src/logic/services/service-layer-catalog.ts 
b/apps/bff/src/logic/services/service-layer-catalog.ts
new file mode 100644
index 0000000..adb88d9
--- /dev/null
+++ b/apps/bff/src/logic/services/service-layer-catalog.ts
@@ -0,0 +1,164 @@
+/*
+ * 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.
+ */
+
+/**
+ * **Server-global** service-by-layer index. The one place that issues
+ * `listLayers` + the aliased `listServices(layer)` fan-out, with a 60s
+ * TTL and single-flight dedup. Every BFF surface that needs the
+ * service ↔ layer mapping — the sidebar menu's per-layer counts, the
+ * alarms tagger, future consumers — reads from here so they all see the
+ * same snapshot and OAP gets at most one fan-out per minute regardless
+ * of how many routes are polling.
+ *
+ * The cached snapshot exposes three views:
+ *
+ *   - `layers`  — every layer key OAP's `listLayers` returned, RAW (not
+ *     alias-collapsed; consumers canonicalize where it matters).
+ *   - `byLayer` — `Map<layer, ServiceRow[]>` for count / first-normal /
+ *     full roster needs.
+ *   - `byName`  — `Map<lower-cased service name, layer>` for the
+ *     reverse lookup the alarms tagger needs (name → layer). Last-wins
+ *     when the same name appears under multiple layers; operators
+ *     shouldn't reuse names cross-layer, but if they do the tag is
+ *     best-effort.
+ *
+ * Soft-fails to an empty snapshot when OAP is unreachable, so callers
+ * never break — the sidebar simply renders without counts.
+ */
+
+import { buildOapOpts, graphqlPost } from '../../client/graphql.js';
+import type { ConfigSource } from '../../config/loader.js';
+import type { FetchLike } from '@skywalking-horizon-ui/api-client';
+import { logger } from '../../logger.js';
+
+export interface ServiceRow {
+  id: string;
+  name: string;
+  /** Per-layer `normal` flag from `listServices` — drives MQE entity
+   *  scope (`{ normal: true|false }`) without a second roundtrip. */
+  normal: boolean | null;
+}
+
+export interface ServiceCatalog {
+  layers: string[];
+  byLayer: Map<string, ServiceRow[]>;
+  byName: Map<string, string>;
+}
+
+export interface ServiceLayerCatalogDeps {
+  config: ConfigSource;
+  fetch?: FetchLike;
+}
+
+const LAYERS_QUERY = /* GraphQL */ `
+  query HorizonServiceCatalogLayers {
+    layers: listLayers
+  }
+`;
+
+interface LayersRaw {
+  layers: string[];
+}
+
+export class ServiceLayerCatalog {
+  private cached: ServiceCatalog | null = null;
+  private lastFetchAt = 0;
+  private inflight: Promise<ServiceCatalog> | null = null;
+  /** ms */
+  private readonly ttl = 60_000;
+
+  constructor(private readonly deps: ServiceLayerCatalogDeps) {}
+
+  async get(): Promise<ServiceCatalog> {
+    const now = Date.now();
+    if (this.cached && now - this.lastFetchAt < this.ttl) return this.cached;
+    if (this.inflight) return this.inflight;
+    this.inflight = this.refresh()
+      .then((r) => {
+        this.cached = r;
+        this.lastFetchAt = Date.now();
+        return r;
+      })
+      .finally(() => {
+        this.inflight = null;
+      });
+    return this.inflight;
+  }
+
+  /** Force a refresh on the next `get()`. Used when something just
+   *  mutated the layer / alarms config and the existing snapshot is
+   *  stale (e.g. a layer key was added to the alarms layer list). */
+  invalidate(): void {
+    this.cached = null;
+    this.lastFetchAt = 0;
+  }
+
+  private async refresh(): Promise<ServiceCatalog> {
+    const cfg = this.deps.config.current;
+    const opts = buildOapOpts(cfg, this.deps.fetch);
+    let layers: string[];
+    try {
+      const got = await graphqlPost<LayersRaw>(opts, LAYERS_QUERY);
+      layers = Array.isArray(got.layers) ? got.layers : [];
+    } catch (err) {
+      logger.warn({ err }, 'service-layer-catalog: listLayers failed');
+      return { layers: [], byLayer: new Map(), byName: new Map() };
+    }
+    if (layers.length === 0) {
+      return { layers, byLayer: new Map(), byName: new Map() };
+    }
+    // One aliased GraphQL call instead of N separate roundtrips —
+    // a single TCP/TLS handshake amortises across every layer.
+    const aliased = layers
+      .map((l, i) => `_${i}: listServices(layer: ${JSON.stringify(l)}) { id 
name normal }`)
+      .join('\n');
+    const query = `query HorizonServiceCatalogServices { ${aliased} }`;
+    try {
+      const data = await graphqlPost<Record<string, Array<{ id: string; name: 
string; normal?: boolean | null }>>>(
+        opts,
+        query,
+      );
+      const byLayer = new Map<string, ServiceRow[]>();
+      const byName = new Map<string, string>();
+      layers.forEach((layer, i) => {
+        const rows = (data[`_${i}`] ?? []).map<ServiceRow>((r) => ({
+          id: r.id,
+          name: r.name,
+          normal: r.normal === true ? true : r.normal === false ? false : null,
+        }));
+        byLayer.set(layer, rows);
+        for (const r of rows) if (r.name) byName.set(r.name.toLowerCase(), 
layer);
+      });
+      return { layers, byLayer, byName };
+    } catch (err) {
+      logger.warn({ err }, 'service-layer-catalog: listServices fan-out 
failed');
+      return { layers, byLayer: new Map(), byName: new Map() };
+    }
+  }
+}
+
+// Process-global singleton. The first caller wins the dep injection;
+// subsequent calls return the same instance regardless of the deps
+// argument. Tests that need a fresh instance can `resetServiceLayerCatalog()`.
+let inst: ServiceLayerCatalog | null = null;
+export function serviceLayerCatalog(deps: ServiceLayerCatalogDeps): 
ServiceLayerCatalog {
+  if (!inst) inst = new ServiceLayerCatalog(deps);
+  return inst;
+}
+export function resetServiceLayerCatalog(): void {
+  inst = null;
+}
diff --git a/apps/bff/src/server.ts b/apps/bff/src/server.ts
index 1ca49f9..82b7c3a 100644
--- a/apps/bff/src/server.ts
+++ b/apps/bff/src/server.ts
@@ -76,7 +76,7 @@ import { registerAuthHealthRoute } from 
'./http/auth-health.js';
 // Logic / stores
 import { AlarmsStore } from './logic/alarms/store.js';
 import { SetupStore } from './logic/setup/store.js';
-import { ServiceLayerMap } from './logic/alarms/service-layer-map.js';
+import { serviceLayerCatalog } from 
'./logic/services/service-layer-catalog.js';
 import { HttpError } from './errors.js';
 import { logger, loggerOptions } from './logger.js';
 
@@ -126,8 +126,11 @@ const setupStore = new 
SetupStore(source.current.setup.file);
 await setupStore.load();
 const alarmsStore = new AlarmsStore(source.current.alarms.file);
 await alarmsStore.load();
-// Shared between alarms query (read) + alarms config (write+invalidate).
-const serviceLayer = new ServiceLayerMap({ config: source });
+// Server-global service-by-layer index — shared by the sidebar menu, the
+// alarms tagger, and any other surface that needs the service ↔ layer
+// mapping. 60s TTL + single-flight dedup; one OAP fan-out per minute
+// regardless of how many routes are polling.
+const serviceLayer = serviceLayerCatalog({ config: source });
 
 await app.register(cookie);
 
@@ -149,6 +152,7 @@ registerMenuRoute(app, {
   config: source,
   sessions,
   uiTemplateClient: () => buildOapClients(source.current).uiTemplate(),
+  serviceCatalog: serviceLayer,
 });
 registerLandingRoute(app, { config: source, sessions });
 registerInstanceRoute(app, { config: source, sessions });
diff --git a/apps/ui/src/controls/debugPanel.ts 
b/apps/ui/src/controls/debugPanel.ts
index a07976d..f29f94a 100644
--- a/apps/ui/src/controls/debugPanel.ts
+++ b/apps/ui/src/controls/debugPanel.ts
@@ -17,21 +17,17 @@
 
 /**
  * Visibility toggle for the bottom-of-page event panel. Defaults to
- * ON for local development hosts (`localhost`, `127.0.0.1`,
- * `0.0.0.0`) and OFF everywhere else, so operators in production
- * don't see the dev-mode framework chatter unless they explicitly
- * flip the Admin → "Debug events" item in the sidebar.
+ * OFF everywhere — even on local dev hosts — so operators (and
+ * developers reproducing operator issues) see the same baseline. Flip
+ * it on via Admin → "Debug events" in the sidebar when needed.
  *
  * The choice is sticky per browser via localStorage so the operator
- * doesn't have to re-enable it on every reload. The hostname
- * default only applies on the very first visit (when storage is
- * empty).
+ * doesn't have to re-enable it on every reload.
  */
 
 import { ref, watch } from 'vue';
 
 const STORAGE_KEY = 'horizon:debugPanel:v1';
-const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1']);
 
 function detectInitial(): boolean {
   if (typeof localStorage !== 'undefined') {
@@ -39,8 +35,9 @@ function detectInitial(): boolean {
     if (raw === '1') return true;
     if (raw === '0') return false;
   }
-  if (typeof window === 'undefined') return false;
-  return LOCAL_HOSTS.has(window.location.hostname);
+  // No stored preference → off. Was previously on for localhost; now
+  // uniform so operator-reported behavior matches what developers see.
+  return false;
 }
 
 const enabled = ref<boolean>(detectInitial());
diff --git a/apps/ui/src/render/overview/OverviewLanding.vue 
b/apps/ui/src/render/overview/OverviewLanding.vue
index 991185c..66e21d2 100644
--- a/apps/ui/src/render/overview/OverviewLanding.vue
+++ b/apps/ui/src/render/overview/OverviewLanding.vue
@@ -15,55 +15,115 @@
   limitations under the License.
 -->
 <!--
-  Root landing. Resolves the first public overview dashboard available
-  in this deployment and redirects there. The cross-layer KPI strip
-  that used to live here has been retired — every overview is now a
-  named dashboard under `/overview/:id`.
+  Root landing. Resolves a sensible first destination via a cascade so
+  the user never sees a blank "nothing to show" screen:
+
+    1. First available public overview (already gated by service
+       availability via `useOverviewDashboards`).
+    2. Else first layer with services (`availableLayers`).
+    3. Else first layer the BFF knows about (bundled template), even
+       with no services yet — gives operators the layer page to land
+       on while data is starting to flow.
+    4. Else fall back to a page the user's verbs allow — `/alarms` is
+       ungated for logged-in users; admins also land on the templates
+       editor where they can configure the empty deployment.
 -->
 <script setup lang="ts">
-import { watchEffect } from 'vue';
-import { RouterLink, useRouter } from 'vue-router';
+import { computed, watchEffect } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
 import { useOverviewDashboards } from 
'@/render/overview/useOverviewDashboards';
-import { useLayers } from '@/shell/useLayers';
+import { firstLayerTab, useLayers } from '@/shell/useLayers';
 
 const router = useRouter();
-const { publicOverviews, isLoading } = useOverviewDashboards();
-const { oapReachable, oapError, availableLayers } = useLayers();
+const route = useRoute();
+const { publicOverviews, isLoading: overviewsLoading } = 
useOverviewDashboards();
+const {
+  oapReachable,
+  oapError,
+  availableLayers,
+  isLoading: layersLoading,
+} = useLayers();
+
+/** Render the empty card (no redirect cascade) when the route is the
+ *  dedicated `/landing-empty` path — set either by a direct visit or
+ *  by the cascade itself when there's nothing to land on. */
+const forceEmpty = computed<boolean>(() => route.name === 'landing-empty');
 
 watchEffect(() => {
-  if (isLoading.value) return;
-  const first = publicOverviews.value[0];
-  if (first) {
-    void router.replace({ name: 'overview-dashboard', params: { id: first.id } 
});
+  // Wait for both data sources — without `layers`, a fresh boot would
+  // briefly fall through while the menu is still in flight.
+  if (overviewsLoading.value || layersLoading.value) return;
+  // Direct visit to `/landing-empty` — render the card, no redirect.
+  if (forceEmpty.value) return;
+
+  // 1. First available public overview.
+  const overview = publicOverviews.value[0];
+  if (overview) {
+    void router.replace({ name: 'overview-dashboard', params: { id: 
overview.id } });
+    return;
+  }
+
+  // 2. First layer with services. We deliberately do NOT fall back to a
+  //    bundled-but-inactive layer here: the sidebar filters layers by
+  //    `serviceCount > 0`, so landing on an inactive layer would put
+  //    the user on a page that doesn't appear in their menu (no way
+  //    back). When no service-backed layer exists, the empty landing
+  //    is the honest answer.
+  const layer = availableLayers.value[0];
+  if (layer) {
+    void router.replace({ path: `/layer/${layer.key}/${firstLayerTab(layer)}` 
});
+    return;
   }
+
+  // 3. No overview, no service-backed layer — show the empty landing
+  //    automatically. Same component re-mounts with
+  //    `route.name === 'landing-empty'` so the watchEffect short-
+  //    circuits next tick (no redirect loop).
+  void router.replace({ name: 'landing-empty' });
 });
 </script>
 
 <template>
   <div class="landing">
-    <div v-if="!oapReachable && !isLoading" class="banner err">
+    <div v-if="!oapReachable && !overviewsLoading && !layersLoading" 
class="banner err">
       <strong>OAP unreachable.</strong>
       {{ oapError ?? 'Check that the OAP query host is up and reachable from 
the BFF.' }}
     </div>
-    <div v-else-if="isLoading" class="empty">Loading…</div>
-    <div v-else-if="publicOverviews.length === 0" class="empty">
-      <div class="empty-card">
-        <h2>No public overview is currently active</h2>
-        <p v-if="availableLayers.length === 0">
-          No layer is reporting services yet. Once data flows through OAP, the 
relevant
-          overview (Services / Mesh / …) will appear here automatically.
+    <!-- Empty landing — rendered for the dedicated `/landing-empty`
+         route. Cascade lands here automatically when there's no
+         available overview and no available layer dashboard. Two
+         distinct empty states with distinct messaging:
+
+           - no services reported → it's a data problem (agents /
+             receivers), not a dashboard problem.
+           - services reported but no overview configured → it's a
+             dashboard problem.
+    -->
+    <div v-else-if="forceEmpty" class="empty">
+      <div v-if="availableLayers.length === 0" class="empty-card">
+        <h2>No data is flowing yet</h2>
+        <p>
+          OAP hasn't received any service data. The relevant overview will 
appear here
+          automatically as soon as data starts arriving.
+        </p>
+        <p class="empty-ask">
+          Ask your operations team to verify that the agents or receivers for 
your
+          services are configured and pointing at this OAP.
+        </p>
+      </div>
+      <div v-else class="empty-card">
+        <h2>No dashboard configured yet</h2>
+        <p>
+          {{ availableLayers.length }} layer{{ availableLayers.length === 1 ? 
'' : 's' }}
+          {{ availableLayers.length === 1 ? 'is' : 'are' }} reporting 
services, but no
+          overview dashboard has been set up for them.
         </p>
-        <p v-else>
-          The deployment is reporting on
-          {{ availableLayers.length }} layer{{ availableLayers.length === 1 ? 
'' : 's' }},
-          but no overview is set to <code>visibility: public</code>. 
Operations-only
-          overviews are reachable from the Admin section in the sidebar.
+        <p class="empty-ask">
+          Ask your operations team to set up a dashboard for you.
         </p>
-        <RouterLink class="sw-btn is-primary" to="/admin/overview-templates">
-          Open Overview templates
-        </RouterLink>
       </div>
     </div>
+    <div v-else class="empty">Routing…</div>
   </div>
 </template>
 
@@ -87,10 +147,10 @@ watchEffect(() => {
 }
 .empty-card h2 { font-size: 15px; color: var(--sw-fg-0); margin: 0 0 8px; }
 .empty-card p { font-size: 12px; color: var(--sw-fg-2); margin: 0 0 16px; 
line-height: 1.5; }
-.empty-card code {
-  font-family: var(--sw-mono); font-size: 11px;
-  padding: 0 4px; border-radius: 3px;
-  background: var(--sw-bg-2); color: var(--sw-fg-1);
+.empty-ask {
+  margin-top: 18px !important;
+  font-size: 12.5px !important;
+  color: var(--sw-fg-1) !important;
+  font-weight: 500;
 }
-.empty-card .sw-btn { display: inline-flex; text-decoration: none; }
 </style>
diff --git a/apps/ui/src/render/overview/useOverviewDashboards.ts 
b/apps/ui/src/render/overview/useOverviewDashboards.ts
index b8ef3cb..485c86d 100644
--- a/apps/ui/src/render/overview/useOverviewDashboards.ts
+++ b/apps/ui/src/render/overview/useOverviewDashboards.ts
@@ -67,11 +67,23 @@ export function useOverviewDashboards() {
     }
     return q.data.value?.dashboards ?? [];
   });
+  // Layers a dashboard touches = union of its explicit `layers[]` field
+  // (kept for back-compat with bundled JSON that lists them by hand) AND
+  // every layer referenced by its widgets. User-created dashboards from
+  // "+ New" don't carry `layers[]`, so widget-derived is what gates them.
+  function dashLayers(d: { layers?: string[]; widgets?: Array<{ layer?: string 
}> }): string[] {
+    const set = new Set<string>();
+    for (const k of d.layers ?? []) set.add(k.toUpperCase());
+    for (const w of d.widgets ?? []) if (w.layer) 
set.add(w.layer.toUpperCase());
+    return Array.from(set);
+  }
   const visible = computed(() =>
     all.value.filter((d) => {
-      const layers = d.layers ?? [];
+      const layers = dashLayers(d);
+      // No layer referenced anywhere → always show (e.g. a future fleet
+      // overview that pulls only from cross-layer / All scope).
       if (layers.length === 0) return true;
-      return layers.some((l) => activeLayerKeys.value.has(l.toUpperCase()));
+      return layers.some((l) => activeLayerKeys.value.has(l));
     }),
   );
   const publicOverviews = computed(() =>
diff --git a/apps/ui/src/render/widgets/AlarmsWidget.vue 
b/apps/ui/src/render/widgets/AlarmsWidget.vue
index 78b6523..8973902 100644
--- a/apps/ui/src/render/widgets/AlarmsWidget.vue
+++ b/apps/ui/src/render/widgets/AlarmsWidget.vue
@@ -117,8 +117,8 @@ const alarms = computed<AlarmMessage[]>(() => 
alarmsQuery.data.value?.msgs ?? []
 const truncated = computed<boolean>(() => alarmsQuery.data.value?.truncated ?? 
false);
 
 /* In legacy mode the BFF can't server-side-filter by layer, but every
- * row already carries `layerKey` (resolved against the
- * service-layer-map). Filter client-side so the widget shows only
+ * row already carries `layerKey` (resolved against the server-global
+ * service-layer catalog). Filter client-side so the widget shows only
  * the layer the dashboard is scoped to — same observable behavior as
  * the new-API path. Rows whose `layerKey` couldn't be resolved
  * (unknown service prefix, instance/endpoint scopes) drop out
diff --git a/apps/ui/src/shell/router/index.ts 
b/apps/ui/src/shell/router/index.ts
index a873cc9..347e209 100644
--- a/apps/ui/src/shell/router/index.ts
+++ b/apps/ui/src/shell/router/index.ts
@@ -123,6 +123,13 @@ const shellRoutes: RouteRecordRaw[] = [
     name: 'overview-dashboard',
     component: () => import('@/render/overview/OverviewDashboardView.vue'),
   },
+  /* Empty-landing surface. The root `/` cascade always picks a real
+   * destination, so this is the only path that renders the
+   * "nothing-here-yet" card — useful for reviewing the empty state and
+   * as the visible fallback for a viewer in a deployment with no
+   * configured dashboards. Same component as `/`, distinguished by
+   * route name. */
+  { path: 'landing-empty', name: 'landing-empty', component: () => 
import('@/render/overview/OverviewLanding.vue') },
   /* Legacy `/setup` route — the read-only "Overview dashboards"
    * browser was replaced by `/admin/overview-templates` which both
    * lists AND edits. Redirect rather than 404 so old bookmarks /
diff --git a/docs/customization/menu-structure.md 
b/docs/customization/menu-structure.md
index 7986ee5..37259d9 100644
--- a/docs/customization/menu-structure.md
+++ b/docs/customization/menu-structure.md
@@ -37,6 +37,27 @@ A layer appears under **Layers** when all of these are true:
 
 If a layer is meant for SkyWalking self-observability rather than application 
observability, set its template visibility to `operate`; Horizon places it 
under the Operate area instead of the main Layers list.
 
+## Overview Visibility
+
+An overview dashboard appears under **Overviews** when at least one of the 
layers it touches is reporting services. Horizon derives "the layers it 
touches" from two sources, unioned:
+
+- the explicit `layers[]` field on the dashboard, and
+- every `widget.layer` referenced by its widgets.
+
+A dashboard with no layer reference on either side (e.g. a cross-layer "All" 
view) is always shown. See [Overview 
templates](/customization/overview-templates).
+
+## Landing Page
+
+When a user opens the app at `/`, Horizon picks a real destination so they 
never see a blank page:
+
+1. The first available public overview dashboard, or
+2. The first layer with services, or
+3. The empty landing (`/landing-empty`).
+
+The cascade only lands on destinations that also appear in the sidebar. A 
bundled layer template that has no services is intentionally **not** a fallback 
— it would put the user on a page that doesn't appear in their menu.
+
+`/landing-empty` is a real route (also reachable directly). It explains the 
situation in plain language — "No data is flowing yet" or "No dashboard 
configured yet" — and points the viewer at their operations team. As soon as a 
service starts reporting or an operator publishes a dashboard, the next visit 
(or the next 60s menu refresh) replaces the empty landing with the real one.
+
 ## First Tab for a Layer
 
 When a user clicks a layer, Horizon opens the first enabled tab in this order:
diff --git a/docs/customization/overview-templates.md 
b/docs/customization/overview-templates.md
index 2392210..24a1cce 100644
--- a/docs/customization/overview-templates.md
+++ b/docs/customization/overview-templates.md
@@ -38,7 +38,7 @@ Bundled templates: 
`apps/bff/src/bundled_templates/overviews/<id>.json`. Example
 | `visibility` | `public` \| `operate` | `public` | Sidebar placement. 
`operate` puts the overview under the Operate group (admin-only by convention). 
|
 | `icon` | string | — | Sidebar icon name (from Horizon's icon set). |
 | `order` | number | — | Sort order within the visibility bucket (lower = 
earlier). |
-| `layers` | string[] | — | Layer enums this overview aggregates. Optional — 
used as a hint by the sidebar and by widgets that want a default layer for MQE 
evaluation. |
+| `layers` | string[] | — | Layer enums this overview aggregates. Optional — 
Horizon also unions in every widget's `layer` field, so a dashboard created via 
"+ New" (no `layers[]`) gates correctly off its widgets alone. See *Sidebar 
visibility* below. |
 | `widgets` | array | **required** | Ordered widget list. The renderer 
iterates and lays out per the grid model. |
 
 ## Widget types
@@ -204,6 +204,17 @@ Following widgets render in a **6-column** grid (rather 
than 12) until the next
 
 Read-only — Horizon does not support acknowledge / close / silence operations. 
Alarm recovery is backend-automatic.
 
+## Sidebar visibility
+
+An overview entry appears in the sidebar only when **at least one of its 
declared layers is currently reporting services**. Declared layers come from 
two sources, unioned:
+
+- the explicit `layers[]` field on the dashboard, and
+- every `widget.layer` referenced by its widgets.
+
+A dashboard with no layer reference on either side (no `layers[]` and no 
widgets with `layer` set — e.g. a future cross-layer "All" overview) is always 
shown.
+
+This makes the sidebar honest: it stops listing a Services dashboard when 
nothing is reporting and lights it back up automatically when an agent / 
receiver does start, on the same 60-second cadence the menu refreshes. It also 
means a dashboard you create via "+ New" — which has no `layers[]` — gates 
correctly off its widgets without you having to maintain a separate list.
+
 ## Admin Editor
 
 Overview templates are editable at runtime via **Dashboard setup → Overview 
templates** (`/admin/overview-templates`, verb `overview:write`). Pick a 
dashboard from the filterable dropdown (title + id + sync status), then lay it 
out on a **12-column canvas**: drag a widget to reorder, corner-drag to resize, 
click a widget to edit it in the right-hand drawer. Section breaks ("text 
widget" / line break) and the dashboard title are selectable too. The canvas 
shows **sample data** so you can  [...]


Reply via email to