You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by wu...@apache.org on 2026/05/29 14:21:03 UTC

(skywalking-horizon-ui) branch main updated: feat(infra-3d): 3D infrastructure map — live metrics & alarms, topology clustering, tier navigation (#30)

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 94f68e3  feat(infra-3d): 3D infrastructure map — live metrics & alarms, topology clustering, tier navigation (#30)
94f68e3 is described below

commit 94f68e37b7f1d4247e4c636a54ea08ae93f6dadb
Author: 吴晟 Wu Sheng <wu...@foxmail.com>
AuthorDate: Fri May 29 22:20:58 2026 +0800

    feat(infra-3d): 3D infrastructure map — live metrics & alarms, topology clustering, tier navigation (#30)
    
    ## 3D Infrastructure Map — enhancement pass
    
    Builds the `/3d/map` proof-of-concept into a usable bird's-eye view of a deployment: services as cubes on stacked tier-planes, live metrics/alarms, topology clustering, and tier-by-tier navigation. Backend contracts are unchanged (same OAP GraphQL query-protocol + MQE).
    
    ### Operator-visible changes
    
    - **Live data windows.** Auto-refreshes every minute — per-cube traffic rolls up the last 2h of metrics (HOUR step), and services with a still-firing alarm light up from the last 20m of alarms (recovered ones don't), matched to the cube by `(layer, name)`. A toolbar chip shows the active scopes; an alarmed cube burns red with a radiating ripple.
    - **Beacon mode.** Dims healthy cubes to wireframe ghosts so firing services stand out during an incident.
    - **Logic groups.** Related layers cluster into one labelled block on a tier; the bundled config ships a **Self-Observability** group (OAP, Satellite, BanyanDB, Java/Go agents) on the middleware tier.
    - **Topology clustering.** Within layers that carry a service map, services group into named clusters drawn as a wireframe frame with the cluster name baked into the lower-left corner — service-mesh by showcase group, Kubernetes by namespace. Follows each layer's naming rule; un-ruled layers stay flat.
    - **Navigate by tier.** The right-side **Tiers** panel is a two-level tree (tier → layer / logic-group). Clicking an entry resets the view, glides the camera to face that region, zooms in, and flashes the region briefly. A **Reset** button restores the initial framing.
    - **Hover = preview.** Hovering a cube shows the same detail card as a click (tier / layer / service / group / cluster), anchored at the cube, minus the open-in-dashboard link.
    - **Call-direction flow.** Call relationships animate directional particles so traffic direction reads at a glance.
    - **Focus one layer.** The per-layer service map gains a **View in 3D** link that opens the map focused on just that layer.
    - **Configurable tiers + layers.** Tier order, per-layer plane mapping, cube colors, and the per-layer traffic MQE are all driven by the 3D map config (bundled defaults, admin-overridable; config now validates `group.level` and rejects duplicate group ids).
    
    ### Fixes also in this branch
    
    - Brand link + post-login redirect no longer resolve to an empty address.
    - Trace span detail labels span direction as **Kind** (Local/Entry/Exit) instead of the mistranslated verb; the cross-trace refs column is now **Ref type** (its values are OAP `RefType`: CROSS_PROCESS/CROSS_THREAD, a distinct concept) across all eight locales; long tag keys wrap instead of overflowing.
    - The admin "Topology cluster setup" card now lives inside the Topology scope instead of a top-of-page banner.
    
    ### Validation
    
    - UI + BFF `type-check` clean; `license-eye` header check clean (0 invalid).
    - The 3D scene is still **snapshot-backed** (`data/demo-topology.json`) for the always-on structure; **metrics and alarms are live**. Wiring the scene's service/topology structure to live OAP is the remaining follow-up (see below) — visual sign-off should be done against the live demo OAP.
    
    ### Follow-up (not in this PR)
    
    - **Live sequential delta pipeline:** load the service/topology structure live, one stage at a time (service → topology → hierarchy → metrics) with low concurrency, the countdown anchored to the next stage, and delta-applied to the scene. This is the one remaining item; it rewrites the scene's data source (highest blast radius) and needs visual validation, so it's deliberately staged separately.
---
 CHANGELOG.md                                       |  52 ++
 .../bff/src/bundled_templates/infra-3d/config.json |  32 +-
 apps/bff/src/logic/infra-3d/types.ts               |  27 +
 apps/bff/src/logic/infra-3d/validate.ts            |  36 +
 apps/ui/src/api/client.ts                          |  10 +
 .../admin/layer-templates/LayerDashboardsAdmin.vue | 296 ++++----
 apps/ui/src/features/auth/LoginView.vue            |   7 +-
 apps/ui/src/features/infra-3d/Infra3DScene.vue     | 778 +++++++++++++++++----
 apps/ui/src/features/infra-3d/Infra3DView.vue      | 466 +++++++++---
 apps/ui/src/features/infra-3d/PipelineTimeline.vue |  51 +-
 .../infra-3d/composables/useInfra3dAlarms.ts       |  70 +-
 .../infra-3d/composables/useInfra3dConfig.ts       |  37 +
 .../infra-3d/composables/useLayerIconTexture.ts    |  33 +-
 .../infra-3d/composables/useScenePlacement.ts      | 346 +++++++--
 apps/ui/src/i18n/locales/de.json                   |   1 +
 apps/ui/src/i18n/locales/en.json                   |   1 +
 apps/ui/src/i18n/locales/es.json                   |   1 +
 apps/ui/src/i18n/locales/fr.json                   |   1 +
 apps/ui/src/i18n/locales/ja.json                   |   1 +
 apps/ui/src/i18n/locales/ko.json                   |   1 +
 apps/ui/src/i18n/locales/pt.json                   |   1 +
 apps/ui/src/i18n/locales/zh-CN.json                |   1 +
 .../src/layer/service-map/LayerServiceMapView.vue  |  10 +-
 apps/ui/src/layer/traces/LayerTracesView.vue       |   4 +-
 apps/ui/src/layer/traces/TracePopout.vue           |   8 +-
 25 files changed, 1744 insertions(+), 527 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index e1ab159..bf6bd9b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -502,6 +502,53 @@ live, shared version is whatever OAP serves.
 - **Zipkin trace mode** drops the per-layer service-KPI header — the Zipkin
   explorer is a self-contained, cross-service view.
 
+### 3D Infrastructure Map
+
+A standalone, bird's-eye view of the deployment at `/3d/map`: services
+render as cubes on stacked tier-planes (apps · service mesh · middleware
+· infra), each tier subdivided into per-layer zones with the layer's
+brand mark stamped on its colored swatch. Drag to rotate, scroll to
+zoom, arrow keys / WASD to pan; click a cube for its detail card and a
+link into that layer's dashboard.
+
+- **Live data windows.** The map auto-refreshes every minute — per-cube
+  traffic rolls up the last 2h of metrics (HOUR step) and alarmed
+  services light up from the last 20m of alarms. A toolbar chip shows
+  the active scopes (`metrics 2h · alarms 20m · ↻ 1m`). An alarmed cube
+  burns red with a radiating ripple, matched to its service by (layer,
+  name) so only the firing service in the right tier is flagged.
+- **Beacon mode.** A toolbar toggle dims every healthy cube to a
+  wireframe ghost and lets only alarming cubes glow, so the services
+  that are firing jump out instantly during an incident.
+- **Logic groups.** Related layers can be clustered into a single
+  labelled block on a tier — the bundled config ships a
+  **Self-Observability** group (OAP, Satellite, BanyanDB, and the Java /
+  Go agents) on the middleware tier. Members keep their own cube colors
+  but read as one block on the map.
+- **Configurable tiers + layers.** Tier order, per-layer plane mapping,
+  cube colors, and the traffic MQE per layer are all driven by the 3D
+  map config (bundled defaults, admin-overridable).
+- **Topology clustering.** Within layers that carry a service map,
+  services group into named clusters drawn as a wireframe frame with the
+  cluster name baked into the frame's lower-left corner — service-mesh
+  services cluster by their showcase group, Kubernetes services by
+  namespace. Clustering follows each layer's naming rule, so layers
+  without one keep rendering flat.
+- **Navigate by tier.** The right-side **Tiers** panel is a two-level
+  tree (tier → layer / logic-group). Clicking any entry resets the
+  view, glides the camera to face that region, zooms in, and flashes
+  the region for a few seconds so it is easy to spot. A **Reset** button
+  in the panel header restores the initial framing.
+- **Hover = preview.** Hovering a cube shows the same detail card as a
+  click — tier, layer, service, and (when present) group and cluster —
+  anchored at the cube, minus the open-in-dashboard link.
+- **Call-direction flow.** Call relationships animate directional
+  particles so the direction of traffic reads at a glance.
+- **Focus one layer.** The per-layer service map gains a **View in 3D**
+  link that opens the map focused on just that layer.
+- **Refresh countdown.** The load status strip shows a live countdown to
+  the next refresh, anchored to the stage that will run next.
+
 ### Reliability
 
 - When the BFF is unreachable the UI now shows a clear "Cannot reach the
@@ -515,6 +562,11 @@ live, shared version is whatever OAP serves.
   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.
+- The brand link and the post-login redirect no longer resolve to an
+  empty address — both now land on the operator's actual landing route.
+- Trace span detail now labels the span direction as **Kind** (the noun)
+  rather than the mistranslated verb form, and long tag keys wrap inside
+  the panel instead of overflowing their column.
 
 ## 0.5.0
 
diff --git a/apps/bff/src/bundled_templates/infra-3d/config.json b/apps/bff/src/bundled_templates/infra-3d/config.json
index 7b27871..5019a50 100644
--- a/apps/bff/src/bundled_templates/infra-3d/config.json
+++ b/apps/bff/src/bundled_templates/infra-3d/config.json
@@ -40,20 +40,19 @@
     },
     {
       "id":          "mesh",
-      "order":       1,
+      "order":       2,
       "label":       "Service Mesh",
       "layerFilter": ".*",
       "layers": [
         "MESH",
         "MESH_DP",
         "MESH_CP",
-        "CILIUM_SERVICE",
-        "ENVOY_AI_GATEWAY"
+        "CILIUM_SERVICE"
       ]
     },
     {
       "id":          "middleware",
-      "order":       2,
+      "order":       1,
       "label":       "Middleware",
       "layerFilter": ".*",
       "layers": [
@@ -85,7 +84,8 @@
         "AWS_S3",
         "AWS_DYNAMODB",
         "AWS_GATEWAY",
-        "AWS_API_GATEWAY"
+        "AWS_API_GATEWAY",
+        "ENVOY_AI_GATEWAY"
       ]
     },
     {
@@ -103,6 +103,22 @@
       ]
     }
   ],
+  "groups": [
+    {
+      "id": "self-observability",
+      "label": "Self-Observability",
+      "level": "middleware",
+      "color": "#a78bfa",
+      "icon": "skywalking",
+      "layers": [
+        "SO11Y_OAP",
+        "SO11Y_SATELLITE",
+        "SO11Y_BANYANDB",
+        "SO11Y_JAVA_AGENT",
+        "SO11Y_GO_AGENT"
+      ]
+    }
+  ],
   "layers": {
     "GENERAL": {
       "color": "#e76f00",
@@ -265,10 +281,12 @@
       "color": "#a8d5ff"
     },
     "SO11Y_JAVA_AGENT": {
-      "color": "#9fb3c2"
+      "color": "#9fb3c2",
+      "$note": "No traffic chip by design: every meter_java_agent_* metric is ServiceInstance-scope (verified against the OAP metric catalog); OAP does not roll ServiceInstance up to Service, and the cube queries at Service scope. No Service-scope agent metric exists to put on the cube."
     },
     "SO11Y_GO_AGENT": {
-      "color": "#80b8c0"
+      "color": "#80b8c0",
+      "$note": "No traffic chip by design: every meter_sw_go_* metric is ServiceInstance-scope (verified against the OAP metric catalog); the cube queries at Service scope and OAP won't roll up. No Service-scope agent metric exists."
     },
     "AWS_S3": {
       "color": "#ff9900",
diff --git a/apps/bff/src/logic/infra-3d/types.ts b/apps/bff/src/logic/infra-3d/types.ts
index 6634505..5506bef 100644
--- a/apps/bff/src/logic/infra-3d/types.ts
+++ b/apps/bff/src/logic/infra-3d/types.ts
@@ -90,6 +90,29 @@ export interface InfraLevelSpec {
   layers: string[];
 }
 
+/** A logic group inside a tier — several layers drawn as ONE colored
+ *  block on `level`, but each member layer keeps its own cube colors and
+ *  starts its own column run (members are row-aligned, never colour-mixed).
+ *  Defined explicitly in the 3D-map config (managed on the config page),
+ *  NOT inferred from a layer template's `group`. A grouped layer is
+ *  placed on the group's `level`, overriding its own level resolution. */
+export interface InfraGroupSpec {
+  /** Stable kebab id (config key + side-panel key). */
+  id: string;
+  /** Operator-facing name (group block label + side-panel row). */
+  label: string;
+  /** Level (tier) id the group sits on. */
+  level: string;
+  /** Group block color (hex `#rrggbb` or any CSS/Three color string). */
+  color: string;
+  /** Icon stamped on the group block. One of the shared 3D icon names
+   *  (see the UI icon catalog); `skywalking` renders the SkyWalking
+   *  crescent brand mark. */
+  icon: string;
+  /** Member OAP layer keys (canonical upper-case). */
+  layers: string[];
+}
+
 export interface InfraEdgeStyle {
   color: string;
   /** `solid` | `dashed` — passed through to the line renderer; anything
@@ -130,6 +153,10 @@ export interface Infra3dConfig {
     badge: string;
   };
   levels: InfraLevelSpec[];
+  /** Logic groups inside tiers (e.g. Self-Observability). Optional;
+   *  defaults to empty. A layer listed in a group is clustered into the
+   *  group's block on the group's level. */
+  groups: InfraGroupSpec[];
   /** Per-layer overrides keyed by canonical upper-case OAP layer key.
    *  Unknown layers fall back to a neutral default at render time. */
   layers: Record<string, InfraLayerSpec>;
diff --git a/apps/bff/src/logic/infra-3d/validate.ts b/apps/bff/src/logic/infra-3d/validate.ts
index 4ddb1bf..bd59080 100644
--- a/apps/bff/src/logic/infra-3d/validate.ts
+++ b/apps/bff/src/logic/infra-3d/validate.ts
@@ -62,6 +62,19 @@ const edgeStyleSchema = z
   })
   .strict();
 
+const groupSpecSchema = z
+  .object({
+    id: z.string().regex(/^[a-z][a-z0-9-]*$/, {
+      message: 'group id must be lower-kebab (a-z, 0-9, -; start with a letter)',
+    }),
+    label: z.string().min(1),
+    level: z.string().min(1),
+    color: z.string().min(1),
+    icon: z.string().min(1),
+    layers: z.array(z.string().min(1)).min(1),
+  })
+  .strict();
+
 const levelSpecSchema = z
   .object({
     id: z.string().regex(/^[a-z][a-z0-9-]*$/, {
@@ -106,6 +119,8 @@ const configSchema = z
       })
       .strict(),
     levels: z.array(levelSpecSchema).min(1),
+    // Optional — older saved configs predate groups; default to none.
+    groups: z.array(groupSpecSchema).default([]),
     layers: z.record(z.string().min(1), layerSpecSchema),
   })
   .strict()
@@ -129,6 +144,27 @@ const configSchema = z
         path: ['unknownLayer', 'level'],
       });
     }
+    // Each group must sit on a real level (a dangling `level` silently
+    // drops the group block + scatters its members at render time), and
+    // group ids must be unique (placement + side panel key on the id).
+    const groupIds = new Set<string>();
+    for (const g of cfg.groups) {
+      if (groupIds.has(g.id)) {
+        ctx.addIssue({
+          code: z.ZodIssueCode.custom,
+          message: `duplicate group id: ${g.id}`,
+          path: ['groups'],
+        });
+      }
+      groupIds.add(g.id);
+      if (!levelIds.has(g.level)) {
+        ctx.addIssue({
+          code: z.ZodIssueCode.custom,
+          message: `group "${g.id}" level "${g.level}" must be one of: ${Array.from(levelIds).join(', ')}`,
+          path: ['groups'],
+        });
+      }
+    }
     // A layer can be referenced by an explicit list only once across all
     // levels — otherwise the level membership is ambiguous.
     const claimed = new Map<string, string>();
diff --git a/apps/ui/src/api/client.ts b/apps/ui/src/api/client.ts
index 7a735a0..463606d 100644
--- a/apps/ui/src/api/client.ts
+++ b/apps/ui/src/api/client.ts
@@ -601,6 +601,15 @@ export interface InfraLevelSpec {
   layers: string[];
 }
 
+export interface InfraGroupSpec {
+  id: string;
+  label: string;
+  level: string;
+  color: string;
+  icon: string;
+  layers: string[];
+}
+
 export interface InfraEdgeStyle {
   color: string;
   style: 'solid' | 'dashed';
@@ -623,6 +632,7 @@ export interface Infra3dConfig {
   pipeline: InfraPipelineLimits;
   unknownLayer: { level: string; badge: string };
   levels: InfraLevelSpec[];
+  groups?: InfraGroupSpec[];
   layers: Record<string, InfraLayerSpec>;
 }
 
diff --git a/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue b/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
index d5fdc4c..12b5c01 100644
--- a/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
+++ b/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
@@ -1417,16 +1417,6 @@ const showGroupForScope = computed<boolean>(() => {
   return false;
 });
 
-// Pointer from the topology scope panel ⤴ to the always-visible
-// Topology cluster card. Smooth-scrolls and briefly highlights so the
-// operator's eye lands on it without ambiguity.
-function scrollToClusterCard(): void {
-  const el = document.getElementById('topology-cluster');
-  if (!el) return;
-  el.scrollIntoView({ behavior: 'smooth', block: 'start' });
-  el.classList.add('cluster-pulse');
-  setTimeout(() => el.classList.remove('cluster-pulse'), 1400);
-}
 const namingSample = ref<string>('songs.sample.svc.cluster.local');
 interface NamingTestResult {
   ok: boolean;
@@ -1898,130 +1888,6 @@ const namingTest = computed<NamingTestResult>(() => {
           </div>
         </section>
 
-        <!-- Topology cluster setup: a named-capture regex run against
-             every service name. The topology view buckets nodes by the
-             resolved cluster value (e.g. k8s namespace) and renders an
-             `<alias · value>` chip next to the service display label.
-             Distinct from the layer-agnostic OAP `<group>::<base>`
-             prefix convention, which is global and never produces a
-             topology cluster — clusters are explicit per-layer opt-in.
-             Live tester at the bottom evaluates the current draft
-             against a sample name so the operator can see the result
-             before saving. -->
-        <section
-          v-if="selectedTpl.components?.topology"
-          id="topology-cluster"
-          class="sw-card components-card"
-        >
-          <div class="card-head">
-            <h4>Topology cluster setup</h4>
-            <span class="sub">parse service name → display label + cluster dimension (k8s/mesh namespace, tenant, fleet, …)</span>
-            <button
-              v-if="!selectedTpl.naming"
-              class="sw-btn add"
-              type="button"
-              @click="enableNaming"
-            >+ Enable rule</button>
-            <button
-              v-else
-              class="sw-btn small ghost danger"
-              type="button"
-              @click="disableNaming"
-            >Remove rule</button>
-          </div>
-          <div v-if="!selectedTpl.naming" class="topo-cfg-help" style="padding: 12px 16px;">
-            No cluster rule configured — the topology view renders without cluster bounding
-            boxes. Enable a rule for layers whose service names encode a cluster dimension
-            (k8s namespace, fleet, tenant) so topology can cluster nodes accordingly.
-          </div>
-          <div v-else class="naming-body">
-            <div class="naming-row">
-              <label class="mf mf-wide">
-                <span>regex pattern</span>
-                <input
-                  v-model="selectedTpl.naming.pattern"
-                  class="mf-input mono"
-                  type="text"
-                  spellcheck="false"
-                  placeholder="^(?<service>[^.]+)\.(?<namespace>[^.]+)$"
-                />
-              </label>
-              <label class="mf mf-narrow" title="JavaScript regex flags: i = case-insensitive, m = multiline, s = dotall, u = unicode. Service names are case-sensitive single-line strings, so this is almost always empty.">
-                <span>regex flags</span>
-                <input
-                  v-model="selectedTpl.naming.flags"
-                  class="mf-input mono"
-                  type="text"
-                  spellcheck="false"
-                  placeholder="(empty)"
-                />
-              </label>
-            </div>
-            <div class="naming-flags-hint">
-              <code>flags</code> are passed as the second argument to
-              <code>new RegExp(pattern, flags)</code>. Common values: <code>i</code>
-              (case-insensitive), <code>m</code> (multiline). Leave empty for typical
-              k8s/mesh service names.
-            </div>
-            <div class="naming-row">
-              <label class="mf">
-                <span>display capture</span>
-                <input
-                  v-model="selectedTpl.naming.displayGroup"
-                  class="mf-input mono"
-                  type="text"
-                  placeholder="service"
-                />
-              </label>
-              <label class="mf">
-                <span>cluster capture</span>
-                <input
-                  v-model="selectedTpl.naming.valueGroup"
-                  class="mf-input mono"
-                  type="text"
-                  placeholder="namespace"
-                />
-              </label>
-              <label class="mf">
-                <span>alias (cluster label)</span>
-                <input
-                  v-model="selectedTpl.naming.alias"
-                  class="mf-input"
-                  type="text"
-                  placeholder="namespace"
-                />
-              </label>
-            </div>
-            <div class="naming-test">
-              <label class="mf mf-wide">
-                <span>test service name</span>
-                <input
-                  v-model="namingSample"
-                  class="mf-input mono"
-                  type="text"
-                  spellcheck="false"
-                  placeholder="songs.sample"
-                />
-              </label>
-              <div class="naming-result" :class="{ ok: namingTest.ok, err: !namingTest.ok }">
-                <div v-if="namingTest.error" class="naming-error">
-                  <span class="naming-tag">error</span>
-                  <span>{{ namingTest.error }}</span>
-                </div>
-                <template v-else>
-                  <div class="naming-result-row">
-                    <span class="naming-tag">display</span>
-                    <span class="mono">{{ namingTest.display ?? '—' }}</span>
-                  </div>
-                  <div class="naming-result-row">
-                    <span class="naming-tag">{{ namingTest.alias ?? 'group' }}</span>
-                    <span class="mono accent">{{ namingTest.group ?? '—' }}</span>
-                  </div>
-                </template>
-              </div>
-            </div>
-          </div>
-        </section>
 
         <!-- Service-list metrics: the columns shown in the picker
              zone's services table + the default sort. Used across
@@ -2172,6 +2038,130 @@ const namingTest = computed<NamingTestResult>(() => {
              Drawer (right): config fields for the selected widget.
              Hidden when nothing is selected so the canvas gets the
              full width. -->
+        <!-- Topology cluster setup: a named-capture regex run against
+             every service name. The topology view buckets nodes by the
+             resolved cluster value (e.g. k8s namespace) and renders an
+             `<alias · value>` chip next to the service display label.
+             Distinct from the layer-agnostic OAP `<group>::<base>`
+             prefix convention, which is global and never produces a
+             topology cluster — clusters are explicit per-layer opt-in.
+             Live tester at the bottom evaluates the current draft
+             against a sample name so the operator can see the result
+             before saving. -->
+        <section
+          v-if="selectedTpl.components?.topology && activeScope === 'topology'"
+          class="sw-card components-card"
+        >
+          <div class="card-head">
+            <h4>Topology cluster setup</h4>
+            <span class="sub">parse service name → display label + cluster dimension (k8s/mesh namespace, tenant, fleet, …)</span>
+            <button
+              v-if="!selectedTpl.naming"
+              class="sw-btn add"
+              type="button"
+              @click="enableNaming"
+            >+ Enable rule</button>
+            <button
+              v-else
+              class="sw-btn small ghost danger"
+              type="button"
+              @click="disableNaming"
+            >Remove rule</button>
+          </div>
+          <div v-if="!selectedTpl.naming" class="topo-cfg-help" style="padding: 12px 16px;">
+            No cluster rule configured — the topology view renders without cluster bounding
+            boxes. Enable a rule for layers whose service names encode a cluster dimension
+            (k8s namespace, fleet, tenant) so topology can cluster nodes accordingly.
+          </div>
+          <div v-else class="naming-body">
+            <div class="naming-row">
+              <label class="mf mf-wide">
+                <span>regex pattern</span>
+                <input
+                  v-model="selectedTpl.naming.pattern"
+                  class="mf-input mono"
+                  type="text"
+                  spellcheck="false"
+                  placeholder="^(?<service>[^.]+)\.(?<namespace>[^.]+)$"
+                />
+              </label>
+              <label class="mf mf-narrow" title="JavaScript regex flags: i = case-insensitive, m = multiline, s = dotall, u = unicode. Service names are case-sensitive single-line strings, so this is almost always empty.">
+                <span>regex flags</span>
+                <input
+                  v-model="selectedTpl.naming.flags"
+                  class="mf-input mono"
+                  type="text"
+                  spellcheck="false"
+                  placeholder="(empty)"
+                />
+              </label>
+            </div>
+            <div class="naming-flags-hint">
+              <code>flags</code> are passed as the second argument to
+              <code>new RegExp(pattern, flags)</code>. Common values: <code>i</code>
+              (case-insensitive), <code>m</code> (multiline). Leave empty for typical
+              k8s/mesh service names.
+            </div>
+            <div class="naming-row">
+              <label class="mf">
+                <span>display capture</span>
+                <input
+                  v-model="selectedTpl.naming.displayGroup"
+                  class="mf-input mono"
+                  type="text"
+                  placeholder="service"
+                />
+              </label>
+              <label class="mf">
+                <span>cluster capture</span>
+                <input
+                  v-model="selectedTpl.naming.valueGroup"
+                  class="mf-input mono"
+                  type="text"
+                  placeholder="namespace"
+                />
+              </label>
+              <label class="mf">
+                <span>alias (cluster label)</span>
+                <input
+                  v-model="selectedTpl.naming.alias"
+                  class="mf-input"
+                  type="text"
+                  placeholder="namespace"
+                />
+              </label>
+            </div>
+            <div class="naming-test">
+              <label class="mf mf-wide">
+                <span>test service name</span>
+                <input
+                  v-model="namingSample"
+                  class="mf-input mono"
+                  type="text"
+                  spellcheck="false"
+                  placeholder="songs.sample"
+                />
+              </label>
+              <div class="naming-result" :class="{ ok: namingTest.ok, err: !namingTest.ok }">
+                <div v-if="namingTest.error" class="naming-error">
+                  <span class="naming-tag">error</span>
+                  <span>{{ namingTest.error }}</span>
+                </div>
+                <template v-else>
+                  <div class="naming-result-row">
+                    <span class="naming-tag">display</span>
+                    <span class="mono">{{ namingTest.display ?? '—' }}</span>
+                  </div>
+                  <div class="naming-result-row">
+                    <span class="naming-tag">{{ namingTest.alias ?? 'group' }}</span>
+                    <span class="mono accent">{{ namingTest.group ?? '—' }}</span>
+                  </div>
+                </template>
+              </div>
+            </div>
+          </div>
+        </section>
+
         <!-- Topology + API dependency config editor — node + line
              metric definitions, with optional 4-band thresholds.
              Each metric edits id / label / MQE / unit / role /
@@ -2184,14 +2174,6 @@ const namingTest = computed<NamingTestResult>(() => {
             <h4>Topology config</h4>
             <span class="sub">node + server-side + client-side line metrics. Add rows; bind a metric to a visual role.</span>
           </div>
-          <div class="topo-cluster-pointer">
-            <span class="topo-cluster-pointer-icon">⤴</span>
-            <span>
-              Cluster setup (k8s/mesh namespace grouping) lives in
-              <a href="#topology-cluster" @click.prevent="scrollToClusterCard">Topology cluster setup</a>
-              above.
-            </span>
-          </div>
           <!-- showGroup: per-topology toggle for the legacy `<group>::`
                prefix chip in the node detail panel. Off (default) ⇒
                prefix never appears in the UI. On ⇒ surfaces as its own
@@ -3523,36 +3505,6 @@ const namingTest = computed<NamingTestResult>(() => {
   border-color: var(--sw-accent-line);
   color: var(--sw-accent-2);
 }
-/* Topology-config → cluster-setup pointer banner. */
-.topo-cluster-pointer {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  margin: 10px 14px 0;
-  padding: 8px 12px;
-  background: var(--sw-accent-soft);
-  border: 1px solid var(--sw-accent-line);
-  border-radius: 6px;
-  font-size: 11.5px;
-  color: var(--sw-fg-1);
-}
-.topo-cluster-pointer-icon {
-  font-size: 14px;
-  color: var(--sw-accent-2);
-}
-.topo-cluster-pointer a {
-  color: var(--sw-accent-2);
-  text-decoration: underline;
-}
-/* Brief pulse when arrowed-into from the topology config panel. */
-@keyframes cluster-pulse-frames {
-  0%   { box-shadow: 0 0 0 0 var(--sw-accent-line); }
-  40%  { box-shadow: 0 0 0 6px var(--sw-accent-line); }
-  100% { box-shadow: 0 0 0 0 transparent; }
-}
-.cluster-pulse {
-  animation: cluster-pulse-frames 1.2s ease-out;
-}
 .naming-flags-hint {
   margin: -4px 0 4px;
   font-size: 11px;
diff --git a/apps/ui/src/features/auth/LoginView.vue b/apps/ui/src/features/auth/LoginView.vue
index b567fb6..f9bb174 100644
--- a/apps/ui/src/features/auth/LoginView.vue
+++ b/apps/ui/src/features/auth/LoginView.vue
@@ -91,8 +91,11 @@ async function submit(): Promise<void> {
     const ok = await auth.login(username.value, password.value);
     if (ok) {
       const raw = typeof route.query.redirect === 'string' ? route.query.redirect : '';
-      const landing = auth.user?.landingRoute ?? '/';
-      const redirect = raw && raw !== '/login' ? raw : landing;
+      // `||` not `??`: landingRoute can be an empty string, which would
+      // otherwise push('') → an empty address. Belt-and-suspenders final
+      // fallback too.
+      const landing = auth.user?.landingRoute || '/';
+      const redirect = (raw && raw !== '/login' ? raw : landing) || '/';
       await router.push(redirect);
     }
   } finally {
diff --git a/apps/ui/src/features/infra-3d/Infra3DScene.vue b/apps/ui/src/features/infra-3d/Infra3DScene.vue
index eb5a8af..e92a974 100644
--- a/apps/ui/src/features/infra-3d/Infra3DScene.vue
+++ b/apps/ui/src/features/infra-3d/Infra3DScene.vue
@@ -33,16 +33,25 @@ import { computed, onUnmounted, ref, shallowRef, watch } from 'vue';
 import { TresCanvas } from '@tresjs/core';
 import { OrbitControls, Html } from '@tresjs/cientos';
 import {
+  AdditiveBlending,
   BoxGeometry,
+  CanvasTexture,
   CatmullRomCurve3,
   Color,
   ConeGeometry,
   DoubleSide,
+  EdgesGeometry,
+  LinearFilter,
+  LineBasicMaterial,
   MeshBasicMaterial,
   MeshLambertMaterial,
+  type Object3D,
   PerspectiveCamera,
+  PlaneGeometry,
   Quaternion,
+  RingGeometry,
   SphereGeometry,
+  SpriteMaterial,
   type Texture,
   TubeGeometry,
   Vector3,
@@ -57,6 +66,7 @@ import {
 } from './composables/useDemoTopology';
 import {
   computePlacement,
+  type SceneGroupSpec,
   readTintColor,
   type NodePlacement,
   type PlaneSpec,
@@ -64,14 +74,16 @@ import {
   type ZonePlacement,
   type ZoneTint,
 } from './composables/useScenePlacement';
+import type { ServiceNamingRule } from '@skywalking-horizon-ui/api-client';
 import { colorForLayer, levelForLayer } from './composables/useInfra3dConfig';
+import { resolveServiceIdentity } from '@/utils/serviceName';
 import { layerIcon as layerIconByKey } from '@/shell/icons';
 import {
   disposeLayerIconTextures,
   getLayerIconTexture,
   type LayerIconName,
 } from './composables/useLayerIconTexture';
-import { useInfra3dAlarms } from './composables/useInfra3dAlarms';
+import { useInfra3dAlarms, alarmKey } from './composables/useInfra3dAlarms';
 import { useInfra3dMetrics, formatMetricValue } from './composables/useInfra3dMetrics';
 
 interface Props {
@@ -87,6 +99,23 @@ interface Props {
    *  (re-centers on the zone). The scene lerps the orbit target
    *  toward this each frame for a smooth move. */
   focusTarget: { x: number; y: number; z: number } | null;
+  /** Beacon mode — dim every healthy cube to a dark wireframe ghost and
+   *  let only alarmed cubes glow, so the operator's eye locks onto what
+   *  is firing. */
+  beaconMode?: boolean;
+  /** Logic groups from the config — clustered into one block per group
+   *  on the group's tier. */
+  groups?: SceneGroupSpec[];
+  /** Single-layer focus mode (`/3d/map?layer=<key>`): render ONLY this
+   *  plane's slab so the scene reads as one layer's internal topology
+   *  rather than the full multi-tier map. Null = full map (default). The
+   *  parent also narrows `visibleLayers` to the focused layer, so only
+   *  its zone + cubes draw. */
+  soloPlane?: string | null;
+  /** Per-layer topology-cluster rules (upper-case key → rule). A solo
+   *  layer with a rule yielding ≥2 clusters is laid out cluster-by-
+   *  cluster (k8s/mesh namespace grouping), matching the 2D map. */
+  namingByLayer?: Record<string, ServiceNamingRule | null>;
 }
 const props = defineProps<Props>();
 const emit = defineEmits<{
@@ -105,7 +134,7 @@ const emit = defineEmits<{
 // fallback. `planeOrder` is the source of truth for vertical stacking.
 const topo = loadDemoTopology();
 const graph = buildSceneGraph(topo, levelForLayer);
-const placement = computePlacement(graph, props.planeOrder);
+const placement = computePlacement(graph, props.planeOrder, props.groups, props.namingByLayer);
 
 /** Resolve the per-layer icon glyph. Routed through the same helper
  *  the sidebar uses (`shell/icons.layerIcon`) so the two surfaces
@@ -113,7 +142,7 @@ const placement = computePlacement(graph, props.planeOrder);
  *  Narrowed to the subset the texture baker knows; unknown glyphs
  *  fall back to the generic service mark. */
 const KNOWN_ICONS: ReadonlySet<LayerIconName> = new Set([
-  'mesh', 'cluster', 'sky', 'web', 'fn', 'db', 'cache', 'topic', 'flame', 'svc',
+  'mesh', 'cluster', 'sky', 'skywalking', 'web', 'fn', 'db', 'cache', 'topic', 'flame', 'svc',
 ]);
 function iconForLayer(layerKey: string): LayerIconName {
   const n = layerIconByKey(layerKey);
@@ -128,9 +157,7 @@ function iconForLayer(layerKey: string): LayerIconName {
  *  Materials are cached by `(icon-name, hex)` so two zones with the
  *  same brand mark share one GL material. */
 const iconStampMaterials = new Map<string, MeshBasicMaterial>();
-function iconStampMaterial(layerKey: string, tint: ZoneTint): MeshBasicMaterial {
-  const hex = resolveLayerColor(layerKey, tint);
-  const name = iconForLayer(layerKey);
+function stampMaterial(name: LayerIconName, hex: string): MeshBasicMaterial {
   const key = `${name}|${hex}`;
   let m = iconStampMaterials.get(key);
   if (!m) {
@@ -149,21 +176,36 @@ function iconStampMaterial(layerKey: string, tint: ZoneTint): MeshBasicMaterial
   }
   return m;
 }
+function iconStampMaterial(layerKey: string, tint: ZoneTint): MeshBasicMaterial {
+  return stampMaterial(iconForLayer(layerKey), resolveLayerColor(layerKey, tint));
+}
+/** Stamp for a logic group — its configured icon (e.g. `skywalking` for
+ *  the SkyWalking brand mark) in the group color. Unknown icon names
+ *  fall back to the generic service glyph. */
+function groupStampMaterial(icon: string, hex: string): MeshBasicMaterial {
+  const name = KNOWN_ICONS.has(icon as LayerIconName) ? (icon as LayerIconName) : 'svc';
+  return stampMaterial(name, hex);
+}
 
 // Past-20m alarm overlay — affected service names get the red alarm
-// material in place of the layer tint. Polled at 30s on a shared
+// material in place of the layer tint. Polled every 1 min on a shared
 // timer (refcount inside the composable).
-const { alarmedServiceNames } = useInfra3dAlarms();
-// Convenience name kept short for the template's per-cube material
-// expression; the actual set value reactively drives Vue re-render.
-const alarmedServiceIds = alarmedServiceNames;
-
-// Visible cubes that currently carry an active 20m alarm. The cube's
-// own material stays the layer color; the beacon below picks these up
-// to render a small red sphere on the top corner. We compute the
-// subset once per render so the template can iterate ONLY the
-// alarmed cubes (no v-if per cube).
-const alarmedNodes = computed(() => visibleNodes.value.filter((n) => alarmedServiceIds.value.has(n.node.name)));
+const { alarmedKeys, alarmedNamesNoLayer } = useInfra3dAlarms();
+
+// Visible cubes that currently carry an active 20m alarm. Matched on
+// (layer, name) so an alarm reddens only the exact service in the exact
+// tier — never every same-named cube across layers; alarms with no
+// resolved layer fall back to name-only. We compute the subset once per
+// render so the template iterates ONLY the alarmed cubes (no per-cube v-if).
+const alarmedNodes = computed(() =>
+  visibleNodes.value.filter(
+    (n) =>
+      alarmedKeys.value.has(alarmKey(n.node.layerKey, n.node.name)) ||
+      alarmedNamesNoLayer.value.has(n.node.name),
+  ),
+);
+/** Alarmed cube ids — O(1) lookup for the per-cube material pick. */
+const alarmedNodeIds = computed(() => new Set(alarmedNodes.value.map((n) => n.node.nodeId)));
 
 // Live traffic-MQE values produced by stage 5 of the pipeline. Each
 // node optionally gets a small numeric chip below its cube; the chip
@@ -205,7 +247,96 @@ function isVisible(layerKey: string): boolean {
   return props.visibleLayers.has(layerKey);
 }
 
-const visibleZones = computed(() => placement.zones.filter((z) => isVisible(z.layerKey)));
+const visibleZones = computed(() =>
+  placement.zones.filter((z) =>
+    // A group zone shows if any member layer is visible; a solo zone if
+    // its own layer is.
+    z.group ? z.group.layerKeys.some((k) => isVisible(k)) : isVisible(z.layerKey),
+  ),
+);
+
+// Topology-cluster bands (k8s/mesh namespace grouping) of the visible
+// zones, flattened with their plane Y for the floating labels. Absent
+// for layers without a naming rule (most), so this is usually empty.
+const clusterBands = computed(() =>
+  visibleZones.value.flatMap((z) =>
+    (z.clusters ?? []).map((b, i) => ({
+      ...b,
+      y: z.y,
+      colorHex: resolveLayerColor(z.layerKey, z.tint),
+      key: `${z.layerKey}:${i}`,
+    })),
+  ),
+);
+
+// ── Cluster frames + baked-text labels ─────────────────────────────
+// Each topology-cluster band draws as a thin wireframe rectangle on the
+// plane (一个线条的框) with its namespace baked into a flat texture stamp
+// at the frame's bottom-left corner — the same baked-plane treatment as
+// the layer-icon stamps, so the name tilts with the camera in 3D.
+const clusterFrameMat = new LineBasicMaterial({
+  color: new Color('#5a6b86'),
+  transparent: true,
+  opacity: 0.85,
+});
+// Baked namespace labels (cached by text|colour). The canvas is sized to
+// the TEXT (up to a wide cap) so the namespace isn't truncated; the plane
+// then uses the frame width. Mirrors the icon-stamp baker.
+const clusterLabels = new Map<string, { mat: MeshBasicMaterial; aspect: number }>();
+function bakeClusterLabel(text: string, hex: string): { tex: CanvasTexture; aspect: number } | null {
+  if (typeof document === 'undefined') return null;
+  const H = 128, FONT = 84, MAXW = 2048, PAD = 18;
+  const probe = document.createElement('canvas').getContext('2d');
+  if (!probe) return null;
+  const font = `700 ${FONT}px system-ui, -apple-system, sans-serif`;
+  probe.font = font;
+  let t = text;
+  while (probe.measureText(t).width > MAXW - PAD * 2 && t.length > 1) t = `${t.slice(0, -2)}…`;
+  const W = Math.max(Math.ceil(probe.measureText(t).width) + PAD * 2, 64);
+  const cv = document.createElement('canvas');
+  cv.width = W;
+  cv.height = H;
+  const cx = cv.getContext('2d');
+  if (!cx) return null;
+  cx.font = font;
+  cx.fillStyle = hex;
+  cx.textBaseline = 'middle';
+  cx.textAlign = 'left';
+  cx.fillText(t, PAD, H / 2 + 2);
+  const tex = new CanvasTexture(cv);
+  tex.minFilter = LinearFilter;
+  tex.magFilter = LinearFilter;
+  tex.anisotropy = 4;
+  return { tex, aspect: W / H };
+}
+function clusterLabel(text: string, hex: string): { mat: MeshBasicMaterial; aspect: number } {
+  const key = `${text}|${hex}`;
+  let e = clusterLabels.get(key);
+  if (!e) {
+    const baked = bakeClusterLabel(text, hex);
+    const mat = new MeshBasicMaterial({ map: baked?.tex ?? undefined, transparent: true, depthWrite: false, opacity: 0.95 });
+    e = { mat, aspect: baked?.aspect ?? 4 };
+    clusterLabels.set(key, e);
+  }
+  return e;
+}
+/** Base world height of a cluster label stamp. The width follows the
+ *  frame (left-anchored), capped at the label's natural width so a short
+ *  namespace isn't blown up; aspect keeps the baked text un-stretched. */
+const CLUSTER_LABEL_H = 0.66;
+const clusterFrames = computed(() =>
+  clusterBands.value.map((b) => {
+    const pg = new PlaneGeometry(b.width, b.depth);
+    const geom = new EdgesGeometry(pg);
+    pg.dispose();
+    const { mat, aspect } = clusterLabel(b.label, b.colorHex);
+    const labelW = Math.min(b.width - 0.4, aspect * CLUSTER_LABEL_H);
+    const labelH = labelW / aspect;
+    return { band: b, geom, labelMat: mat, labelW, labelH };
+  }),
+);
+// Free the previous frame batch when a visibility change re-mints the set.
+watch(clusterFrames, (_now, prev) => { for (const f of prev) f.geom.dispose(); }, { flush: 'post' });
 
 interface VisibleNode {
   node: SceneServiceNode;
@@ -219,7 +350,14 @@ interface VisibleNode {
   colorHex: string;
 }
 const visibleNodes = computed<VisibleNode[]>(() => {
-  const zoneByLayer = new Map(placement.zones.map((z) => [z.layerKey, z]));
+  // Map every layer to its zone. A group zone is keyed by the group id,
+  // so fan it out to each member layer key — otherwise grouped layers
+  // (e.g. so11y_*) find no zone and their cubes vanish (empty block).
+  const zoneByLayer = new Map<string, ZonePlacement>();
+  for (const z of placement.zones) {
+    if (z.group) for (const k of z.group.layerKeys) zoneByLayer.set(k, z);
+    else zoneByLayer.set(z.layerKey, z);
+  }
   const out: VisibleNode[] = [];
   for (const L of graph.layers) {
     if (!isVisible(L.key)) continue;
@@ -448,16 +586,42 @@ function resolveLayerColor(layerKey: string, tintFallback: ZoneTint): string {
   return readTintColor(tintFallback);
 }
 
+// Tier "planes" are volumetric glass slabs, not flat sheets — a box of
+// PLANE_THICKNESS whose TOP face sits at the plane's Y (where cubes
+// rest). Transparent slate glass so the slab reads as a physical tray
+// the cubes sit on. Backface culling off so the slab looks solid glass
+// from any angle; depthWrite off so cubes/zones blend through cleanly.
+const PLANE_THICKNESS = 0.5;
 const planeMaterial = new MeshBasicMaterial({
   color: new Color('#151a23'),
   transparent: true,
-  opacity: 0.55,
+  opacity: 0.4,
   side: DoubleSide,
   depthWrite: false,
   polygonOffset: true,
   polygonOffsetFactor: 1,
   polygonOffsetUnits: 1,
 });
+// Bright rim on the slab edges — sells the "pane of glass" read and
+// gives each tier a crisp footprint in the dark scene.
+const planeEdgeMaterial = new LineBasicMaterial({
+  color: new Color('#3a4658'),
+  transparent: true,
+  opacity: 0.8,
+});
+// Slab geometry is per-plane (each tier has its own footprint) and the
+// layout is static, so bake the box + its edge wireframe once. Centred
+// half a thickness BELOW the plane Y so the slab's top face is exactly
+// where the cubes rest.
+const planeSlabs = placement.planes.map((P) => {
+  const box = new BoxGeometry(P.width, PLANE_THICKNESS, P.depth);
+  return {
+    id: P.id,
+    y: P.y - PLANE_THICKNESS / 2,
+    box,
+    edges: new EdgesGeometry(box),
+  };
+});
 
 const zoneMaterials = new Map<string, MeshBasicMaterial>();
 function zoneMaterial(hex: string): MeshBasicMaterial {
@@ -488,7 +652,11 @@ const _hsl = { h: 0, s: 0, l: 0 };
 function mutedCubeColor(hex: string): Color {
   const c = colorByHex(hex).clone();
   c.getHSL(_hsl);
-  c.setHSL(_hsl.h, _hsl.s * 0.6, Math.min(1, _hsl.l * 1.06 + 0.03));
+  // Pastel pull: drop most of the saturation and lift lightness so the
+  // cubes read as soft, gently-lit tints (think frosted candy) rather
+  // than the gaudy fully-saturated tokens. The side-panel swatches keep
+  // the full tint, so the hue stays recognizable.
+  c.setHSL(_hsl.h, _hsl.s * 0.5, Math.min(1, _hsl.l * 1.1 + 0.06));
   return c;
 }
 
@@ -497,7 +665,8 @@ function nodeMaterial(hex: string): MeshLambertMaterial {
   let m = nodeMaterials.get(hex);
   if (!m) {
     const base = mutedCubeColor(hex);
-    m = new MeshLambertMaterial({ color: base, emissive: base.clone().multiplyScalar(0.3) });
+    // Soft self-glow so the pastel reads luminous, not matte-flat.
+    m = new MeshLambertMaterial({ color: base, emissive: base.clone().multiplyScalar(0.22) });
     nodeMaterials.set(hex, m);
   }
   return m;
@@ -523,19 +692,78 @@ function hoverMaterial(hex: string): MeshLambertMaterial {
   return m;
 }
 const selectedMat = new MeshLambertMaterial({ color: 0xf97316, emissive: 0xf97316, emissiveIntensity: 0.85 });
+// Alarmed cubes burn red (the whole cube, not just a cap) — paired with
+// the radiating ripple below, red is the unmistakable "this service is
+// alarming" signal. Selection still wins so the operator can inspect it.
+const alarmMat = new MeshLambertMaterial({ color: 0xef4444, emissive: 0xef4444, emissiveIntensity: 0.6 });
+
+// Alarm ripple — a radar / seismic wave radiating out from an alarmed
+// cube across its plane. RIPPLE_PHASES concentric red rings expand and
+// fade on staggered phases so the waves read as a continuous outward
+// pulse. The rings are driven DIRECTLY on the THREE meshes in
+// onSceneLoop (TresJS copies Vector3 props on patch, so a reactive
+// scale binding wouldn't animate per frame) — the same direct-mutation
+// approach the camera and the old beacon pulse use. One shared material
+// per phase (opacity animated); the per-ring scale is set on each mesh.
+const RIPPLE_PHASES = 3;
+const RIPPLE_PERIOD = 3.2; // seconds for a ring to travel fully out
+const RIPPLE_MIN_SCALE = 0.9;
+const RIPPLE_MAX_SCALE = 4.2;
+const RIPPLE_MAX_OPACITY = 0.5;
+const rippleGeometry = new RingGeometry(0.6, 0.78, 44);
+const rippleMats = Array.from(
+  { length: RIPPLE_PHASES },
+  () =>
+    new MeshBasicMaterial({
+      color: 0xef4444,
+      transparent: true,
+      opacity: 0,
+      side: DoubleSide,
+      depthWrite: false,
+    }),
+);
 
-// Alarm marker — the top ~20% of an alarmed cube is capped in red.
-// The cube is 1.0 tall, so a 0.22-high flat box at its crown reads as
-// "the top fifth is red". Footprint is a hair larger than the cube
-// (1.32 vs 1.3) so the cap's sides clear the cube faces without
-// z-fighting, and it sits just above the cube's top face. Static red,
-// no pulse — the alert is a state, not an animation; the cube keeps
-// its layer color below the cap.
-const alarmCapGeometry = new BoxGeometry(1.32, 0.22, 1.32);
-const alarmCapMat = new MeshLambertMaterial({
-  color: 0xef4444,
-  emissive: 0xef4444,
-  emissiveIntensity: 0.5,
+// ── Beacon mode resources ──────────────────────────────────────────
+// Healthy cubes drop to a near-invisible dark body plus a faint
+// wireframe (shared EdgesGeometry of the cube), so the grid reads as a
+// blueprint and only the red alarmed cubes + their glow stand out.
+const ghostMat = new MeshBasicMaterial({
+  color: new Color('#1b2433'),
+  transparent: true,
+  opacity: 0.5,
+  depthWrite: false,
+});
+const cubeEdgesGeometry = new EdgesGeometry(nodeGeometry);
+const cubeEdgeMat = new LineBasicMaterial({
+  color: new Color('#7d8ba3'),
+  transparent: true,
+  opacity: 0.9,
+});
+// Soft red radial halo billboarded over each alarmed cube — the "beacon"
+// glow. Built from a canvas radial-gradient texture so it's a cheap
+// sprite, additively blended for a luminous bloom.
+function makeGlowTexture(): CanvasTexture | null {
+  if (typeof document === 'undefined') return null;
+  const size = 128;
+  const cv = document.createElement('canvas');
+  cv.width = cv.height = size;
+  const cx = cv.getContext('2d');
+  if (!cx) return null;
+  const g = cx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
+  g.addColorStop(0, 'rgba(255,120,110,0.95)');
+  g.addColorStop(0.35, 'rgba(239,68,68,0.55)');
+  g.addColorStop(1, 'rgba(239,68,68,0)');
+  cx.fillStyle = g;
+  cx.fillRect(0, 0, size, size);
+  return new CanvasTexture(cv);
+}
+const glowTexture = makeGlowTexture();
+const glowMat = new SpriteMaterial({
+  map: glowTexture ?? undefined,
+  color: 0xffffff,
+  transparent: true,
+  depthWrite: false,
+  blending: AdditiveBlending,
 });
 
 /** Intra-layer call edge — calls between two services in the same
@@ -640,6 +868,58 @@ function rebuildPackets(): void {
 }
 watch(visibleCallEdges, rebuildPackets, { immediate: true });
 
+// Cross-layer call edges flow too — packets travel caller→callee along
+// the curve (getPoint 0→1 = from→to) so the operator reads the call
+// DIRECTION, not just the static arrow head. Covers the always-on
+// same-plane edges and the selection-gated vertical ones. Amber to
+// match the cross-edge tubes; intra-layer packets stay cyan.
+const crossPacketMat = new MeshBasicMaterial({ color: new Color('#ffd9b0') });
+const crossPackets = shallowRef<Packet[]>([]);
+function rebuildCrossPackets(): void {
+  const out: Packet[] = [];
+  const add = (e: { curve: CatmullRomCurve3 }): void => {
+    for (let k = 0; k < 2; k++) out.push({ curve: e.curve, phase: k / 2, pos: new Vector3() });
+  };
+  visibleCrossEdges.value.forEach(add);
+  visibleVerticalEdges.value.forEach(add);
+  crossPackets.value = out;
+}
+watch([visibleCrossEdges, visibleVerticalEdges], rebuildCrossPackets, { immediate: true });
+
+// One ripple entry per (alarmed cube × phase). `pos` is the ring's
+// anchor on the plane (static — the cube doesn't move); `mesh` is the
+// live THREE mesh captured via the template ref so onSceneLoop can set
+// its scale each frame.
+interface AlarmRipple {
+  pos: [number, number, number];
+  phase: number;
+  mesh: Object3D | null;
+}
+const alarmRipples = shallowRef<AlarmRipple[]>([]);
+function rebuildAlarmRipples(): void {
+  const out: AlarmRipple[] = [];
+  for (const n of alarmedNodes.value) {
+    for (let p = 0; p < RIPPLE_PHASES; p++) {
+      out.push({ pos: [n.pos.x, n.pos.y + 0.03, n.pos.z], phase: p, mesh: null });
+    }
+  }
+  alarmRipples.value = out;
+}
+watch(alarmedNodes, rebuildAlarmRipples, { immediate: true });
+function bindRipple(r: AlarmRipple, el: unknown): void {
+  if (!el) {
+    r.mesh = null;
+    return;
+  }
+  const obj = (el as { isObject3D?: boolean }).isObject3D
+    ? (el as Object3D)
+    : (el as { value?: { isObject3D?: boolean } }).value?.isObject3D
+      ? (el as { value: Object3D }).value
+      : null;
+  r.mesh = obj;
+  if (obj) obj.raycast = _noopRaycast;
+}
+
 // ── Frame loop: animate packets + lerp the orbit controls' target
 // toward the caller's focus target (when the operator clicks a node
 // or a zone). Also recomputes which zone labels overflow their
@@ -715,16 +995,62 @@ function updateCloseNodes(): void {
  *  lerping — saves work and prevents jitter at the destination. */
 let lastFocusKey: string | null = null;
 const FOCUS_SNAP_EPS = 0.04;
+let sceneElapsed = 0;
+// Side-panel focus goal (pos+target), lerped per frame; precedes focusTarget.
+const camGoal = shallowRef<{ pos: Vector3; target: Vector3 } | null>(null);
+const FLASH_SECONDS = 4;
+const flashMat = new MeshBasicMaterial({
+  color: new Color('#cfe3ff'),
+  transparent: true,
+  opacity: 0,
+  side: DoubleSide,
+  depthWrite: false,
+});
+const flashState = shallowRef<{ keys: Set<string>; start: number } | null>(null);
 function onSceneLoop(ctx: { elapsed: number; delta: number }): void {
+  sceneElapsed = ctx.elapsed;
   for (const p of packets.value) {
     const t = ((ctx.elapsed / period) + p.phase) % 1;
     p.curve.getPoint(t, p.pos);
   }
-  // Focus lerp — write straight into the live OrbitControls.target so
-  // the move sticks even when the operator mouses on top of it. We
-  // snap when close enough so the loop doesn't keep nudging the
-  // sub-pixel residual forever.
-  if (props.focusTarget) {
+  for (const p of crossPackets.value) {
+    const t = ((ctx.elapsed / period) + p.phase) % 1;
+    p.curve.getPoint(t, p.pos);
+  }
+  // Alarm ripples — expand + fade each phase's ring; the shared
+  // per-phase material carries the opacity, each ring mesh its scale.
+  if (alarmRipples.value.length > 0) {
+    const scaleByPhase: number[] = [];
+    for (let p = 0; p < RIPPLE_PHASES; p++) {
+      const t = ((ctx.elapsed / RIPPLE_PERIOD) + p / RIPPLE_PHASES) % 1;
+      scaleByPhase[p] = RIPPLE_MIN_SCALE + t * (RIPPLE_MAX_SCALE - RIPPLE_MIN_SCALE);
+      rippleMats[p]!.opacity = (1 - t) * RIPPLE_MAX_OPACITY;
+    }
+    for (const r of alarmRipples.value) r.mesh?.scale.setScalar(scaleByPhase[r.phase]!);
+  }
+  // Side-panel focus — glide pos+target; precedes the target-only path.
+  if (camGoal.value) {
+    const cam = getCamera();
+    const c = getControls();
+    if (cam && c) {
+      const g = camGoal.value;
+      const k = Math.min(1, ctx.delta * 4);
+      cam.position.lerp(g.pos, k);
+      c.target.lerp(g.target, k);
+      c.update();
+      if (cam.position.distanceTo(g.pos) < 0.15 && c.target.distanceTo(g.target) < 0.1) {
+        cam.position.copy(g.pos);
+        c.target.copy(g.target);
+        c.update();
+        camGoal.value = null;
+      }
+    } else {
+      camGoal.value = null;
+    }
+  } else if (props.focusTarget) {
+    // Focus lerp — write straight into the live OrbitControls.target so
+    // the move sticks even when the operator mouses on top of it. Snap
+    // when close so the loop doesn't keep nudging the sub-pixel residual.
     const c = getControls();
     if (c) {
       const ft = props.focusTarget;
@@ -749,6 +1075,17 @@ function onSceneLoop(ctx: { elapsed: number; delta: number }): void {
   } else {
     lastFocusKey = null;
   }
+  // Region flash — pulse + fade the selected zones' plates over ~4s.
+  if (flashState.value) {
+    const age = ctx.elapsed - flashState.value.start;
+    if (age >= FLASH_SECONDS) {
+      flashState.value = null;
+      flashMat.opacity = 0;
+    } else {
+      const pulse = 0.5 + 0.5 * Math.sin(age * Math.PI * 2.2);
+      flashMat.opacity = pulse * (1 - age / FLASH_SECONDS) * 0.4;
+    }
+  }
   if (ctx.elapsed - lastLabelTick > 0.15) {
     lastLabelTick = ctx.elapsed;
     updateSelectedSide();
@@ -1025,6 +1362,7 @@ function pan(rightAmount: number, upAmount: number): void {
 
 /** Reset to the initial framing. */
 function resetView(): void {
+  camGoal.value = null; // cancel any in-flight side-panel focus
   const cam = getCamera();
   const c = getControls();
   if (!cam || !c) return;
@@ -1035,39 +1373,100 @@ function resetView(): void {
   c.update();
 }
 
-defineExpose({ zoom, rotateY, pan, resetView });
+/** Glide the camera to face `target` from the default isometric heading,
+ *  zoomed to fit `radius` (side panel — moves, doesn't pivot in place). */
+function focusOn(t: { x: number; y: number; z: number }, radius: number): void {
+  const target = new Vector3(t.x, t.y, t.z);
+  const dir = new Vector3(0.62, 0.62, 0.62).normalize();
+  const dist = Math.min(220, Math.max(9, radius * 2.6 + 6));
+  camGoal.value = { target, pos: target.clone().addScaledVector(dir, dist) };
+}
+function flashZones(layerKeys: string[]): void {
+  flashState.value = { keys: new Set(layerKeys), start: sceneElapsed };
+}
+const flashRender = computed(() => {
+  const fs = flashState.value;
+  if (!fs) return [];
+  return placement.zones.filter((z) =>
+    z.group ? z.group.layerKeys.some((k) => fs.keys.has(k)) : fs.keys.has(z.layerKey),
+  );
+});
 
+defineExpose({ zoom, rotateY, pan, resetView, focusOn, flashZones });
+
+// Pre-built layer-key → {name, group} + layer-key → tier (plane) name,
+// so the hover + detail cards can show the human-readable layer, its
+// family group, and the tier the cube sits on.
+const layerInfo = new Map<string, { name: string; group: string | null }>();
+for (const L of graph.layers) layerInfo.set(L.key, { name: L.name, group: L.group });
+const planeNameById = new Map(placement.planes.map((p) => [p.id, p.name]));
+const layerPlaneName = new Map<string, string>();
+for (const z of placement.zones) {
+  const pn = planeNameById.get(z.plane) ?? z.plane;
+  if (z.group) for (const k of z.group.layerKeys) layerPlaneName.set(k, pn);
+  else layerPlaneName.set(z.layerKey, pn);
+}
+
+/** Shared cube description — drives BOTH the hover preview and the click
+ *  detail card so they read identically. `group` is the service's
+ *  `<group>::` prefix; `cluster` is the topology-cluster (k8s/mesh
+ *  namespace) when the layer has a naming rule. */
+interface NodeInfo {
+  display: string;
+  service: string;
+  tier: string | null;
+  layer: string;
+  group: string | null;
+  cluster: string | null;
+  clusterAlias: string | null;
+}
+function describeNode(node: SceneServiceNode): NodeInfo {
+  const li = layerInfo.get(node.layerKey);
+  const rule = props.namingByLayer?.[node.layerKey.toUpperCase()] ?? null;
+  const id = resolveServiceIdentity(node.name, rule);
+  return {
+    display: id.display || node.shortName,
+    service: node.name,
+    tier: layerPlaneName.get(node.layerKey) ?? null,
+    layer: li?.name ?? node.layerKey,
+    group: id.legacyGroup,
+    cluster: id.cluster,
+    clusterAlias: id.clusterAlias,
+  };
+}
+
+// Hover preview is suppressed while a node is selected — the detail card
+// is the active info surface and a concurrent hover would double up.
 const hoveredNode = computed(() => {
-  if (!props.hoveredNodeId) return null;
-  // Suppress the hover preview entirely whenever a node is selected —
-  // the detail card next to the selected cube is the active info
-  // surface, and any concurrent hover tooltip just doubles up the
-  // same content on screen ("info shows twice"). Operators can still
-  // see hover previews on every other cube once they dismiss the
-  // selection.
-  if (props.selectedNodeId) return null;
+  if (!props.hoveredNodeId || props.selectedNodeId) return null;
   const n = graph.nodesByKey.get(props.hoveredNodeId);
   if (!n) return null;
   const pos = placement.nodes.get(props.hoveredNodeId);
   if (!pos) return null;
-  return { node: n, pos };
+  return { node: n, pos, info: describeNode(n) };
 });
 
-// Pre-built layer-key → {name, group} map so the detail card can show
-// the human-readable layer name + group alongside the cube it sits on.
-const layerInfo = new Map<string, { name: string; group: string | null }>();
-for (const L of graph.layers) {
-  layerInfo.set(L.key, { name: L.name, group: L.group });
-}
-
 const selectedNodeDetail = computed(() => {
   if (!props.selectedNodeId) return null;
   const n = graph.nodesByKey.get(props.selectedNodeId);
   if (!n) return null;
   const pos = placement.nodes.get(props.selectedNodeId);
   if (!pos) return null;
-  const layer = layerInfo.get(n.layerKey) ?? null;
-  return { node: n, pos, layer };
+  return { node: n, pos, info: describeNode(n) };
+});
+
+/** Deep-link to the selected service's layer dashboard, pre-selecting
+ *  the service. The layer page seeds its pick from `?service=<id>` where
+ *  `<id>` is the OAP service id (the cube carries the real id, e.g.
+ *  `base64(name).1`); without it the page just auto-picks the first
+ *  service. `import.meta.env.BASE_URL` keeps the path correct under a
+ *  gateway sub-path (a bare `/layer/...` ignores the router base — the
+ *  same trap fixed for the brand / login links). */
+const openDashboardHref = computed<string>(() => {
+  const d = selectedNodeDetail.value;
+  if (!d) return import.meta.env.BASE_URL;
+  const base = import.meta.env.BASE_URL; // ends with '/'
+  return `${base}layer/${d.node.layerKey}/service?service=${encodeURIComponent(d.node.serviceId)}`;
 });
 
 // ── Detail-card side: flip to whichever side of the canvas has more
@@ -1115,24 +1514,40 @@ onUnmounted(() => {
   nodeGeometry.dispose();
   packetGeometry.dispose();
   planeMaterial.dispose();
+  planeEdgeMaterial.dispose();
+  for (const s of planeSlabs) {
+    s.box.dispose();
+    s.edges.dispose();
+  }
   for (const m of zoneMaterials.values()) m.dispose();
   for (const m of nodeMaterials.values()) m.dispose();
   for (const m of hoverMaterials.values()) m.dispose();
   selectedMat.dispose();
+  alarmMat.dispose();
   callEdgeMat.dispose();
   callPacketMat.dispose();
+  crossPacketMat.dispose();
+  flashMat.dispose();
   crossEdgeMat.dispose();
   crossArrowMat.dispose();
   hierarchyMat.dispose();
   crossArrowGeometry.dispose();
-  alarmCapGeometry.dispose();
-  alarmCapMat.dispose();
+  rippleGeometry.dispose();
+  for (const m of rippleMats) m.dispose();
+  ghostMat.dispose();
+  cubeEdgesGeometry.dispose();
+  cubeEdgeMat.dispose();
+  glowTexture?.dispose();
+  glowMat.dispose();
   for (const m of iconStampMaterials.values()) m.dispose();
   disposeLayerIconTextures();
   for (const t of callTubes.value) t.geometry.dispose();
   for (const t of crossTubes.value) t.geometry.dispose();
   for (const t of verticalTubes.value) t.geometry.dispose();
   for (const t of hierarchyTubes.value) t.geometry.dispose();
+  clusterFrameMat.dispose();
+  for (const f of clusterFrames.value) f.geom.dispose();
+  for (const e of clusterLabels.values()) { e.mat.map?.dispose(); e.mat.dispose(); }
 });
 </script>
 
@@ -1178,15 +1593,27 @@ onUnmounted(() => {
            handler, so a click on an empty plane area lands on the
            plane and is harmlessly dropped — exactly what we want for
            empty-space behaviour. -->
-      <TresMesh
-        v-for="P in placement.planes"
-        :key="`plane:${P.id}`"
-        :position="[0, P.y, 0]"
-        :rotation="[-Math.PI / 2, 0, 0]"
+      <template
+        v-for="s in planeSlabs"
+        :key="`plane:${s.id}`"
       >
-        <TresPlaneGeometry :args="[P.width, P.depth]" />
-        <primitive :object="planeMaterial" />
-      </TresMesh>
+        <!-- In single-layer focus mode only the focused layer's plane
+             slab renders; the other tiers are hidden so the view reads
+             as one layer's internal topology. -->
+        <template v-if="!props.soloPlane || s.id === props.soloPlane">
+        <!-- Volumetric glass slab — raycast left ON so it occludes
+             cubes behind it and absorbs empty clicks (see CLAUDE.md). -->
+        <TresMesh :position="[0, s.y, 0]">
+          <primitive :object="s.box" />
+          <primitive :object="planeMaterial" />
+        </TresMesh>
+        <!-- Rim wireframe — decorative, raycast disabled. -->
+        <TresLineSegments :position="[0, s.y, 0]" :ref="(el) => disableRaycast(el)">
+          <primitive :object="s.edges" />
+          <primitive :object="planeEdgeMaterial" />
+        </TresLineSegments>
+        </template>
+      </template>
 
       <!-- Layer zones — colored backplates. Same occlusion rule as
            the tier planes above: they raycast, so they hide cubes
@@ -1197,7 +1624,7 @@ onUnmounted(() => {
           :rotation="[-Math.PI / 2, 0, 0]"
         >
           <TresPlaneGeometry :args="[Z.width, Z.depth]" />
-          <primitive :object="zoneMaterial(resolveLayerColor(Z.layerKey, Z.tint))" />
+          <primitive :object="zoneMaterial(Z.group ? Z.group.color : resolveLayerColor(Z.layerKey, Z.tint))" />
         </TresMesh>
         <!-- Layer mark — the project's logo PRINTED onto the zone's
              colour swatch. Built as a textured PlaneGeometry rotated
@@ -1214,10 +1641,49 @@ onUnmounted(() => {
           :ref="(el) => disableRaycast(el)"
         >
           <TresPlaneGeometry :args="[1.7, 1.7]" />
-          <primitive :object="iconStampMaterial(Z.layerKey, Z.tint)" />
+          <primitive
+            :object="Z.group ? groupStampMaterial(Z.group.icon, Z.group.color) : iconStampMaterial(Z.layerKey, Z.tint)"
+          />
         </TresMesh>
       </template>
 
+      <!-- Cluster frames (k8s/mesh namespace): wireframe rect + baked
+           namespace stamp at the bottom-left. Decorative → raycast off. -->
+      <template v-for="f in clusterFrames" :key="`cframe:${f.band.key}`">
+        <TresLineSegments
+          :position="[f.band.centerX, f.band.y + 0.05, f.band.centerZ]"
+          :rotation="[-Math.PI / 2, 0, 0]"
+          :ref="(el) => disableRaycast(el)"
+        >
+          <primitive :object="f.geom" />
+          <primitive :object="clusterFrameMat" />
+        </TresLineSegments>
+        <TresMesh
+          :position="[
+            f.band.centerX - f.band.width / 2 + f.labelW / 2 + 0.25,
+            f.band.y + 0.06,
+            f.band.centerZ + f.band.depth / 2 - f.labelH / 2 - 0.25,
+          ]"
+          :rotation="[-Math.PI / 2, 0, 0]"
+          :ref="(el) => disableRaycast(el)"
+        >
+          <TresPlaneGeometry :args="[f.labelW, f.labelH]" />
+          <primitive :object="f.labelMat" />
+        </TresMesh>
+      </template>
+
+      <!-- Side-panel selection flash. -->
+      <TresMesh
+        v-for="z in flashRender"
+        :key="`flash:${z.layerKey}`"
+        :position="[z.centerX, z.y + 0.04, z.centerZ]"
+        :rotation="[-Math.PI / 2, 0, 0]"
+        :ref="(el) => disableRaycast(el)"
+      >
+        <TresPlaneGeometry :args="[z.width, z.depth]" />
+        <primitive :object="flashMat" />
+      </TresMesh>
+
       <TresMesh
         v-for="n in visibleNodes"
         :key="n.node.nodeId"
@@ -1229,6 +1695,10 @@ onUnmounted(() => {
           :object="
             props.selectedNodeId === n.node.nodeId
               ? selectedMat
+              : alarmedNodeIds.has(n.node.nodeId)
+              ? alarmMat
+              : props.beaconMode
+              ? ghostMat
               : props.hoveredNodeId === n.node.nodeId
               ? hoverMaterial(n.colorHex)
               : nodeMaterial(n.colorHex)
@@ -1236,18 +1706,43 @@ onUnmounted(() => {
         />
       </TresMesh>
 
-      <!-- Alarm cap — the top ~20% of an alarmed cube, painted red. The
-           cube keeps its layer color below; the red crown is the alert
-           signal. raycast disabled (it sits on the cube top and would
-           otherwise steal clicks aimed at the cube). -->
+      <!-- Beacon mode: faint wireframe on every cube (blueprint read)
+           + a red radial glow billboarded over each alarmed cube. Both
+           decorative → raycast disabled. -->
+      <template v-if="props.beaconMode">
+        <TresLineSegments
+          v-for="n in visibleNodes"
+          :key="`cube-edge:${n.node.nodeId}`"
+          :position="[n.pos.x, n.pos.y + 0.55, n.pos.z]"
+          :ref="(el) => disableRaycast(el)"
+        >
+          <primitive :object="cubeEdgesGeometry" />
+          <primitive :object="cubeEdgeMat" />
+        </TresLineSegments>
+        <TresSprite
+          v-for="n in alarmedNodes"
+          :key="`glow:${n.node.nodeId}`"
+          :position="[n.pos.x, n.pos.y + 0.55, n.pos.z]"
+          :scale="[2.8, 2.8, 1]"
+          :ref="(el) => disableRaycast(el)"
+        >
+          <primitive :object="glowMat" />
+        </TresSprite>
+      </template>
+
+      <!-- Alarm ripples — concentric red rings radiating across the
+           plane from each alarmed cube (radar / seismic pulse). Scale
+           is driven per-frame on the THREE mesh in onSceneLoop; raycast
+           disabled so the flat rings never absorb cube clicks. -->
       <TresMesh
-        v-for="n in alarmedNodes"
-        :key="`alarmcap:${n.node.nodeId}`"
-        :position="[n.pos.x, n.pos.y + 0.95, n.pos.z]"
-        :ref="(el) => disableRaycast(el)"
+        v-for="(r, ri) in alarmRipples"
+        :key="`ripple:${ri}`"
+        :position="r.pos"
+        :rotation="[-Math.PI / 2, 0, 0]"
+        :ref="(el) => bindRipple(r, el)"
       >
-        <primitive :object="alarmCapGeometry" />
-        <primitive :object="alarmCapMat" />
+        <primitive :object="rippleGeometry" />
+        <primitive :object="rippleMats[r.phase]" />
       </TresMesh>
 
       <!-- Traffic chip — small numeric pill that LIVES WITH its cube.
@@ -1348,22 +1843,56 @@ onUnmounted(() => {
         <primitive :object="callPacketMat" />
       </TresMesh>
 
+      <!-- Cross-layer call-edge packets — flow caller→callee. -->
+      <TresMesh
+        v-for="(p, pi) in crossPackets"
+        :key="`xpkt:${pi}`"
+        :position="p.pos"
+        :ref="(el) => disableRaycast(el)"
+      >
+        <primitive :object="packetGeometry" />
+        <primitive :object="crossPacketMat" />
+      </TresMesh>
+
       <!-- Hover tooltip — anchored above the hovered cube via cientos
            <Html>, so it follows the cube in 3D. occlude=false because
            the operator's intent is to read what they just hovered;
            hiding the tooltip behind 3D geometry would be surprising. -->
       <Html
         v-if="hoveredNode"
-        :position="[hoveredNode.pos.x, hoveredNode.pos.y + 1.3, hoveredNode.pos.z]"
-        center
+        :position="[hoveredNode.pos.x, hoveredNode.pos.y + 0.6, hoveredNode.pos.z]"
         :occlude="false"
         pointer-events="none"
         :z-index-range="[2000000000, 2000000000]"
       >
-        <div class="floating-tip">
-          <span class="tip-name">{{ hoveredNode.node.shortName }}</span>
-          <span class="tip-layer">{{ hoveredNode.node.layerKey }}</span>
-          <span class="tip-full">{{ hoveredNode.node.name }}</span>
+        <!-- Hover preview — SAME card as the click detail, minus the
+             "Open dashboard" footer (hover is read-only) and pointer-
+             events (must never steal the click). -->
+        <div class="detail-card hover">
+          <div class="d-head">
+            <div class="d-title">
+              <span class="d-name">{{ hoveredNode.info.display }}</span>
+              <span class="d-sub">service</span>
+            </div>
+          </div>
+          <div class="d-rows">
+            <div v-if="hoveredNode.info.tier" class="d-row">
+              <span class="d-label">Tier</span><span class="d-val">{{ hoveredNode.info.tier }}</span>
+            </div>
+            <div class="d-row">
+              <span class="d-label">Layer</span><span class="d-val">{{ hoveredNode.info.layer }}</span>
+            </div>
+            <div v-if="hoveredNode.info.group" class="d-row">
+              <span class="d-label">Group</span><span class="d-val">{{ hoveredNode.info.group }}</span>
+            </div>
+            <div v-if="hoveredNode.info.cluster" class="d-row">
+              <span class="d-label">{{ hoveredNode.info.clusterAlias || 'Cluster' }}</span>
+              <span class="d-val">{{ hoveredNode.info.cluster }}</span>
+            </div>
+            <div class="d-row">
+              <span class="d-label">Service</span><span class="d-val mono">{{ hoveredNode.info.service }}</span>
+            </div>
+          </div>
         </div>
       </Html>
 
@@ -1382,7 +1911,7 @@ onUnmounted(() => {
         <div class="detail-card" :data-side="selectedSide">
           <div class="d-head">
             <div class="d-title">
-              <span class="d-name">{{ selectedNodeDetail.node.shortName }}</span>
+              <span class="d-name">{{ selectedNodeDetail.info.display }}</span>
               <span class="d-sub">service</span>
             </div>
             <button
@@ -1393,15 +1922,21 @@ onUnmounted(() => {
             >×</button>
           </div>
           <div class="d-rows">
+            <div v-if="selectedNodeDetail.info.tier" class="d-row">
+              <span class="d-label">Tier</span><span class="d-val">{{ selectedNodeDetail.info.tier }}</span>
+            </div>
             <div class="d-row">
-              <span class="d-label">Layer</span>
-              <span class="d-val">
-                {{ selectedNodeDetail.layer?.name ?? selectedNodeDetail.node.layerKey }}
-              </span>
+              <span class="d-label">Layer</span><span class="d-val">{{ selectedNodeDetail.info.layer }}</span>
+            </div>
+            <div v-if="selectedNodeDetail.info.group" class="d-row">
+              <span class="d-label">Group</span><span class="d-val">{{ selectedNodeDetail.info.group }}</span>
             </div>
-            <div v-if="selectedNodeDetail.layer?.group" class="d-row">
-              <span class="d-label">Group</span>
-              <span class="d-val">{{ selectedNodeDetail.layer.group }}</span>
+            <div v-if="selectedNodeDetail.info.cluster" class="d-row">
+              <span class="d-label">{{ selectedNodeDetail.info.clusterAlias || 'Cluster' }}</span>
+              <span class="d-val">{{ selectedNodeDetail.info.cluster }}</span>
+            </div>
+            <div class="d-row">
+              <span class="d-label">Service</span><span class="d-val mono">{{ selectedNodeDetail.info.service }}</span>
             </div>
           </div>
           <div class="d-foot">
@@ -1409,7 +1944,7 @@ onUnmounted(() => {
                  real new tab. We point at the layer's main service view,
                  which is the default landing for every active layer. -->
             <a
-              :href="`/layer/${selectedNodeDetail.node.layerKey}/service`"
+              :href="openDashboardHref"
               target="_blank"
               rel="noopener"
               class="d-btn"
@@ -1446,45 +1981,6 @@ onUnmounted(() => {
    has higher specificity than any external class rule we could write.
    See CLAUDE.md "click-thief" section. */
 
-/* Hover tooltip — portaled via cientos <Html>, so non-scoped.
-   Anchored at the hovered cube; the translate-Y offset lifts it
-   slightly above the cube so the cube stays visible while the
-   tooltip reads. pointer-events: none keeps it from absorbing
-   the click the operator's about to make on the cube. */
-.floating-tip {
-  background: rgba(15, 19, 26, 0.96);
-  border: 1px solid var(--sw-line-2);
-  border-radius: 6px;
-  padding: 5px 9px;
-  display: flex;
-  flex-direction: column;
-  gap: 1px;
-  font-size: 11px;
-  color: var(--sw-fg-0);
-  pointer-events: none;
-  white-space: nowrap;
-  transform: translateY(-100%);
-  box-shadow: 0 4px 14px -6px rgba(0, 0, 0, 0.6);
-  /* Sits above every traffic chip — cientos portals every <Html> as a
-     stacking-context-less absolute element, and without an explicit
-     z-index the per-cube chips (rendered later in the v-for) end up
-     painted on top of the hover tooltip. The tooltip is the operator's
-     focused-on info, so it always wins. */
-  position: relative;
-  z-index: 20;
-}
-.floating-tip .tip-name { font-weight: 700; font-size: 12px; }
-.floating-tip .tip-layer {
-  font-size: 9px;
-  color: var(--sw-fg-3);
-  letter-spacing: 0.08em;
-  text-transform: uppercase;
-}
-.floating-tip .tip-full {
-  font-size: 10px;
-  color: var(--sw-fg-2);
-  font-family: var(--sw-mono, monospace);
-}
 
 /* Traffic chip — small numeric pill below each cube reading the
    stage-5 MQE result. Border picks up the layer color so the chip
@@ -1549,6 +2045,12 @@ onUnmounted(() => {
 .detail-card[data-side='left'] {
   transform: translate(calc(-100% - 20px), -50%);
 }
+/* Hover variant — same card, but read-only: centred above the cube and
+   never absorbs pointer events (the click must reach the cube). */
+.detail-card.hover {
+  pointer-events: none;
+  transform: translate(-50%, calc(-100% - 8px));
+}
 .detail-card .d-head {
   display: flex;
   align-items: flex-start;
@@ -1613,6 +2115,12 @@ onUnmounted(() => {
   min-width: 0;
   word-break: break-word;
 }
+.detail-card .d-val.mono {
+  font-family: var(--sw-mono, monospace);
+  font-size: 11px;
+  color: var(--sw-fg-2);
+  word-break: break-all;
+}
 .detail-card .d-foot {
   padding: 0 12px 12px;
 }
diff --git a/apps/ui/src/features/infra-3d/Infra3DView.vue b/apps/ui/src/features/infra-3d/Infra3DView.vue
index 9b0d39d..4a83a10 100644
--- a/apps/ui/src/features/infra-3d/Infra3DView.vue
+++ b/apps/ui/src/features/infra-3d/Infra3DView.vue
@@ -22,7 +22,10 @@
   operator can toggle whole tiers or individual zones.
 -->
 <script setup lang="ts">
-import { computed, onMounted, onUnmounted, ref } from 'vue';
+import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
+import { useRoute } from 'vue-router';
+import { useLayers } from '@/shell/useLayers';
+import type { ServiceNamingRule } from '@skywalking-horizon-ui/api-client';
 import Infra3DScene from './Infra3DScene.vue';
 import PipelineTimeline from './PipelineTimeline.vue';
 import {
@@ -51,9 +54,27 @@ interface SceneHandle {
   rotateY: (degrees: number) => void;
   pan: (rightAmount: number, upAmount: number) => void;
   resetView: () => void;
+  /** Reset the orbit to the default isometric orientation, then glide to
+   *  FACE the target and zoom to fit `radius`. Used by the side panel. */
+  focusOn: (target: { x: number; y: number; z: number }, radius: number) => void;
+  /** Briefly flash the given layers' zone plates (light pulse, ~4s) to
+   *  show which region a side-panel click selected. */
+  flashZones: (layerKeys: string[]) => void;
 }
 const sceneRef = ref<SceneHandle | null>(null);
 
+// Beacon mode — dims the whole scene to a dark wireframe and lets only
+// alarming cubes glow, so the operator's eye goes straight to what's
+// firing. Toggled from the toolbar; the scene reads it as a prop.
+const beaconMode = ref(false);
+// Auto-refresh: re-run the pipeline every REFRESH_MS. A self-rescheduling
+// timeout (not setInterval) so a manual refresh resets the countdown
+// cleanly instead of drifting against a fixed interval. `nextRefreshAt`
+// (epoch ms) feeds the timeline strip's live countdown. Cleared on unmount.
+const REFRESH_MS = 60_000;
+let refreshTimer: ReturnType<typeof setTimeout> | null = null;
+const nextRefreshAt = ref<number | null>(null);
+
 const planes = ref<PlanePlacement[]>([]);
 const zones = ref<ZonePlacement[]>([]);
 const nodesByLayer = ref<Record<string, SceneServiceNode[]>>({});
@@ -64,13 +85,38 @@ const visibleLayers = ref<Set<string>>(new Set());
  *  toward this each frame so the camera glides rather than teleports. */
 const focusTarget = ref<{ x: number; y: number; z: number } | null>(null);
 
+// Single-layer focus mode: `/3d/map?layer=<key>` (linked from a layer's
+// 2D Service Map) opens the existing 3D scene zoomed to ONE layer's
+// internal topology — only that layer's cubes + its tier plane render,
+// and the camera recenters on its zone. `null` ⇒ the full multi-tier map.
+const route = useRoute();
+const focusLayer = computed<string | null>(() => {
+  const q = route.query.layer;
+  return typeof q === 'string' && q.length > 0 ? q.toLowerCase() : null;
+});
+/** Plane to render solo in focus mode (the focused layer's tier). */
+const soloPlane = ref<string | null>(null);
+
 // Admin config gates the scene mount — the build-graph pass below is
 // config-aware (level resolver, plane order, per-layer color) and
 // running it before the config resolves would freeze the 3-plane
 // fallback into the rendered layout. `ready` flips once the BFF /
 // bundled defaults are in hand.
-const { config: infraConfig, levelsOrdered, ensureLoaded, levelForLayer } = useInfra3dConfig();
+const { config: infraConfig, levelsOrdered, groups: infraGroups, ensureLoaded, levelForLayer } = useInfra3dConfig();
 const ready = ref(false);
+
+// Per-layer topology-cluster rules (k8s/mesh namespace) from the live
+// layer menu — drives the 3D namespace clustering, matching the 2D
+// Service Map. Keyed upper-case to match the scene's lookup.
+const { layers: menuLayers, isLoading: menuLoading } = useLayers();
+const namingByLayer = computed<Record<string, ServiceNamingRule | null>>(() => {
+  const out: Record<string, ServiceNamingRule | null> = {};
+  for (const L of menuLayers.value) out[L.key.toUpperCase()] = L.naming ?? null;
+  return out;
+});
+// Scene builds placement once at setup from namingByLayer; if the menu
+// lands AFTER the mount gate, re-key so it rebuilds with the cluster rules.
+const namingReady = computed(() => menuLayers.value.length > 0);
 // Set when the config fetch rejects (OAP/BFF offline, or a role without
 // `infra-3d:read`). Without this the page sat on "Loading…" forever —
 // the operator couldn't tell a slow load from a hard failure.
@@ -131,7 +177,7 @@ const pipelineImpls: Record<PipelineStageId, StageImpl<PipelineCtx>> = {
       if (t && t.calls.length > 0) withTopology.push(L.key);
       else withoutTopology.push(L.key);
     }
-    rep.ok(`${withTopology.length} with topology`, {
+    rep.ok(`${withTopology.length} topology-bearing`, {
       kind: 'templates',
       layersWithTopology: withTopology,
       layersWithoutTopology: withoutTopology,
@@ -147,7 +193,7 @@ const pipelineImpls: Record<PipelineStageId, StageImpl<PipelineCtx>> = {
       nodeCount: t.nodes.length,
       edgeCount: t.calls.length,
     }));
-    rep.ok(`${probes.filter((p) => p.status === 'ok').length} topologies`, {
+    rep.ok(`${probes.filter((p) => p.status === 'ok').length} maps with edges`, {
       kind: 'topologies',
       probes,
     });
@@ -158,7 +204,9 @@ const pipelineImpls: Record<PipelineStageId, StageImpl<PipelineCtx>> = {
     // The scene rebuilds placement on its own; we just measure the cost.
     const topo = loadDemoTopology();
     const g = buildSceneGraph(topo, levelForLayer);
-    const p = computePlacement(g, planeOrder.value);
+    // Pass groups so the measured zone count matches the rendered scene
+    // (Infra3DScene collapses each logic group into one zone).
+    const p = computePlacement(g, planeOrder.value, infraGroups.value);
     const ms = Math.round(performance.now() - t0);
     rep.ok(`${p.zones.length} zones laid in ${ms} ms`, {
       kind: 'layout',
@@ -259,6 +307,10 @@ const pipelineImpls: Record<PipelineStageId, StageImpl<PipelineCtx>> = {
           services: chunk.units.map((u) => ({
             name: u.name, layer: u.layer, normal: u.normal, mqe: u.mqe,
           })),
+          // Last 2h at HOUR step — two buckets, the cheapest query that
+          // still smooths a single spiky minute. The BFF reduces the
+          // series to one scalar for the cube's traffic chip.
+          window: { startMs: Date.now() - 2 * 3600_000, endMs: Date.now(), step: 'HOUR' },
         });
         setMetricValues(r.values);
         if (r.errors && Object.keys(r.errors).length > 0) errCount += Object.keys(r.errors).length;
@@ -294,19 +346,73 @@ async function runPipeline(): Promise<void> {
   await runPipelineState(ctx, pipelineImpls);
 }
 
+/** Arm (or re-arm) the auto-refresh timer + countdown anchor. */
+function scheduleRefresh(): void {
+  if (refreshTimer !== null) clearTimeout(refreshTimer);
+  nextRefreshAt.value = Date.now() + REFRESH_MS;
+  refreshTimer = setTimeout(() => {
+    void runPipeline();
+    scheduleRefresh();
+  }, REFRESH_MS);
+}
+
+/** Manual refresh from the timeline strip — run now and reset the
+ *  countdown so the next auto-refresh is a full interval away. */
+function refreshNow(): void {
+  void runPipeline();
+  scheduleRefresh();
+}
+
 function onPlanes(p: PlanePlacement[]): void {
   planes.value = p;
 }
+/** Expand a zone to the layer key(s) the Scene gates visibility on. A
+ *  solo zone keys on its own layer; a GROUP zone (e.g. Self-Observability
+ *  clustering so11y_*) keys on its MEMBER layers. The Scene checks
+ *  `visibleNodes` / `visibleZones` against member layer keys, never the
+ *  group id — so visibility seeding, the tier toggle, and per-layer
+ *  counts must all use the members. Keying on the group id alone leaves
+ *  grouped cubes unrendered and undercounts the tier. */
+function zoneLayerKeys(z: ZonePlacement): string[] {
+  return z.group ? z.group.layerKeys : [z.layerKey];
+}
+/** Derive cube/plane visibility from the current zones + focus state.
+ *  Focus mode (`?layer=`) shows only the one layer's cubes on its plane
+ *  and recenters the camera on its zone — the existing scene scoped to a
+ *  single layer's internal topology. A solo layer matches its own zone;
+ *  a grouped layer matches the group zone that lists it. */
+function applyVisibility(z: ZonePlacement[]): void {
+  if (focusLayer.value) {
+    const fz = z.find(
+      (zz) =>
+        zz.layerKey.toLowerCase() === focusLayer.value ||
+        (zz.group?.layerKeys ?? []).some((k) => k.toLowerCase() === focusLayer.value),
+    );
+    if (fz) {
+      visibleLayers.value = new Set([focusLayer.value]);
+      soloPlane.value = fz.plane;
+      focusTarget.value = { x: fz.centerX, y: fz.y + 0.5, z: fz.centerZ };
+      return;
+    }
+    // Unknown layer in the URL → fall through to the full map.
+  }
+  // Default: every layer visible — group zones expand to their member
+  // keys so the Scene's member-key gate lets the grouped cubes render.
+  soloPlane.value = null;
+  visibleLayers.value = new Set(z.flatMap(zoneLayerKeys));
+}
 function onZones(z: ZonePlacement[]): void {
   zones.value = z;
-  // Default: every zone visible.
-  visibleLayers.value = new Set(z.map((zz) => zz.layerKey));
+  applyVisibility(z);
 }
+// Re-derive visibility when the focus changes after mount (e.g. the
+// "view full map" link flips `?layer` off) — no reload needed.
+watch(focusLayer, () => applyVisibility(zones.value));
 function onNodesByLayer(byLayer: Record<string, SceneServiceNode[]>): void {
   nodesByLayer.value = byLayer;
 }
 function togglePlane(planeId: string): void {
-  const inPlane = zones.value.filter((z) => z.plane === planeId).map((z) => z.layerKey);
+  const inPlane = zones.value.filter((z) => z.plane === planeId).flatMap(zoneLayerKeys);
   const allOn = inPlane.every((k) => visibleLayers.value.has(k));
   const next = new Set(visibleLayers.value);
   if (allOn) inPlane.forEach((k) => next.delete(k));
@@ -334,44 +440,29 @@ function onSelect(node: SceneServiceNode | null): void {
     selectedNodeId.value = node.nodeId;
   }
 }
-/** Tier-level focus: re-centre the camera on the geometric centroid
- *  of every visible zone within this tier. Falls back to the tier's
- *  baseline Y when the tier has no zones rendered (defensive). */
-function onPanelTierFocus(planeId: string, _event: MouseEvent): void {
-  const inTier = zones.value.filter((z) => z.plane === planeId);
-  if (inTier.length === 0) return;
-  let sx = 0;
-  let sz = 0;
-  for (const z of inTier) { sx += z.centerX; sz += z.centerZ; }
-  focusTarget.value = {
-    x: sx / inTier.length,
-    y: inTier[0]!.y + 0.5,
-    z: sz / inTier.length,
-  };
-}
-
 /** "all" → every layer in this tier is on; "none" → every layer is
  *  off; "some" → mixed. Drives the eye-toggle icon state and the
  *  hidden-row class on the tier row. */
 function tierVisibility(tierZones: ZonePlacement[]): 'all' | 'some' | 'none' {
-  if (tierZones.length === 0) return 'none';
+  const keys = tierZones.flatMap(zoneLayerKeys);
+  if (keys.length === 0) return 'none';
   let on = 0;
-  for (const z of tierZones) if (visibleLayers.value.has(z.layerKey)) on++;
+  for (const k of keys) if (visibleLayers.value.has(k)) on++;
   if (on === 0) return 'none';
-  if (on === tierZones.length) return 'all';
+  if (on === keys.length) return 'all';
   return 'some';
 }
 function visibleServicesInTier(tierZones: ZonePlacement[]): number {
   let n = 0;
-  for (const z of tierZones) {
-    if (!visibleLayers.value.has(z.layerKey)) continue;
-    n += (nodesByLayer.value[z.layerKey] || []).length;
+  for (const k of tierZones.flatMap(zoneLayerKeys)) {
+    if (!visibleLayers.value.has(k)) continue;
+    n += (nodesByLayer.value[k] || []).length;
   }
   return n;
 }
 function totalServicesInTier(tierZones: ZonePlacement[]): number {
   let n = 0;
-  for (const z of tierZones) n += (nodesByLayer.value[z.layerKey] || []).length;
+  for (const k of tierZones.flatMap(zoneLayerKeys)) n += (nodesByLayer.value[k] || []).length;
   return n;
 }
 
@@ -473,24 +564,85 @@ onMounted(async () => {
     configError.value = err instanceof Error ? err.message : String(err);
     return;
   }
+  // Wait for the layer menu too — it carries the per-layer naming rules
+  // that drive namespace clustering, and the scene reads them ONCE when
+  // it builds placement. Bounded so a slow/unreachable menu just renders
+  // the map unclustered rather than hanging on the loading state.
+  await new Promise<void>((resolve) => {
+    if (!menuLoading.value) return resolve();
+    const stop = watch(menuLoading, (loading) => {
+      if (!loading) { stop(); resolve(); }
+    });
+    setTimeout(() => { stop(); resolve(); }, 4000);
+  });
   ready.value = true;
-  // Kick the loading pipeline once. Subsequent runs are operator-
-  // initiated (timeline strip's refresh button).
+  // Kick the loading pipeline once, then refresh every minute. Alarms
+  // poll on their own 1-min timer (20m window); metrics ride the
+  // pipeline (2h @ HOUR). The timeline strip's refresh button still
+  // forces an immediate run.
   void runPipeline();
+  scheduleRefresh();
 });
 onUnmounted(() => {
   window.removeEventListener('keydown', onKeyDown);
+  if (refreshTimer !== null) clearTimeout(refreshTimer);
 });
 
 // Group zones by plane for the side panel — order matches `levels`
 // from the admin config (apps on top, infra at the bottom by default).
-const groupedZones = computed(() => {
-  return (levelsOrdered.value ?? []).map((lvl) => ({
-    id: lvl.id,
-    name: lvl.label,
-    zones: zones.value.filter((z) => z.plane === lvl.id),
-  }));
-});
+function servicesIn(key: string): number {
+  return nodesByLayer.value[key]?.length ?? 0;
+}
+
+interface PanelEntry {
+  kind: 'layer' | 'group';
+  key: string;
+  name: string;
+  color?: string;
+  services: number;
+}
+/** Side-panel hierarchy: tier → layer | logic-group (2 levels). */
+const panelTree = computed(() =>
+  (levelsOrdered.value ?? []).map((lvl) => {
+    const tierZones = zones.value.filter((z) => z.plane === lvl.id);
+    const entries: PanelEntry[] = tierZones.map((z) =>
+      z.group
+        ? {
+            kind: 'group',
+            key: z.layerKey,
+            name: z.layerName,
+            color: z.group.color,
+            services: z.group.layerKeys.reduce((a, k) => a + servicesIn(k), 0),
+          }
+        : { kind: 'layer', key: z.layerKey, name: z.layerName, services: servicesIn(z.layerKey) },
+    );
+    return { id: lvl.id, name: lvl.label, zones: tierZones, entries };
+  }),
+);
+
+/** Focus a tier (all its zones) — face + zoom the scene there and flash
+ *  the region. Called from the side-panel tier row. */
+function onPanelTierFocus(planeId: string): void {
+  const inTier = zones.value.filter((z) => z.plane === planeId);
+  if (inTier.length === 0) return;
+  let sx = 0, sz = 0, halfW = 0, halfD = 0;
+  for (const z of inTier) {
+    sx += z.centerX;
+    sz += z.centerZ;
+    halfW = Math.max(halfW, Math.abs(z.centerX) + z.width / 2);
+    halfD = Math.max(halfD, Math.abs(z.centerZ) + z.depth / 2);
+  }
+  const center = { x: sx / inTier.length, y: inTier[0]!.y + 0.5, z: sz / inTier.length };
+  sceneRef.value?.focusOn(center, Math.max(halfW, halfD));
+  sceneRef.value?.flashZones(inTier.flatMap(zoneLayerKeys));
+}
+/** Focus a single zone (layer / logic group) — face + zoom + flash. */
+function onPanelZoneFocus(zoneKey: string): void {
+  const z = zones.value.find((zz) => zz.layerKey === zoneKey);
+  if (!z) return;
+  sceneRef.value?.focusOn({ x: z.centerX, y: z.y + 0.5, z: z.centerZ }, Math.max(z.width, z.depth) / 2);
+  sceneRef.value?.flashZones(zoneLayerKeys(z));
+}
 
 const totalServices = computed(() =>
   Object.values(nodesByLayer.value).reduce((acc, arr) => acc + arr.length, 0),
@@ -513,8 +665,18 @@ const visibleServices = computed(() => {
          scene first, the chrome is glanceable when they need stats. -->
     <header class="bar floating">
       <div class="title">
-        <span class="kicker">3D Infrastructure Map</span>
-        <span class="hint">apps · service mesh · middleware · infra · drag to rotate · scroll to zoom · arrow keys / WASD to pan</span>
+        <template v-if="focusLayer">
+          <span class="kicker">3D Layer Topology</span>
+          <span class="hint">
+            focused on <strong>{{ focusLayer.toUpperCase() }}</strong> ·
+            <router-link class="title-link" :to="{ path: '/3d/map' }">view full map</router-link>
+            · drag to rotate · scroll to zoom
+          </span>
+        </template>
+        <template v-else>
+          <span class="kicker">3D Infrastructure Map</span>
+          <span class="hint">apps · service mesh · middleware · infra · drag to rotate · scroll to zoom · arrow keys / WASD to pan</span>
+        </template>
       </div>
       <div class="stats">
         <span class="stat">
@@ -523,6 +685,12 @@ const visibleServices = computed(() => {
         <span class="stat">
           <strong>{{ zones.length }}</strong> layers · <strong>{{ planes.length }}</strong> levels
         </span>
+        <!-- Query scopes so the operator knows what window each signal
+             reflects: topology/metrics roll up the last 2h (HOUR step),
+             alarms the last 20m; everything refreshes each minute. -->
+        <span class="stat scope" title="Topology &amp; metrics: last 2h (HOUR step) · Alarms: last 20m · auto-refresh every 1 min">
+          metrics <strong>2h</strong> · alarms <strong>20m</strong> · ↻ <strong>1m</strong>
+        </span>
         <router-link class="back" to="/" title="Exit 3D map">×</router-link>
       </div>
     </header>
@@ -537,12 +705,17 @@ const visibleServices = computed(() => {
       <div v-else-if="!ready" class="cfg-loading">Loading 3D map configuration…</div>
       <Infra3DScene
         v-else
+        :key="namingReady ? 'with-naming' : 'no-naming'"
         ref="sceneRef"
         :plane-order="planeOrder"
         :visible-layers="visibleLayers"
         :hovered-node-id="hoveredNodeId"
         :selected-node-id="selectedNodeId"
         :focus-target="focusTarget"
+        :beacon-mode="beaconMode"
+        :groups="infraGroups"
+        :solo-plane="soloPlane"
+        :naming-by-layer="namingByLayer"
         @hover="onHover"
         @select="onSelect"
         @planes="onPlanes"
@@ -571,77 +744,77 @@ const visibleServices = computed(() => {
         </div>
       </aside>
 
-      <!-- Side panel — TIERS ONLY. Per-layer rows were removed once
-           the 3D scene grew its own brand-stamps on each zone (the
-           layer becomes identifiable visually, on the map, not in
-           chrome). The remaining controls are tier-level toggles:
-           click a row to focus the camera on the tier; click the eye
-           to show/hide every layer in that tier at once. -->
+      <!-- Beacon mode toggle — dims the scene to wireframe so only
+           alarming cubes stand out. Sits under the camera toolbar. -->
+      <button
+        class="beacon-toggle"
+        :class="{ 'is-on': beaconMode }"
+        :title="beaconMode ? 'Beacon mode on — click to show all' : 'Beacon mode — focus on alarms'"
+        @click="beaconMode = !beaconMode"
+      >
+        <span class="beacon-dot" />
+        <span class="beacon-label">Beacon</span>
+      </button>
+
+      <!-- Side panel: tier → layer/logic-group. Click focuses + flashes
+           that region; the eye toggles the tier. -->
       <aside class="layer-panel">
         <div class="panel-head">
           <span>Tiers</span>
+          <button type="button" class="panel-reset" title="Reset the view to the default framing" @click="btnReset">⌂ Reset</button>
         </div>
         <div class="panel-body">
           <ul class="tier-list">
             <li
-              v-for="g in groupedZones"
+              v-for="g in panelTree"
               :key="g.id"
-              class="tier-item"
+              class="tier-block"
               :class="{ hidden: tierVisibility(g.zones) === 'none' }"
-              @click="(e) => onPanelTierFocus(g.id, e)"
             >
-              <span class="grp-dot" :data-plane="g.id" />
-              <span class="tier-name">{{ g.name }}</span>
-              <span class="tier-stat">
-                {{ visibleServicesInTier(g.zones) }} / {{ totalServicesInTier(g.zones) }}
-              </span>
-              <button
-                type="button"
-                class="eye-btn"
-                :title="tierVisibility(g.zones) === 'all' ? 'hide this tier' : 'show this tier'"
-                :aria-pressed="tierVisibility(g.zones) !== 'none'"
-                @click.stop="togglePlane(g.id)"
-              >
-                <svg
-                  v-if="tierVisibility(g.zones) !== 'none'"
-                  viewBox="0 0 16 16"
-                  width="14"
-                  height="14"
-                  aria-hidden="true"
+              <!-- Tier row — click to focus the tier, eye to toggle it. -->
+              <div class="tier-item" @click="onPanelTierFocus(g.id)">
+                <span class="grp-dot" :data-plane="g.id" />
+                <span class="tier-name">{{ g.name }}</span>
+                <span class="tier-stat">
+                  {{ visibleServicesInTier(g.zones) }} / {{ totalServicesInTier(g.zones) }}
+                </span>
+                <button
+                  type="button"
+                  class="eye-btn"
+                  :title="tierVisibility(g.zones) === 'all' ? 'hide this tier' : 'show this tier'"
+                  :aria-pressed="tierVisibility(g.zones) !== 'none'"
+                  @click.stop="togglePlane(g.id)"
                 >
-                  <path
-                    d="M8 4c-3.5 0-6 4-6 4s2.5 4 6 4 6-4 6-4-2.5-4-6-4z"
-                    fill="none"
-                    stroke="currentColor"
-                    stroke-width="1.4"
-                  />
-                  <circle cx="8" cy="8" r="2" fill="currentColor" />
-                </svg>
-                <svg v-else viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
-                  <path
-                    d="M8 4c-3.5 0-6 4-6 4s2.5 4 6 4 6-4 6-4-2.5-4-6-4z"
-                    fill="none"
-                    stroke="currentColor"
-                    stroke-width="1.4"
-                    opacity="0.6"
-                  />
-                  <line
-                    x1="2.5"
-                    y1="2.5"
-                    x2="13.5"
-                    y2="13.5"
-                    stroke="currentColor"
-                    stroke-width="1.4"
-                    stroke-linecap="round"
-                  />
-                </svg>
-              </button>
+                  <svg v-if="tierVisibility(g.zones) !== 'none'" viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
+                    <path d="M8 4c-3.5 0-6 4-6 4s2.5 4 6 4 6-4 6-4-2.5-4-6-4z" fill="none" stroke="currentColor" stroke-width="1.4" />
+                    <circle cx="8" cy="8" r="2" fill="currentColor" />
+                  </svg>
+                  <svg v-else viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
+                    <path d="M8 4c-3.5 0-6 4-6 4s2.5 4 6 4 6-4 6-4-2.5-4-6-4z" fill="none" stroke="currentColor" stroke-width="1.4" opacity="0.6" />
+                    <line x1="2.5" y1="2.5" x2="13.5" y2="13.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" />
+                  </svg>
+                </button>
+              </div>
+
+              <!-- Layers + logic groups on this tier (level 2). -->
+              <ul v-if="g.entries.length" class="layer-sublist">
+                <li
+                  v-for="e in g.entries"
+                  :key="e.key"
+                  class="layer-row"
+                  :class="{ 'is-group': e.kind === 'group' }"
+                  :title="`Focus ${e.name}`"
+                  @click="onPanelZoneFocus(e.key)"
+                >
+                  <span class="lr-dot" :style="e.color ? { background: e.color } : undefined" />
+                  <span class="lr-name">{{ e.name }}</span>
+                  <span v-if="e.kind === 'group'" class="lr-tag">group</span>
+                  <span class="lr-stat">{{ e.services }}</span>
+                </li>
+              </ul>
             </li>
           </ul>
         </div>
-        <div class="panel-foot">
-          source: apache skywalking-showcase
-        </div>
       </aside>
 
       <PipelineTimeline
@@ -650,20 +823,25 @@ const visibleServices = computed(() => {
         :stages="stages"
         :stage-order="stageOrder"
         :running="pipelineRunning"
-        @refresh="runPipeline"
+        :next-refresh-at="nextRefreshAt"
+        @refresh="refreshNow"
       />
 
       <!-- Bottom-left brand mark — anchors the standalone view to the
            SkyWalking product identity. No link / no chrome; pure
            identification so an operator opening the page mid-incident
            still knows where they are. -->
-      <a class="sw-brand" href="/" title="Back to Horizon">
+      <!-- router-link, not a bare <a href="/">: a hardcoded "/" ignores
+           the router base (import.meta.env.BASE_URL) and full-reloads to
+           the server root, which lands on an empty page under a gateway
+           sub-path. router-link prepends the base and stays in-SPA. -->
+      <router-link class="sw-brand" to="/" title="Back to Horizon">
         <span class="sw-brand-logo" v-html="logoSw" />
         <span class="sw-brand-text">
           <span class="sw-brand-line1">Apache SkyWalking</span>
           <span class="sw-brand-line2">Horizon · 3D Infra Map</span>
         </span>
-      </a>
+      </router-link>
 
     </div>
   </div>
@@ -699,6 +877,8 @@ const visibleServices = computed(() => {
 .title { display: flex; align-items: baseline; gap: 10px; min-width: 0; }
 .kicker { font-weight: 700; font-size: 12.5px; letter-spacing: 0.03em; color: var(--sw-fg-0); }
 .hint   { font-size: 10.5px; color: var(--sw-fg-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.title-link { color: var(--sw-accent); text-decoration: none; }
+.title-link:hover { text-decoration: underline; }
 .stats  { display: flex; align-items: center; gap: 12px; flex: 0 0 auto; }
 .stat   { font-size: 11px; color: var(--sw-fg-2); }
 .stat strong { color: var(--sw-fg-0); font-weight: 700; margin-right: 4px; }
@@ -815,6 +995,9 @@ const visibleServices = computed(() => {
   z-index: 50;
 }
 .panel-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
   padding: 8px 10px;
   border-bottom: 1px solid var(--sw-line);
   font-size: 10.5px;
@@ -823,6 +1006,21 @@ const visibleServices = computed(() => {
   letter-spacing: 0.1em;
   color: var(--sw-fg-2);
 }
+.panel-reset {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  padding: 2px 7px;
+  background: var(--sw-bg-2);
+  border: 1px solid var(--sw-line-2);
+  border-radius: 4px;
+  color: var(--sw-fg-2);
+  font-size: 9.5px;
+  font-weight: 600;
+  letter-spacing: 0.04em;
+  cursor: pointer;
+}
+.panel-reset:hover { background: var(--sw-bg-3); color: var(--sw-fg-0); border-color: var(--sw-line-3); }
 .panel-body {
   overflow-y: auto;
   flex: 1;
@@ -888,14 +1086,36 @@ const visibleServices = computed(() => {
   cursor: pointer;
   font-size: 11.5px;
   color: var(--sw-fg-1);
-  border-bottom: 1px solid var(--sw-line);
 }
-.tier-item:last-child { border-bottom: none; }
+.tier-block { border-bottom: 1px solid var(--sw-line); }
+.tier-block:last-child { border-bottom: none; }
+.tier-block.hidden { opacity: 0.5; }
 .tier-item:hover {
   background: rgba(255, 255, 255, 0.04);
   color: var(--sw-fg-0);
 }
-.tier-item.hidden { opacity: 0.5; }
+/* ── Nested hierarchy: layers / logic groups under a tier, members /
+      clusters under those. Indented, lighter than the tier row. ── */
+.layer-sublist { list-style: none; margin: 0; padding: 2px 0 6px; }
+.layer-row {
+  display: flex;
+  align-items: center;
+  gap: 7px;
+  padding: 4px 12px 4px 22px;
+  cursor: pointer;
+  font-size: 11px;
+  color: var(--sw-fg-2);
+}
+.layer-row:hover { background: rgba(255, 255, 255, 0.04); color: var(--sw-fg-0); }
+.layer-row.is-group .lr-name { font-weight: 700; }
+.lr-dot { width: 7px; height: 7px; border-radius: 2px; flex: 0 0 7px; background: var(--sw-fg-3); }
+.lr-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.lr-tag {
+  font-size: 8px; text-transform: uppercase; letter-spacing: 0.06em;
+  color: var(--sw-accent-2); background: var(--sw-accent-soft);
+  border-radius: 3px; padding: 0 4px;
+}
+.lr-stat { font-size: 9.5px; color: var(--sw-fg-3); font-variant-numeric: tabular-nums; }
 .tier-name {
   flex: 1;
   min-width: 0;
@@ -977,14 +1197,6 @@ const visibleServices = computed(() => {
   color: var(--sw-fg-0);
   font-weight: 600;
 }
-.panel-foot {
-  padding: 7px 10px;
-  border-top: 1px solid var(--sw-line);
-  font-size: 9.5px;
-  color: var(--sw-fg-3);
-  text-align: center;
-  letter-spacing: 0.02em;
-}
 
 /* Detail card moved INTO the Scene component as a cientos <Html>
    anchored at the selected cube — its styles now live alongside the
@@ -1011,6 +1223,34 @@ const visibleServices = computed(() => {
   display: flex;
   gap: 4px;
 }
+.beacon-toggle {
+  position: absolute;
+  top: 256px;
+  left: 12px;
+  display: inline-flex;
+  align-items: center;
+  gap: 7px;
+  padding: 7px 11px;
+  background: rgba(15, 19, 26, 0.88);
+  border: 1px solid var(--sw-line-2);
+  border-radius: 8px;
+  backdrop-filter: blur(6px);
+  color: var(--sw-fg-2);
+  font-size: 11px;
+  letter-spacing: 0.04em;
+  cursor: pointer;
+  z-index: 50;
+  transition: border-color 0.15s, color 0.15s;
+}
+.beacon-toggle:hover { color: var(--sw-fg-0); border-color: var(--sw-line); }
+.beacon-toggle.is-on { border-color: #ef4444; color: #fca5a5; }
+.beacon-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--sw-fg-3); }
+.beacon-toggle.is-on .beacon-dot {
+  background: #ef4444;
+  box-shadow: 0 0 8px 1px rgba(239, 68, 68, 0.8);
+  animation: beacon-pulse 1.4s infinite ease-in-out;
+}
+@keyframes beacon-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
 .cam-pad {
   display: grid;
   grid-template-columns: repeat(3, 26px);
diff --git a/apps/ui/src/features/infra-3d/PipelineTimeline.vue b/apps/ui/src/features/infra-3d/PipelineTimeline.vue
index fc63259..56e6c99 100644
--- a/apps/ui/src/features/infra-3d/PipelineTimeline.vue
+++ b/apps/ui/src/features/infra-3d/PipelineTimeline.vue
@@ -30,7 +30,7 @@
   want to render a half-state timeline before the config is loaded.
 -->
 <script setup lang="ts">
-import { computed, ref } from 'vue';
+import { computed, onMounted, onUnmounted, ref } from 'vue';
 import type { DeepReadonly } from 'vue';
 import type { PipelineStageId, StageState } from './composables/useInfra3dPipeline';
 
@@ -40,8 +40,29 @@ const props = defineProps<{
   stages: DeepReadonly<Record<PipelineStageId, StageState>>;
   stageOrder: readonly PipelineStageId[];
   running: boolean;
+  /** Epoch ms of the next scheduled auto-refresh (parent-owned). Drives
+   *  the live countdown beside the refresh button. Null = no countdown. */
+  nextRefreshAt?: number | null;
 }>();
 
+// Live 1 Hz clock so the countdown ticks down on its own — the parent
+// only updates `nextRefreshAt` once per (re)schedule, not every second.
+const now = ref(Date.now());
+let clockTimer: ReturnType<typeof setInterval> | null = null;
+onMounted(() => {
+  clockTimer = setInterval(() => { now.value = Date.now(); }, 1000);
+});
+onUnmounted(() => {
+  if (clockTimer !== null) clearInterval(clockTimer);
+});
+/** `m:ss` until the next auto-refresh; null while a run is in flight
+ *  (the strip shows "refreshing…" then) or when no refresh is armed. */
+const countdown = computed<string | null>(() => {
+  if (props.running || props.nextRefreshAt == null) return null;
+  const secs = Math.max(0, Math.ceil((props.nextRefreshAt - now.value) / 1000));
+  return `${Math.floor(secs / 60)}:${String(secs % 60).padStart(2, '0')}`;
+});
+
 const emit = defineEmits<{
   /** Operator clicked the refresh button — re-run the whole pipeline. */
   (e: 'refresh'): void;
@@ -195,12 +216,26 @@ const openStageState = computed<ROStageState | null>(() => {
         <span class="step-label">{{ stageLabels[id] }}</span>
         <span class="step-sum" :title="props.stages[id].summary">{{ props.stages[id].summary }}</span>
       </button>
+      <!-- Countdown sits right after the stages — next to the data that
+           re-runs on each refresh (Metrics is the live-window stage) —
+           rather than by the button, so it reads as "this data refreshes
+           in m:ss". -->
+      <span
+        v-if="props.running"
+        class="next-refresh running"
+        title="pipeline is running…"
+      >refreshing…</span>
+      <span
+        v-else-if="countdown"
+        class="next-refresh"
+        title="time until the next auto-refresh"
+      >next ↻ {{ countdown }}</span>
       <span class="spacer" />
       <button
         type="button"
         class="refresh"
         :disabled="props.running"
-        :title="props.running ? 'pipeline is running…' : 're-run pipeline'"
+        :title="props.running ? 'pipeline is running…' : 're-run pipeline now'"
         @click="emit('refresh')"
       >↻</button>
     </div>
@@ -263,6 +298,18 @@ const openStageState = computed<ROStageState | null>(() => {
 @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.45; } }
 
 .spacer { flex: 1; }
+.next-refresh {
+  display: inline-flex;
+  align-items: center;
+  align-self: center;
+  font-size: 10.5px;
+  color: var(--sw-fg-3);
+  margin-right: 8px;
+  font-variant-numeric: tabular-nums;
+  letter-spacing: 0.02em;
+  white-space: nowrap;
+}
+.next-refresh.running { color: #fcc419; }
 .refresh {
   width: 28px;
   border: 1px solid var(--sw-line-2);
diff --git a/apps/ui/src/features/infra-3d/composables/useInfra3dAlarms.ts b/apps/ui/src/features/infra-3d/composables/useInfra3dAlarms.ts
index c6e47e1..d0dffd2 100644
--- a/apps/ui/src/features/infra-3d/composables/useInfra3dAlarms.ts
+++ b/apps/ui/src/features/infra-3d/composables/useInfra3dAlarms.ts
@@ -24,29 +24,50 @@
  *   - 20m rolling window matches the alarm-page topbar default; the
  *     operator's mental model is "anything fresh enough to still be a
  *     concern."
- *   - We key on `service.name`. OAP returns `alarm.name` equal to the
- *     entity name when `scope === 'Service'`, which matches the
- *     `SceneServiceNode.name` field rendered on the cube. ServiceRelation
- *     / Endpoint / Process scopes don't map to a single cube and are
- *     ignored — they'd flag the wrong service.
- *   - We include both firing alarms (recoveryTime === null) and
- *     recently-recovered alarms (any alarm whose event lands in the
- *     window). Operators want to see "this service had trouble in the
- *     last 20m" even if it's currently fine — the red is a recency
- *     signal, not a live state.
- *   - Polled at 30s. Fast enough that a new firing alarm shows up
- *     within a refresh; slow enough that the cost is negligible.
+ *   - Only `scope === 'Service'` alarms count: OAP returns `alarm.name`
+ *     equal to the service entity name there, matching the cube's
+ *     `SceneServiceNode.name`. ServiceRelation / Endpoint / Process
+ *     scopes don't map to a single cube and are ignored — they'd flag
+ *     the wrong service.
+ *   - Matched on a composite `(layer, name)` key (see `alarmKey`), so a
+ *     same-named service is reddened only in its own tier; alarms with
+ *     no resolved layer fall back to a name-only set.
+ *   - Only currently-FIRING alarms (recoveryTime === null) redden a
+ *     cube — red means "alarming now", which matches the alarms page's
+ *     ACTIVE incident count. A recovered-within-window alarm is no
+ *     longer a live problem, so it is NOT reddened (including it made
+ *     the map show more red cubes than the page's active count).
+ *   - Polled every 1 min (POLL_INTERVAL_MS). Fast enough that a new
+ *     firing alarm shows up within a refresh; slow enough that the cost
+ *     is negligible.
  */
 
 import { onMounted, onUnmounted, readonly, ref, shallowRef } from 'vue';
 import { bff } from '../../../api/client';
 
 const TWENTY_MIN_MS = 20 * 60_000;
-const POLL_INTERVAL_MS = 30_000;
+const POLL_INTERVAL_MS = 60_000;
 const FETCH_PAGE_SIZE = 500;
 
-const alarmedServiceNames = shallowRef<Set<string>>(new Set());
+/** Composite `LAYER\u0000serviceName` keys for alarms that carry a resolved
+ *  layer — the precise match (the same service name can exist in several
+ *  layers; keying on name alone would redden all of them). */
+const alarmedKeys = shallowRef<Set<string>>(new Set());
+/** Service names from alarms with NO resolved layer — a name-only
+ *  fallback so those alarms still light their cube(s). */
+const alarmedNamesNoLayer = shallowRef<Set<string>>(new Set());
 const lastUpdatedAt = ref<number | null>(null);
+
+/** Build the composite key the scene matches a cube against. The layer
+ *  and name are joined by U+0000, written as a `\u0000` ESCAPE in the
+ *  template below — never a raw NUL byte, which would make this a binary
+ *  file (unreviewable git diffs, tooling/editor mangling). NUL can appear
+ *  in neither an OAP layer key nor a service name, so the join is
+ *  collision-proof; both producer and the lone consumer call this, so the
+ *  key stays in-memory and is never serialized. */
+export function alarmKey(layerKey: string, serviceName: string): string {
+  return `${layerKey.toUpperCase()}\u0000${serviceName}`;
+}
 const error = ref<string | null>(null);
 let timer: ReturnType<typeof setInterval> | null = null;
 let inflight: Promise<void> | null = null;
@@ -63,7 +84,8 @@ async function refresh(): Promise<void> {
         pageSize: FETCH_PAGE_SIZE,
         pageNum: 1,
       });
-      const next = new Set<string>();
+      const keys = new Set<string>();
+      const namesNoLayer = new Set<string>();
       for (const m of r.msgs) {
         // Only single-service alarms — relation/instance/endpoint
         // alarms would otherwise spuriously redden every cube that
@@ -71,9 +93,20 @@ async function refresh(): Promise<void> {
         // intentionally skipped: their entity name carries an instance
         // suffix that doesn't match `service.name` on the cube.
         if (m.scope !== 'Service') continue;
-        if (typeof m.name === 'string' && m.name.length > 0) next.add(m.name);
+        if (typeof m.name !== 'string' || m.name.length === 0) continue;
+        // Firing only — a recovered alarm (recoveryTime set) is no longer
+        // an active problem. Reddening recovered-within-window alarms made
+        // the map show more red cubes than the alarms page's ACTIVE count.
+        if (m.recoveryTime !== null) continue;
+        // Precise: key on (layer, name) so an alarm reddens only the
+        // matching service in the matching tier, not every same-named
+        // cube across layers. Alarms with no resolved layer fall back
+        // to name-only matching.
+        if (m.layerKey) keys.add(alarmKey(m.layerKey, m.name));
+        else namesNoLayer.add(m.name);
       }
-      alarmedServiceNames.value = next;
+      alarmedKeys.value = keys;
+      alarmedNamesNoLayer.value = namesNoLayer;
       lastUpdatedAt.value = Date.now();
       error.value = null;
     } catch (err) {
@@ -102,7 +135,8 @@ export function useInfra3dAlarms() {
     }
   });
   return {
-    alarmedServiceNames: readonly(alarmedServiceNames),
+    alarmedKeys: readonly(alarmedKeys),
+    alarmedNamesNoLayer: readonly(alarmedNamesNoLayer),
     lastUpdatedAt: readonly(lastUpdatedAt),
     error: readonly(error),
     /** Force-refresh outside the timer (e.g. on visibility change). */
diff --git a/apps/ui/src/features/infra-3d/composables/useInfra3dConfig.ts b/apps/ui/src/features/infra-3d/composables/useInfra3dConfig.ts
index 17d94af..b8de8f2 100644
--- a/apps/ui/src/features/infra-3d/composables/useInfra3dConfig.ts
+++ b/apps/ui/src/features/infra-3d/composables/useInfra3dConfig.ts
@@ -37,6 +37,7 @@ import { bff } from '../../../api/client';
 import type {
   Infra3dConfig,
   InfraLayerSpec,
+  InfraGroupSpec,
   InfraLevelSpec,
   InfraMqe,
 } from '../../../api/client';
@@ -55,6 +56,16 @@ const layerToLevelId = computed<Record<string, string>>(() => {
   const filter = safeRegex(c.filter.layer);
   const out: Record<string, string> = {};
 
+  // Logic-group membership wins outright: a grouped layer is placed on
+  // the group's level, overriding the layer's own level resolution.
+  for (const g of c.groups ?? []) {
+    for (const k of g.layers) {
+      const u = k.toUpperCase();
+      if (filter && !filter.test(u)) continue;
+      out[u] = g.level;
+    }
+  }
+
   // Explicit `layers` lists are the authoritative source — they always
   // win over regex matches, so an admin pinning `K8S_SERVICE` to a
   // specific level is never overridden by a permissive regex elsewhere.
@@ -88,11 +99,28 @@ const levelsOrdered = computed<InfraLevelSpec[]>(() => {
   return [...cfg.value.levels].sort((a, b) => a.order - b.order);
 });
 
+const groups = computed<InfraGroupSpec[]>(() => cfg.value?.groups ?? []);
+
+/** layer key (upper) → group id, for the layers that belong to a group. */
+const layerToGroupId = computed<Record<string, string>>(() => {
+  const c = cfg.value;
+  if (!c) return {};
+  const out: Record<string, string> = {};
+  for (const g of c.groups ?? []) {
+    for (const k of g.layers) out[k.toUpperCase()] = g.id;
+  }
+  return out;
+});
+
 /** Public reader hook. Multiple callers share the same fetch + snapshot. */
 export function useInfra3dConfig() {
   return {
     config: readonly(cfg),
     levelsOrdered: readonly(levelsOrdered),
+    /** Logic groups from the config (e.g. Self-Observability). A computed,
+     *  so already read-only; not wrapped in readonly() so it stays
+     *  structurally assignable to the Scene's groups prop. */
+    groups,
     loading: readonly(loading),
     error: readonly(error),
     /** Trigger a fetch if not already loaded. Resolves with the live
@@ -107,6 +135,8 @@ export function useInfra3dConfig() {
     /** Render color for a layer. Falls back to the configured unknown
      *  badge color if the layer is missing from the config. */
     colorForLayer,
+    /** The logic group a layer belongs to, or null if ungrouped. */
+    groupForLayer,
     /** Resolved traffic MQE for a layer — `topology.server` on
      *  topology layers (preferred), `topology.client` as fallback, then
      *  `load` for non-topology layers. Returns null when no MQE is
@@ -157,6 +187,13 @@ export function colorForLayer(layerKey: string): string {
   return spec?.color ?? '#8a8a8a';
 }
 
+/** The logic group a layer belongs to, or null if ungrouped. */
+export function groupForLayer(layerKey: string): InfraGroupSpec | null {
+  const id = layerToGroupId.value[layerKey.toUpperCase()];
+  if (!id) return null;
+  return cfg.value?.groups?.find((g) => g.id === id) ?? null;
+}
+
 export function layerSpec(layerKey: string): InfraLayerSpec | null {
   const u = layerKey.toUpperCase();
   return cfg.value?.layers[u] ?? null;
diff --git a/apps/ui/src/features/infra-3d/composables/useLayerIconTexture.ts b/apps/ui/src/features/infra-3d/composables/useLayerIconTexture.ts
index b0b57dc..592d394 100644
--- a/apps/ui/src/features/infra-3d/composables/useLayerIconTexture.ts
+++ b/apps/ui/src/features/infra-3d/composables/useLayerIconTexture.ts
@@ -42,6 +42,7 @@ export type LayerIconName =
   | 'mesh'
   | 'cluster'
   | 'sky'
+  | 'skywalking'
   | 'web'
   | 'fn'
   | 'db'
@@ -67,10 +68,28 @@ const PATHS: Record<LayerIconName, string> = {
     '<path d="M12 3 L19.6 7 L20.7 15.6 L15 21 L9 21 L3.3 15.6 L4.4 7 Z" />' +
     '<path d="M12 3 L12 9 M19.6 7 L14.5 10 M20.7 15.6 L14.5 13.5 M15 21 L13 13.5 M9 21 L11 13.5 M3.3 15.6 L9.5 13.5 M4.4 7 L9.5 10" />' +
     '<circle cx="12" cy="12" r="2.4" />',
-  // SkyWalking-flavoured "sky" mark — a stylised "S"-curve arrow.
+  // GENERAL application-services layer mark — the filled "sky" swoosh.
+  // Mirrors Icon.vue's `sky` EXACTLY (currentColor body at .95 + a faint
+  // white highlight at .22) so the layer's 3D map stamp matches the
+  // sidebar-menu icon for the same layer.
   sky:
-    '<path d="M5 8 C 8 6, 11 6, 14 9 S 19 14, 21 12" />' +
-    '<path d="M3 14 C 6 16, 9 16, 12 13 S 17 8, 19 10" />',
+    '<path fill="currentColor" stroke="none" opacity="0.95" d="M3 14c4-3 8-3 12-1 3 1.4 5 .5 6-1-1 5-4 8-9 8-4 0-7-2-9-6z" />' +
+    '<path fill="#fff" stroke="none" opacity="0.22" d="M5 10c3-2 7-2 11 0 3 1.3 5 .6 6-1-1 3.6-4 6-8 6-4 0-7-1.6-9-5z" />',
+  // SkyWalking brand mark — the "Sw" lettermark + crescent moon from the
+  // project icon (logo.svg). Both paths FILLED in the layer/group colour
+  // via `fill="currentColor"` (resolved by the `color` attr buildSvg sets
+  // on the root <svg>). The logo's viewBox is 3450×1823, so the wrapping
+  // transform scales it onto the 24-wide stamp and centres the (wide)
+  // mark vertically; the inner transforms are the logo's own (the group
+  // y-shift and the moon's rotate(-183) tilt). Baker-only (not in
+  // Icon.vue) — used solely as a logic-group brand glyph via the 3D-map
+  // config `icon`.
+  skywalking:
+    '<g transform="translate(0 6) scale(0.006957)" fill="currentColor" stroke="none">' +
+    '<g transform="translate(0 -29)">' +
+    '<path d="M1050.01772,1394.31899 C1050.01772,1615.24051 912.21519,1851.47342 474.746835,1851.47342 C310.696203,1851.47342 192.579747,1836.16203 87.5873418,1812.10127 C65.7139241,1807.72658 46.0278481,1792.41519 46.0278481,1768.35443 L46.0278481,1610.86582 C46.0278481,1586.80506 65.7139241,1569.30633 87.5873418,1569.30633 L91.9620253,1569.30633 C179.455696,1580.24304 398.189873,1591.17975 479.121519,1591.17975 C673.794937,1591.17975 732.853165,1521.18481 732.853165,1394.31899 C732.853 [...]
+    '<g transform="translate(2932.164557 596) rotate(-183) translate(-2932.164557 -596) translate(2415.708861 26.379747)"><path d="M1025.31646,927.371333 C992.796119,932.841177 959.292071,935.576099 925.845888,935.576099 C590.40035,935.576099 318.259524,661.909325 318.259524,324.582876 C318.259524,209.134252 351.705707,96.3623597 412.290747,0 C171.802278,71.8062511 0,293.684076 0,557.342199 C0,878.317305 259.46831,1139.24051 578.65368,1139.24051 C753.17563,1139.24051 916.818891,1059.2294 [...]
+    '</g></g>',
   // Browser / RUM / mini-program — globe-style grid.
   web:
     '<circle cx="12" cy="12" r="9" />' +
@@ -140,12 +159,14 @@ export function disposeLayerIconTextures(): void {
 function buildSvg(name: LayerIconName, hex: string): string {
   const inner = PATHS[name] ?? PATHS.svc;
   // 1024 long-edge SVG ensures crisp rasterisation when the browser's
-  // <img> loader hands it off to the GL texture. `stroke="${hex}"` is
+  // <img> loader hands it off to the GL texture. `stroke`/`color` are
   // inserted unencoded — hex strings are URL-safe (#rrggbb), so the
-  // base64 encoding below is purely for the data URI.
+  // base64 encoding below is purely for the data URI. `color="${hex}"`
+  // makes `fill="currentColor"` paths (the SkyWalking crescent, the svc
+  // dots) fill in the layer colour rather than the default black.
   const svg =
     '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="1024" height="1024"' +
-    ` fill="none" stroke="${hex}" stroke-width="1.6"` +
+    ` fill="none" stroke="${hex}" color="${hex}" stroke-width="1.6"` +
     ' stroke-linecap="round" stroke-linejoin="round">' +
     inner +
     '</svg>';
diff --git a/apps/ui/src/features/infra-3d/composables/useScenePlacement.ts b/apps/ui/src/features/infra-3d/composables/useScenePlacement.ts
index 0eb37cf..f6bb42d 100644
--- a/apps/ui/src/features/infra-3d/composables/useScenePlacement.ts
+++ b/apps/ui/src/features/infra-3d/composables/useScenePlacement.ts
@@ -38,6 +38,8 @@
  */
 
 import type { PlaneId, SceneGraph, SceneLayer, SceneServiceNode } from './useDemoTopology';
+import { resolveServiceIdentity } from '@/utils/serviceName';
+import type { ServiceNamingRule } from '@skywalking-horizon-ui/api-client';
 
 // ── Output types ────────────────────────────────────────────────────────
 
@@ -57,7 +59,9 @@ export interface PlanePlacement {
 }
 
 export interface ZonePlacement {
+  /** Solo zone: the layer key. Group zone: the group id. */
   layerKey: string;
+  /** Solo zone: the layer name. Group zone: the group label. */
   layerName: string;
   plane: PlaneId;
   y: number;
@@ -70,6 +74,41 @@ export interface ZonePlacement {
    *  drives the "center zones with topology, sides for the rest"
    *  arrangement on each plane. */
   hasTopology: boolean;
+  /** Present on GROUP zones (a logic group clustering several layers
+   *  into one block). `color` overrides the tint for the backplate;
+   *  `layerKeys` are the member layers (for the side panel). */
+  group?: { id: string; color: string; icon: string; layerKeys: string[] };
+  /** Topology-cluster bands (k8s/mesh namespace grouping) when the
+   *  layer has a naming rule yielding ≥2 clusters — the 3D analogue of
+   *  the 2D map's cluster bounding boxes. `centerX`/`centerZ` are
+   *  ABSOLUTE (same frame as the zone), so the renderer draws each band
+   *  + its label directly. Absent when the layer isn't clustered. */
+  clusters?: ClusterBand[];
+}
+
+/** One topology-cluster band inside a zone (e.g. a k8s/mesh namespace).
+ *  Drawn as a labelled boundary around the cluster's cubes. */
+export interface ClusterBand {
+  /** Cluster value (e.g. the namespace), or `(ungrouped)` for the
+   *  rule-miss bucket. */
+  label: string;
+  /** Cluster dimension name (e.g. `namespace`), from the naming rule. */
+  alias: string | null;
+  centerX: number;
+  centerZ: number;
+  width: number;
+  depth: number;
+}
+
+/** A logic group passed into placement — clusters several layers into one
+ *  block on `level`. Mirrors the config `InfraGroupSpec`. */
+export interface SceneGroupSpec {
+  id: string;
+  label: string;
+  level: string;
+  color: string;
+  icon: string;
+  layers: string[];
 }
 
 export type ZoneTint =
@@ -113,6 +152,15 @@ const CELL_STRIDE_X = 2.4;
 const CELL_STRIDE_Z = 2.4;
 /** Padding INSIDE a zone — keeps cubes off the colored edge. */
 const ZONE_INNER_PAD = 1.2;
+/** Horizontal gap between member layers tiled inside one group zone.
+ *  Kept well below ZONE_GAP_X (and below the cube stride) so the member
+ *  layers sit tight against each other and read as ONE block — only a
+ *  hairline separates each layer's column run. */
+const GROUP_MEMBER_GAP = 1.0;
+/** Gap between topology-cluster blocks inside one layer zone, and the
+ *  pad between a cluster's cubes and its boundary band. */
+const CLUSTER_GAP_X = 2.2;
+const CLUSTER_INNER_PAD = 0.9;
 
 // ── Layer → zone tint ───────────────────────────────────────────────────
 
@@ -359,25 +407,139 @@ function rankBasedLayout(L: SceneLayer): { positions: Map<string, LocalPoint>; r
 
 // ── Top-level placement ────────────────────────────────────────────────
 
-/** Sort key for arranging zones on a plane. Topology-bearing zones
- *  (largest first) take the center; non-topology zones tile outward
- *  alternating left/right by size, so the visual weight stays
- *  balanced. The returned `centerIndex` is the index in the input
- *  array that maps to the center slot. */
-function arrangeZonesByCenter(layers: SceneLayer[]): SceneLayer[] {
-  // Center = topology-bearing layers, ordered by node count desc.
-  // Sides = others, ordered by node count desc.
-  const center = layers.filter((L) => L.callEdges.length > 0)
-    .sort((a, b) => b.nodes.length - a.nodes.length);
-  const sides = layers.filter((L) => L.callEdges.length === 0)
-    .sort((a, b) => b.nodes.length - a.nodes.length);
-  // Interleave the sides around the center: side[0] → left, side[1] →
-  // right, side[2] → further left, side[3] → further right, …
-  const left: SceneLayer[] = [];
-  const right: SceneLayer[] = [];
-  sides.forEach((L, i) => {
-    if (i % 2 === 0) left.unshift(L);
-    else right.push(L);
+/** A placement unit on a plane — either a single layer or a logic group
+ *  clustering several layers. `positions` are node offsets relative to
+ *  the unit's center; `width`/`depth` already include the zone pad. */
+interface PreUnit {
+  kind: 'layer' | 'group';
+  key: string;
+  name: string;
+  layer?: SceneLayer;
+  group?: SceneGroupSpec;
+  /** Member layer keys (for the side panel). Solo unit: just its own. */
+  layerKeys: string[];
+  positions: Map<string, LocalPoint>;
+  hasTopology: boolean;
+  width: number;
+  depth: number;
+  nodeCount: number;
+  /** Cluster bands (local to the unit center) when the solo layer is
+   *  cluster-grouped. Offset to the zone frame on push. */
+  clusters?: ClusterBand[];
+}
+
+interface RawLayout {
+  positions: Map<string, LocalPoint>;
+  rawW: number;
+  rawD: number;
+  hasTopology: boolean;
+  clusters?: ClusterBand[];
+}
+
+/** Bbox of a positions map (empty → all zeros). */
+function bboxOf(positions: Iterable<LocalPoint>): { minX: number; maxX: number; minZ: number; maxZ: number } {
+  let minX = 0, maxX = 0, minZ = 0, maxZ = 0, any = false;
+  for (const p of positions) {
+    if (!any) { minX = maxX = p.x; minZ = maxZ = p.z; any = true; }
+    else { if (p.x < minX) minX = p.x; if (p.x > maxX) maxX = p.x; if (p.z < minZ) minZ = p.z; if (p.z > maxZ) maxZ = p.z; }
+  }
+  return { minX, maxX, minZ, maxZ };
+}
+
+/** Cluster a layer's nodes by its topology-cluster rule (k8s/mesh
+ *  namespace) and lay each cluster out as its own column-fill block,
+ *  arranged left-to-right — the 3D analogue of the 2D map's cluster
+ *  buckets. Returns null (caller falls back to the normal layout) when
+ *  the rule yields fewer than two distinct clusters, so layers without a
+ *  meaningful cluster split are untouched. Node offsets + band centers
+ *  are local to the layer center; the caller offsets them to the zone. */
+function clusteredLayout(L: SceneLayer, rule: ServiceNamingRule): RawLayout | null {
+  interface Bucket { label: string; alias: string | null; nodes: SceneServiceNode[] }
+  const buckets = new Map<string, Bucket>();
+  for (const n of L.nodes) {
+    const id = resolveServiceIdentity(n.name, rule);
+    const key = id.cluster ?? '';
+    let b = buckets.get(key);
+    if (!b) { b = { label: id.cluster ?? '(ungrouped)', alias: id.clusterAlias, nodes: [] }; buckets.set(key, b); }
+    b.nodes.push(n);
+  }
+  if (buckets.size < 2) return null;
+
+  // Lay each bucket out (column-fill, centered) and measure its footprint.
+  const laid = [...buckets.values()]
+    .map((b) => {
+      const cf = columnFillLayout(b.nodes);
+      const bb = bboxOf(cf.positions.values());
+      return {
+        b, cf, bb,
+        w: (bb.maxX - bb.minX) + CELL_STRIDE_X + CLUSTER_INNER_PAD * 2,
+        d: (bb.maxZ - bb.minZ) + CELL_STRIDE_Z + CLUSTER_INNER_PAD * 2,
+      };
+    })
+    // Largest cluster (by service count) first — stable order.
+    .sort((a, c) => c.b.nodes.length - a.b.nodes.length);
+
+  const totalW = laid.reduce((a, l, i) => a + l.w + (i > 0 ? CLUSTER_GAP_X : 0), 0);
+  const maxD = laid.reduce((a, l) => Math.max(a, l.d), 0);
+  const positions = new Map<string, LocalPoint>();
+  const clusters: ClusterBand[] = [];
+  let cursor = -totalW / 2;
+  for (const l of laid) {
+    const cxLocal = cursor + l.w / 2;
+    cursor += l.w + CLUSTER_GAP_X;
+    const bcx = (l.bb.minX + l.bb.maxX) / 2;
+    const bcz = (l.bb.minZ + l.bb.maxZ) / 2;
+    for (const [nodeId, p] of l.cf.positions) {
+      positions.set(nodeId, { x: cxLocal + (p.x - bcx), z: p.z - bcz });
+    }
+    clusters.push({ label: l.b.label, alias: l.b.alias, centerX: cxLocal, centerZ: 0, width: l.w, depth: l.d });
+  }
+  return { positions, rawW: totalW + CELL_STRIDE_X, rawD: maxD + CELL_STRIDE_Z, hasTopology: L.callEdges.length > 0, clusters };
+}
+
+/** Lay out one layer and return its node offsets (centered at 0) plus
+ *  the RAW footprint (bbox + one cell stride, no zone pad) so callers
+ *  can either pad it (solo zone) or tile it inside a group. When a
+ *  topology-cluster `rule` is supplied and yields ≥2 clusters, the layer
+ *  is laid out cluster-by-cluster (k8s/mesh namespace grouping). */
+function rawLayout(L: SceneLayer, rule?: ServiceNamingRule | null): RawLayout {
+  if (rule) {
+    const clustered = clusteredLayout(L, rule);
+    if (clustered) return clustered;
+  }
+  const layout = L.callEdges.length > 0 ? rankBasedLayout(L) : columnFillLayout(L.nodes);
+  let minX = 0, maxX = 0, minZ = 0, maxZ = 0, any = false;
+  for (const p of layout.positions.values()) {
+    if (!any) {
+      minX = maxX = p.x;
+      minZ = maxZ = p.z;
+      any = true;
+    } else {
+      if (p.x < minX) minX = p.x;
+      if (p.x > maxX) maxX = p.x;
+      if (p.z < minZ) minZ = p.z;
+      if (p.z > maxZ) maxZ = p.z;
+    }
+  }
+  return {
+    positions: layout.positions,
+    rawW: (maxX - minX) + CELL_STRIDE_X,
+    rawD: (maxZ - minZ) + CELL_STRIDE_Z,
+    hasTopology: L.callEdges.length > 0,
+  };
+}
+
+/** Arrange units on a plane: topology-bearing units (largest first) take
+ *  the center; the rest tile outward alternating left/right by size, so
+ *  the visual weight stays balanced. */
+function arrangeUnits(units: PreUnit[]): PreUnit[] {
+  const center = units.filter((u) => u.hasTopology).sort((a, b) => b.nodeCount - a.nodeCount);
+  const sides = units.filter((u) => !u.hasTopology).sort((a, b) => b.nodeCount - a.nodeCount);
+  const left: PreUnit[] = [];
+  const right: PreUnit[] = [];
+  sides.forEach((u, i) => {
+    if (i % 2 === 0) left.unshift(u);
+    else right.push(u);
   });
   return [...left, ...center, ...right];
 }
@@ -396,7 +558,16 @@ export interface PlaneSpec {
  *  each subsequent plane stacks up by `PLANE_VGAP`. An empty array
  *  falls back to the legacy 3-plane order so the function stays useful
  *  in tests and very-early renders. */
-export function computePlacement(graph: SceneGraph, planeOrder?: PlaneSpec[]): ScenePlacement {
+export function computePlacement(
+  graph: SceneGraph,
+  planeOrder?: PlaneSpec[],
+  groups?: SceneGroupSpec[],
+  /** Per-layer topology-cluster rules (upper-case layer key → rule),
+   *  e.g. from each layer's `naming`. A solo layer with a rule that
+   *  yields ≥2 clusters is laid out cluster-by-cluster (k8s/mesh
+   *  namespace grouping). */
+  namingByLayer?: Record<string, ServiceNamingRule | null>,
+): ScenePlacement {
   const order: PlaneSpec[] =
     planeOrder && planeOrder.length > 0
       ? planeOrder
@@ -406,6 +577,7 @@ export function computePlacement(graph: SceneGraph, planeOrder?: PlaneSpec[]): S
           { id: 'middleware', label: 'Middleware' },
           { id: 'infra', label: 'Infrastructure' },
         ];
+  const groupList = groups ?? [];
 
   const byPlane = new Map<PlaneId, SceneLayer[]>();
   for (const p of order) byPlane.set(p.id, []);
@@ -434,44 +606,79 @@ export function computePlacement(graph: SceneGraph, planeOrder?: PlaneSpec[]): S
 
   for (const spec of order) {
     const id = spec.id;
-    const ordered = arrangeZonesByCenter(byPlane.get(id) ?? []);
+    const planeLayers = byPlane.get(id) ?? [];
     const planeY = planeYById.get(id)!;
 
-    // ── Per-zone layout. We compute the local positions first, then
-    //    derive each zone's footprint from the layout bbox + padding.
-    interface PreZone {
-      layer: SceneLayer;
-      positions: Map<string, LocalPoint>;
-      hasTopology: boolean;
-      width: number;
-      depth: number;
-    }
-    const pre: PreZone[] = ordered.map((L) => {
-      const layout =
-        L.callEdges.length > 0 ? rankBasedLayout(L) : columnFillLayout(L.nodes);
-      // Bounding box from the layout's local points.
-      let minX = 0, maxX = 0, minZ = 0, maxZ = 0;
-      let any = false;
-      for (const p of layout.positions.values()) {
-        if (!any) {
-          minX = maxX = p.x;
-          minZ = maxZ = p.z;
-          any = true;
-        } else {
-          if (p.x < minX) minX = p.x;
-          if (p.x > maxX) maxX = p.x;
-          if (p.z < minZ) minZ = p.z;
-          if (p.z > maxZ) maxZ = p.z;
+    // ── Build units: one per logic group on this plane (clustering its
+    //    present member layers), then one per remaining solo layer.
+    const byKey = new Map(planeLayers.map((L) => [L.key.toUpperCase(), L]));
+    const claimed = new Set<string>();
+    const units: PreUnit[] = [];
+
+    for (const g of groupList) {
+      if (g.level !== id) continue;
+      const members: SceneLayer[] = [];
+      for (const k of g.layers) {
+        const L = byKey.get(k.toUpperCase());
+        if (L && !claimed.has(L.key.toUpperCase())) {
+          members.push(L);
+          claimed.add(L.key.toUpperCase());
         }
       }
-      const w = (maxX - minX) + CELL_STRIDE_X + ZONE_INNER_PAD * 2;
-      const d = (maxZ - minZ) + CELL_STRIDE_Z + ZONE_INNER_PAD * 2;
-      return { layer: L, positions: layout.positions, hasTopology: L.callEdges.length > 0, width: w, depth: d };
-    });
+      if (members.length === 0) continue;
+      // Tile member layers left-to-right inside the group, each starting
+      // its own column run, all sharing the centered Z baseline (row-
+      // aligned). Cubes keep their own per-layer color — only the
+      // backplate is one group block.
+      const ml = members.map((m) => ({ L: m, ...rawLayout(m) }));
+      const rawW = ml.reduce((a, m, i) => a + m.rawW + (i > 0 ? GROUP_MEMBER_GAP : 0), 0);
+      const rawD = ml.reduce((a, m) => Math.max(a, m.rawD), 0);
+      const positions = new Map<string, LocalPoint>();
+      let mc = -rawW / 2;
+      for (const m of ml) {
+        const memberCenterX = mc + m.rawW / 2;
+        mc += m.rawW + GROUP_MEMBER_GAP;
+        for (const [nodeId, p] of m.positions) {
+          positions.set(nodeId, { x: memberCenterX + p.x, z: p.z });
+        }
+      }
+      units.push({
+        kind: 'group',
+        key: g.id,
+        name: g.label,
+        group: g,
+        layerKeys: members.map((m) => m.key),
+        positions,
+        hasTopology: ml.some((m) => m.hasTopology),
+        width: rawW + ZONE_INNER_PAD * 2,
+        depth: rawD + ZONE_INNER_PAD * 2,
+        nodeCount: members.reduce((a, m) => a + m.nodes.length, 0),
+      });
+    }
+
+    for (const L of planeLayers) {
+      if (claimed.has(L.key.toUpperCase())) continue;
+      const r = rawLayout(L, namingByLayer?.[L.key.toUpperCase()] ?? null);
+      units.push({
+        kind: 'layer',
+        key: L.key,
+        name: L.name,
+        layer: L,
+        layerKeys: [L.key],
+        positions: r.positions,
+        hasTopology: r.hasTopology,
+        clusters: r.clusters,
+        width: r.rawW + ZONE_INNER_PAD * 2,
+        depth: r.rawD + ZONE_INNER_PAD * 2,
+        nodeCount: L.nodes.length,
+      });
+    }
+
+    const ordered = arrangeUnits(units);
 
-    // ── Tile zones left-to-right on the plane, centered at X = 0.
-    const totalW = pre.reduce((acc, z, i) => acc + z.width + (i > 0 ? ZONE_GAP_X : 0), 0);
-    const maxD = pre.reduce((acc, z) => Math.max(acc, z.depth), 0);
+    // ── Tile units left-to-right on the plane, centered at X = 0.
+    const totalW = ordered.reduce((acc, u, i) => acc + u.width + (i > 0 ? ZONE_GAP_X : 0), 0);
+    const maxD = ordered.reduce((acc, u) => Math.max(acc, u.depth), 0);
     const planeW = Math.max(totalW + 4, 14);
     const planeD = maxD + 2;
     planes.push({ id, name: spec.label, y: planeY, width: planeW, depth: planeD });
@@ -479,26 +686,33 @@ export function computePlacement(graph: SceneGraph, planeOrder?: PlaneSpec[]): S
     maxPlaneD = Math.max(maxPlaneD, planeD);
 
     let cursor = -totalW / 2;
-    for (const z of pre) {
-      const cx = cursor + z.width / 2;
+    for (const u of ordered) {
+      const cx = cursor + u.width / 2;
       const cz = 0;
-      cursor += z.width + ZONE_GAP_X;
-      const tint = tintForLayer(z.layer.key, z.layer.group);
+      cursor += u.width + ZONE_GAP_X;
       zones.push({
-        layerKey: z.layer.key,
-        layerName: z.layer.name,
+        layerKey: u.key,
+        layerName: u.name,
         plane: id,
         y: planeY,
         centerX: cx,
         centerZ: cz,
-        width: z.width,
-        depth: z.depth,
-        tint,
-        hasTopology: z.hasTopology,
+        width: u.width,
+        depth: u.depth,
+        tint: u.kind === 'layer' ? tintForLayer(u.layer!.key, u.layer!.group) : 'misc',
+        hasTopology: u.hasTopology,
+        group:
+          u.kind === 'group'
+            ? { id: u.group!.id, color: u.group!.color, icon: u.group!.icon, layerKeys: u.layerKeys }
+            : undefined,
+        clusters: u.clusters?.map((b) => ({
+          ...b,
+          centerX: cx + b.centerX,
+          centerZ: cz + b.centerZ,
+        })),
       });
-      for (const n of z.layer.nodes) {
-        const off = z.positions.get(n.nodeId) ?? { x: 0, z: 0 };
-        nodes.set(n.nodeId, { nodeId: n.nodeId, x: cx + off.x, y: planeY, z: cz + off.z });
+      for (const [nodeId, off] of u.positions) {
+        nodes.set(nodeId, { nodeId, x: cx + off.x, y: planeY, z: cz + off.z });
       }
     }
   }
diff --git a/apps/ui/src/i18n/locales/de.json b/apps/ui/src/i18n/locales/de.json
index 737e554..013daa3 100644
--- a/apps/ui/src/i18n/locales/de.json
+++ b/apps/ui/src/i18n/locales/de.json
@@ -105,6 +105,7 @@
   "Scope:": "Bereich:",
   "Live preview": "Live-Vorschau",
   "Kind": "Art",
+  "Ref type": "Referenztyp",
   "Overview": "Übersicht",
   "Layer": "Ebene",
   "Dashboard": "Dashboard",
diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json
index 9584dd9..add6647 100644
--- a/apps/ui/src/i18n/locales/en.json
+++ b/apps/ui/src/i18n/locales/en.json
@@ -105,6 +105,7 @@
   "Scope:": "Scope:",
   "Live preview": "Live preview",
   "Kind": "Kind",
+  "Ref type": "Ref type",
   "Overview": "Overview",
   "Layer": "Layer",
   "Dashboard": "Dashboard",
diff --git a/apps/ui/src/i18n/locales/es.json b/apps/ui/src/i18n/locales/es.json
index 9d90d79..304d877 100644
--- a/apps/ui/src/i18n/locales/es.json
+++ b/apps/ui/src/i18n/locales/es.json
@@ -105,6 +105,7 @@
   "Scope:": "Ámbito:",
   "Live preview": "Vista previa en vivo",
   "Kind": "Tipo",
+  "Ref type": "Tipo de referencia",
   "Overview": "Resumen",
   "Layer": "Capa",
   "Dashboard": "Panel",
diff --git a/apps/ui/src/i18n/locales/fr.json b/apps/ui/src/i18n/locales/fr.json
index 0d64b90..9d97b22 100644
--- a/apps/ui/src/i18n/locales/fr.json
+++ b/apps/ui/src/i18n/locales/fr.json
@@ -105,6 +105,7 @@
   "Scope:": "Portée :",
   "Live preview": "Aperçu en direct",
   "Kind": "Type",
+  "Ref type": "Type de référence",
   "Overview": "Vue d'ensemble",
   "Layer": "Couche",
   "Dashboard": "Tableau de bord",
diff --git a/apps/ui/src/i18n/locales/ja.json b/apps/ui/src/i18n/locales/ja.json
index 4a2321e..54be75d 100644
--- a/apps/ui/src/i18n/locales/ja.json
+++ b/apps/ui/src/i18n/locales/ja.json
@@ -105,6 +105,7 @@
   "Scope:": "スコープ:",
   "Live preview": "ライブプレビュー",
   "Kind": "種類",
+  "Ref type": "参照タイプ",
   "Overview": "概要",
   "Layer": "レイヤー",
   "Dashboard": "ダッシュボード",
diff --git a/apps/ui/src/i18n/locales/ko.json b/apps/ui/src/i18n/locales/ko.json
index 481270a..d549051 100644
--- a/apps/ui/src/i18n/locales/ko.json
+++ b/apps/ui/src/i18n/locales/ko.json
@@ -105,6 +105,7 @@
   "Scope:": "범위:",
   "Live preview": "실시간 미리보기",
   "Kind": "종류",
+  "Ref type": "참조 유형",
   "Overview": "개요",
   "Layer": "레이어",
   "Dashboard": "대시보드",
diff --git a/apps/ui/src/i18n/locales/pt.json b/apps/ui/src/i18n/locales/pt.json
index 90378e1..007a655 100644
--- a/apps/ui/src/i18n/locales/pt.json
+++ b/apps/ui/src/i18n/locales/pt.json
@@ -105,6 +105,7 @@
   "Scope:": "Escopo:",
   "Live preview": "Visualização em tempo real",
   "Kind": "Tipo",
+  "Ref type": "Tipo de referência",
   "Overview": "Visão geral",
   "Layer": "Camada",
   "Dashboard": "Painel",
diff --git a/apps/ui/src/i18n/locales/zh-CN.json b/apps/ui/src/i18n/locales/zh-CN.json
index e7bb9fe..883551a 100644
--- a/apps/ui/src/i18n/locales/zh-CN.json
+++ b/apps/ui/src/i18n/locales/zh-CN.json
@@ -105,6 +105,7 @@
   "Scope:": "范围:",
   "Live preview": "实时预览",
   "Kind": "类型",
+  "Ref type": "引用类型",
   "Overview": "总览",
   "Layer": "层级",
   "Dashboard": "仪表板",
diff --git a/apps/ui/src/layer/service-map/LayerServiceMapView.vue b/apps/ui/src/layer/service-map/LayerServiceMapView.vue
index 6cf1df2..f0447d8 100644
--- a/apps/ui/src/layer/service-map/LayerServiceMapView.vue
+++ b/apps/ui/src/layer/service-map/LayerServiceMapView.vue
@@ -498,7 +498,7 @@ function computeBoosterLevels(
 // Sentinel encoding for the cluster key — `null` (unclustered) gets
 // pinned to a stable string so it can serve as a Map key alongside
 // real cluster values. Decoded back on the read side.
-const UNCLUSTERED = '__ungrouped__';
+const UNCLUSTERED = '\u0000__ungrouped__';
 function ckeyEnc(k: string | null): string { return k ?? UNCLUSTERED; }
 function ckeyDec(s: string): string | null { return s === UNCLUSTERED ? null : s; }
 
@@ -1487,6 +1487,14 @@ function fmtWithUnit(v: number | null | undefined, unit: string | undefined): st
             <option :value="3">3 hops</option>
           </select>
         </label>
+        <!-- Open this layer's topology in the 3D map, focused on just
+             this layer (the existing /3d/map scene scoped via ?layer). -->
+        <router-link
+          class="sw-btn small"
+          :to="{ path: '/3d/map', query: { layer: layerKey } }"
+          target="_blank"
+          title="View this layer's topology in 3D"
+        >View in 3D</router-link>
         <button class="sw-btn small" type="button" @click="() => refetch()">Refresh</button>
       </div>
     </header>
diff --git a/apps/ui/src/layer/traces/LayerTracesView.vue b/apps/ui/src/layer/traces/LayerTracesView.vue
index 72329ad..e8aae05 100644
--- a/apps/ui/src/layer/traces/LayerTracesView.vue
+++ b/apps/ui/src/layer/traces/LayerTracesView.vue
@@ -1672,7 +1672,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onPageKeyDown, true)
               </dd>
               <dt>{{ t('Instance') }}</dt><dd class="mono">{{ openSpan.serviceInstanceName }}</dd>
               <dt>{{ t('Endpoint') }}</dt><dd class="mono">{{ openSpan.endpointName || '—' }}</dd>
-              <dt>{{ t('Type') }}</dt><dd><span class="tr-kind" :style="{ color: kindColor(openSpan.type) }">{{ openSpan.type }}</span></dd>
+              <dt>{{ t('Kind') }}</dt><dd><span class="tr-kind" :style="{ color: kindColor(openSpan.type) }">{{ openSpan.type }}</span></dd>
               <dt>{{ t('Component') }}</dt><dd class="mono">{{ openSpan.component || '—' }}</dd>
               <dt>{{ t('Peer') }}</dt><dd class="mono">{{ openSpan.peer || '—' }}</dd>
               <dt>{{ t('Layer') }}</dt><dd class="mono dim">{{ openSpan.layer || '—' }}</dd>
@@ -1687,7 +1687,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onPageKeyDown, true)
           >
             <h5>{{ t('Cross-trace refs') }}</h5>
             <table class="kv-table">
-              <thead><tr><th>{{ t('Trace ID') }}</th><th>{{ t('Parent segment') }}</th><th class="num">{{ t('Parent span') }}</th><th>{{ t('Type') }}</th></tr></thead>
+              <thead><tr><th>{{ t('Trace ID') }}</th><th>{{ t('Parent segment') }}</th><th class="num">{{ t('Parent span') }}</th><th>{{ t('Ref type') }}</th></tr></thead>
               <tbody>
                 <template v-for="(r, i) in openSpan.refs" :key="i">
                   <tr v-if="r.traceId !== selectedTraceId">
diff --git a/apps/ui/src/layer/traces/TracePopout.vue b/apps/ui/src/layer/traces/TracePopout.vue
index 5ad3ad2..4852363 100644
--- a/apps/ui/src/layer/traces/TracePopout.vue
+++ b/apps/ui/src/layer/traces/TracePopout.vue
@@ -342,7 +342,7 @@ function nativeSpanError(s: NativeSpan): boolean { return s.isError; }
                   </dd>
                   <dt>{{ t('Instance') }}</dt><dd class="mono wba">{{ selectedSpan.serviceInstanceName }}</dd>
                   <dt>{{ t('Endpoint') }}</dt><dd class="mono wba">{{ selectedSpan.endpointName || '—' }}</dd>
-                  <dt>{{ t('Type') }}</dt><dd><span class="tp-kind" :style="{ color: kindColor(selectedSpan.type) }">{{ selectedSpan.type }}</span></dd>
+                  <dt>{{ t('Kind') }}</dt><dd><span class="tp-kind" :style="{ color: kindColor(selectedSpan.type) }">{{ selectedSpan.type }}</span></dd>
                   <dt>{{ t('Component') }}</dt><dd class="mono">{{ selectedSpan.component || '—' }}</dd>
                   <dt>{{ t('Peer') }}</dt><dd class="mono wba">{{ selectedSpan.peer || '—' }}</dd>
                   <dt>{{ t('Layer') }}</dt><dd class="mono dim">{{ selectedSpan.layer || '—' }}</dd>
@@ -724,8 +724,10 @@ function nativeSpanError(s: NativeSpan): boolean { return s.isError; }
   margin: 0;
   font-size: 11px;
 }
-.kv dt { color: var(--sw-fg-3); font-size: 10.5px; }
-.kv dd { margin: 0; color: var(--sw-fg-1); }
+/* Long dotted tag keys (e.g. gen_ai.response.finish_reasons) must wrap
+   inside the 100px key column instead of spilling over the value. */
+.kv dt { color: var(--sw-fg-3); font-size: 10.5px; min-width: 0; overflow-wrap: anywhere; }
+.kv dd { margin: 0; color: var(--sw-fg-1); min-width: 0; }
 .kv-table {
   width: 100%;
   border-collapse: collapse;