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/06/01 12:44:38 UTC
(skywalking-horizon-ui) branch feat/infra-3d-config-redesign created (now 45b7729)
This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a change to branch feat/infra-3d-config-redesign
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
at 45b7729 feat(infra-3d): redesign the 3D-map config as an OAP template kind
This branch includes the following new commits:
new 45b7729 feat(infra-3d): redesign the 3D-map config as an OAP template kind
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(skywalking-horizon-ui) 01/01: feat(infra-3d): redesign the 3D-map config as an OAP template kind
Posted by wu...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a commit to branch feat/infra-3d-config-redesign
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
commit 45b7729c6ebfca289cd3044dd4825ac0294c85ef
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Mon Jun 1 20:39:59 2026 +0800
feat(infra-3d): redesign the 3D-map config as an OAP template kind
The 3D Infrastructure Map config is now a first-class OAP template kind
(horizon.infra-3d.config) instead of a BFF-local file, so it follows the
same bundled -> local(browser) -> remote(OAP) flow as the layer / overview
dashboards: edit a local draft, "Check diff & push" to publish, remote
wins at render with bundled as the fallback.
Admin editor (/admin/3d-map):
- Combined "Tiers & layers" view: each layer is pinned to one tier via a
dropdown; explicit pins + a single failover tier are the only placement
mechanism. Removing from a tier unpins (never deletes).
- One traffic metric per layer (mqe / label / unit) -- the topology/load
and per-tier-regex models are gone. The single global filter.layer is the
top-level gate: an excluded layer is off the map AND off the live pipeline.
- Logic-groups editor (members / color / icon / tier); a layer belongs to
at most one group; read-only Service-map-layers section.
- Save-local / Monaco diff / push; OAP-unreachable and BFF-unreachable
states surfaced instead of an indefinite spinner.
3D map (/3d/map):
- Restored the cross-layer Smartscape hierarchy the dynamic loader had
dropped (hierarchy: []). Fetched incrementally -- only newly-appeared
services are probed, the rest reused from a persistent cache -- and
revealed on cube-select. Folded into the scene re-key signature.
- Fixed live metric resolution after the single-metric migration.
Cross-cutting:
- Auto-refresh + global time picker disabled on all /admin/* and /operate/*
routes (no time context).
- Renamed the topology model Demo* -> Map* (useMapTopology /
fallback-topology.json) -- it carries live data, not a demo snapshot.
- RBAC: 3D-map admin page gated on overview:write (the verb its push uses);
removed the orphaned infra-3d:write verb and the dead infra3d.file config
(kept accepted-and-ignored so existing configs still boot).
---
CHANGELOG.md | 12 +-
.../bff/src/bundled_templates/infra-3d/config.json | 88 +-
apps/bff/src/config/schema.ts | 14 +-
apps/bff/src/http/admin/template-sync.ts | 12 +
apps/bff/src/http/config/infra-3d.ts | 91 +-
apps/bff/src/logic/infra-3d/bundled.ts | 5 +-
apps/bff/src/logic/infra-3d/store.ts | 114 --
apps/bff/src/logic/infra-3d/types.ts | 65 +-
apps/bff/src/logic/infra-3d/validate.ts | 40 +-
apps/bff/src/logic/templates/aggregator.ts | 7 +
apps/bff/src/logic/templates/names.ts | 15 +-
apps/bff/src/rbac/route-policy.ts | 2 -
apps/bff/src/server.ts | 9 +-
apps/ui/src/api/client.ts | 5 +-
apps/ui/src/api/scopes/configs.ts | 12 +-
apps/ui/src/api/scopes/infra-3d.ts | 19 +-
.../features/admin/infra-3d/Infra3dAdminView.vue | 1311 +++++++++++---------
apps/ui/src/features/infra-3d/CLAUDE.md | 13 +-
apps/ui/src/features/infra-3d/Infra3DScene.vue | 14 +-
apps/ui/src/features/infra-3d/Infra3DView.vue | 102 +-
apps/ui/src/features/infra-3d/PipelineTimeline.vue | 1 +
.../infra-3d/composables/useInfra3dConfig.ts | 59 +-
.../infra-3d/composables/useInfra3dPipeline.ts | 11 +-
.../infra-3d/composables/useLiveTopology.ts | 154 ++-
.../{useDemoTopology.ts => useMapTopology.ts} | 67 +-
.../infra-3d/composables/useScenePlacement.ts | 2 +-
.../{demo-topology.json => fallback-topology.json} | 2 +-
apps/ui/src/shell/AppSidebar.vue | 2 +-
apps/ui/src/shell/AppTopbar.vue | 24 +-
apps/ui/src/shell/router/index.ts | 5 +-
30 files changed, 1241 insertions(+), 1036 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 211d569..e6a36ac 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -533,8 +533,16 @@ link into that layer's dashboard.
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).
+ cube colors, the traffic MQE per layer, and the logic groups are all
+ driven by the 3D map config, edited on a structured admin page at
+ `/admin/3d-map`. Pin each layer to a tier (with a single global layer
+ filter as the top-level gate), edit each layer's color + traffic metric,
+ manage logic groups (members, color, icon, tier), and choose the single
+ failover tier for anything unpinned. The
+ config is published to OAP and shared across the deployment the same
+ way as dashboards: edits save to a local draft in your browser, then
+ **Check diff & push** publishes to OAP — the map renders the remote,
+ with the bundled defaults as fallback.
- **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
diff --git a/apps/bff/src/bundled_templates/infra-3d/config.json b/apps/bff/src/bundled_templates/infra-3d/config.json
index 5019a50..5c54f98 100644
--- a/apps/bff/src/bundled_templates/infra-3d/config.json
+++ b/apps/bff/src/bundled_templates/infra-3d/config.json
@@ -22,7 +22,6 @@
"id": "apps",
"order": 0,
"label": "Apps",
- "layerFilter": ".*",
"layers": [
"GENERAL",
"BROWSER",
@@ -42,7 +41,6 @@
"id": "mesh",
"order": 2,
"label": "Service Mesh",
- "layerFilter": ".*",
"layers": [
"MESH",
"MESH_DP",
@@ -54,7 +52,6 @@
"id": "middleware",
"order": 1,
"label": "Middleware",
- "layerFilter": ".*",
"layers": [
"MYSQL",
"POSTGRESQL",
@@ -92,7 +89,6 @@
"id": "infra",
"order": 3,
"label": "Infra",
- "layerFilter": ".*",
"layers": [
"K8S",
"K8S_SERVICE",
@@ -122,25 +118,23 @@
"layers": {
"GENERAL": {
"color": "#e76f00",
- "topology": {
- "server": { "mqe": "service_cpm", "label": "RPM", "unit": "rpm" }
- }
+ "metric": { "mqe": "service_cpm", "label": "RPM", "unit": "rpm" }
},
"BROWSER": {
"color": "#c9a227",
- "load": { "mqe": "browser_app_pv", "label": "Page Views", "unit": "pv/min" }
+ "metric": { "mqe": "browser_app_pv", "label": "Page Views", "unit": "pv/min" }
},
"IOS": {
"color": "#a3aaae",
- "load": { "mqe": "meter_ios_foreground_abnormal_exit_count + meter_ios_background_abnormal_exit_count", "label": "Crashes", "unit": "crashes" }
+ "metric": { "mqe": "meter_ios_foreground_abnormal_exit_count + meter_ios_background_abnormal_exit_count", "label": "Crashes", "unit": "crashes" }
},
"WECHAT_MINI_PROGRAM": {
"color": "#07c160",
- "load": { "mqe": "meter_wechat_mp_request_cpm", "label": "Request RPM", "unit": "rpm" }
+ "metric": { "mqe": "meter_wechat_mp_request_cpm", "label": "Request RPM", "unit": "rpm" }
},
"ALIPAY_MINI_PROGRAM": {
"color": "#1677ff",
- "load": { "mqe": "meter_alipay_mp_request_cpm", "label": "Request RPM", "unit": "rpm" }
+ "metric": { "mqe": "meter_alipay_mp_request_cpm", "label": "Request RPM", "unit": "rpm" }
},
"BAIDU_MINI_PROGRAM": {
"color": "#3385ff"
@@ -150,29 +144,27 @@
},
"VIRTUAL_DATABASE": {
"color": "#5c8bc6",
- "load": { "mqe": "database_access_cpm", "label": "Access RPM", "unit": "rpm" }
+ "metric": { "mqe": "database_access_cpm", "label": "Access RPM", "unit": "rpm" }
},
"VIRTUAL_CACHE": {
"color": "#dc382d",
- "load": { "mqe": "cache_access_cpm", "label": "Access RPM", "unit": "rpm" }
+ "metric": { "mqe": "cache_access_cpm", "label": "Access RPM", "unit": "rpm" }
},
"VIRTUAL_MQ": {
"color": "#d97706",
- "load": { "mqe": "mq_service_consume_cpm", "label": "Consume RPM", "unit": "rpm" }
+ "metric": { "mqe": "mq_service_consume_cpm", "label": "Consume RPM", "unit": "rpm" }
},
"VIRTUAL_GATEWAY": {
"color": "#00aa55"
},
"VIRTUAL_GENAI": {
"color": "#a855f7",
- "load": { "mqe": "gen_ai_provider_resp_time", "label": "Avg Latency", "unit": "ms" }
+ "metric": { "mqe": "gen_ai_provider_resp_time", "label": "Avg Latency", "unit": "ms" }
},
"MESH": {
"color": "#466bb0",
- "topology": {
- "server": { "mqe": "service_cpm", "label": "RPM", "unit": "rpm" }
- }
+ "metric": { "mqe": "service_cpm", "label": "RPM", "unit": "rpm" }
},
"MESH_DP": {
"color": "#5e87cf",
@@ -180,69 +172,67 @@
},
"MESH_CP": {
"color": "#7da4dc",
- "load": { "mqe": "meter_istio_cpu", "label": "CPU", "unit": "%" }
+ "metric": { "mqe": "meter_istio_cpu", "label": "CPU", "unit": "%" }
},
"CILIUM_SERVICE": {
"color": "#f8c517",
- "topology": {
- "server": { "mqe": "cilium_service_protocol_cpm", "label": "RPM", "unit": "rpm" }
- }
+ "metric": { "mqe": "cilium_service_protocol_cpm", "label": "RPM", "unit": "rpm" }
},
"ENVOY_AI_GATEWAY": {
"color": "#ac6dcc",
- "load": { "mqe": "meter_envoy_ai_gw_input_token_rate + meter_envoy_ai_gw_output_token_rate", "label": "Tokens/min", "unit": "tok/min" }
+ "metric": { "mqe": "meter_envoy_ai_gw_input_token_rate + meter_envoy_ai_gw_output_token_rate", "label": "Tokens/min", "unit": "tok/min" }
},
"MYSQL": {
"color": "#00758f",
- "load": { "mqe": "aggregate_labels(meter_mysql_qps,sum)", "label": "QPS", "unit": "qps" }
+ "metric": { "mqe": "aggregate_labels(meter_mysql_qps,sum)", "label": "QPS", "unit": "qps" }
},
"POSTGRESQL": {
"color": "#336791",
- "load": { "mqe": "aggregate_labels(meter_pg_fetched_rows_rate,sum)", "label": "Fetched rows/s", "unit": "rows/s" }
+ "metric": { "mqe": "aggregate_labels(meter_pg_fetched_rows_rate,sum)", "label": "Fetched rows/s", "unit": "rows/s" }
},
"MARIADB": {
"color": "#003545"
},
"ELASTICSEARCH": {
"color": "#005571",
- "load": { "mqe": "meter_elasticsearch_cluster_health_status", "label": "Health", "unit": "status" }
+ "metric": { "mqe": "meter_elasticsearch_cluster_health_status", "label": "Health", "unit": "status" }
},
"MONGODB": {
"color": "#47a248",
- "load": { "mqe": "aggregate_labels(meter_mongodb_cluster_document_avg_qps, sum(doc_op_type))", "label": "Doc QPS", "unit": "qps" }
+ "metric": { "mqe": "aggregate_labels(meter_mongodb_cluster_document_avg_qps, sum(doc_op_type))", "label": "Doc QPS", "unit": "qps" }
},
"REDIS": {
"color": "#dc382d",
- "load": { "mqe": "aggregate_labels(meter_redis_total_commands_rate,sum(cmd))", "label": "Commands/s", "unit": "ops/s" }
+ "metric": { "mqe": "aggregate_labels(meter_redis_total_commands_rate,sum(cmd))", "label": "Commands/s", "unit": "ops/s" }
},
"KAFKA": {
"color": "#7d2929",
- "load": { "mqe": "meter_kafka_max_lag", "label": "Max Lag", "unit": "msgs" }
+ "metric": { "mqe": "meter_kafka_max_lag", "label": "Max Lag", "unit": "msgs" }
},
"ROCKETMQ": {
"color": "#d2691e",
- "load": { "mqe": "meter_rocketmq_cluster_total_producer_tps + meter_rocketmq_cluster_total_consumer_tps", "label": "Producer + Consumer TPS", "unit": "msg/s" }
+ "metric": { "mqe": "meter_rocketmq_cluster_total_producer_tps + meter_rocketmq_cluster_total_consumer_tps", "label": "Producer + Consumer TPS", "unit": "msg/s" }
},
"RABBITMQ": {
"color": "#ff6600",
- "load": { "mqe": "aggregate_labels(meter_rabbitmq_messages_published,sum)", "label": "Published / min", "unit": "msg/min" }
+ "metric": { "mqe": "aggregate_labels(meter_rabbitmq_messages_published,sum)", "label": "Published / min", "unit": "msg/min" }
},
"PULSAR": {
"color": "#188fff",
- "load": { "mqe": "aggregate_labels(meter_pulsar_message_rate_in,sum) + aggregate_labels(meter_pulsar_message_rate_out,sum)", "label": "Msg In + Out", "unit": "msg/s" }
+ "metric": { "mqe": "aggregate_labels(meter_pulsar_message_rate_in,sum) + aggregate_labels(meter_pulsar_message_rate_out,sum)", "label": "Msg In + Out", "unit": "msg/s" }
},
"ACTIVEMQ": {
"color": "#78bc1b",
- "load": { "mqe": "meter_activemq_cluster_enqueue_rate", "label": "Enqueue/s", "unit": "msg/s" }
+ "metric": { "mqe": "meter_activemq_cluster_enqueue_rate", "label": "Enqueue/s", "unit": "msg/s" }
},
"BOOKKEEPER": {
"color": "#188fff",
- "load": { "mqe": "aggregate_labels(meter_bookkeeper_bookie_write_rate,sum)", "label": "Write B/s", "unit": "B/s" }
+ "metric": { "mqe": "aggregate_labels(meter_bookkeeper_bookie_write_rate,sum)", "label": "Write B/s", "unit": "B/s" }
},
"CLICKHOUSE": {
"color": "#ffcc00",
- "load": { "mqe": "aggregate_labels(meter_clickhouse_query_select_rate,sum) + aggregate_labels(meter_clickhouse_query_insert_rate,sum)", "label": "Select + Insert QPS", "unit": "qps" }
+ "metric": { "mqe": "aggregate_labels(meter_clickhouse_query_select_rate,sum) + aggregate_labels(meter_clickhouse_query_insert_rate,sum)", "label": "Select + Insert QPS", "unit": "qps" }
},
"INFLUXDB": {
"color": "#22adf6"
@@ -255,19 +245,19 @@
},
"APISIX": {
"color": "#00bfa6",
- "load": { "mqe": "aggregate_labels(meter_apisix_sv_http_requests,sum(service_instance_id))", "label": "RPS", "unit": "rps" }
+ "metric": { "mqe": "aggregate_labels(meter_apisix_sv_http_requests,sum(service_instance_id))", "label": "RPS", "unit": "rps" }
},
"NGINX": {
"color": "#009639",
- "load": { "mqe": "aggregate_labels(meter_nginx_service_http_requests, sum)", "label": "RPS", "unit": "rps" }
+ "metric": { "mqe": "aggregate_labels(meter_nginx_service_http_requests, sum)", "label": "RPS", "unit": "rps" }
},
"KONG": {
"color": "#003459",
- "load": { "mqe": "aggregate_labels(meter_kong_service_http_requests,sum)", "label": "RPS", "unit": "rps" }
+ "metric": { "mqe": "aggregate_labels(meter_kong_service_http_requests,sum)", "label": "RPS", "unit": "rps" }
},
"FLINK": {
"color": "#e6526f",
- "load": { "mqe": "latest(meter_flink_jobManager_running_job_number)", "label": "Running Jobs", "unit": "jobs" }
+ "metric": { "mqe": "latest(meter_flink_jobManager_running_job_number)", "label": "Running Jobs", "unit": "jobs" }
},
"SO11Y_OAP": {
"color": "#69b1ff",
@@ -275,7 +265,7 @@
},
"SO11Y_SATELLITE": {
"color": "#85c1ff",
- "load": { "mqe": "satellite_service_server_cpu_utilization", "label": "CPU", "unit": "%" }
+ "metric": { "mqe": "satellite_service_server_cpu_utilization", "label": "CPU", "unit": "%" }
},
"SO11Y_BANYANDB": {
"color": "#a8d5ff"
@@ -290,15 +280,15 @@
},
"AWS_S3": {
"color": "#ff9900",
- "load": { "mqe": "latest(aws_s3_all_requests)", "label": "Requests", "unit": "req" }
+ "metric": { "mqe": "latest(aws_s3_all_requests)", "label": "Requests", "unit": "req" }
},
"AWS_DYNAMODB": {
"color": "#4053d6",
- "load": { "mqe": "aws_dynamodb_read_throttled_requests + aws_dynamodb_write_throttled_requests", "label": "Throttled Requests", "unit": "req" }
+ "metric": { "mqe": "aws_dynamodb_read_throttled_requests + aws_dynamodb_write_throttled_requests", "label": "Throttled Requests", "unit": "req" }
},
"AWS_GATEWAY": {
"color": "#cc6600",
- "load": { "mqe": "aws_gateway_service_count", "label": "Requests", "unit": "req" }
+ "metric": { "mqe": "aws_gateway_service_count", "label": "Requests", "unit": "req" }
},
"AWS_API_GATEWAY": {
"color": "#ff9900"
@@ -306,28 +296,26 @@
"K8S": {
"color": "#326ce5",
- "load": { "mqe": "k8s_cluster_pod_total", "label": "Pods", "unit": "pods" }
+ "metric": { "mqe": "k8s_cluster_pod_total", "label": "Pods", "unit": "pods" }
},
"K8S_SERVICE": {
"color": "#5a8de6",
- "topology": {
- "server": { "mqe": "kubernetes_service_http_call_cpm", "label": "RPM", "unit": "rpm" }
- }
+ "metric": { "mqe": "kubernetes_service_http_call_cpm", "label": "RPM", "unit": "rpm" }
},
"OS_LINUX": {
"color": "#fcc419",
- "load": { "mqe": "meter_vm_cpu_total_percentage", "label": "CPU", "unit": "%" }
+ "metric": { "mqe": "meter_vm_cpu_total_percentage", "label": "CPU", "unit": "%" }
},
"OS_WINDOWS": {
"color": "#0078d4",
- "load": { "mqe": "meter_win_cpu_total_percentage", "label": "CPU", "unit": "%" }
+ "metric": { "mqe": "meter_win_cpu_total_percentage", "label": "CPU", "unit": "%" }
},
"VIRTUAL_MACHINE": {
"color": "#f0b429"
},
"AWS_EKS": {
"color": "#ff9900",
- "load": { "mqe": "latest(eks_cluster_node_count)", "label": "Nodes", "unit": "nodes" }
+ "metric": { "mqe": "latest(eks_cluster_node_count)", "label": "Nodes", "unit": "nodes" }
}
}
}
diff --git a/apps/bff/src/config/schema.ts b/apps/bff/src/config/schema.ts
index fdd52ff..37a0005 100644
--- a/apps/bff/src/config/schema.ts
+++ b/apps/bff/src/config/schema.ts
@@ -221,7 +221,6 @@ const rbacSchema = z
'alarm-rule:read',
'alarm-rule:write',
'infra-3d:read',
- 'infra-3d:write',
'rule:read',
'rule:write',
'rule:write:structural',
@@ -266,7 +265,6 @@ const sessionSchema = z
const auditDefault = process.env.HORIZON_AUDIT_FILE ?? './horizon-audit.jsonl';
const setupDefault = process.env.HORIZON_SETUP_FILE ?? './horizon-setup.json';
const alarmsDefault = process.env.HORIZON_ALARMS_FILE ?? './horizon-alarms.json';
-const infra3dDefault = process.env.HORIZON_INFRA3D_FILE ?? './horizon-infra3d.json';
const wireLogDefault = process.env.HORIZON_WIRE_LOG_FILE ?? './horizon-wire.jsonl';
const auditSchema = z
@@ -290,12 +288,6 @@ const alarmsSchema = z
.strict()
.default({ file: alarmsDefault });
-const infra3dSchema = z
- .object({
- file: z.string().default(infra3dDefault),
- })
- .strict()
- .default({ file: infra3dDefault });
const debugLogSchema = z
.object({
@@ -322,8 +314,12 @@ export const configSchema = z
audit: auditSchema,
setup: setupSchema,
alarms: alarmsSchema,
- infra3d: infra3dSchema,
debugLog: debugLogSchema,
+ // Deprecated + ignored. The 3D-map config moved to OAP (a template kind);
+ // the old file-backed `infra3d.file` knob is gone. Accepted here (rather
+ // than rejected by `.strict()`) so an existing config carrying the block
+ // still boots — the value is unused.
+ infra3d: z.unknown().optional(),
})
.strict();
diff --git a/apps/bff/src/http/admin/template-sync.ts b/apps/bff/src/http/admin/template-sync.ts
index ed2c110..a3c300c 100644
--- a/apps/bff/src/http/admin/template-sync.ts
+++ b/apps/bff/src/http/admin/template-sync.ts
@@ -79,6 +79,7 @@ import { writeLayerTemplate } from '../../logic/layers/loader.js';
import type { OverviewDashboard } from '@skywalking-horizon-ui/api-client';
import { writeOverviewDashboard } from '../../logic/overview/loader.js';
import { getLayerOverlay, getOverviewOverlay, isLocale } from '../../i18n/index.js';
+import { validateInfra3dConfig } from '../../logic/infra-3d/validate.js';
import { logger } from '../../logger.js';
export interface TemplateSyncAdminDeps {
@@ -557,6 +558,17 @@ export function registerTemplateSyncAdminRoutes(
message: `expected horizon.<overview|layer|alert>.<key>, got ${JSON.stringify(name)}`,
});
}
+ // Per-kind content validation. The envelope machinery is content-opaque,
+ // so the 3D-map config (the one kind with a strict structural schema)
+ // is checked here before it can reach OAP — a bad regex / dangling
+ // group level is rejected with the same issue list the admin editor
+ // already surfaces, rather than silently shipping garbage to remote.
+ if (parsed.kind === 'infra-3d') {
+ const v = validateInfra3dConfig(content);
+ if (!v.ok) {
+ return reply.code(400).send({ code: 'invalid_content', issues: v.issues });
+ }
+ }
resync(); // fresh OAP read before deciding create-vs-update — peers / past races shouldn't leave us writing duplicates
const status = await loadStatus(deps);
if (status.unreachable) {
diff --git a/apps/bff/src/http/config/infra-3d.ts b/apps/bff/src/http/config/infra-3d.ts
index 0d65b59..be03c3f 100644
--- a/apps/bff/src/http/config/infra-3d.ts
+++ b/apps/bff/src/http/config/infra-3d.ts
@@ -16,27 +16,37 @@
*/
/**
- * `/api/infra-3d/config` — read + write the 3D Infrastructure Map admin
- * config. The GET path is the same source the `/3d/map` view consumes;
- * the POST path is what the `/admin/3d-map` Monaco YAML editor calls on
- * save. Validation happens at the route edge so a bad PUT never reaches
- * the store — the response carries the issue list back to the editor.
+ * `GET /api/infra-3d/config` — the EFFECTIVE 3D Infrastructure Map config
+ * the `/3d/map` view consumes. The config is a first-class template kind
+ * (`horizon.infra-3d.config`), so it follows the same bundled → remote
+ * policy as every other template: when OAP holds a non-disabled remote
+ * row, that wins; otherwise the bundled default is served. Writes do NOT
+ * happen here — the admin editor publishes through the generic template
+ * sync surface (`POST /api/admin/templates/save`), which validates the
+ * 3D-map content before it reaches OAP.
*/
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
+import type { UITemplateClient } from '@skywalking-horizon-ui/api-client';
import type { ConfigSource } from '../../config/loader.js';
import type { SessionStore } from '../../user/sessions.js';
-import type { AuditLogger } from '../../audit/logger.js';
import { requireAuth } from '../../user/middleware.js';
-import type { Infra3dStore } from '../../logic/infra-3d/store.js';
-import { validateInfra3dConfig } from '../../logic/infra-3d/validate.js';
+import { getSyncStatus } from '../../logic/templates/sync.js';
+import { iterateBundledTemplates } from '../../logic/templates/aggregator.js';
+import {
+ formatName,
+ parseEnvelope,
+ INFRA3D_CONFIG_KEY,
+} from '../../logic/templates/names.js';
import { loadBundledInfra3dConfig } from '../../logic/infra-3d/bundled.js';
+import { validateInfra3dConfig } from '../../logic/infra-3d/validate.js';
+import type { Infra3dConfig } from '../../logic/infra-3d/types.js';
+import { logger } from '../../logger.js';
export interface Infra3dConfigRouteDeps {
config: ConfigSource;
sessions: SessionStore;
- audit: AuditLogger;
- store: Infra3dStore;
+ uiTemplateClient: () => UITemplateClient;
}
export function registerInfra3dConfigRoutes(
@@ -49,43 +59,36 @@ export function registerInfra3dConfigRoutes(
'/api/infra-3d/config',
{ preHandler: auth },
async (_req: FastifyRequest, reply: FastifyReply) => {
- const cfg = await deps.store.load();
+ const cfg = await resolveEffectiveConfig(deps);
return reply.send(cfg);
},
);
+}
- // Read-only bundled defaults — the admin editor's "Reset to bundled"
- // button needs a way to retrieve the unsaved baseline so it can POST
- // it back. Same `infra-3d:read` verb as the main GET.
- app.get(
- '/api/infra-3d/config/bundled',
- { preHandler: auth },
- async (_req: FastifyRequest, reply: FastifyReply) => {
- return reply.send(loadBundledInfra3dConfig());
- },
- );
-
- app.post(
- '/api/infra-3d/config',
- { preHandler: auth },
- async (req: FastifyRequest, reply: FastifyReply) => {
- const parsed = validateInfra3dConfig(req.body);
- if (!parsed.ok) {
- return reply.code(400).send({ error: 'invalid_body', issues: parsed.issues });
+/** Remote-wins resolution, mirroring `pickLayerContent` in the config
+ * bundle. The remote envelope is re-validated defensively — a row
+ * hand-edited on OAP into an invalid shape falls back to bundled rather
+ * than breaking the map. */
+async function resolveEffectiveConfig(deps: Infra3dConfigRouteDeps): Promise<Infra3dConfig> {
+ const bundled = loadBundledInfra3dConfig();
+ try {
+ const sync = await getSyncStatus({
+ client: deps.uiTemplateClient(),
+ bundled: () => iterateBundledTemplates(),
+ logger,
+ });
+ const name = formatName('infra-3d', INFRA3D_CONFIG_KEY);
+ const row = sync.rows.find((r) => r.name === name);
+ if (row && row.status !== 'disabled' && row.effective === 'remote' && row.remote) {
+ const env = parseEnvelope(row.remote.configuration);
+ if (env) {
+ const v = validateInfra3dConfig(env.content);
+ if (v.ok) return v.value;
+ logger.warn({ issues: v.issues }, 'remote infra-3d config invalid; using bundled');
}
- await deps.store.save(parsed.value);
- deps.audit.record({
- action: 'infra-3d.config.save',
- actor: req.session?.username ?? null,
- outcome: 'ok',
- details: {
- levels: parsed.value.levels.map((l) => l.id),
- layers: Object.keys(parsed.value.layers).length,
- },
- fromIp: req.ip,
- sessionId: req.session?.sid,
- });
- return reply.send(parsed.value);
- },
- );
+ }
+ } catch (err) {
+ logger.warn({ err }, 'infra-3d effective resolve failed; using bundled');
+ }
+ return bundled;
}
diff --git a/apps/bff/src/logic/infra-3d/bundled.ts b/apps/bff/src/logic/infra-3d/bundled.ts
index 4af0aa7..801ed42 100644
--- a/apps/bff/src/logic/infra-3d/bundled.ts
+++ b/apps/bff/src/logic/infra-3d/bundled.ts
@@ -21,8 +21,9 @@
* path-search as the alarms / overview loaders so dev + container layouts
* both resolve.
*
- * The bundled file is the BFF's read-only baseline; admin-saved deltas
- * shadow it via `Infra3dStore` (file-backed at `source.current.infra3d.file`).
+ * The bundled file is the BFF's read-only baseline; admin edits live on
+ * OAP as the `horizon.infra-3d.config` template row (remote wins at
+ * render, same policy as layer / overview dashboards).
* `loadBundledInfra3dConfig` strips the `$comment` / `$note` JSON-Schema
* helper keys so the returned object matches `Infra3dConfig` exactly.
*/
diff --git a/apps/bff/src/logic/infra-3d/store.ts b/apps/bff/src/logic/infra-3d/store.ts
deleted file mode 100644
index 86c5da8..0000000
--- a/apps/bff/src/logic/infra-3d/store.ts
+++ /dev/null
@@ -1,114 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements. See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/**
- * File-backed live store for the 3D Infrastructure Map config. Mirrors
- * `AlarmsStore` and `SetupStore`:
- * - first `load()` reads the file once, caches it, and falls back to
- * the bundled defaults on missing or unparseable file
- * - `save()` writes via `.tmp` + rename so a concurrent reader never
- * observes a half-written file
- *
- * Bundled-vs-saved policy: a missing store file means "no admin override
- * has been recorded" → return bundled defaults verbatim. A saved file
- * fully replaces the bundled config; partial overrides at the field level
- * are NOT supported (the Monaco YAML editor sends a complete document).
- * Keeps the merge surface zero — the admin sees exactly what's on disk.
- */
-
-import { existsSync } from 'node:fs';
-import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
-import { dirname, resolve } from 'node:path';
-import { logger } from '../../logger.js';
-import { loadBundledInfra3dConfig } from './bundled.js';
-import { validateInfra3dConfig } from './validate.js';
-import type { Infra3dConfig } from './types.js';
-
-export class Infra3dStore {
- private readonly absPath: string;
- private cache: Infra3dConfig | null = null;
- private writing: Promise<void> | null = null;
-
- constructor(filePath: string) {
- this.absPath = resolve(filePath);
- }
-
- async load(): Promise<Infra3dConfig> {
- if (this.cache) return this.cache;
- if (!existsSync(this.absPath)) {
- this.cache = cloneConfig(loadBundledInfra3dConfig());
- return this.cache;
- }
- try {
- const raw = await readFile(this.absPath, 'utf8');
- const parsed = JSON.parse(raw) as unknown;
- const validated = validateInfra3dConfig(parsed);
- if (!validated.ok) {
- logger.warn(
- { path: this.absPath, issues: validated.issues },
- 'infra-3d store validation failed; using bundled defaults',
- );
- this.cache = cloneConfig(loadBundledInfra3dConfig());
- return this.cache;
- }
- this.cache = validated.value;
- return this.cache;
- } catch (err) {
- logger.warn({ err, path: this.absPath }, 'infra-3d store unreadable; using bundled defaults');
- this.cache = cloneConfig(loadBundledInfra3dConfig());
- return this.cache;
- }
- }
-
- async save(next: Infra3dConfig): Promise<void> {
- // Validate again at the store edge — defence in depth. The HTTP
- // route validates the inbound body too, but callers reaching the
- // store directly (CLI, tests) shouldn't be able to persist garbage.
- const validated = validateInfra3dConfig(next);
- if (!validated.ok) {
- throw new Error(`infra-3d config rejected: ${validated.issues.join('; ')}`);
- }
- while (this.writing) await this.writing;
- const snapshot = cloneConfig(validated.value);
- const tmp = `${this.absPath}.tmp`;
- const work = (async () => {
- await mkdir(dirname(this.absPath), { recursive: true });
- await writeFile(tmp, JSON.stringify({ generatedAt: Date.now(), ...snapshot }, null, 2), 'utf8');
- await rename(tmp, this.absPath);
- this.cache = snapshot;
- })();
- this.writing = work;
- try {
- await work;
- } finally {
- this.writing = null;
- }
- }
-
- /** Force the next `load()` to re-read from disk. Used by the admin
- * "reset to bundled defaults" path and by tests. */
- invalidate(): void {
- this.cache = null;
- }
-}
-
-function cloneConfig(cfg: Infra3dConfig): Infra3dConfig {
- // Structured clone keeps the cache and the returned reference
- // distinct so a route that mutates the response (legal — they own
- // their copy) can't corrupt the cached object.
- return JSON.parse(JSON.stringify(cfg)) as Infra3dConfig;
-}
diff --git a/apps/bff/src/logic/infra-3d/types.ts b/apps/bff/src/logic/infra-3d/types.ts
index 5506bef..d773aa6 100644
--- a/apps/bff/src/logic/infra-3d/types.ts
+++ b/apps/bff/src/logic/infra-3d/types.ts
@@ -17,33 +17,27 @@
/**
* Shape of the 3D Infrastructure Map admin config. Bundled defaults live
- * at `apps/bff/src/bundled_templates/infra-3d/config.json`. Admin saves
- * shadow the bundled file via `Infra3dStore` (same file-backed pattern as
- * alarms / setup) — the resolved config the UI consumes is `store.load()`,
- * which returns the saved file when present, otherwise the bundled
- * defaults verbatim.
+ * at `apps/bff/src/bundled_templates/infra-3d/config.json`. The config is
+ * a first-class template kind (`horizon.infra-3d.config`): admin edits are
+ * published to OAP through the generic template-sync surface and win over
+ * bundled at render, exactly like layer / overview dashboards.
*
* Vocabulary:
- * - level — one of the four planes the operator sees stacked top↓bottom
- * (apps / mesh / middleware / infra). Owns a label, a layer
- * allow-list, and an additional per-level regex filter.
+ * - level — one of the planes the operator sees stacked top↓bottom
+ * (apps / mesh / middleware / infra). Owns a label and an
+ * explicit layer allow-list — the only way to pin a layer.
* - layer — OAP layer key (GENERAL, MESH, K8S_SERVICE, …). Carries
- * its render color and either a `topology` (server + client)
- * metric pair OR a single `load` metric. Topology layers
- * prefer server-side for the cube traffic ring, fall back
- * to client-side; non-topology layers always use `load`.
- * - filter — two-tier: `filter.layer` global regex AND the per-level
- * `layerFilter` regex (intersection). A layer is admitted
- * to a level if it passes the global regex AND (matches
- * the level's regex OR appears in the explicit `layers`
- * list).
- * - edges — render styling for the three edge classes drawn on the
- * map (hierarchy / cross-level call / intra-layer call).
+ * its render color and a single traffic `metric`.
+ * - filter — `filter.layer`, ONE global regex applied before levelling:
+ * a layer it excludes is off the map entirely. There is no
+ * per-level regex — pinning is via the explicit `layers` list,
+ * and anything unpinned falls to `unknownLayer.level`.
+ * - edges — render styling carried for a future interaction layer;
+ * not rendered on the always-on map today.
*
- * Schema versioning: there is none on the wire — admin saves are full-doc
- * replacements, structural drift is detected at load time and falls back
- * to bundled defaults (the operator sees a stale UI rather than a hard
- * 500). Bump intentionally if the shape ever changes incompatibly.
+ * Schema versioning: there is none on the wire — saves are full-doc
+ * replacements validated before they reach OAP (see validate.ts); a
+ * remote row that's somehow invalid falls back to bundled at read.
*/
/** MQE entry — used for both topology-pair metrics and the single load
@@ -59,18 +53,19 @@ export interface InfraLayerSpec {
/** Render color for the cube + level tinting. Hex (`#rrggbb`) or any
* CSS color string accepted by Three.js (`new THREE.Color(str)`). */
color: string;
- /** Two metrics for layers whose template carries a topology widget.
- * `server` is preferred for the cube traffic; `client` is the
- * fallback when server data is empty. `client` may be omitted (e.g.
- * `cilium_service` ships server-only). */
+ /** The cube's traffic ring is a single scalar, so each layer carries
+ * ONE metric. Absent ⇒ the cube renders without a ring. This is the
+ * canonical shape; new saves write only this. */
+ metric?: InfraMqe;
+ /** @deprecated Pre-single-metric shapes. The cube ring always resolves
+ * to one value (`server ?? client ?? load`), so these collapsed into
+ * `metric`. Still accepted on the wire so an older saved OAP row keeps
+ * rendering; the renderer prefers `metric`, then falls back to these. */
topology?: {
server?: InfraMqe;
client?: InfraMqe;
};
- /** Single metric for layers without a topology widget. Mutually
- * exclusive with `topology` at the bundled layer; admin overrides
- * may set either. If neither is present the cube renders without a
- * traffic ring. */
+ /** @deprecated See `topology` — folded into `metric`. */
load?: InfraMqe;
}
@@ -82,11 +77,9 @@ export interface InfraLevelSpec {
order: number;
/** Operator-facing name for the level (rendered in the side panel). */
label: string;
- /** Per-level layer regex. Default `.*`. A layer is admitted to a
- * level when it passes the global filter AND (matches this regex OR
- * appears in `layers`). */
- layerFilter: string;
- /** Explicit allow-list of OAP layer keys (canonical upper-case form). */
+ /** Explicit allow-list of OAP layer keys (canonical upper-case form).
+ * The ONLY way a layer is pinned to a level — there is no per-level
+ * regex; layers no level claims fall to `unknownLayer.level`. */
layers: string[];
}
diff --git a/apps/bff/src/logic/infra-3d/validate.ts b/apps/bff/src/logic/infra-3d/validate.ts
index bd59080..06945f2 100644
--- a/apps/bff/src/logic/infra-3d/validate.ts
+++ b/apps/bff/src/logic/infra-3d/validate.ts
@@ -16,14 +16,14 @@
*/
/**
- * Zod-validated shape for `Infra3dConfig`. The HTTP route uses this on
- * inbound PUTs; the store uses it again on its own writes (defence in
- * depth) and on reads (so a hand-edited file with the wrong shape falls
- * back to bundled defaults instead of crashing the UI). The schema is
- * deliberately strict (`.strict()` everywhere) — unknown keys are an
- * error, not a forward-compat hint, because admin saves are full-doc
- * replacements: an extra key is almost always a typo or a stale field
- * from a UI bug.
+ * Zod-validated shape for `Infra3dConfig`. Used in two places: the
+ * generic template save route validates 3D-map content before it reaches
+ * OAP, and `resolveEffectiveConfig` re-validates the remote envelope on
+ * read so a row hand-edited on OAP into the wrong shape falls back to
+ * bundled instead of breaking the map. The schema is deliberately strict
+ * (`.strict()` everywhere) — unknown keys are an error, not a forward-compat
+ * hint, because saves are full-doc replacements: an extra key is almost
+ * always a typo or a stale field from a UI bug.
*
* Returns a tagged-union result rather than throwing — the validation
* paths above all want issue lists to render in the admin UI / logs.
@@ -43,6 +43,11 @@ const mqeSchema = z
const layerSpecSchema = z
.object({
color: z.string().min(1),
+ // Canonical single metric for the cube ring.
+ metric: mqeSchema.optional(),
+ // Deprecated pre-single-metric shapes — still accepted so older saved
+ // rows validate; the renderer folds them into `metric` (server ??
+ // client ?? load). New saves write `metric` only.
topology: z
.object({
server: mqeSchema.optional(),
@@ -82,7 +87,6 @@ const levelSpecSchema = z
}),
order: z.number().int().nonnegative(),
label: z.string().min(1),
- layerFilter: z.string().refine(isValidRegex, { message: 'invalid regex' }),
layers: z.array(z.string().min(1)),
})
.strict();
@@ -165,6 +169,24 @@ const configSchema = z
});
}
}
+ // A layer belongs to at most ONE logic group — multiple memberships make
+ // the group/tier lookup ambiguous (different render passes pick different
+ // groups). Same single-claim rule as the per-level `layers` lists below.
+ const groupClaimed = new Map<string, string>();
+ for (const g of cfg.groups) {
+ for (const key of g.layers) {
+ const k = key.toUpperCase();
+ const prev = groupClaimed.get(k);
+ if (prev && prev !== g.id) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `layer ${k} claimed by both groups: ${prev}, ${g.id}`,
+ path: ['groups'],
+ });
+ }
+ groupClaimed.set(k, g.id);
+ }
+ }
// 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/bff/src/logic/templates/aggregator.ts b/apps/bff/src/logic/templates/aggregator.ts
index a429402..8bbf34a 100644
--- a/apps/bff/src/logic/templates/aggregator.ts
+++ b/apps/bff/src/logic/templates/aggregator.ts
@@ -32,10 +32,12 @@ import {
loadBundledThemeActive,
loadBundledTimeDefaults,
} from './global-defaults-bundled.js';
+import { loadBundledInfra3dConfig } from '../infra-3d/bundled.js';
import {
ALERT_PAGE_SETUP_KEY,
THEME_ACTIVE_KEY,
TIME_DEFAULTS_KEY,
+ INFRA3D_CONFIG_KEY,
type TemplateKind,
} from './names.js';
import type { BundledTemplate } from './sync.js';
@@ -63,6 +65,11 @@ export function* iterateBundledTemplates(): IterableIterator<BundledTemplate> {
key: TIME_DEFAULTS_KEY,
content: loadBundledTimeDefaults(),
};
+ yield {
+ kind: 'infra-3d' satisfies TemplateKind,
+ key: INFRA3D_CONFIG_KEY,
+ content: loadBundledInfra3dConfig(),
+ };
}
/** Iterate per-locale translation overlays the BFF ships on disk —
diff --git a/apps/bff/src/logic/templates/names.ts b/apps/bff/src/logic/templates/names.ts
index 0405c5f..1cef5a9 100644
--- a/apps/bff/src/logic/templates/names.ts
+++ b/apps/bff/src/logic/templates/names.ts
@@ -47,7 +47,13 @@
* key order is stable.
*/
-export type TemplateKind = 'overview' | 'layer' | 'alert' | 'theme' | 'time-defaults';
+export type TemplateKind =
+ | 'overview'
+ | 'layer'
+ | 'alert'
+ | 'theme'
+ | 'time-defaults'
+ | 'infra-3d';
export const TEMPLATE_KINDS: readonly TemplateKind[] = [
'overview',
@@ -55,6 +61,7 @@ export const TEMPLATE_KINDS: readonly TemplateKind[] = [
'alert',
'theme',
'time-defaults',
+ 'infra-3d',
] as const;
/** Single alert template key — alert page-setup is a singleton. */
@@ -63,14 +70,16 @@ export const ALERT_PAGE_SETUP_KEY = 'page-setup' as const;
export const THEME_ACTIVE_KEY = 'active' as const;
/** Singleton key for the global time-defaults setup. */
export const TIME_DEFAULTS_KEY = 'global' as const;
+/** Singleton key for the 3D Infrastructure Map config. */
+export const INFRA3D_CONFIG_KEY = 'config' as const;
// Source-row name (no `.i18n.` segment). Layer / overview keys are
// `[A-Za-z0-9_-]+` — no dots, so the `.i18n.` discriminator can't
// collide with a real key.
-const SOURCE_NAME_RE = /^horizon\.(overview|layer|alert|theme|time-defaults)\.([A-Za-z0-9_-]+)$/;
+const SOURCE_NAME_RE = /^horizon\.(overview|layer|alert|theme|time-defaults|infra-3d)\.([A-Za-z0-9_-]+)$/;
// Per-locale overlay row. Locale segment matches BCP-47 forms shipped
// by Horizon today: `zh-CN`, `es`, `pt`, `ja`, `ko` (`en` has no row).
-const OVERLAY_NAME_RE = /^horizon\.(overview|layer|alert|theme|time-defaults)\.([A-Za-z0-9_-]+)\.i18n\.([A-Za-z][A-Za-z0-9-]*)$/;
+const OVERLAY_NAME_RE = /^horizon\.(overview|layer|alert|theme|time-defaults|infra-3d)\.([A-Za-z0-9_-]+)\.i18n\.([A-Za-z][A-Za-z0-9-]*)$/;
export interface ParsedName {
kind: TemplateKind;
diff --git a/apps/bff/src/rbac/route-policy.ts b/apps/bff/src/rbac/route-policy.ts
index 7614180..ae428e0 100644
--- a/apps/bff/src/rbac/route-policy.ts
+++ b/apps/bff/src/rbac/route-policy.ts
@@ -145,8 +145,6 @@ export const ROUTE_POLICY: Record<string, RoutePolicy> = {
'GET /api/alarms/config': 'alarm-setup:read',
'POST /api/alarms/config': 'alarm-setup:write',
'GET /api/infra-3d/config': 'infra-3d:read',
- 'GET /api/infra-3d/config/bundled': 'infra-3d:read',
- 'POST /api/infra-3d/config': 'infra-3d:write',
'POST /api/infra-3d/metrics': 'infra-3d:read',
'GET /api/setup': 'setup:read',
'POST /api/setup': 'setup:write',
diff --git a/apps/bff/src/server.ts b/apps/bff/src/server.ts
index 1fe25d7..bb759f8 100644
--- a/apps/bff/src/server.ts
+++ b/apps/bff/src/server.ts
@@ -80,7 +80,6 @@ import { registerAuthHealthRoute } from './http/auth-health.js';
import { registerColdStageHook } from './util/duration.js';
// Logic / stores
import { AlarmsStore } from './logic/alarms/store.js';
-import { Infra3dStore } from './logic/infra-3d/store.js';
import { SetupStore } from './logic/setup/store.js';
import { serviceLayerCatalog } from './logic/services/service-layer-catalog.js';
import { HttpError } from './errors.js';
@@ -132,8 +131,6 @@ const setupStore = new SetupStore(source.current.setup.file);
await setupStore.load();
const alarmsStore = new AlarmsStore(source.current.alarms.file);
await alarmsStore.load();
-const infra3dStore = new Infra3dStore(source.current.infra3d.file);
-await infra3dStore.load();
// Server-global service-by-layer index — shared by the sidebar menu, the
// alarms tagger, and any other surface that needs the service ↔ layer
// mapping. 60s TTL + single-flight dedup; one OAP fan-out per minute
@@ -201,7 +198,11 @@ registerLayerTemplateRoutes(app, { config: source, sessions });
// would exhaust the fd ceiling on low-ulimit CI).
if (process.env.NODE_ENV === 'development') startLayerTemplateWatcher();
registerAlarmsConfigRoutes(app, { config: source, sessions, audit, store: alarmsStore, serviceLayer });
-registerInfra3dConfigRoutes(app, { config: source, sessions, audit, store: infra3dStore });
+registerInfra3dConfigRoutes(app, {
+ config: source,
+ sessions,
+ uiTemplateClient: () => buildOapClients(source.current).uiTemplate(),
+});
registerInfra3dMetricsRoute(app, { config: source, sessions });
registerSetupRoutes(app, { config: source, sessions, audit, store: setupStore });
registerOverviewRoutes(app, { config: source, sessions });
diff --git a/apps/ui/src/api/client.ts b/apps/ui/src/api/client.ts
index 463606d..787dbd5 100644
--- a/apps/ui/src/api/client.ts
+++ b/apps/ui/src/api/client.ts
@@ -589,7 +589,11 @@ export interface InfraMqe {
export interface InfraLayerSpec {
color: string;
+ /** Single traffic metric for the cube ring — canonical shape. */
+ metric?: InfraMqe;
+ /** @deprecated folded into `metric`; accepted for back-compat. */
topology?: { server?: InfraMqe; client?: InfraMqe };
+ /** @deprecated folded into `metric`; accepted for back-compat. */
load?: InfraMqe;
}
@@ -597,7 +601,6 @@ export interface InfraLevelSpec {
id: string;
order: number;
label: string;
- layerFilter: string;
layers: string[];
}
diff --git a/apps/ui/src/api/scopes/configs.ts b/apps/ui/src/api/scopes/configs.ts
index 85f2465..8c2f0d9 100644
--- a/apps/ui/src/api/scopes/configs.ts
+++ b/apps/ui/src/api/scopes/configs.ts
@@ -27,9 +27,15 @@ export type BundleScopeMap = Partial<
Record<'service' | 'instance' | 'endpoint', DashboardWidget[]>
>;
-/** What kind of template a sync-status row describes. Five reserved
- * kinds — see the BFF's `apps/bff/src/logic/templates/names.ts`. */
-export type TemplateKind = 'overview' | 'layer' | 'alert' | 'theme' | 'time-defaults';
+/** What kind of template a sync-status row describes. Reserved kinds —
+ * see the BFF's `apps/bff/src/logic/templates/names.ts`. */
+export type TemplateKind =
+ | 'overview'
+ | 'layer'
+ | 'alert'
+ | 'theme'
+ | 'time-defaults'
+ | 'infra-3d';
/** Status of a single template, mirrored from the BFF sync orchestrator.
* - `synced` — bundled == remote, byte-equal
diff --git a/apps/ui/src/api/scopes/infra-3d.ts b/apps/ui/src/api/scopes/infra-3d.ts
index 519d038..3e9bae8 100644
--- a/apps/ui/src/api/scopes/infra-3d.ts
+++ b/apps/ui/src/api/scopes/infra-3d.ts
@@ -17,10 +17,11 @@
import type { BffClient, Infra3dConfig } from '../client';
-/** `bff.infra3d` — 3D Infrastructure Map admin config CRUD. The GET
- * returns the live config (admin-saved override OR bundled defaults
- * when no save exists). The POST accepts the full document — server
- * validates and returns 400 with `{issues:[...]}` on failure. */
+/** `bff.infra3d` — 3D Infrastructure Map read surface. `config()` returns
+ * the EFFECTIVE config (remote OAP copy when present, bundled fallback).
+ * Writes go through the generic template-sync surface
+ * (`bff.templateSync.save('horizon.infra-3d.config', content)`), same as
+ * layer / overview dashboards. */
export class Infra3dApi {
constructor(private readonly bff: BffClient) {}
@@ -28,16 +29,6 @@ export class Infra3dApi {
return this.bff.request<Infra3dConfig>('GET', '/api/infra-3d/config');
}
- /** Bundled defaults — independent of admin saves. Used by the
- * admin editor's "Reset to bundled" button. */
- bundledConfig(): Promise<Infra3dConfig> {
- return this.bff.request<Infra3dConfig>('GET', '/api/infra-3d/config/bundled');
- }
-
- saveConfig(next: Infra3dConfig): Promise<Infra3dConfig> {
- return this.bff.request<Infra3dConfig>('POST', '/api/infra-3d/config', next);
- }
-
/** Batched per-service MQE fetch for the cube traffic ring. Caller
* pre-chunks the service list to fit the BFF's per-call cap (the
* endpoint will 400 on > MAX_SERVICES). */
diff --git a/apps/ui/src/features/admin/infra-3d/Infra3dAdminView.vue b/apps/ui/src/features/admin/infra-3d/Infra3dAdminView.vue
index d32f1f9..23a627b 100644
--- a/apps/ui/src/features/admin/infra-3d/Infra3dAdminView.vue
+++ b/apps/ui/src/features/admin/infra-3d/Infra3dAdminView.vue
@@ -17,43 +17,50 @@
<!--
Admin: 3D Infrastructure Map · structured config editor.
+ The 3D-map config is a singleton template kind (`horizon.infra-3d.config`)
+ on the same bundled → local(browser) → remote(OAP) machinery as the layer
+ / overview dashboards: edits save to a browser-local draft, "Check diff &
+ push" publishes to OAP, and the map renders the remote (bundled fallback).
+
Sections (top-to-bottom):
- 1. Header + action bar (Save / Reset to bundled / Discard).
- 2. Banner — dirty / saved / issues list from server validation.
+ 1. Header — source pill + sync badge + Reset menu (to remote / to
+ bundled) + Save (local draft) + Check diff & push (Monaco diff vs
+ remote, then publish).
+ 2. SyncStatusBanner — synced / diverged / OAP-unreachable state.
3. Global filter — one regex (`filter.layer`).
- 4. Levels — per-level cards (id, order, label, layerFilter regex,
- explicit-layer chip grid).
- 5. Layers — table over the UNION of (a) OAP `listLayers` catalog
- (live, refetched on the menu's TTL) and (b) layer keys already
- in the config. Each row carries color, level dropdown, has-
- topology badge (from `caps.serviceMap`), and an expandable MQE
- editor (server + client pair OR single load).
- 6. Edges — three cards (hierarchy / cross-level call / intra-layer
- call) with color + style + arrow toggle.
- 7. Pipeline — three numeric inputs (chunk + concurrency limits).
- 8. Unknown layer — fallback level dropdown + badge text.
- 9. Advanced (collapsible) — Monaco JSON editor showing the full
- resolved doc, for power-edits the structured UI can't express.
-
- Editing is local-first: the page hydrates from
- `GET /api/infra-3d/config`, mutates a draft, and POSTs the whole doc
- on Save. Server validation issues land back in the banner with the
- exact field path so the operator can jump to the input that failed.
+ 4. Tiers & layers — per-tier cards: id / order / label + the layers
+ pinned to each tier (explicit `layers` lists are the only placement
+ mechanism). Anything unpinned falls to the failover tier.
+ 5. Layers — color + traffic MQE per layer, with each layer's RESOLVED
+ level shown read-only (assignment lives in Levels).
+ 6. Groups — logic-group cards (id / label / tier / color / icon +
+ member chips).
+ 7. Failover tier — the single catch-all for unpinned layers.
+
+ Validation runs server-side on push (the generic save endpoint checks
+ 3D-map content before it reaches OAP); issues land in the push error list.
-->
<script setup lang="ts">
-import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
-import * as monaco from 'monaco-editor';
+import { computed, onMounted, ref, watch } from 'vue';
import { useLayers } from '@/shell/useLayers';
import {
bff,
type Infra3dConfig,
- type InfraEdgeStyle,
+ type InfraGroupSpec,
type InfraLayerSpec,
type InfraLevelSpec,
type InfraMqe,
} from '@/api/client';
import { refresh as refreshLiveInfraConfig } from '@/features/infra-3d/composables/useInfra3dConfig';
-import { setupMonaco, RR_THEME_NAME } from '@/monaco/setup';
+import { useTemplateSources } from '@/features/admin/_shared/useTemplateSources';
+import { useTemplateSync } from '@/features/admin/_shared/useTemplateSync';
+import { useLocalTemplateEdits } from '@/controls/localTemplateEdits';
+import { refreshConfigBundle } from '@/controls/configBundle';
+import { stableStringify } from '@/utils/stableJson';
+import SyncStatusBanner from '@/features/admin/_shared/SyncStatusBanner.vue';
+import TemplateStatusBadge from '@/features/admin/_shared/TemplateStatusBadge.vue';
+import Modal from '@/features/operate/_shared/Modal.vue';
+import MonacoDiff from '@/features/operate/_shared/MonacoDiff.vue';
// ── Live OAP layer catalog ────────────────────────────────────────────
// We hydrate the Layers section from the catalog union'd with config
@@ -62,107 +69,194 @@ import { setupMonaco, RR_THEME_NAME } from '@/monaco/setup';
// also shows so the admin can remove it.
const { availableLayers } = useLayers();
-// ── State ─────────────────────────────────────────────────────────────
-const loading = ref(true);
-const saving = ref(false);
-const flash = ref<{ kind: 'ok' | 'err'; text: string } | null>(null);
-const issues = ref<string[]>([]);
+// ── Template-sync state ───────────────────────────────────────────────
+// The 3D-map config is a singleton template kind — ONE row,
+// `horizon.infra-3d.config`. It rides the same bundled → local(browser)
+// → remote(OAP) machinery as the layer / overview dashboards: edits save
+// to a browser-local draft, "Check diff & push" publishes to OAP, and
+// the map renders the remote (with bundled as fallback).
+const NAME = 'horizon.infra-3d.config';
+const sources = useTemplateSources('infra-3d');
+const sync = useTemplateSync({ kind: 'infra-3d' });
+const localEdits = useLocalTemplateEdits();
+
+/** Working copy the structured editor below mutates. */
const draft = ref<Infra3dConfig | null>(null);
-/** Persisted snapshot — used as the dirty-check baseline and as the
- * "Discard changes" target. Updated on every successful load / save. */
-const orig = ref<string | null>(null);
-/** Per-row UI state — which layer rows are expanded for MQE editing. */
-const expandedLayers = ref<Set<string>>(new Set());
const layerSearch = ref('');
-const advancedOpen = ref(false);
+/** Which of the three sources the editor is currently showing. */
+const editorSource = ref<'local' | 'bundled' | 'remote'>('remote');
+const pushing = ref(false);
+const pushErr = ref<string[] | null>(null);
+const pushDiffOpen = ref(false);
+const resetMenuOpen = ref(false);
+
+const readOnly = computed(() => sync.readOnly.value);
+const ready = computed(() => !sources.isLoading.value && draft.value !== null);
+const hasLocalDraft = computed(() => localEdits.has(NAME));
+const hasRemote = computed(() => sources.hasRemote(NAME));
+/** Per-row sync badge (synced / diverged / …) for the source pill. */
+const badge = computed(() => sync.badgeFor(NAME));
+/** Bundled byte-equals OAP-live — resetting to bundled is then a no-op. */
+const isSynced = computed(() => badge.value === 'synced');
+
+function clone<T>(v: T): T {
+ return JSON.parse(JSON.stringify(v)) as T;
+}
+
+/** Collapse the deprecated `topology` / `load` shapes into the single
+ * `metric` the editor edits, so an OAP row saved before the single-metric
+ * change still shows its metric. Mutates in place. */
+function normalizeSpecs(cfg: Infra3dConfig): void {
+ for (const spec of Object.values(cfg.layers)) {
+ if (!spec.metric) {
+ const m = spec.topology?.server ?? spec.topology?.client ?? spec.load;
+ if (m) spec.metric = m;
+ }
+ delete spec.topology;
+ delete spec.load;
+ }
+}
+
+/** Cloned + normalized — used as a comparison baseline so an old-shape
+ * remote doesn't read as "dirty" the moment it loads. */
+function normalizedClone(cfg: Infra3dConfig | null): Infra3dConfig | null {
+ if (!cfg) return null;
+ const c = clone(cfg);
+ normalizeSpecs(c);
+ return c;
+}
-function setFlash(kind: 'ok' | 'err', text: string): void {
- flash.value = { kind, text };
- setTimeout(() => {
- if (flash.value && flash.value.text === text) flash.value = null;
- }, 4000);
+function loadFrom(src: 'local' | 'bundled' | 'remote'): void {
+ let next: Infra3dConfig | null = null;
+ if (src === 'local') next = localEdits.get<Infra3dConfig>(NAME) ?? null;
+ else if (src === 'remote') next = sources.remote<Infra3dConfig>(NAME);
+ else next = sources.bundled<Infra3dConfig>(NAME);
+ // Bundled always exists; remote/local may be null — fall back so the
+ // editor always has a working doc.
+ if (!next) next = sources.bundled<Infra3dConfig>(NAME);
+ draft.value = normalizedClone(next);
+ editorSource.value = src;
}
-function snapshot(cfg: Infra3dConfig): string {
- return JSON.stringify(cfg);
+/** Pick the source on (re)load: local draft if present, else remote (the
+ * runtime source of truth), else bundled. */
+function syncDraft(): void {
+ if (hasLocalDraft.value) loadFrom('local');
+ else if (hasRemote.value) loadFrom('remote');
+ else loadFrom('bundled');
}
+/** Set when the sources settle but still produce no working doc (the BFF
+ * itself is unreachable — bundled rows are absent, so there's nothing to
+ * edit). Surfaces an error instead of an indefinite spinner. */
+const loadError = ref(false);
+
+onMounted(async () => {
+ // Force a fresh OAP read so badges + remote content are current.
+ await refreshConfigBundle({ force: true });
+ await sources.refetch();
+ syncDraft();
+ loadError.value = draft.value === null;
+});
+
+// Cold-cache arrival — hydrate once the sources land if mount raced ahead.
+watch(
+ () => sources.isLoading.value,
+ (loading) => {
+ if (loading) return;
+ if (!draft.value) syncDraft();
+ loadError.value = draft.value === null;
+ },
+);
+
+const localContent = computed<Infra3dConfig | null>(
+ () => localEdits.get<Infra3dConfig>(NAME) ?? null,
+);
+const remoteContent = computed<Infra3dConfig | null>(() => sources.remote<Infra3dConfig>(NAME));
+
+/** Dirty = the editor diverges from its baseline (the local draft when
+ * one exists, otherwise remote / bundled). Drives the Save button. */
const dirty = computed(() => {
- if (!draft.value || orig.value === null) return false;
- return snapshot(draft.value) !== orig.value;
+ if (!draft.value) return false;
+ const baseline = hasLocalDraft.value
+ ? localContent.value
+ : (remoteContent.value ?? sources.bundled<Infra3dConfig>(NAME));
+ // Normalize the baseline to the single-metric shape so an old-shape
+ // remote (topology/load) doesn't read as dirty against the normalized draft.
+ return stableStringify(draft.value) !== stableStringify(normalizedClone(baseline ?? null));
});
-async function loadConfig(): Promise<void> {
- loading.value = true;
- try {
- const live = await bff.infra3d.config();
- draft.value = live;
- orig.value = snapshot(live);
- issues.value = [];
- } catch (err) {
- setFlash('err', err instanceof Error ? err.message : String(err));
- } finally {
- loading.value = false;
- }
+/** Save the current editor state as a browser-local draft. The only
+ * "save" — it never writes OAP. Publish via "Check diff & push". */
+function save(): void {
+ if (!draft.value) return;
+ localEdits.set(NAME, clone(draft.value));
+ editorSource.value = 'local';
}
-onMounted(() => {
- void loadConfig();
-});
+const pushLocalPretty = computed(() => (draft.value ? stableStringify(draft.value, 2) : ''));
+const pushRemotePretty = computed(() => stableStringify(remoteContent.value ?? null, 2));
+
+function openPushDiff(): void {
+ if (!draft.value) return;
+ // Persist the editor state first so the diff reflects exactly what
+ // would be pushed.
+ save();
+ pushErr.value = null;
+ pushDiffOpen.value = true;
+}
-async function onSave(): Promise<void> {
- if (!draft.value || saving.value) return;
- saving.value = true;
- issues.value = [];
+async function pushToOap(): Promise<void> {
+ if (!draft.value || pushing.value) return;
+ pushing.value = true;
+ pushErr.value = null;
+ // 1) The publish — the only thing whose failure means "not saved".
try {
- const saved = await bff.infra3d.saveConfig(draft.value);
- draft.value = saved;
- orig.value = snapshot(saved);
- // Pop the next /3d/map view's read cache so it picks up the new
- // config without a page reload.
- await refreshLiveInfraConfig();
- setFlash('ok', 'Saved. The 3D map will reload on next visit.');
+ await bff.templateSync.save(NAME, draft.value);
} catch (err) {
const anyErr = err as { body?: { issues?: string[] }; message?: string };
- if (anyErr.body?.issues && Array.isArray(anyErr.body.issues)) {
- issues.value = anyErr.body.issues;
- setFlash('err', `Rejected: ${anyErr.body.issues.length} issue(s) — see below.`);
- } else {
- setFlash('err', anyErr.message ?? String(err));
- }
- } finally {
- saving.value = false;
+ pushErr.value = anyErr.body?.issues ?? [anyErr.message ?? String(err)];
+ pushing.value = false;
+ return; // keep the modal open with the error; nothing was committed
}
-}
-
-function onDiscard(): void {
- if (!orig.value) return;
- draft.value = JSON.parse(orig.value) as Infra3dConfig;
- issues.value = [];
-}
-
-async function onResetBundled(): Promise<void> {
+ // Committed. Clear the local draft + close the modal regardless of the
+ // post-write cache refresh outcome below.
+ localEdits.remove(NAME);
+ pushDiffOpen.value = false;
+ // 2) Best-effort cache refreshes. A failure here (e.g. the /3d/map read
+ // cache pop 403-ing for a role without infra-3d:read) does NOT undo the
+ // publish, so it must never surface as a "push failed".
try {
- const b = await bff.infra3d.bundledConfig();
- draft.value = b;
- // dirty intentionally — operator reviews and clicks Save.
- setFlash('ok', 'Bundled defaults loaded — review and Save to apply.');
+ await bff.templateSync.resync();
+ await Promise.all([sources.refetch(), refreshConfigBundle({ force: true })]);
+ await refreshLiveInfraConfig();
+ loadFrom('remote');
} catch (err) {
- setFlash('err', err instanceof Error ? err.message : String(err));
+ console.warn('[infra-3d] post-push refresh failed (the publish already committed):', err);
+ } finally {
+ pushing.value = false;
}
}
+/** Reset the editor to a clean source and drop any local draft. */
+function resetTo(src: 'bundled' | 'remote'): void {
+ loadFrom(src);
+ localEdits.remove(NAME);
+ pushErr.value = null;
+ resetMenuOpen.value = false;
+}
+
// ── Levels editing ────────────────────────────────────────────────────
function addLevel(): void {
if (!draft.value) return;
const maxOrder = Math.max(-1, ...draft.value.levels.map((l) => l.order));
- draft.value.levels.push({
- id: `level-${draft.value.levels.length + 1}`,
- order: maxOrder + 1,
- label: 'New level',
- layerFilter: '.*',
- layers: [],
- });
+ // Unique id — a length-based id collides after add-then-remove editing
+ // (and a duplicate level id aliases tier buckets + fails save validation).
+ const taken = new Set(draft.value.levels.map((l) => l.id));
+ let n = draft.value.levels.length + 1;
+ let id = `level-${n}`;
+ while (taken.has(id)) id = `level-${++n}`;
+ draft.value.levels.push({ id, order: maxOrder + 1, label: 'New level', layers: [] });
}
function removeLevel(id: string): void {
if (!draft.value) return;
@@ -171,23 +265,75 @@ function removeLevel(id: string): void {
draft.value.unknownLayer.level = draft.value.levels[0]!.id;
}
}
+const levelsSorted = computed<InfraLevelSpec[]>(() => {
+ if (!draft.value) return [];
+ return [...draft.value.levels].sort((a, b) => a.order - b.order);
+});
+
+/** `idx` is the index in `levelsSorted` (what the template renders) — NOT
+ * the unsorted `draft.levels` array, whose order can differ from the sort
+ * (bundled has mesh order 2 before middleware order 1). Swap the `order`
+ * values of the two SORTED neighbours so ↑/↓ moves the intended tier. */
function moveLevel(idx: number, delta: number): void {
- if (!draft.value) return;
+ const sorted = levelsSorted.value;
const target = idx + delta;
- if (target < 0 || target >= draft.value.levels.length) return;
- // Swap `order` values so the resulting sort is stable.
- const a = draft.value.levels[idx]!;
- const b = draft.value.levels[target]!;
+ if (target < 0 || target >= sorted.length) return;
+ const a = sorted[idx]!;
+ const b = sorted[target]!;
const ao = a.order;
a.order = b.order;
b.order = ao;
}
-const levelsSorted = computed<InfraLevelSpec[]>(() => {
- if (!draft.value) return [];
- return [...draft.value.levels].sort((a, b) => a.order - b.order);
+// ── Layer ⇄ level resolution ──────────────────────────────────────────
+// Level membership is edited in ONE place — the Levels section's member
+// chips. The Layers section only DISPLAYS where each layer lands, using
+// the same resolution order the BFF applies, so the two surfaces never
+// drift into the old "edit the same array twice" redundancy.
+
+/** Compile that never throws — a half-typed regex in an input just stops
+ * matching rather than blowing up the page. */
+function safeRegex(src: string): RegExp | null {
+ try {
+ return new RegExp(src);
+ } catch {
+ return null;
+ }
+}
+
+/** Union of OAP-reported + config-declared layer keys (canonical upper). */
+const allLayerKeys = computed<string[]>(() => {
+ const s = new Set<string>();
+ for (const L of availableLayers.value ?? []) s.add(L.key.toUpperCase());
+ if (draft.value) for (const k of Object.keys(draft.value.layers)) s.add(k.toUpperCase());
+ return [...s].sort((a, b) => a.localeCompare(b));
});
+type LevelVia = 'group' | 'explicit' | 'default' | 'filtered';
+
+/** Mirror the BFF's layer→level resolution (see useInfra3dConfig): global
+ * filter → group membership → explicit list → unknownLayer fallback.
+ * Returns HOW it resolved so the unpinned rows can flag fallback / filtered
+ * layers the operator didn't pin by hand. */
+function resolveLevel(key: string): { levelId: string | null; via: LevelVia } {
+ if (!draft.value) return { levelId: null, via: 'default' };
+ const u = key.toUpperCase();
+ const gf = safeRegex(draft.value.filter.layer);
+ if (gf && !gf.test(u)) return { levelId: null, via: 'filtered' };
+ for (const g of draft.value.groups ?? []) {
+ if (g.layers.some((k) => k.toUpperCase() === u)) return { levelId: g.level, via: 'group' };
+ }
+ for (const lvl of draft.value.levels) {
+ if (lvl.layers.some((k) => k.toUpperCase() === u)) return { levelId: lvl.id, via: 'explicit' };
+ }
+ return { levelId: draft.value.unknownLayer.level, via: 'default' };
+}
+
+function levelLabel(levelId: string | null): string {
+ if (!levelId) return '—';
+ return draft.value?.levels.find((l) => l.id === levelId)?.label ?? levelId;
+}
+
// ── Layers editing ────────────────────────────────────────────────────
/** Union of OAP-known + config-known layers. The keys are canonical
* upper-case; the entry's `inOap`/`inConfig` flags drive the row badges. */
@@ -196,19 +342,14 @@ interface LayerRow {
inOap: boolean;
inConfig: boolean;
hasTopology: boolean;
- /** Layer template's `group` / OAP grouping. Surfaces as a row badge. */
- group: string | null;
spec: InfraLayerSpec | null;
}
const layerRows = computed<LayerRow[]>(() => {
if (!draft.value) return [];
- const oap = new Map<string, { hasTopology: boolean; group: string | null }>();
+ const oap = new Map<string, { hasTopology: boolean }>();
for (const L of availableLayers.value ?? []) {
- oap.set(L.key.toUpperCase(), {
- hasTopology: !!L.caps?.serviceMap,
- group: L.group ?? null,
- });
+ oap.set(L.key.toUpperCase(), { hasTopology: !!L.caps?.serviceMap });
}
const keys = new Set<string>([...oap.keys(), ...Object.keys(draft.value.layers).map((k) => k.toUpperCase())]);
const out: LayerRow[] = [];
@@ -219,7 +360,6 @@ const layerRows = computed<LayerRow[]>(() => {
inOap: !!o,
inConfig: !!draft.value.layers[k],
hasTopology: o?.hasTopology ?? !!draft.value.layers[k]?.topology,
- group: o?.group ?? null,
spec: draft.value.layers[k] ?? null,
});
}
@@ -232,11 +372,72 @@ const layerRows = computed<LayerRow[]>(() => {
});
const filteredLayerRows = computed<LayerRow[]>(() => {
+ // Search-only. The global `filter.layer` does NOT hide rows here — an
+ // excluded layer still shows (in Unpinned, tagged "filtered out") so the
+ // operator can see what the filter drops; it's the SCENE that omits it.
const q = layerSearch.value.trim().toUpperCase();
if (!q) return layerRows.value;
return layerRows.value.filter((r) => r.key.includes(q));
});
+/** Layers whose template provides a service map (`caps.serviceMap`) — their
+ * cubes render as a call graph on the map. Read-only here (it's a layer
+ * template property, not a 3D-config field), surfaced in its own section. */
+const serviceMapLayers = computed<string[]>(() =>
+ layerRows.value.filter((r) => r.hasTopology).map((r) => r.key),
+);
+
+/** Resolved level per layer-key — drives the read-only "via" qualifier on
+ * UNPINNED rows so the operator sees how a layer lands when no tier pins
+ * it (regex / fallback / filtered). */
+const resolvedLevels = computed<Record<string, { levelId: string | null; via: LevelVia }>>(() => {
+ const out: Record<string, { levelId: string | null; via: LevelVia }> = {};
+ for (const r of layerRows.value) out[r.key] = resolveLevel(r.key);
+ return out;
+});
+
+/** Explicit tier (level id) a layer is pinned to, or absent when unpinned. */
+const explicitTier = computed<Record<string, string>>(() => {
+ const m: Record<string, string> = {};
+ for (const lvl of draft.value?.levels ?? []) {
+ for (const k of lvl.layers) m[k.toUpperCase()] = lvl.id;
+ }
+ return m;
+});
+
+/** Filtered layer rows grouped under their pinned tier. Every tier gets an
+ * entry (possibly empty) so the combined view renders one block per tier. */
+const layersByTier = computed<Record<string, LayerRow[]>>(() => {
+ const out: Record<string, LayerRow[]> = {};
+ for (const lvl of draft.value?.levels ?? []) out[lvl.id] = [];
+ for (const r of filteredLayerRows.value) {
+ // Filter-excluded layers are OFF the map — they belong in the Unpinned
+ // bucket (tagged "filtered out"), never under a tier even if pinned, so
+ // the editor matches what the scene renders.
+ if (resolvedLevels.value[r.key]?.via === 'filtered') continue;
+ const t = explicitTier.value[r.key];
+ if (t && out[t]) out[t].push(r);
+ }
+ return out;
+});
+
+/** Rows not shown under a tier: not pinned (→ failover) OR filter-excluded
+ * (→ off the map). The via tag distinguishes "→ <tier>" from "filtered out". */
+const unpinnedRows = computed<LayerRow[]>(() =>
+ filteredLayerRows.value.filter((r) => {
+ const via = resolvedLevels.value[r.key]?.via;
+ return via === 'filtered' || !explicitTier.value[r.key];
+ }),
+);
+
+/** A layer is "unclassified" when nothing places it — it lands on the
+ * failover tier (or is filtered out). Highlights the unpinned row so the
+ * operator notices layers that silently fall through. */
+function isUnclassified(key: string): boolean {
+ const via = resolvedLevels.value[key]?.via;
+ return via === 'default' || via === 'filtered';
+}
+
function ensureLayerSpec(key: string): InfraLayerSpec {
const u = key.toUpperCase();
if (!draft.value!.layers[u]) {
@@ -245,30 +446,25 @@ function ensureLayerSpec(key: string): InfraLayerSpec {
return draft.value!.layers[u]!;
}
-function levelForLayerKey(key: string): string | null {
- if (!draft.value) return null;
- const u = key.toUpperCase();
- for (const l of draft.value.levels) {
- if (l.layers.some((k) => k.toUpperCase() === u)) return l.id;
- }
- return null;
-}
-
+/** Pin a layer to one tier, or unpin (nextLevelId null → falls to
+ * failover). A layer belongs to at most one tier's explicit list. */
function assignLayerToLevel(key: string, nextLevelId: string | null): void {
if (!draft.value) return;
const u = key.toUpperCase();
- // Remove from all levels first — a layer can only belong to one
- // explicit list (the validator rejects otherwise).
for (const lvl of draft.value.levels) {
lvl.layers = lvl.layers.filter((k) => k.toUpperCase() !== u);
}
if (nextLevelId) {
const lvl = draft.value.levels.find((l) => l.id === nextLevelId);
if (lvl) lvl.layers.push(u);
+ // Pinning implies the layer is configured (gets a spec to colour/metric).
+ ensureLayerSpec(u);
}
- // Make sure the layer has a config entry so the row doesn't snap
- // back to "unconfigured" once a level is picked.
- ensureLayerSpec(u);
+}
+
+/** Tier dropdown handler — empty value unpins (→ failover). */
+function onMoveTier(key: string, e: Event): void {
+ assignLayerToLevel(key, (e.target as HTMLSelectElement).value || null);
}
function removeLayerFromConfig(key: string): void {
@@ -278,128 +474,93 @@ function removeLayerFromConfig(key: string): void {
for (const lvl of draft.value.levels) {
lvl.layers = lvl.layers.filter((k) => k.toUpperCase() !== u);
}
- expandedLayers.value.delete(u);
-}
-
-function setLayerMode(key: string, mode: 'topology' | 'load' | 'none'): void {
- if (!draft.value) return;
- const spec = ensureLayerSpec(key);
- if (mode === 'topology') {
- spec.load = undefined;
- spec.topology = spec.topology ?? { server: emptyMqe('Server RPM', 'rpm') };
- } else if (mode === 'load') {
- spec.topology = undefined;
- spec.load = spec.load ?? emptyMqe('Load', '');
- } else {
- spec.topology = undefined;
- spec.load = undefined;
+ // Also drop it from any logic group — otherwise a "deleted" layer keeps
+ // rendering inside its group block (spec-less, default colour).
+ for (const g of draft.value.groups ?? []) {
+ g.layers = g.layers.filter((k) => k.toUpperCase() !== u);
}
}
-function ensureTopologyHalf(key: string, side: 'server' | 'client'): void {
+/** Ensure a single metric object exists to bind the mqe/label/unit inputs
+ * to (creates the layer spec if missing). */
+function ensureMetric(key: string): InfraMqe {
const spec = ensureLayerSpec(key);
- if (!spec.topology) spec.topology = {};
- if (!spec.topology[side]) {
- spec.topology[side] = emptyMqe(side === 'server' ? 'Server RPM' : 'Client RPM', 'rpm');
- }
+ if (!spec.metric) spec.metric = { mqe: '', label: '', unit: '' };
+ return spec.metric;
}
-function dropTopologyHalf(key: string, side: 'server' | 'client'): void {
+function clearMetric(key: string): void {
const spec = draft.value?.layers[key.toUpperCase()];
- if (spec?.topology) delete spec.topology[side];
+ if (spec) spec.metric = undefined;
}
-function emptyMqe(label: string, unit: string): InfraMqe {
- return { mqe: '', label, unit };
-}
+// ── Groups editing ────────────────────────────────────────────────────
+// Logic groups (e.g. Self-Observability) cluster several layers into one
+// block on a tier. The icon set is the bakeable subset the 3D stamp
+// renderer knows (useLayerIconTexture.LayerIconName); an unknown name
+// falls back to the generic `svc` glyph at render time.
+const ICON_NAMES = [
+ 'svc', 'sky', 'skywalking', 'mesh', 'cluster',
+ 'web', 'fn', 'db', 'cache', 'topic', 'flame',
+] as const;
-function toggleLayerExpand(key: string): void {
+function addGroup(): void {
+ if (!draft.value) return;
+ // `groups` is optional on the wire (older saved configs predate it) —
+ // materialise it before pushing so the rest of the editor can treat
+ // it as always-present.
+ const groups = draft.value.groups ?? (draft.value.groups = []);
+ const taken = new Set(groups.map((g) => g.id));
+ let n = groups.length + 1;
+ let id = `group-${n}`;
+ while (taken.has(id)) id = `group-${++n}`;
+ groups.push({
+ id,
+ label: 'New group',
+ level: levelsSorted.value[0]?.id ?? '',
+ color: '#a78bfa',
+ icon: 'svc',
+ layers: [],
+ });
+}
+function removeGroup(id: string): void {
+ if (!draft.value?.groups) return;
+ draft.value.groups = draft.value.groups.filter((g) => g.id !== id);
+}
+function onAddLayerToGroup(g: InfraGroupSpec, e: Event): void {
+ const sel = e.target as HTMLSelectElement;
+ const key = sel.value;
+ sel.value = '';
const u = key.toUpperCase();
- const s = new Set(expandedLayers.value);
- if (s.has(u)) s.delete(u);
- else s.add(u);
- expandedLayers.value = s;
+ if (u && !g.layers.some((k) => k.toUpperCase() === u)) g.layers.push(u);
}
-
-// ── Advanced JSON editor ──────────────────────────────────────────────
-const advHost = ref<HTMLDivElement | null>(null);
-let advEditor: monaco.editor.IStandaloneCodeEditor | null = null;
-let advModel: monaco.editor.ITextModel | null = null;
-let suppressAdvChange = false;
-
-watch(advancedOpen, async (open) => {
- if (!open) return;
- // Lazy-init Monaco only when the operator actually expands the
- // section — avoids paying the editor cost for everyone.
- await Promise.resolve();
- if (advEditor || !advHost.value || !draft.value) return;
- setupMonaco();
- advModel = monaco.editor.createModel(JSON.stringify(draft.value, null, 2), 'json');
- advEditor = monaco.editor.create(advHost.value, {
- model: advModel,
- theme: RR_THEME_NAME,
- automaticLayout: true,
- minimap: { enabled: false },
- folding: true,
- scrollBeyondLastLine: false,
- fontSize: 12.5,
- tabSize: 2,
- });
- advModel.onDidChangeContent(() => {
- if (suppressAdvChange) return;
- if (!advModel) return;
- try {
- draft.value = JSON.parse(advModel.getValue()) as Infra3dConfig;
- } catch {
- // Parse error — operator is mid-typing; ignore. Save catches it.
- }
- });
-});
-
-// Keep advanced editor synchronised when the structured UI mutates
-// draft. We diff to avoid clobbering operator cursor when the
-// structured edit is from the editor itself.
-watch(
- draft,
- (next) => {
- if (!advEditor || !advModel || !next) return;
- const want = JSON.stringify(next, null, 2);
- if (advModel.getValue() === want) return;
- suppressAdvChange = true;
- advModel.setValue(want);
- suppressAdvChange = false;
- },
- { deep: true },
-);
-
-onBeforeUnmount(() => {
- advEditor?.dispose();
- advModel?.dispose();
-});
-
-// ── Edge style helpers ────────────────────────────────────────────────
-const EDGE_STYLES = ['solid', 'dashed'] as const;
-type EdgeStyleKind = (typeof EDGE_STYLES)[number];
-function castEdgeStyle(s: string): EdgeStyleKind {
- return s === 'dashed' ? 'dashed' : 'solid';
+function removeLayerFromGroup(g: InfraGroupSpec, key: string): void {
+ const u = key.toUpperCase();
+ g.layers = g.layers.filter((k) => k.toUpperCase() !== u);
+}
+/** A group's add-picker candidates — every layer not already in ANY group
+ * (a layer belongs to at most one logic group; the validator rejects double
+ * membership). Group membership is independent of tier pinning. */
+function groupCandidates(_g: InfraGroupSpec): string[] {
+ const inAnyGroup = new Set<string>();
+ for (const gr of draft.value?.groups ?? []) {
+ for (const k of gr.layers) inAnyGroup.add(k.toUpperCase());
+ }
+ return allLayerKeys.value.filter((k) => !inAnyGroup.has(k));
}
-// Quick-stat counts for the section heads.
+// Quick-stat counts for the Layers section head. `oap` = layers OAP
+// actually reports; `unclassified` = live OAP layers that nothing places
+// (they fall to the unknown-layer level). We deliberately don't surface a
+// "configured" count — every layer carries a spec, so it was always ~52
+// and told the operator nothing.
const stats = computed(() => {
- if (!draft.value) return { layersConfigured: 0, layersOap: 0, layersUnclassified: 0 };
- const oap = (availableLayers.value ?? []).map((l) => l.key.toUpperCase());
- const claimed = new Set<string>();
- for (const lvl of draft.value.levels) for (const k of lvl.layers) claimed.add(k.toUpperCase());
+ const rows = layerRows.value;
return {
- layersConfigured: Object.keys(draft.value.layers).length,
- layersOap: oap.length,
- layersUnclassified: oap.filter((k) => !claimed.has(k)).length,
+ oap: rows.filter((r) => r.inOap).length,
+ unclassified: rows.filter((r) => r.inOap && isUnclassified(r.key)).length,
};
});
-
-function edgeRef(key: 'hierarchy' | 'crossLevelCall' | 'intraCall'): InfraEdgeStyle | null {
- return draft.value?.edges[key] ?? null;
-}
</script>
<template>
@@ -409,33 +570,63 @@ function edgeRef(key: 'hierarchy' | 'crossLevelCall' | 'intraCall'): InfraEdgeSt
<span class="kicker">Dashboard setup · 3D Infra Map</span>
<h1>3D Infrastructure Map</h1>
<p class="lede">
- Global config for the <code>/3d/map</code> view. Levels control
- the vertical stack; per-layer color + metrics drive each cube.
- Bundled defaults ship with the BFF; saves shadow them locally and
- take effect on the next visit to the map.
+ Config for the <code>/3d/map</code> view, published to OAP. Levels
+ control the vertical stack; per-layer color + metrics drive each
+ cube. Edits save to a <strong>local draft in this browser</strong>;
+ <strong>Check diff & push</strong> publishes to OAP — the map
+ renders the remote, with bundled defaults as fallback.
</p>
</div>
<div class="hd-actions">
- <button class="btn" :disabled="saving || !dirty" @click="onDiscard">Discard changes</button>
- <button class="btn" :disabled="saving" @click="onResetBundled">Reset to bundled</button>
- <button class="btn primary" :disabled="!dirty || saving" @click="onSave">
- {{ saving ? 'Saving…' : dirty ? 'Save' : 'Saved' }}
+ <span class="src-pill" :data-src="editorSource">
+ editing: {{ editorSource }}
+ <TemplateStatusBadge v-if="badge" :status="badge" />
+ </span>
+ <div class="reset-wrap">
+ <button class="btn" :disabled="pushing" @click="resetMenuOpen = !resetMenuOpen">Reset to ▾</button>
+ <div v-if="resetMenuOpen" class="reset-menu">
+ <button
+ class="reset-item"
+ :disabled="isSynced"
+ :title="isSynced ? 'Bundled equals OAP-live — nothing to reset to.' : 'Discard current edits and reload the bundled (shipped) default.'"
+ @click="resetTo('bundled')"
+ >
+ Bundled<span v-if="isSynced" class="reset-suffix"> (synced)</span>
+ </button>
+ <button
+ class="reset-item"
+ :disabled="!hasRemote"
+ :title="hasRemote ? 'Discard current edits and reload OAP\'s live version.' : 'OAP has no copy of this config yet.'"
+ @click="resetTo('remote')"
+ >
+ Remote
+ </button>
+ </div>
+ </div>
+ <button class="btn" :disabled="!dirty" @click="save">
+ {{ hasLocalDraft && !dirty ? 'Saved local' : 'Save local' }}
+ </button>
+ <button class="btn primary" :disabled="readOnly || (!dirty && !hasLocalDraft)" @click="openPushDiff">
+ Check diff & push
</button>
</div>
</header>
- <div v-if="flash" class="flash" :data-kind="flash.kind">{{ flash.text }}</div>
- <ul v-if="issues.length" class="issues">
- <li v-for="(it, i) in issues" :key="i"><code>{{ it }}</code></li>
+ <SyncStatusBanner :banner="sync.banner.value" />
+ <ul v-if="pushErr && pushErr.length" class="issues">
+ <li v-for="(it, i) in pushErr" :key="i"><code>{{ it }}</code></li>
</ul>
- <div v-if="loading" class="loading">Loading config…</div>
+ <div v-if="loadError" class="loading">
+ Couldn't load the 3D-map config — the BFF may be unreachable. Refresh the page to retry.
+ </div>
+ <div v-else-if="!ready" class="loading">Loading config…</div>
<template v-else-if="draft">
<!-- ── Global filter ─────────────────────────────────────────── -->
<section class="sect">
<header class="sect-head">
<h2>Global layer filter</h2>
- <span class="sec-hint">JS regex applied before levelling. Default <code>.*</code>.</span>
+ <span class="sec-hint">The one top-level gate — a layer this JS regex excludes is off the map (it still shows below in Unpinned, tagged "filtered out"). Default <code>.*</code> admits all.</span>
</header>
<div class="sect-body">
<label class="field">
@@ -445,329 +636,210 @@ function edgeRef(key: 'hierarchy' | 'crossLevelCall' | 'intraCall'): InfraEdgeSt
</div>
</section>
- <!-- ── Levels ────────────────────────────────────────────────── -->
+ <!-- ── Tiers & layers ─────────────────────────────────────────── -->
<section class="sect">
<header class="sect-head">
- <h2>Levels (top → bottom)</h2>
- <span class="sec-hint">Each level is one plane on the 3D map. Order = vertical stacking.</span>
- <button type="button" class="btn small" @click="addLevel">+ add level</button>
+ <h2>Tiers & layers (top → bottom)</h2>
+ <span class="sec-hint">
+ Every layer belongs to one tier — set it with the tier dropdown; order = vertical stacking.
+ <strong>{{ stats.oap }}</strong> in OAP<template v-if="stats.unclassified > 0"> ·
+ <strong class="warn-count">{{ stats.unclassified }}</strong> unpinned → fallback</template>
+ </span>
+ <input class="inp search" v-model="layerSearch" placeholder="filter layers…" />
+ <button type="button" class="btn small" @click="addLevel">+ add tier</button>
</header>
<div class="sect-body">
- <article
- v-for="(lvl, idx) in levelsSorted"
- :key="lvl.id"
- class="lvl-card"
- >
+ <article v-for="(lvl, idx) in levelsSorted" :key="lvl.id" class="tier-card">
<header class="lvl-head">
<span class="lvl-order">#{{ lvl.order }}</span>
<input class="inp lvl-id mono" v-model="lvl.id" placeholder="apps" />
<input class="inp lvl-label" v-model="lvl.label" placeholder="Apps" />
+ <span class="tier-count">{{ layersByTier[lvl.id]?.length ?? 0 }} layers</span>
<div class="lvl-spacer" />
<button type="button" class="btn tiny" :disabled="idx === 0" @click="moveLevel(idx, -1)">↑</button>
<button type="button" class="btn tiny" :disabled="idx === levelsSorted.length - 1" @click="moveLevel(idx, 1)">↓</button>
<button type="button" class="btn tiny danger" @click="removeLevel(lvl.id)">remove</button>
</header>
- <div class="lvl-body">
- <label class="field">
- <span class="lbl">layerFilter (regex)</span>
- <input class="inp mono" v-model="lvl.layerFilter" placeholder=".*" />
- </label>
- <div class="field">
- <span class="lbl">explicit layers ({{ lvl.layers.length }})</span>
- <div class="chips">
- <span v-for="k in lvl.layers" :key="k" class="chip">
- {{ k }}
- <button class="x" type="button" @click="assignLayerToLevel(k, null)">×</button>
- </span>
- <span v-if="lvl.layers.length === 0" class="chips-empty">(none — admit by regex)</span>
+ <div class="tier-body">
+ <div class="layer-rows">
+ <div v-for="row in layersByTier[lvl.id]" :key="row.key" class="layer-row">
+ <input type="color" class="color-pick" :value="row.spec?.color ?? '#8a8a8a'" @input="(e) => (ensureLayerSpec(row.key).color = (e.target as HTMLInputElement).value)" />
+ <span class="layer-key">{{ row.key }}</span>
+ <template v-if="row.spec?.metric">
+ <input class="inp mono metric-mqe" v-model="row.spec.metric.mqe" placeholder="mqe e.g. service_cpm" />
+ <input class="inp metric-lbl" v-model="row.spec.metric.label" placeholder="label" />
+ <input class="inp metric-unit" v-model="row.spec.metric.unit" placeholder="unit" />
+ <button class="btn tiny danger" type="button" title="remove metric" @click="clearMetric(row.key)">⊘</button>
+ </template>
+ <button v-else class="btn tiny ghost" type="button" @click="ensureMetric(row.key)">+ metric</button>
+ <div class="row-spacer" />
+ <select class="inp tier-select" :value="explicitTier[row.key] ?? ''" @change="(e) => onMoveTier(row.key, e)">
+ <option value="">— unpinned —</option>
+ <option v-for="t in levelsSorted" :key="t.id" :value="t.id">{{ t.label }}</option>
+ </select>
+ <button class="btn tiny ghost" type="button" title="Remove from this tier (moves to Unpinned)" @click="assignLayerToLevel(row.key, null)">×</button>
</div>
+ <div v-if="(layersByTier[lvl.id]?.length ?? 0) === 0" class="tier-empty">No layers pinned — use a layer's tier dropdown to move one here.</div>
</div>
</div>
</article>
</div>
</section>
- <!-- ── Layers ────────────────────────────────────────────────── -->
+ <!-- ── Unpinned → failover ────────────────────────────────────── -->
<section class="sect">
<header class="sect-head">
- <h2>Layers</h2>
+ <h2>Unpinned layers</h2>
<span class="sec-hint">
- <strong>{{ stats.layersOap }}</strong> OAP · <strong>{{ stats.layersConfigured }}</strong> configured ·
- <strong>{{ stats.layersUnclassified }}</strong> unclassified
+ Pinned to no tier — they land on the failover tier
+ (<strong>{{ levelLabel(draft.unknownLayer.level) }}</strong>). Pick a tier to pin one.
</span>
- <input class="inp search" v-model="layerSearch" placeholder="filter by key…" />
</header>
<div class="sect-body">
- <div class="layers-grid">
- <article
- v-for="row in filteredLayerRows"
+ <div class="layer-rows">
+ <div
+ v-for="row in unpinnedRows"
:key="row.key"
- class="layer-card"
- :class="{ unclassified: !levelForLayerKey(row.key) }"
+ class="layer-row"
+ :class="{ unclassified: isUnclassified(row.key) }"
>
- <header class="layer-head" @click="toggleLayerExpand(row.key)">
+ <input type="color" class="color-pick" :value="row.spec?.color ?? '#8a8a8a'" @input="(e) => (ensureLayerSpec(row.key).color = (e.target as HTMLInputElement).value)" />
+ <span class="layer-key">{{ row.key }}</span>
+ <template v-if="row.spec?.metric">
+ <input class="inp mono metric-mqe" v-model="row.spec.metric.mqe" placeholder="mqe" />
+ <input class="inp metric-lbl" v-model="row.spec.metric.label" placeholder="label" />
+ <input class="inp metric-unit" v-model="row.spec.metric.unit" placeholder="unit" />
+ <button class="btn tiny danger" type="button" title="remove metric" @click="clearMetric(row.key)">⊘</button>
+ </template>
+ <button v-else class="btn tiny ghost" type="button" @click="ensureMetric(row.key)">+ metric</button>
+ <div class="row-spacer" />
+ <span class="via-tag" :data-via="resolvedLevels[row.key]?.via" :title="`falls to ${resolvedLevels[row.key]?.via}`">
+ {{ resolvedLevels[row.key]?.via === 'filtered' ? 'filtered out' : '→ ' + levelLabel(resolvedLevels[row.key]?.levelId ?? null) }}
+ </span>
+ <select class="inp tier-select" :value="explicitTier[row.key] ?? ''" @change="(e) => onMoveTier(row.key, e)">
+ <option value="">— pin to tier —</option>
+ <option v-for="t in levelsSorted" :key="t.id" :value="t.id">{{ t.label }}</option>
+ </select>
+ <button class="btn tiny danger" type="button" title="Delete this layer from the config entirely" @click="removeLayerFromConfig(row.key)">×</button>
+ </div>
+ <div v-if="unpinnedRows.length === 0" class="empty">No unpinned layers.</div>
+ </div>
+ </div>
+ </section>
+
+ <!-- ── Groups ────────────────────────────────────────────────── -->
+ <section class="sect">
+ <header class="sect-head">
+ <h2>Logic groups</h2>
+ <span class="sec-hint">Several layers drawn as one labelled block on a tier (e.g. Self-Observability). Each member keeps its own cube color.</span>
+ <button type="button" class="btn small" @click="addGroup">+ add group</button>
+ </header>
+ <div class="sect-body">
+ <div class="groups-grid">
+ <article v-for="g in (draft.groups ?? [])" :key="g.id" class="group-card">
+ <header class="group-head">
<input
type="color"
class="color-pick"
- :value="row.spec?.color ?? '#8a8a8a'"
- @click.stop
- @input="(e) => (ensureLayerSpec(row.key).color = (e.target as HTMLInputElement).value)"
+ :value="parseHexColor(g.color)"
+ @input="(e) => (g.color = (e.target as HTMLInputElement).value)"
/>
- <span class="layer-key">{{ row.key }}</span>
- <span v-if="row.group" class="badge muted">{{ row.group }}</span>
- <span v-if="row.hasTopology" class="badge topo">topology</span>
- <span v-if="!row.inOap" class="badge stale">no OAP data</span>
- <span v-if="!row.inConfig" class="badge new">new</span>
-
- <select
- class="inp level-pick"
- :value="levelForLayerKey(row.key) ?? ''"
- @click.stop
- @change="(e) => assignLayerToLevel(row.key, (e.target as HTMLSelectElement).value || null)"
- >
- <option value="">— pick level —</option>
- <option v-for="lvl in levelsSorted" :key="lvl.id" :value="lvl.id">{{ lvl.label }}</option>
- </select>
-
- <span class="layer-mqe-summary mono">
- <template v-if="row.spec?.topology?.server">srv: {{ row.spec.topology.server.mqe.slice(0, 36) }}{{ row.spec.topology.server.mqe.length > 36 ? '…' : '' }}</template>
- <template v-else-if="row.spec?.load">load: {{ row.spec.load.mqe.slice(0, 36) }}{{ row.spec.load.mqe.length > 36 ? '…' : '' }}</template>
- <template v-else>—</template>
- </span>
-
- <button class="btn tiny" type="button" @click.stop="toggleLayerExpand(row.key)">
- {{ expandedLayers.has(row.key) ? 'collapse' : 'edit' }}
- </button>
+ <input class="inp group-id mono" v-model="g.id" placeholder="self-observability" />
+ <input class="inp group-label" v-model="g.label" placeholder="Self-Observability" />
+ <div class="lvl-spacer" />
+ <button type="button" class="btn tiny danger" @click="removeGroup(g.id)">remove</button>
</header>
-
- <div v-if="expandedLayers.has(row.key)" class="layer-body">
- <div class="field mode-pick">
- <span class="lbl">metric mode</span>
- <div class="seg">
- <button
- type="button"
- class="seg-btn"
- :class="{ on: row.spec?.topology }"
- @click="setLayerMode(row.key, 'topology')"
- >topology (server + client)</button>
- <button
- type="button"
- class="seg-btn"
- :class="{ on: row.spec?.load }"
- @click="setLayerMode(row.key, 'load')"
- >single load</button>
- <button
- type="button"
- class="seg-btn"
- :class="{ on: !row.spec?.topology && !row.spec?.load }"
- @click="setLayerMode(row.key, 'none')"
- >no metric</button>
- </div>
- </div>
-
- <!-- Topology editor -->
- <template v-if="row.spec?.topology">
- <div class="mqe-pair">
- <div class="mqe-half">
- <div class="half-head">
- <span class="lbl">server</span>
- <button
- v-if="!row.spec.topology.server"
- class="btn tiny"
- type="button"
- @click="ensureTopologyHalf(row.key, 'server')"
- >+ add</button>
- <button
- v-else
- class="btn tiny danger"
- type="button"
- @click="dropTopologyHalf(row.key, 'server')"
- >remove</button>
- </div>
- <template v-if="row.spec.topology.server">
- <label class="field">
- <span class="lbl small">MQE</span>
- <input class="inp mono" v-model="row.spec.topology.server.mqe" />
- </label>
- <div class="row-2">
- <label class="field">
- <span class="lbl small">label</span>
- <input class="inp" v-model="row.spec.topology.server.label" />
- </label>
- <label class="field">
- <span class="lbl small">unit</span>
- <input class="inp" v-model="row.spec.topology.server.unit" />
- </label>
- </div>
- </template>
- </div>
- <div class="mqe-half">
- <div class="half-head">
- <span class="lbl">client</span>
- <button
- v-if="!row.spec.topology.client"
- class="btn tiny"
- type="button"
- @click="ensureTopologyHalf(row.key, 'client')"
- >+ add</button>
- <button
- v-else
- class="btn tiny danger"
- type="button"
- @click="dropTopologyHalf(row.key, 'client')"
- >remove</button>
- </div>
- <template v-if="row.spec.topology.client">
- <label class="field">
- <span class="lbl small">MQE</span>
- <input class="inp mono" v-model="row.spec.topology.client.mqe" />
- </label>
- <div class="row-2">
- <label class="field">
- <span class="lbl small">label</span>
- <input class="inp" v-model="row.spec.topology.client.label" />
- </label>
- <label class="field">
- <span class="lbl small">unit</span>
- <input class="inp" v-model="row.spec.topology.client.unit" />
- </label>
- </div>
- </template>
- </div>
- </div>
- <p class="hint-sm">Server-side preferred at render time; client falls in when server has no data.</p>
- </template>
-
- <!-- Load editor -->
- <template v-else-if="row.spec?.load">
+ <div class="group-body">
+ <div class="row-2">
+ <label class="field">
+ <span class="lbl small">level</span>
+ <select class="inp" v-model="g.level">
+ <option v-for="lvl in levelsSorted" :key="lvl.id" :value="lvl.id">{{ lvl.label }} ({{ lvl.id }})</option>
+ </select>
+ </label>
<label class="field">
- <span class="lbl small">MQE</span>
- <input class="inp mono" v-model="row.spec.load.mqe" />
+ <span class="lbl small">icon</span>
+ <select class="inp" v-model="g.icon">
+ <option v-for="ic in ICON_NAMES" :key="ic" :value="ic">{{ ic }}</option>
+ </select>
</label>
- <div class="row-2">
- <label class="field">
- <span class="lbl small">label</span>
- <input class="inp" v-model="row.spec.load.label" />
- </label>
- <label class="field">
- <span class="lbl small">unit</span>
- <input class="inp" v-model="row.spec.load.unit" />
- </label>
+ </div>
+ <div class="field">
+ <span class="lbl small">member layers ({{ g.layers.length }})</span>
+ <div class="chips">
+ <span v-for="k in g.layers" :key="k" class="chip">
+ {{ k }}
+ <button class="x" type="button" title="remove from group" @click="removeLayerFromGroup(g, k)">×</button>
+ </span>
+ <select class="add-layer" :value="''" @change="(e) => onAddLayerToGroup(g, e)">
+ <option value="">+ add layer…</option>
+ <option v-for="k in groupCandidates(g)" :key="k" :value="k">{{ k }}</option>
+ </select>
</div>
- </template>
-
- <template v-else>
- <p class="hint-sm">Cube renders without a traffic ring. Choose a mode above to attach an MQE.</p>
- </template>
-
- <footer class="layer-foot">
- <button class="btn tiny danger" type="button" @click="removeLayerFromConfig(row.key)">
- remove layer from config
- </button>
- </footer>
+ <p v-if="g.layers.length === 0" class="hint-sm warn">A group needs at least one layer — pushing will be rejected until you add one.</p>
+ </div>
</div>
</article>
- <div v-if="filteredLayerRows.length === 0" class="empty">No layers match the filter.</div>
+ <div v-if="(draft.groups ?? []).length === 0" class="empty">No logic groups. Add one to cluster related layers into a single block.</div>
</div>
</div>
</section>
- <!-- ── Edges ─────────────────────────────────────────────────── -->
+ <!-- ── Service-map layers ─────────────────────────────────────── -->
<section class="sect">
<header class="sect-head">
- <h2>Edge styling</h2>
- <span class="sec-hint">Colors for the three edge classes drawn on the map.</span>
+ <h2>Service-map layers</h2>
+ <span class="sec-hint">
+ These layers' templates provide a service map, so their cubes render
+ as a call graph (and seed the cross-layer hierarchy). Read-only —
+ it's a layer-template property, not a 3D-map setting.
+ </span>
</header>
- <div class="sect-body edges-grid">
- <article
- v-for="key in (['hierarchy', 'crossLevelCall', 'intraCall'] as const)"
- :key="key"
- class="edge-card"
- >
- <header class="edge-head">
- <span class="edge-name">{{ key }}</span>
- </header>
- <template v-if="edgeRef(key)">
- <label class="field">
- <span class="lbl small">color</span>
- <div class="color-row">
- <input
- type="color"
- class="color-pick"
- :value="parseHexColor(edgeRef(key)!.color)"
- @input="(e) => (edgeRef(key)!.color = (e.target as HTMLInputElement).value)"
- />
- <input class="inp mono small" v-model="edgeRef(key)!.color" />
- </div>
- </label>
- <label class="field">
- <span class="lbl small">style</span>
- <select
- class="inp"
- :value="edgeRef(key)!.style"
- @change="(e) => (edgeRef(key)!.style = castEdgeStyle((e.target as HTMLSelectElement).value))"
- >
- <option v-for="s in EDGE_STYLES" :key="s" :value="s">{{ s }}</option>
- </select>
- </label>
- <label class="field row">
- <input type="checkbox" v-model="edgeRef(key)!.arrow" />
- <span class="lbl small">arrow at target</span>
- </label>
- </template>
- </article>
- </div>
- </section>
-
- <!-- ── Pipeline + Unknown ─────────────────────────────────────── -->
- <section class="sect two-col">
- <div class="col">
- <header class="sect-head"><h2>Loading pipeline</h2></header>
- <div class="sect-body">
- <label class="field">
- <span class="lbl">metricChunkSize</span>
- <!-- Capped at 12: the BFF metrics route (MAX_SERVICES) rejects
- larger chunks — OAP's GraphQL complexity ceiling 5xx's. -->
- <input type="number" class="inp" v-model.number="draft.pipeline.metricChunkSize" min="1" max="12" />
- </label>
- <label class="field">
- <span class="lbl">topologyConcurrency</span>
- <input type="number" class="inp" v-model.number="draft.pipeline.topologyConcurrency" min="1" max="16" />
- </label>
- <label class="field">
- <span class="lbl">templateConcurrency</span>
- <input type="number" class="inp" v-model.number="draft.pipeline.templateConcurrency" min="1" max="32" />
- </label>
- </div>
- </div>
- <div class="col">
- <header class="sect-head"><h2>Unknown layer fallback</h2></header>
- <div class="sect-body">
- <label class="field">
- <span class="lbl">level</span>
- <select class="inp" v-model="draft.unknownLayer.level">
- <option v-for="lvl in levelsSorted" :key="lvl.id" :value="lvl.id">{{ lvl.label }} ({{ lvl.id }})</option>
- </select>
- </label>
- <label class="field">
- <span class="lbl">badge text</span>
- <input class="inp" v-model="draft.unknownLayer.badge" />
- </label>
+ <div class="sect-body">
+ <div class="chips">
+ <span v-for="k in serviceMapLayers" :key="k" class="chip">{{ k }}</span>
+ <span v-if="serviceMapLayers.length === 0" class="chips-empty">None reporting a service map.</span>
</div>
</div>
</section>
- <!-- ── Advanced ──────────────────────────────────────────────── -->
+ <!-- ── Unknown layer fallback ─────────────────────────────────── -->
<section class="sect">
- <header class="sect-head clickable" @click="advancedOpen = !advancedOpen">
- <h2>{{ advancedOpen ? '▾' : '▸' }} Advanced — raw JSON</h2>
- <span class="sec-hint">Edit the full document directly. Two-way bound with the structured editor above.</span>
+ <header class="sect-head">
+ <h2>Failover tier</h2>
+ <span class="sec-hint">The single catch-all: any layer no tier pins lands here.</span>
</header>
- <div v-show="advancedOpen" class="sect-body">
- <div ref="advHost" class="adv-editor" />
+ <div class="sect-body">
+ <label class="field">
+ <span class="lbl">level</span>
+ <select class="inp fallback-level" v-model="draft.unknownLayer.level">
+ <option v-for="lvl in levelsSorted" :key="lvl.id" :value="lvl.id">{{ lvl.label }} ({{ lvl.id }})</option>
+ </select>
+ </label>
</div>
</section>
+
</template>
+
+ <!-- ── Check diff & push ──────────────────────────────────────────── -->
+ <Modal :open="pushDiffOpen" title="Push 3D-map config to OAP" width="min(1100px, 94vw)" @close="pushDiffOpen = false">
+ <div class="push-modal">
+ <p class="push-lede">
+ Left = live on OAP (remote). Right = your local draft. Pushing
+ replaces the OAP copy — the map renders it on the next visit.
+ </p>
+ <ul v-if="pushErr && pushErr.length" class="issues">
+ <li v-for="(it, i) in pushErr" :key="i"><code>{{ it }}</code></li>
+ </ul>
+ <MonacoDiff :original="pushRemotePretty" :modified="pushLocalPretty" language="json" />
+ </div>
+ <template #footer>
+ <button class="btn" :disabled="pushing" @click="pushDiffOpen = false">Cancel</button>
+ <button class="btn primary" :disabled="pushing || readOnly" @click="pushToOap">
+ {{ pushing ? 'Pushing…' : 'Push to OAP' }}
+ </button>
+ </template>
+ </Modal>
</div>
</template>
@@ -811,7 +883,56 @@ export { parseHexColor };
.hd-text h1 { margin: 2px 0 4px; font-size: 16px; color: var(--sw-fg-0); }
.lede { font-size: 11.5px; color: var(--sw-fg-2); margin: 0; max-width: 760px; line-height: 1.5; }
.lede code { background: var(--sw-bg-3); padding: 1px 4px; border-radius: 3px; }
-.hd-actions { display: flex; gap: 8px; flex: 0 0 auto; }
+.hd-actions { display: flex; align-items: center; gap: 8px; flex: 0 0 auto; }
+
+/* Source pill + reset menu */
+.src-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ height: 26px;
+ padding: 0 10px;
+ border: 1px solid var(--sw-line-2);
+ border-radius: 4px;
+ background: var(--sw-bg-2);
+ color: var(--sw-fg-2);
+ font-size: 10.5px;
+ text-transform: capitalize;
+}
+.src-pill[data-src='local'] { border-color: rgba(94, 234, 212, 0.5); color: #5eead4; }
+.reset-wrap { position: relative; }
+.reset-menu {
+ position: absolute;
+ right: 0;
+ top: calc(100% + 4px);
+ z-index: 20;
+ display: flex;
+ flex-direction: column;
+ min-width: 200px;
+ padding: 4px;
+ border: 1px solid var(--sw-line-2);
+ border-radius: 6px;
+ background: var(--sw-bg-2);
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
+}
+.reset-item {
+ height: 26px;
+ padding: 0 8px;
+ text-align: left;
+ border: none;
+ border-radius: 4px;
+ background: transparent;
+ color: var(--sw-fg-1);
+ font-size: 11.5px;
+ cursor: pointer;
+}
+.reset-item:hover:not([disabled]) { background: var(--sw-bg-3); color: var(--sw-fg-0); }
+.reset-item[disabled] { opacity: 0.45; cursor: default; }
+.reset-suffix { color: var(--sw-fg-3); font-size: 10px; }
+
+/* Push diff modal */
+.push-modal { display: flex; flex-direction: column; gap: 10px; min-width: min(1040px, 90vw); }
+.push-lede { font-size: 11.5px; color: var(--sw-fg-2); margin: 0; }
/* Buttons */
.btn {
@@ -835,14 +956,6 @@ export { parseHexColor };
.btn.danger:hover:not([disabled]) { background: rgba(239, 68, 68, 0.15); color: #fca5a5; }
/* Banners */
-.flash {
- margin: 8px 20px 0;
- padding: 6px 10px;
- font-size: 11.5px;
- border-radius: 4px;
-}
-.flash[data-kind='ok'] { background: rgba(34, 197, 94, 0.16); color: #4ade80; }
-.flash[data-kind='err'] { background: rgba(239, 68, 68, 0.16); color: #f87171; }
.issues {
margin: 8px 20px 0;
padding: 8px 12px;
@@ -873,14 +986,11 @@ export { parseHexColor };
border-bottom: 1px solid var(--sw-line);
background: var(--sw-bg-2);
}
-.sect-head.clickable { cursor: pointer; }
.sect-head h2 { margin: 0; font-size: 12px; color: var(--sw-fg-0); font-weight: 700; letter-spacing: 0.02em; }
.sec-hint { font-size: 11px; color: var(--sw-fg-3); }
.sec-hint code { background: var(--sw-bg-3); padding: 0 4px; border-radius: 3px; }
.sect-body { padding: 12px 14px; }
-.sect.two-col .sect-body { padding: 0; }
-.sect.two-col { display: flex; gap: 12px; padding: 0; background: transparent; border: none; }
-.sect.two-col > .col { flex: 1; border: 1px solid var(--sw-line); border-radius: 6px; background: var(--sw-bg-1); }
+.fallback-level { max-width: 260px; }
/* Inputs */
.field { display: flex; flex-direction: column; gap: 4px; margin-bottom: 8px; }
@@ -902,8 +1012,8 @@ export { parseHexColor };
.inp.search { height: 22px; width: 180px; margin-left: auto; }
.inp:focus { outline: 1px solid var(--sw-accent); }
-/* Levels */
-.lvl-card {
+/* Tiers */
+.tier-card {
border: 1px solid var(--sw-line);
border-radius: 4px;
background: var(--sw-bg-2);
@@ -917,10 +1027,18 @@ export { parseHexColor };
border-bottom: 1px solid var(--sw-line);
}
.lvl-order { font-size: 10.5px; color: var(--sw-fg-3); width: 28px; font-family: var(--sw-mono-font, monospace); }
-.lvl-id { width: 140px; }
-.lvl-label { width: 200px; }
+.lvl-id { width: 130px; }
+.lvl-label { width: 170px; }
.lvl-spacer { flex: 1; }
-.lvl-body { padding: 8px 10px; }
+.tier-count { font-size: 10.5px; color: var(--sw-fg-3); white-space: nowrap; }
+.tier-body { padding: 8px 10px; display: flex; flex-direction: column; gap: 8px; }
+.btn.ghost {
+ align-self: flex-start;
+ background: transparent;
+ border-style: dashed;
+ color: var(--sw-fg-3);
+}
+.btn.ghost:hover:not([disabled]) { color: var(--sw-fg-1); border-color: var(--sw-accent); background: transparent; }
.chips { display: flex; flex-wrap: wrap; gap: 4px; }
.chip {
display: inline-flex;
@@ -943,22 +1061,35 @@ export { parseHexColor };
}
.chip .x:hover { color: #f87171; }
.chips-empty { font-size: 11px; color: var(--sw-fg-3); font-style: italic; }
-
-/* Layers */
-.layers-grid { display: flex; flex-direction: column; gap: 6px; }
-.layer-card {
- border: 1px solid var(--sw-line);
- border-radius: 4px;
- background: var(--sw-bg-2);
+/* Inline "+ add layer" picker — shares the chip row, styled to read as
+ an affordance rather than a form control. */
+.add-layer {
+ height: 22px;
+ padding: 0 6px;
+ background: var(--sw-bg-3);
+ border: 1px dashed var(--sw-line-2);
+ border-radius: 10px;
+ color: var(--sw-fg-2);
+ font-size: 10.5px;
+ font-family: inherit;
+ cursor: pointer;
}
-.layer-card.unclassified { border-color: rgba(239, 158, 68, 0.5); }
-.layer-head {
+.add-layer:hover { border-color: var(--sw-accent); color: var(--sw-fg-0); }
+.add-layer:focus { outline: 1px solid var(--sw-accent); }
+
+/* Layer rows — one dense line per layer (inside a tier or the unpinned
+ bucket): colour · key · badges · metric (mqe/label/unit) · tier · remove. */
+.layer-rows { display: flex; flex-direction: column; gap: 4px; }
+.layer-row {
display: flex;
align-items: center;
- gap: 8px;
- padding: 6px 8px;
- cursor: pointer;
+ gap: 6px;
+ padding: 4px 6px;
+ border: 1px solid var(--sw-line);
+ border-radius: 4px;
+ background: var(--sw-bg-1);
}
+.layer-row.unclassified { border-color: rgba(239, 158, 68, 0.5); }
.color-pick {
width: 24px;
height: 20px;
@@ -967,86 +1098,54 @@ export { parseHexColor };
border-radius: 3px;
background: transparent;
cursor: pointer;
+ flex: 0 0 auto;
}
.layer-key {
font-family: var(--sw-mono-font, monospace);
font-size: 11.5px;
font-weight: 600;
color: var(--sw-fg-0);
- min-width: 160px;
-}
-.badge {
- font-size: 9.5px;
- letter-spacing: 0.04em;
- padding: 1px 6px;
- border-radius: 8px;
- text-transform: uppercase;
-}
-.badge.muted { background: var(--sw-bg-3); color: var(--sw-fg-3); }
-.badge.topo { background: rgba(94, 177, 191, 0.2); color: #5eb1bf; }
-.badge.stale { background: rgba(239, 158, 68, 0.18); color: #f0a04b; }
-.badge.new { background: rgba(94, 234, 212, 0.16); color: #5eead4; }
-.level-pick { min-width: 160px; }
-.layer-mqe-summary {
- flex: 1;
- font-size: 10.5px;
- color: var(--sw-fg-3);
+ /* Fixed column so the metric fields line up across every row regardless
+ of key length (GENERAL … BYTEDANCE_MINI_PROGRAM). */
+ flex: 0 0 190px;
overflow: hidden;
- white-space: nowrap;
text-overflow: ellipsis;
+ white-space: nowrap;
}
-.layer-body {
- padding: 10px 12px;
- border-top: 1px solid var(--sw-line);
- background: var(--sw-bg-1);
- display: flex;
- flex-direction: column;
- gap: 8px;
-}
-.mode-pick { flex-direction: row; align-items: center; gap: 8px; }
-.seg { display: inline-flex; border: 1px solid var(--sw-line-2); border-radius: 4px; overflow: hidden; }
-.seg-btn {
- height: 22px;
- padding: 0 10px;
- background: var(--sw-bg-2);
- border: none;
- border-right: 1px solid var(--sw-line-2);
- color: var(--sw-fg-2);
- font-size: 10.5px;
- cursor: pointer;
-}
-.seg-btn:last-child { border-right: none; }
-.seg-btn.on { background: var(--sw-accent); color: #1a1106; }
+.via-tag { font-size: 10px; color: var(--sw-fg-3); white-space: nowrap; flex: 0 0 auto; }
+.via-tag[data-via='default'],
+.via-tag[data-via='filtered'] { color: #f0a04b; }
+.metric-mqe { flex: 0 1 200px; min-width: 110px; }
+.metric-lbl { flex: 0 0 110px; }
+.metric-unit { flex: 0 0 72px; }
+.row-spacer { flex: 1 1 auto; min-width: 8px; }
+.tier-select { flex: 0 0 130px; }
+.tier-empty { font-size: 11px; color: var(--sw-fg-3); font-style: italic; padding: 2px 2px 4px; }
+.warn-count { color: #f0a04b; }
.row-2 { display: flex; gap: 8px; }
.row-2 .field { flex: 1; }
-.mqe-pair { display: flex; gap: 10px; }
-.mqe-half {
- flex: 1;
- padding: 8px;
- border: 1px solid var(--sw-line);
- border-radius: 4px;
- background: var(--sw-bg-2);
-}
-.half-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
.hint-sm { font-size: 10.5px; color: var(--sw-fg-3); margin: 0; }
-.layer-foot { display: flex; justify-content: flex-end; padding-top: 4px; border-top: 1px dashed var(--sw-line); }
.empty { padding: 14px; text-align: center; color: var(--sw-fg-3); font-size: 11.5px; }
-/* Edges */
-.edges-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
-.edge-card {
+/* Groups */
+.groups-grid { display: flex; flex-direction: column; gap: 8px; }
+.group-card {
border: 1px solid var(--sw-line);
border-radius: 4px;
background: var(--sw-bg-2);
- padding: 8px 10px;
}
-.edge-head .edge-name {
- font-family: var(--sw-mono-font, monospace);
- font-size: 11px;
- color: var(--sw-fg-1);
+.group-head {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 8px;
+ border-bottom: 1px solid var(--sw-line);
}
-.color-row { display: flex; align-items: center; gap: 6px; }
+.group-id { width: 200px; }
+.group-label { width: 220px; }
+.group-body { padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; }
+.group-body .row-2 { max-width: 420px; }
+.hint-sm.warn { color: #f0a04b; }
/* Advanced */
-.adv-editor { width: 100%; height: 360px; border: 1px solid var(--sw-line); border-radius: 4px; overflow: hidden; }
</style>
diff --git a/apps/ui/src/features/infra-3d/CLAUDE.md b/apps/ui/src/features/infra-3d/CLAUDE.md
index d5d0bd0..14b746f 100644
--- a/apps/ui/src/features/infra-3d/CLAUDE.md
+++ b/apps/ui/src/features/infra-3d/CLAUDE.md
@@ -35,7 +35,7 @@ is fine for the dedicated route).
| `mesh` (middle) | 8 | service-mesh sidecars and control planes | `mesh`, `mesh_cp`, `mesh_dp`, `cilium_service`, `envoy_*` |
| `infra` (bottom) | 0 | everything else: K8s, VM, databases, MQ, caches, gateways, cloud, FaaS | the catch-all |
-The mapping is in `planeForLayer()` in `composables/useDemoTopology.ts`.
+The mapping is in `planeForLayer()` in `composables/useMapTopology.ts`.
A layer **belongs to exactly one plane**. Adding a new layer means
adding one branch to that function — not introducing new planes.
@@ -478,14 +478,17 @@ every transition is a property change on the existing mesh.
The scene now reads its structure live from OAP by default: the pipeline's
`services` / `topologies` stages fetch each layer's roster + service map one
-at a time (`useLiveTopology.ts`), assembling a `DemoTopology` that
-`buildSceneGraph` consumes exactly like the snapshot. `data/demo-topology.json`
+at a time (`useLiveTopology.ts`), assembling a `MapTopology` that
+`buildSceneGraph` consumes exactly like the snapshot. `data/fallback-topology.json`
remains the **fallback** — rendered until the first sequential load lands, if
the load comes back empty, or when `?live=0` forces it for comparison. The
scene is re-keyed on a per-layer structure hash so an unchanged refresh keeps
the camera; only a roster/edge change rebuilds. The cross-layer hierarchy
-(`hierarchy`) is still left empty in live mode — Smartscape peers are a later,
-per-service fetch.
+(`hierarchy`) is fetched live by the `hierarchy` stage (`loadLiveHierarchy`,
+`getServiceHierarchy` per service) — **incrementally**: only newly-appeared
+services are probed each refresh, the rest reused from a persistent cache, so a
+steady roster costs zero hierarchy calls. The selected cube reveals its peers
+from this data.
## What this PoC is NOT
diff --git a/apps/ui/src/features/infra-3d/Infra3DScene.vue b/apps/ui/src/features/infra-3d/Infra3DScene.vue
index 983cc08..8315fc8 100644
--- a/apps/ui/src/features/infra-3d/Infra3DScene.vue
+++ b/apps/ui/src/features/infra-3d/Infra3DScene.vue
@@ -58,13 +58,13 @@ import {
} from 'three';
import {
buildSceneGraph,
- loadDemoTopology,
- type DemoTopology,
+ loadFallbackTopology,
+ type MapTopology,
type SceneCallEdge,
type SceneCrossLayerEdge,
type SceneHierarchyEdge,
type SceneServiceNode,
-} from './composables/useDemoTopology';
+} from './composables/useMapTopology';
import {
computePlacement,
type SceneGroupSpec,
@@ -76,7 +76,7 @@ import {
type ZoneTint,
} from './composables/useScenePlacement';
import type { ServiceNamingRule } from '@skywalking-horizon-ui/api-client';
-import { colorForLayer, levelForLayer } from './composables/useInfra3dConfig';
+import { colorForLayer, levelForLayer, isLayerExcluded } from './composables/useInfra3dConfig';
import { resolveServiceIdentity } from '@/utils/serviceName';
import { layerIcon as layerIconByKey } from '@/shell/icons';
import {
@@ -121,7 +121,7 @@ interface Props {
* Null/absent ⇒ the snapshot. The build below runs once at setup, so
* the parent re-keys this component to rebuild when the live structure
* changes. */
- topology?: DemoTopology | null;
+ topology?: MapTopology | null;
}
const props = defineProps<Props>();
const emit = defineEmits<{
@@ -138,8 +138,8 @@ const emit = defineEmits<{
// loaded before mounting this component (see Infra3DView.vue), so
// `levelForLayer` returns deterministic values here, not the synchronous
// fallback. `planeOrder` is the source of truth for vertical stacking.
-const topo = props.topology ?? loadDemoTopology();
-const graph = buildSceneGraph(topo, levelForLayer);
+const topo = props.topology ?? loadFallbackTopology();
+const graph = buildSceneGraph(topo, levelForLayer, isLayerExcluded);
const placement = computePlacement(graph, props.planeOrder, props.groups, props.namingByLayer);
/** Resolve the per-layer icon glyph. Routed through the same helper
diff --git a/apps/ui/src/features/infra-3d/Infra3DView.vue b/apps/ui/src/features/infra-3d/Infra3DView.vue
index c971488..20000eb 100644
--- a/apps/ui/src/features/infra-3d/Infra3DView.vue
+++ b/apps/ui/src/features/infra-3d/Infra3DView.vue
@@ -30,10 +30,11 @@ import Infra3DScene from './Infra3DScene.vue';
import PipelineTimeline from './PipelineTimeline.vue';
import {
buildSceneGraph,
- loadDemoTopology,
- type DemoTopology,
+ loadFallbackTopology,
+ type MapTopology,
+ type MapHierarchyEntry,
type SceneServiceNode,
-} from './composables/useDemoTopology';
+} from './composables/useMapTopology';
import {
computePlacement,
type PlaneSpec,
@@ -50,6 +51,7 @@ import {
liveSkeleton,
loadLiveServices,
loadLiveTopologies,
+ loadLiveHierarchy,
type LiveWindow,
} from './composables/useLiveTopology';
import { bff, type Infra3dConfig } from '@/api/client';
@@ -111,7 +113,7 @@ const soloPlane = ref<string | null>(null);
// 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, groups: infraGroups, ensureLoaded, levelForLayer } = useInfra3dConfig();
+const { config: infraConfig, levelsOrdered, groups: infraGroups, ensureLoaded, levelForLayer, isLayerExcluded } = useInfra3dConfig();
const ready = ref(false);
// Per-layer topology-cluster rules (k8s/mesh namespace) from the live
@@ -148,9 +150,9 @@ const { stages, stageOrder, running: pipelineRunning, run: runPipelineState } =
interface PipelineCtx {
servicesByLayer: Record<string, SceneServiceNode[]>;
- /** Live path only: the DemoTopology assembled as stages land. The
- * snapshot impls leave it null and read loadDemoTopology() directly. */
- topo: DemoTopology | null;
+ /** Live path only: the MapTopology assembled as stages land. The
+ * snapshot impls leave it null and read loadFallbackTopology() directly. */
+ topo: MapTopology | null;
}
// Live data is the default. `?live=0` forces the committed snapshot (a
@@ -158,7 +160,7 @@ interface PipelineCtx {
// assembly, published atomically once a run has the full structure so the
// scene renders complete (see sceneReady), never piecemeal.
const liveTopologyEnabled = computed(() => route.query.live !== '0');
-const liveTopo = shallowRef<DemoTopology | null>(null);
+const liveTopo = shallowRef<MapTopology | null>(null);
const liveWindow = (): LiveWindow => ({
startMs: Date.now() - 2 * 3600_000,
endMs: Date.now(),
@@ -170,7 +172,7 @@ const liveWindow = (): LiveWindow => ({
// counts): a 60s refresh that finds the same structure leaves the key
// unchanged — no remount, so the camera and metric/alarm visuals persist —
// while a service appearing / disappearing rebuilds the scene.
-const sceneTopology = computed<DemoTopology | null>(() => {
+const sceneTopology = computed<MapTopology | null>(() => {
if (!liveTopologyEnabled.value) return null;
const t = liveTopo.value;
if (!t || Object.keys(t.servicesByLayer).length === 0) return null;
@@ -187,7 +189,15 @@ const sceneKey = computed(() => {
return `${L.key}:${calls}:${ids.join(',')}`;
})
.join('|');
- return `${naming}:${struct}`;
+ // Hierarchy is built into the graph at scene setup, so fold a signature of
+ // it into the key — otherwise a hierarchy change with the same roster +
+ // call counts (e.g. a transiently-failed fetch succeeding on a later
+ // refresh) wouldn't re-key and the new peers would never render.
+ const hier = t.hierarchy
+ .map((h) => `${h.fromLayer}/${h.fromService.id}:${h.peers.reduce((a, p) => a + p.services.length, 0)}`)
+ .sort()
+ .join(',');
+ return `${naming}:${struct}#${hier}`;
});
// Render gate: in live mode hold the scene until the first full assembly
@@ -204,10 +214,15 @@ const sceneReady = computed(() => !liveTopologyEnabled.value || liveTopo.value !
type PipelineMode = 'full' | 'light';
const pipelineMode = ref<PipelineMode>('full');
const knownLayers = shallowRef<Set<string> | null>(null);
+// Cross-layer hierarchy cache (LAYER::serviceId → entry | null), persisted
+// across refreshes. The hierarchy stage fetches ONLY services missing from
+// here and reuses the rest, so a steady roster costs zero OAP calls; a
+// `null` records "checked, no peers" so it isn't re-probed.
+const hierarchyCache = new Map<string, MapHierarchyEntry | null>();
-// SceneServiceNode view of a DemoTopology — what the metrics stage and the
+// SceneServiceNode view of a MapTopology — what the metrics stage and the
// scene's node grid consume. shortName = last `::` segment, pre-dot.
-function sceneNodesFrom(topo: DemoTopology): Record<string, SceneServiceNode[]> {
+function sceneNodesFrom(topo: MapTopology): Record<string, SceneServiceNode[]> {
const byLayer: Record<string, SceneServiceNode[]> = {};
for (const [key, refs] of Object.entries(topo.servicesByLayer)) {
byLayer[key] = refs.map((s) => ({
@@ -225,7 +240,7 @@ function sceneNodesFrom(topo: DemoTopology): Record<string, SceneServiceNode[]>
const pipelineImpls: Record<PipelineStageId, StageImpl<PipelineCtx>> = {
services: async (rep, ctx) => {
rep.start();
- const topo = loadDemoTopology();
+ const topo = loadFallbackTopology();
const byLayer: Record<string, SceneServiceNode[]> = {};
let total = 0;
for (const L of topo.layers) {
@@ -251,7 +266,7 @@ const pipelineImpls: Record<PipelineStageId, StageImpl<PipelineCtx>> = {
},
templates: async (rep, _ctx) => {
rep.start();
- const topo = loadDemoTopology();
+ const topo = loadFallbackTopology();
const withTopology: string[] = [];
const withoutTopology: string[] = [];
for (const L of topo.layers) {
@@ -267,7 +282,7 @@ const pipelineImpls: Record<PipelineStageId, StageImpl<PipelineCtx>> = {
},
topologies: async (rep, _ctx) => {
rep.start();
- const topo = loadDemoTopology();
+ const topo = loadFallbackTopology();
const probes = Object.entries(topo.topologies ?? {}).map(([layerKey, t]) => ({
layerKey,
status: (t.calls.length > 0 ? 'ok' : 'empty') as 'ok' | 'empty',
@@ -280,12 +295,26 @@ const pipelineImpls: Record<PipelineStageId, StageImpl<PipelineCtx>> = {
probes,
});
},
+ hierarchy: async (rep, _ctx) => {
+ // Snapshot mode already ships the cross-layer hierarchy in the bundled
+ // topology — nothing to fetch; just report what it carries.
+ rep.start();
+ const topo = loadFallbackTopology();
+ const peers = topo.hierarchy.reduce((a, h) => a + h.peers.reduce((b, p) => b + p.services.length, 0), 0);
+ rep.ok(`${topo.hierarchy.length} cross-layer links`, {
+ kind: 'hierarchy',
+ servicesTotal: topo.hierarchy.length,
+ fetched: 0,
+ reused: topo.hierarchy.length,
+ links: peers,
+ });
+ },
layout: async (rep, _ctx) => {
rep.start();
const t0 = performance.now();
// The scene rebuilds placement on its own; we just measure the cost.
- const topo = loadDemoTopology();
- const g = buildSceneGraph(topo, levelForLayer);
+ const topo = loadFallbackTopology();
+ const g = buildSceneGraph(topo, levelForLayer, isLayerExcluded);
// 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);
@@ -309,9 +338,9 @@ const pipelineImpls: Record<PipelineStageId, StageImpl<PipelineCtx>> = {
}
const chunkSize = Math.max(1, cfg.pipeline.metricChunkSize);
- // Resolve each service to its (mqe, layer, normal). Server-side
- // preferred for topology layers, client-side fallback, then `load`
- // for non-topology layers. Services whose layer has no MQE
+ // Resolve each service to its (mqe, layer, normal) via the single
+ // `metric` (canonical), falling back to the deprecated topology/load
+ // shapes for older saved rows. Services whose layer has no MQE
// configured are skipped — their cube renders without a chip.
interface FetchUnit { name: string; layer: string; normal: boolean; mqe: string; nodeKey: string }
const units: FetchUnit[] = [];
@@ -319,7 +348,7 @@ const pipelineImpls: Record<PipelineStageId, StageImpl<PipelineCtx>> = {
const upperLayer = layerKey.toUpperCase();
const spec = cfg.layers[upperLayer];
if (!spec) continue;
- const mqe = spec.topology?.server ?? spec.topology?.client ?? spec.load ?? null;
+ const mqe = spec.metric ?? spec.topology?.server ?? spec.topology?.client ?? spec.load ?? null;
if (!mqe) continue;
setUnitForLayer(upperLayer, mqe.unit);
for (const n of nodes) {
@@ -424,13 +453,16 @@ const pipelineImpls: Record<PipelineStageId, StageImpl<PipelineCtx>> = {
};
// Live variant of the pipeline: `services` / `topologies` read from
-// sequential per-layer OAP queries (assembled into a DemoTopology); the
+// sequential per-layer OAP queries (assembled into a MapTopology); the
// `liveTopo` ref is published atomically at the END of `topologies`, so the
// scene renders the full structure once. `templates` / `layout` resolve
// once per FULL run (skipped on light auto-refresh). `metrics` is shared.
const livePipelineImpls: Record<PipelineStageId, StageImpl<PipelineCtx>> = {
services: async (rep, ctx) => {
- const roster = liveRoster(menuLayers.value);
+ // Drop globally-filtered layers up front — `filter.layer` puts them OFF
+ // the map, so the pipeline must not spend services / topology / hierarchy
+ // / metric OAP calls on them. Everything downstream derives from `roster`.
+ const roster = liveRoster(menuLayers.value).filter((L) => !isLayerExcluded(L.key));
const known = knownLayers.value;
const light = pipelineMode.value === 'light' && known !== null;
// Light refresh renders only layers known at the last full load. A layer
@@ -468,13 +500,29 @@ const livePipelineImpls: Record<PipelineStageId, StageImpl<PipelineCtx>> = {
const rosterKeys = new Set(topo.layers.map((l) => l.key));
const bearing = menuLayers.value.filter((L) => rosterKeys.has(L.key) && isTopologyBearing(L));
await loadLiveTopologies(rep, bearing, topo, liveWindow());
- // Publish the complete structure (services + topology) in one shot.
- liveTopo.value = topo;
+ },
+ hierarchy: async (rep, ctx) => {
+ const topo = ctx.topo;
+ if (!topo) {
+ rep.ok('no services', { kind: 'hierarchy', servicesTotal: 0, fetched: 0, reused: 0, links: 0 });
+ return;
+ }
+ try {
+ await loadLiveHierarchy(rep, topo, hierarchyCache);
+ } finally {
+ // Publish the assembled structure (services + topology + whatever
+ // hierarchy landed) in one shot. In `finally` so a hierarchy fault
+ // degrades to "no peer tubes" rather than blanking the whole map —
+ // the base structure is already complete and independent of hierarchy.
+ // `sceneKey` folds in a hierarchy signature, so a later refresh that
+ // fills it in re-keys and the tubes appear.
+ liveTopo.value = topo;
+ }
},
layout: async (rep, ctx) => {
rep.start();
const t0 = performance.now();
- const g = buildSceneGraph(ctx.topo ?? loadDemoTopology(), levelForLayer);
+ const g = buildSceneGraph(ctx.topo ?? loadFallbackTopology(), levelForLayer, isLayerExcluded);
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`, {
@@ -504,7 +552,7 @@ async function runLight(): Promise<void> {
if (!liveTopologyEnabled.value) return runFull();
pipelineMode.value = 'light';
const ctx: PipelineCtx = { servicesByLayer: {}, topo: null };
- await runPipelineState(ctx, livePipelineImpls, ['services', 'topologies', 'metrics']);
+ await runPipelineState(ctx, livePipelineImpls, ['services', 'topologies', 'hierarchy', 'metrics']);
}
/** Arm (or re-arm) the auto-refresh timer + countdown anchor. */
diff --git a/apps/ui/src/features/infra-3d/PipelineTimeline.vue b/apps/ui/src/features/infra-3d/PipelineTimeline.vue
index e11a41d..928c5bb 100644
--- a/apps/ui/src/features/infra-3d/PipelineTimeline.vue
+++ b/apps/ui/src/features/infra-3d/PipelineTimeline.vue
@@ -94,6 +94,7 @@ const stageLabels: Record<PipelineStageId, string> = {
services: 'Services',
templates: 'Templates',
topologies: 'Topologies',
+ hierarchy: 'Hierarchy',
layout: 'Layout',
metrics: 'Metrics',
};
diff --git a/apps/ui/src/features/infra-3d/composables/useInfra3dConfig.ts b/apps/ui/src/features/infra-3d/composables/useInfra3dConfig.ts
index b8de8f2..fcb9ff2 100644
--- a/apps/ui/src/features/infra-3d/composables/useInfra3dConfig.ts
+++ b/apps/ui/src/features/infra-3d/composables/useInfra3dConfig.ts
@@ -57,18 +57,21 @@ const layerToLevelId = computed<Record<string, string>>(() => {
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.
+ // the group's level, overriding the layer's own level resolution. A
+ // layer belongs to one group (validated), but resolve first-match to
+ // agree with placement (useScenePlacement) should that ever be violated.
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;
+ if (!out[u]) 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.
+ // Explicit `layers` lists are the only per-level placement mechanism —
+ // a layer pinned to a level lands there. Anything no group or explicit
+ // list claims falls to `unknownLayer.level` (the single failover tier)
+ // via `levelForLayer` below.
for (const lvl of c.levels) {
for (const k of lvl.layers) {
const u = k.toUpperCase();
@@ -76,21 +79,6 @@ const layerToLevelId = computed<Record<string, string>>(() => {
if (!out[u]) out[u] = lvl.id;
}
}
- // Regex pass — only fills holes the explicit lists left open.
- // First-matching-level wins, ordered by `level.order` ascending so the
- // resolution is deterministic when two levels' regexes overlap.
- const ordered = [...c.levels].sort((a, b) => a.order - b.order);
- for (const u of Object.keys(c.layers)) {
- if (out[u]) continue;
- if (filter && !filter.test(u)) continue;
- for (const lvl of ordered) {
- const r = safeRegex(lvl.layerFilter);
- if (r && r.test(u)) {
- out[u] = lvl.id;
- break;
- }
- }
- }
return out;
});
@@ -106,8 +94,12 @@ const layerToGroupId = computed<Record<string, string>>(() => {
const c = cfg.value;
if (!c) return {};
const out: Record<string, string> = {};
+ // First-match wins, matching placement (a layer is in one group anyway).
for (const g of c.groups ?? []) {
- for (const k of g.layers) out[k.toUpperCase()] = g.id;
+ for (const k of g.layers) {
+ const u = k.toUpperCase();
+ if (!out[u]) out[u] = g.id;
+ }
}
return out;
});
@@ -137,14 +129,14 @@ export function useInfra3dConfig() {
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
- * configured (e.g. SO11Y_OAP). */
+ /** Resolved single traffic MQE for a layer — `metric` (canonical),
+ * falling back to the deprecated `topology` / `load` shapes for
+ * older saved rows. Returns null when no MQE is configured. */
trafficMqeForLayer,
- /** Raw per-layer spec — useful when the caller needs the full
- * topology pair (e.g. for a future server-vs-client toggle). */
+ /** Raw per-layer spec (color + metric). */
layerSpec,
+ /** True when the global filter excludes a layer from the map. */
+ isLayerExcluded,
};
}
@@ -181,6 +173,16 @@ export function levelForLayer(layerKey: string): string {
return cfg.value?.unknownLayer.level ?? 'middleware';
}
+/** True when the global `filter.layer` regex excludes this layer — it is
+ * OFF the 3D map entirely (not rendered on any tier), matching the admin
+ * editor's treatment. Default `.*` excludes nothing. */
+export function isLayerExcluded(layerKey: string): boolean {
+ const f = cfg.value?.filter.layer;
+ if (!f) return false;
+ const r = safeRegex(f);
+ return r ? !r.test(layerKey.toUpperCase()) : false;
+}
+
export function colorForLayer(layerKey: string): string {
const u = layerKey.toUpperCase();
const spec = cfg.value?.layers[u];
@@ -205,6 +207,9 @@ export function layerSpec(layerKey: string): InfraLayerSpec | null {
export function trafficMqeForLayer(layerKey: string): InfraMqe | null {
const spec = layerSpec(layerKey);
if (!spec) return null;
+ // `metric` is canonical; topology/load are accepted for back-compat
+ // (older saved rows) and collapse to the same single value.
+ if (spec.metric) return spec.metric;
if (spec.topology?.server) return spec.topology.server;
if (spec.topology?.client) return spec.topology.client;
return spec.load ?? null;
diff --git a/apps/ui/src/features/infra-3d/composables/useInfra3dPipeline.ts b/apps/ui/src/features/infra-3d/composables/useInfra3dPipeline.ts
index ee7399b..ced1b6d 100644
--- a/apps/ui/src/features/infra-3d/composables/useInfra3dPipeline.ts
+++ b/apps/ui/src/features/infra-3d/composables/useInfra3dPipeline.ts
@@ -21,8 +21,10 @@
* stage 1: services — list services with their layer
* stage 2: templates — flag layers that ship a topology widget
* stage 3: topologies — pull per-layer service-map snapshot
- * stage 4: layout — client-side placement (re)compute
- * stage 5: metrics — chunked traffic-MQE fetch, server-pref
+ * stage 4: hierarchy — cross-layer Smartscape peers (NEW services only;
+ * existing entries are reused, not re-fetched)
+ * stage 5: layout — client-side placement (re)compute
+ * stage 6: metrics — chunked traffic-MQE fetch
*
* Each stage owns a typed state + detail blob the bottom-of-page
* timeline reads to render its drawer. The pipeline is iteration-1
@@ -41,7 +43,7 @@
import { readonly, shallowRef } from 'vue';
import { ensureLoaded as ensureInfraConfigLoaded } from './useInfra3dConfig';
-export type PipelineStageId = 'services' | 'templates' | 'topologies' | 'layout' | 'metrics';
+export type PipelineStageId = 'services' | 'templates' | 'topologies' | 'hierarchy' | 'layout' | 'metrics';
export type PipelineStageStatus = 'idle' | 'running' | 'ok' | 'warn' | 'error';
/** A row inside the drawer for stage 3 (per-layer topology probe).
@@ -75,13 +77,14 @@ export type StageDetail =
| { kind: 'services'; servicesTotal: number; layersTotal: number; addedSince: number | null; removedSince: number | null; hiddenNoTemplate?: string[] }
| { kind: 'templates'; layersWithTopology: string[]; layersWithoutTopology: string[] }
| { kind: 'topologies'; probes: TopologyProbe[] }
+ | { kind: 'hierarchy'; servicesTotal: number; fetched: number; reused: number; links: number }
| { kind: 'layout'; layersReLaid: number; ms: number }
| { kind: 'metrics'; servicesTotal: number; servicesDone: number; chunkIndex: number; chunkTotal: number; currentLevel: string | null }
| { kind: 'empty' };
const emptyDetail: StageDetail = { kind: 'empty' };
-const STAGE_ORDER: PipelineStageId[] = ['services', 'templates', 'topologies', 'layout', 'metrics'];
+const STAGE_ORDER: PipelineStageId[] = ['services', 'templates', 'topologies', 'hierarchy', 'layout', 'metrics'];
function initialState(): Record<PipelineStageId, StageState> {
return Object.fromEntries(
diff --git a/apps/ui/src/features/infra-3d/composables/useLiveTopology.ts b/apps/ui/src/features/infra-3d/composables/useLiveTopology.ts
index 2814826..f2ba3e5 100644
--- a/apps/ui/src/features/infra-3d/composables/useLiveTopology.ts
+++ b/apps/ui/src/features/infra-3d/composables/useLiveTopology.ts
@@ -17,30 +17,30 @@
/**
* Live assembly of the 3D-map topology — the sequential, low-concurrency
- * counterpart to the committed `demo-topology.json` snapshot.
+ * counterpart to the committed `fallback-topology.json` snapshot.
*
- * The output is a `DemoTopology` byte-compatible with `loadDemoTopology()`,
- * so `buildSceneGraph` consumes either source unchanged. It is built one
- * layer at a time (concurrency 1) from OAP: per-layer service rosters
- * (stage `services`) and per-layer service maps (stage `topologies`).
- *
- * Phase-1 scope: cross-layer call edges fall out of `buildSceneGraph`
- * itself (it derives them from cross-layer entries in `topologies[].calls`),
- * so nothing here pre-computes them; `hierarchy` stays `[]` (the Smartscape
- * peers are a later phase, fetched per-service). The reporter wiring is
- * split across the two stages by the caller so each owns its own
- * `StageReporter` — see Infra3DView's live pipeline impls.
+ * The output is a `MapTopology` byte-compatible with `loadFallbackTopology()`,
+ * so `buildSceneGraph` consumes either source unchanged. It is built from OAP
+ * one layer at a time: per-layer service rosters (stage `services`), per-layer
+ * service maps (stage `topologies`), and the cross-layer Smartscape peers
+ * (stage `hierarchy`, `loadLiveHierarchy` — incremental: only newly-appeared
+ * services are fetched, the rest reused from the caller's cache). Cross-layer
+ * CALL edges, by contrast, fall out of `buildSceneGraph` from cross-layer
+ * entries in `topologies[].calls`, so nothing here pre-computes those. Each
+ * stage owns its own `StageReporter` — see Infra3DView's live pipeline impls.
*/
-import type { LayerDef } from '@skywalking-horizon-ui/api-client';
+import type { LayerDef, ServiceHierarchyResponse } from '@skywalking-horizon-ui/api-client';
import { bff } from '@/api/client';
import type {
- DemoLayer,
- DemoLayerTopology,
- DemoServiceRef,
- DemoTopology,
- DemoTopologyCall,
-} from './useDemoTopology';
+ MapHierarchyEntry,
+ MapHierarchyPeer,
+ MapLayer,
+ MapLayerTopology,
+ MapServiceRef,
+ MapTopology,
+ MapTopologyCall,
+} from './useMapTopology';
import type { StageReporter, TopologyProbe } from './useInfra3dPipeline';
export interface LiveWindow {
@@ -70,7 +70,7 @@ export function liveRoster(layers: LayerDef[]): LayerDef[] {
);
}
-function toDemoLayer(L: LayerDef): DemoLayer {
+function toMapLayer(L: LayerDef): MapLayer {
return {
key: L.key,
name: L.name,
@@ -83,11 +83,11 @@ function toDemoLayer(L: LayerDef): DemoLayer {
/** Layers-only skeleton; `servicesByLayer` / `topologies` fill in as the
* sequential stages land. `hierarchy` stays `[]` in Phase 1. */
-export function liveSkeleton(roster: LayerDef[]): DemoTopology {
+export function liveSkeleton(roster: LayerDef[]): MapTopology {
return {
capturedAt: new Date().toISOString(),
- oapDemo: 'live',
- layers: roster.map(toDemoLayer),
+ source: 'live',
+ layers: roster.map(toMapLayer),
servicesByLayer: {},
hierarchy: [],
topologies: {},
@@ -102,7 +102,7 @@ export function liveSkeleton(roster: LayerDef[]): DemoTopology {
export async function loadLiveServices(
rep: StageReporter,
roster: LayerDef[],
- topo: DemoTopology,
+ topo: MapTopology,
hiddenNoTemplate: string[] = [],
): Promise<number> {
rep.start();
@@ -120,7 +120,7 @@ export async function loadLiveServices(
try {
const resp = await bff.layer.services(L.key);
if (resp.reachable && resp.services.length > 0) {
- const refs: DemoServiceRef[] = resp.services.map((s) => ({
+ const refs: MapServiceRef[] = resp.services.map((s) => ({
id: s.id,
name: s.name,
normal: s.normal ?? true,
@@ -149,13 +149,13 @@ export async function loadLiveServices(
}
/** Stage `topologies` — pull each topology-bearing layer's service map one
- * at a time. Builds a `DemoLayerTopology` (calls + faithful-but-inert nodes)
+ * at a time. Builds a `MapLayerTopology` (calls + faithful-but-inert nodes)
* and a per-layer `TopologyProbe`. Per-layer try/catch so one failure
* records a `failed` probe and continues. */
export async function loadLiveTopologies(
rep: StageReporter,
topoLayers: LayerDef[],
- topo: DemoTopology,
+ topo: MapTopology,
window: LiveWindow,
): Promise<TopologyProbe[]> {
rep.start();
@@ -169,9 +169,9 @@ export async function loadLiveTopologies(
const t0 = performance.now();
try {
const resp = await bff.layer.topology(L.key, undefined, 1, window);
- const map: DemoLayerTopology = {
+ const map: MapLayerTopology = {
nodes: resp.nodes.map((n) => ({ id: n.id, name: n.name, layer: L.key })),
- calls: resp.calls.map<DemoTopologyCall>((c) => ({
+ calls: resp.calls.map<MapTopologyCall>((c) => ({
source: c.source,
target: c.target,
detectPoints: c.detectPoints,
@@ -202,3 +202,99 @@ export async function loadLiveTopologies(
else rep.ok(`${okCount} maps with edges`, detail);
return probes;
}
+
+/** Cache key — `LAYER::serviceId`, the unique identity of a service projection. */
+function hierKey(layer: string, id: string): string {
+ return `${layer.toUpperCase()}::${id}`;
+}
+
+/** Flatten OAP's per-layer hierarchy response into the snapshot's
+ * `MapHierarchyEntry` shape: drop the focused service's own layer + the
+ * `self` peer, keep the cross-layer twins. Returns null when the service
+ * has no cross-layer projection (so the cache records "checked, none"). */
+function toHierarchyEntry(
+ layer: string,
+ ref: MapServiceRef,
+ resp: ServiceHierarchyResponse,
+): MapHierarchyEntry | null {
+ if (!resp.reachable || resp.relations <= 0) return null;
+ const peers: MapHierarchyPeer[] = resp.peers
+ .filter((g) => g.layer.toUpperCase() !== layer.toUpperCase())
+ .map((g) => ({
+ layer: g.layer,
+ services: g.services
+ .filter((s) => s.role !== 'self')
+ .map((s) => ({ id: s.id, name: s.name, normal: s.normal, role: s.role })),
+ }))
+ .filter((g) => g.services.length > 0);
+ if (peers.length === 0) return null;
+ return {
+ fromLayer: layer,
+ fromService: { id: ref.id, name: ref.name, normal: ref.normal },
+ peers,
+ };
+}
+
+/** Stage `hierarchy` — restore the cross-layer Smartscape peers the static
+ * snapshot carried (lost when the dynamic loader hard-coded `hierarchy: []`).
+ *
+ * INCREMENTAL: `getServiceHierarchy` is one call per service, so only
+ * services NOT already in `cache` are fetched; existing entries are reused
+ * and entries for services that vanished are dropped. `cache` is owned by
+ * the caller and persists across refreshes, so an unchanged roster costs
+ * ZERO OAP calls — the "reuse the initialization data, only fetch new
+ * services" contract. The scene reveals these tubes only for the selected
+ * cube, so the caller must populate `topo.hierarchy` (done here) BEFORE
+ * publishing `liveTopo` / building the scene graph. */
+export async function loadLiveHierarchy(
+ rep: StageReporter,
+ topo: MapTopology,
+ cache: Map<string, MapHierarchyEntry | null>,
+): Promise<void> {
+ rep.start();
+ const current: Array<{ layer: string; ref: MapServiceRef }> = [];
+ for (const [layer, refs] of Object.entries(topo.servicesByLayer)) {
+ for (const ref of refs) current.push({ layer, ref });
+ }
+ const present = new Set(current.map((c) => hierKey(c.layer, c.ref.id)));
+ for (const k of [...cache.keys()]) if (!present.has(k)) cache.delete(k);
+
+ const fresh = current.filter((c) => !cache.has(hierKey(c.layer, c.ref.id)));
+ const reused = current.length - fresh.length;
+ const CONC = 6;
+ for (let i = 0; i < fresh.length; i += CONC) {
+ const batch = fresh.slice(i, i + CONC);
+ rep.progress(`reading hierarchy (${Math.min(i + CONC, fresh.length)}/${fresh.length} new · ${reused} reused)`, {
+ kind: 'hierarchy',
+ servicesTotal: current.length,
+ fetched: i,
+ reused,
+ links: 0,
+ });
+ await Promise.all(
+ batch.map(async (c) => {
+ const k = hierKey(c.layer, c.ref.id);
+ try {
+ const resp = await bff.layer.serviceHierarchy(c.layer, c.ref.id);
+ // Success caches the result (an entry, or null for "checked, no
+ // peers") so it isn't re-probed. A FAILURE is NOT cached — leaving
+ // the key absent re-fetches it next refresh rather than freezing a
+ // transient error into a permanent "no hierarchy" for the page.
+ cache.set(k, toHierarchyEntry(c.layer, c.ref, resp));
+ } catch (err) {
+ console.warn(`[infra-3d] live hierarchy failed for ${k}:`, err);
+ }
+ }),
+ );
+ }
+
+ const entries = [...cache.values()].filter((e): e is MapHierarchyEntry => e !== null);
+ topo.hierarchy = entries;
+ rep.ok(`${entries.length} cross-layer links · ${fresh.length} fetched / ${reused} reused`, {
+ kind: 'hierarchy',
+ servicesTotal: current.length,
+ fetched: fresh.length,
+ reused,
+ links: entries.length,
+ });
+}
diff --git a/apps/ui/src/features/infra-3d/composables/useDemoTopology.ts b/apps/ui/src/features/infra-3d/composables/useMapTopology.ts
similarity index 85%
rename from apps/ui/src/features/infra-3d/composables/useDemoTopology.ts
rename to apps/ui/src/features/infra-3d/composables/useMapTopology.ts
index 8aba5ee..642df9a 100644
--- a/apps/ui/src/features/infra-3d/composables/useDemoTopology.ts
+++ b/apps/ui/src/features/infra-3d/composables/useMapTopology.ts
@@ -16,19 +16,20 @@
*/
/**
- * Demo topology loader for the 3D infra map. Reads a snapshot of the
- * SkyWalking showcase deployment captured from the running BFF (layers,
- * services per layer, cross-layer hierarchy peers, per-layer service
- * topology for the layers that have a service map).
+ * The 3D infra map's topology model (`MapTopology`) + the `buildSceneGraph`
+ * pass that turns it into the renderable `SceneGraph`. The model is the same
+ * whether assembled live from OAP (see `useLiveTopology`) or read from the
+ * committed fallback snapshot via `loadFallbackTopology()` — layers, services
+ * per layer, cross-layer hierarchy peers, and per-layer service maps.
*
- * The snapshot is committed JSON, not a live fetch — first iteration
- * of this view ships as a static page so we can iterate on the visual
- * vocabulary before wiring it to the per-session BFF.
+ * `loadFallbackTopology()` reads `data/fallback-topology.json` (a snapshot of
+ * the showcase deployment) — rendered until the first live load lands and as
+ * the offline fallback when OAP is unreachable.
*/
-import demoJson from '../data/demo-topology.json';
+import fallbackJson from '../data/fallback-topology.json';
-export interface DemoLayer {
+export interface MapLayer {
key: string;
name: string;
level: number | null;
@@ -36,40 +37,40 @@ export interface DemoLayer {
serviceCount: number;
color: string | null;
}
-export interface DemoServiceRef {
+export interface MapServiceRef {
id: string;
name: string;
normal: boolean;
}
-export interface DemoHierarchyPeer {
+export interface MapHierarchyPeer {
layer: string;
services: Array<{ id: string; name: string; normal: boolean; role: string }>;
}
-export interface DemoHierarchyEntry {
+export interface MapHierarchyEntry {
fromLayer: string;
- fromService: DemoServiceRef;
- peers: DemoHierarchyPeer[];
+ fromService: MapServiceRef;
+ peers: MapHierarchyPeer[];
}
-export interface DemoTopologyCall {
+export interface MapTopologyCall {
source: string;
target: string;
detectPoints: string[];
}
-export interface DemoLayerTopology {
+export interface MapLayerTopology {
nodes: Array<{ id: string; name: string; layer: string }>;
- calls: DemoTopologyCall[];
+ calls: MapTopologyCall[];
}
-export interface DemoTopology {
+export interface MapTopology {
capturedAt: string;
- oapDemo: string;
- layers: DemoLayer[];
- servicesByLayer: Record<string, DemoServiceRef[]>;
- hierarchy: DemoHierarchyEntry[];
+ source: string;
+ layers: MapLayer[];
+ servicesByLayer: Record<string, MapServiceRef[]>;
+ hierarchy: MapHierarchyEntry[];
/** Per-layer service-map snapshot. Only layers whose template carries
* a topology component populate this (general / mesh / k8s_service in
* the showcase demo). */
- topologies: Record<string, DemoLayerTopology>;
+ topologies: Record<string, MapLayerTopology>;
}
/** Level id from the admin config (`apps` / `mesh` / `middleware` /
@@ -158,7 +159,7 @@ export interface SceneCrossLayerEdge {
detectPoints: string[];
}
-export interface SceneLayer extends DemoLayer {
+export interface SceneLayer extends MapLayer {
plane: PlaneId;
nodes: SceneServiceNode[];
/** Same-plane call edges between this layer's services. */
@@ -191,13 +192,21 @@ function shortName(name: string): string {
return afterColon.split('.')[0] || afterColon;
}
-export function loadDemoTopology(): DemoTopology {
- return demoJson as DemoTopology;
+export function loadFallbackTopology(): MapTopology {
+ return fallbackJson as MapTopology;
}
-export function buildSceneGraph(topo: DemoTopology, resolver?: LevelResolver): SceneGraph {
- // Build the per-layer node + call-edge view.
- const layers: SceneLayer[] = topo.layers.map((L) => {
+export function buildSceneGraph(
+ topo: MapTopology,
+ resolver?: LevelResolver,
+ /** Layers this returns true for are dropped from the graph entirely —
+ * the global `filter.layer` gate, so an excluded layer is off the map
+ * (not silently re-homed on the failover tier). */
+ isExcluded?: (layerKey: string) => boolean,
+): SceneGraph {
+ // Build the per-layer node + call-edge view (filter-excluded layers out).
+ const visibleLayers = isExcluded ? topo.layers.filter((L) => !isExcluded(L.key)) : topo.layers;
+ const layers: SceneLayer[] = visibleLayers.map((L) => {
const plane = planeForLayer(L.key, resolver);
const nodes: SceneServiceNode[] = (topo.servicesByLayer[L.key] ?? []).map((s) => ({
nodeId: nodeKey(L.key, s.id),
diff --git a/apps/ui/src/features/infra-3d/composables/useScenePlacement.ts b/apps/ui/src/features/infra-3d/composables/useScenePlacement.ts
index f6bb42d..b8643a6 100644
--- a/apps/ui/src/features/infra-3d/composables/useScenePlacement.ts
+++ b/apps/ui/src/features/infra-3d/composables/useScenePlacement.ts
@@ -37,7 +37,7 @@
* - See CLAUDE.md in this directory for the full layout-rule spec.
*/
-import type { PlaneId, SceneGraph, SceneLayer, SceneServiceNode } from './useDemoTopology';
+import type { PlaneId, SceneGraph, SceneLayer, SceneServiceNode } from './useMapTopology';
import { resolveServiceIdentity } from '@/utils/serviceName';
import type { ServiceNamingRule } from '@skywalking-horizon-ui/api-client';
diff --git a/apps/ui/src/features/infra-3d/data/demo-topology.json b/apps/ui/src/features/infra-3d/data/fallback-topology.json
similarity index 99%
rename from apps/ui/src/features/infra-3d/data/demo-topology.json
rename to apps/ui/src/features/infra-3d/data/fallback-topology.json
index d16dea3..9dc894f 100644
--- a/apps/ui/src/features/infra-3d/data/demo-topology.json
+++ b/apps/ui/src/features/infra-3d/data/fallback-topology.json
@@ -1,6 +1,6 @@
{
"capturedAt": "2026-05-27",
- "oapDemo": "apache skywalking-showcase",
+ "source": "apache skywalking-showcase",
"layers": [
{
"key": "activemq",
diff --git a/apps/ui/src/shell/AppSidebar.vue b/apps/ui/src/shell/AppSidebar.vue
index 8bddc7f..dbe367d 100644
--- a/apps/ui/src/shell/AppSidebar.vue
+++ b/apps/ui/src/shell/AppSidebar.vue
@@ -282,7 +282,7 @@ const sections = computed<NavSection[]>(() => [
{ icon: 'metric', label: t('Layer dashboards'), to: '/admin/layer-dashboards', verb: 'dashboard:read' },
{ icon: 'web', label: t('Translations'), to: '/admin/translations', verb: 'overview:write' },
{ icon: 'alert', label: t('Alert page'), to: '/admin/alert-page-setup', verb: 'alarm-setup:read' },
- { icon: 'set', label: t('3D Infra Map'), to: '/admin/3d-map', verb: 'infra-3d:write' },
+ { icon: 'set', label: t('3D Infra Map'), to: '/admin/3d-map', verb: 'overview:write' },
{ icon: 'set', label: t('Global defaults'), to: '/admin/global-defaults', verb: 'setup:read' },
],
},
diff --git a/apps/ui/src/shell/AppTopbar.vue b/apps/ui/src/shell/AppTopbar.vue
index 742bab6..d949a26 100644
--- a/apps/ui/src/shell/AppTopbar.vue
+++ b/apps/ui/src/shell/AppTopbar.vue
@@ -189,12 +189,15 @@ const localTzLabel = computed<string>(() => {
// browser-vs-server TZ check returns, derive from `useOapInfo().timezone`.
/**
- * Routes that own their own time range — the global topbar picker +
- * refresh button get disabled (greyed + non-clickable) so the
- * operator knows the page's local picker is the source of truth.
+ * Routes where the global topbar time picker + auto-refresh are disabled
+ * (greyed + non-clickable). Two reasons land a route here:
+ * 1. The page owns its OWN time range (traces / logs / alarms /
+ * profiling) — its local picker is the source of truth.
+ * 2. The page has NO time context at all (every config/admin page and
+ * every operate page) — they never poll metrics, so a rolling window
+ * + auto-refresh make no sense. See `noTimeContext` for the wording.
*
- * Add more routes here as Logs / Traces / etc. each opt out of the
- * global rolling window in favour of a per-page picker.
+ * Add more per-page-picker routes here as Logs / Traces / etc. opt out.
*/
const TIME_RANGE_OPT_OUT = [
/^\/layer\/[^/]+\/trace$/,
@@ -224,8 +227,16 @@ const TIME_RANGE_OPT_OUT = [
/^\/layer\/[^/]+\/network-profiling$/,
/^\/layer\/[^/]+\/async-profiling$/,
/^\/layer\/[^/]+\/pprof$/,
+ // Config (admin) + operate pages have no time concept — they never poll
+ // metrics, so the global picker + auto-refresh are off on all of them.
+ /^\/admin\//,
+ /^\/operate\//,
];
const ownsTimeRange = computed<boolean>(() => TIME_RANGE_OPT_OUT.some((r) => r.test(route.path)));
+/** The no-time-context subset of the opt-out (config/admin + operate),
+ * vs. pages that own a page-local picker (traces / logs / …). Only
+ * changes the tooltip wording — the disable behaviour is the same. */
+const noTimeContext = computed<boolean>(() => /^\/(admin|operate)\//.test(route.path));
/** Auto-refresh is paused whenever the operator has frozen the window
* to a custom range — polling a fixed snapshot just re-fetches the
* same bytes. The button still works for a one-shot manual refresh. */
@@ -234,6 +245,7 @@ const hasFrozenRange = computed<boolean>(
);
const autoSuspended = computed<boolean>(() => ownsTimeRange.value || hasFrozenRange.value);
const globalTimeTooltip = computed<string>(() => {
+ if (noTimeContext.value) return 'No time range on config / operate pages.';
if (ownsTimeRange.value) {
return 'This page uses its own time range — disable the page picker to use the global one.';
}
@@ -288,6 +300,7 @@ const refreshLabel = computed<string>(() => {
return `${auto.secondsUntilNext}s`;
});
const refreshTooltip = computed<string>(() => {
+ if (noTimeContext.value) return t('Auto-refresh is off on config / operate pages');
if (ownsTimeRange.value) return t('Auto-refresh paused on this page');
if (hasFrozenRange.value) return t('Auto-refresh paused while a custom time range is selected');
if (auto.intervalSec === null) return t('Auto-refresh off · click to refresh now');
@@ -333,6 +346,7 @@ if (typeof window !== 'undefined') {
window.addEventListener('click', onTimeMenuClickClose);
}
const timeChipLabel = computed<string>(() => {
+ if (noTimeContext.value) return t('Time range N/A');
if (ownsTimeRange.value) return t('This page uses its own time range');
if (timeRange.presetId === 'custom') {
const r = timeRange.range;
diff --git a/apps/ui/src/shell/router/index.ts b/apps/ui/src/shell/router/index.ts
index 72b27a5..ae0acc3 100644
--- a/apps/ui/src/shell/router/index.ts
+++ b/apps/ui/src/shell/router/index.ts
@@ -257,7 +257,10 @@ const shellRoutes: RouteRecordRaw[] = [
path: 'admin/3d-map',
name: 'admin-3d-map',
component: () => import('@/features/admin/infra-3d/Infra3dAdminView.vue'),
- meta: { verb: 'infra-3d:write' },
+ // The config is a template kind published via the generic template-sync
+ // API (overview:write) — gate the editor on the same verb so page
+ // visibility matches what the push actually requires.
+ meta: { verb: 'overview:write' },
},
{
path: 'admin/overview-templates',