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 & 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 = '