This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git

commit 07c5076677dbba1d3a75f3ec1efb5e80bd7268c2
Author: Wu Sheng <[email protected]>
AuthorDate: Fri May 15 09:34:27 2026 +0800

    ui: log condition rebuild + sidebar URL-driven focus + BFF service-id fix
    
    Logs
      - Conditions bar restructured to match the trace tab: head row with
        kicker + orange `Run query` button on the right, conditions in a
        4-col grid using `.cf` / `.cf-input` voice
      - `log.scope` typed as `'service' | 'instance' | 'endpoint'`; the
        three scopes drive which selectors render
      - Instance + Endpoint default to `All`; auto-pick disabled on logs
        (and traces) — operator opts into narrowing. Metrics-scope pages
        (Instance / Endpoint dashboards) keep their auto-pick
      - Endpoint search-and-select combobox (single input + dropdown,
        debounced OAP search-as-you-type, click-outside closes, × clears)
      - Tag autocomplete now uses OAP-native
        `queryLogTagAutocomplete{Keys,Values}` via new
        `/api/log-tags/{keys,values}` routes (replaces the sample-based
        suggest endpoint)
      - Tag input gets a `Tags` label; chips row reuses trace markup +
        css so the two pages read identically
      - Trace-ID is a plain input (no Pin/Clear button)
      - Time range with `Custom…` escape hatch (datetime-local inputs);
        BFF accepts explicit `startTime`/`endTime` alongside
        `windowMinutes`
      - YAML format detection, flat JSON / YAML inline preview that
        keeps row to one line (CSS-clipped), per-format chip on each row
      - Row click → full popout (no inline expand); ESC closes; copy
        button + tag table in popout
      - Density bar: x time / y count / level color + custom hover
        tooltip (replaces the `?` cursor + native title); 5 x-axis ticks
        underneath
      - Service chip dropped from the toolbar (duplicate with the layer
        header); sidecar picker still shown for instance-scope layers
    
    BFF service-id resolution
      - Tightened `<base64>.<digits>` heuristic across log / trace /
        instance / endpoint routes — the previous "contains `.`" check
        mis-classified mesh / k8s service names with embedded dots
        (`mesh-svr::r3-load.sample-services`) as ids and OAP returned
        empty / "service not found"
    
    Sidebar
      - URL-driven focus: navigating to `/layer/<key>/...` auto-expands
        the layer row AND its containing group, so deep links land with
        the right rails open + the active tab highlighted
      - Service tab visibility gated on `caps.dashboards` alone (drop
        the `slots.services` fallback). Fixes BanyanDB-style cases
        where the alias kept the tab visible after
        `components.service` was flipped off
      - Section headers (group + standalone) brightened, bolder font,
        accent-orange left rule on the open section, larger margin
      - General / Browser (ungrouped multi-feature) render as their own
        implicit section header so the hierarchy is consistent
      - `firstLayerTab(layer)` helper picks the first available sub-
        route from caps/slots; replaces the hardcoded `/service`
      - Drop the service-count chip from group rows
    
    Topbar
      - Breadcrumb uses layer aliases: `instance` → `Brokers` for
        ActiveMQ, `Sidecars` for mesh_dp, `Pages` for browser, …
      - Logs added to `TIME_RANGE_OPT_OUT` (already shipped, kept)
    
    Dashboard
      - First service auto-picked for every scope (not just instance) —
        Virtual MQ / Virtual Database land populated
      - Dashboard query gated on `service.value` for service/instance/
        endpoint scopes so it doesn't fire once with null
    
    mesh_dp
      - Instance widget set restored to the upstream OAP UI template
        (the envoy_cluster_* family I had dropped in 0.2.0 — the MAL
        comment block I read was deprecated; live UI templates DO ship
        those metrics)
    
    Layers
      - BanyanDB template removed (BanyanDB is monitored under
        self-observability rather than a standalone Databases layer;
        falls back to BFF defaults if OAP still exposes a `BANYANDB`
        layer)
      - mesh_dp keeps the Self-Observability sidecar focus
    
    Topology
      - Node hexagons keep the same envelope as before; metric line
        suppressed for nodes with no RPM; right detail rail hidden
        until selection
---
 .../bff/src/bundled_templates/layers/banyandb.json | 263 ----------------
 apps/bff/src/bundled_templates/layers/mesh_dp.json | 146 ++++++---
 .../bundled_templates/layers/virtual_genai.json    |   8 +-
 apps/bff/src/oap/endpoint-routes.ts                |   7 +-
 apps/bff/src/oap/instance-routes.ts                |  11 +-
 apps/bff/src/oap/log-routes.ts                     |  12 +-
 apps/bff/src/oap/menu-routes.ts                    |   2 +
 apps/bff/src/oap/trace-routes.ts                   |   9 +-
 apps/ui/src/components/shell/AppSidebar.vue        |  82 +++--
 apps/ui/src/components/shell/AppTopbar.vue         |  56 +++-
 apps/ui/src/composables/useLayers.ts               |   4 +-
 apps/ui/src/composables/useSelectedEndpoint.ts     |   2 +
 apps/ui/src/composables/useSelectedInstance.ts     |   2 +
 apps/ui/src/composables/useSelectedService.ts      |   7 +
 apps/ui/src/views/layer/LayerDashboardsView.vue    |  11 +-
 apps/ui/src/views/layer/LayerLogsView.vue          | 347 ++++++++++++++++-----
 apps/ui/src/views/layer/LayerShell.vue             |  19 +-
 packages/api-client/src/menu.ts                    |   2 +
 18 files changed, 554 insertions(+), 436 deletions(-)

diff --git a/apps/bff/src/bundled_templates/layers/banyandb.json 
b/apps/bff/src/bundled_templates/layers/banyandb.json
deleted file mode 100644
index 2e1aea7..0000000
--- a/apps/bff/src/bundled_templates/layers/banyandb.json
+++ /dev/null
@@ -1,263 +0,0 @@
-{
-  "key": "BANYANDB",
-  "alias": "BanyanDB",
-  "group": "Self-Observability",
-  "color": "var(--sw-cyan)",
-  "documentLink": 
"https://skywalking.apache.org/docs/main/next/en/banyandb/banyandb-introduction/";,
-  "aliases": {
-    "services": "BanyanDB clusters",
-    "instances": "Nodes"
-  },
-  "components": {
-    "service": false,
-    "instances": true,
-    "endpoints": false,
-    "topology": false,
-    "traces": false,
-    "logs": false
-  },
-  "layer-header": {
-    "orderBy": "writeRate",
-    "columns": [
-      {
-        "metric": "writeRate",
-        "label": "Write/s",
-        "mqe": "latest(meter_banyandb_write_rate)",
-        "aggregation": "sum"
-      },
-      {
-        "metric": "queryRate",
-        "label": "Query/s",
-        "mqe": "latest(meter_banyandb_query_rate{method='query'})",
-        "aggregation": "sum"
-      },
-      {
-        "metric": "errRate",
-        "label": "Err/s",
-        "mqe": "latest(meter_banyandb_write_and_query_errors_rate)",
-        "aggregation": "sum"
-      },
-      {
-        "metric": "active",
-        "label": "Active",
-        "mqe": "meter_banyandb_active_instance",
-        "aggregation": "sum"
-      }
-    ]
-  },
-  "overview": {
-    "groups": [
-      {
-        "title": "Throughput",
-        "size": "auto",
-        "metrics": [
-          {
-            "id": "writeRate",
-            "label": "Write/s",
-            "tip": "Writes per second across the cluster.",
-            "mqe": "latest(meter_banyandb_write_rate)",
-            "aggregation": "sum"
-          },
-          {
-            "id": "queryRate",
-            "label": "Query/s",
-            "tip": "Queries per second.",
-            "mqe": "latest(meter_banyandb_query_rate{method='query'})",
-            "aggregation": "sum"
-          },
-          {
-            "id": "errRate",
-            "label": "Errors/s",
-            "tip": "Combined write + query error rate.",
-            "mqe": "latest(meter_banyandb_write_and_query_errors_rate)",
-            "aggregation": "sum"
-          }
-        ]
-      }
-    ]
-  },
-  "dashboards": {
-    "instance": [
-      {
-        "id": "write_rate",
-        "title": "Write Rate",
-        "type": "line",
-        "expressions": [
-          "latest(meter_banyandb_instance_write_rate)"
-        ],
-        "span": 4,
-        "rowSpan": 2
-      },
-      {
-        "id": "query_rate",
-        "title": "Query Rate",
-        "type": "line",
-        "expressions": [
-          "latest(meter_banyandb_instance_query_rate{method='query'})"
-        ],
-        "span": 4,
-        "rowSpan": 2
-      },
-      {
-        "id": "err_rate",
-        "title": "Error Rate",
-        "type": "line",
-        "expressions": [
-          "latest(meter_banyandb_instance_write_and_query_errors_rate)"
-        ],
-        "span": 4,
-        "rowSpan": 2
-      },
-      {
-        "id": "memory",
-        "title": "Total Memory (GB)",
-        "type": "line",
-        "unit": "GB",
-        "expressions": [
-          "latest(meter_banyandb_instance_total_memory/1024/1024/1024)"
-        ],
-        "span": 4,
-        "rowSpan": 2
-      },
-      {
-        "id": "disk",
-        "title": "Disk Usage (GB)",
-        "type": "line",
-        "unit": "GB",
-        "expressions": [
-          "latest(meter_banyandb_instance_disk_usage/1024/1024/1024)"
-        ],
-        "span": 4,
-        "rowSpan": 2
-      },
-      {
-        "id": "cpu",
-        "title": "CPU",
-        "type": "line",
-        "expressions": [
-          "meter_banyandb_instance_cpu_usage/1000"
-        ],
-        "span": 4,
-        "rowSpan": 2
-      },
-      {
-        "id": "etcd",
-        "title": "Etcd Operation Rate",
-        "type": "line",
-        "expressions": [
-          "latest(meter_banyandb_instance_etcd_operation_rate)"
-        ],
-        "span": 4,
-        "rowSpan": 2
-      },
-      {
-        "id": "rss",
-        "title": "RSS Memory",
-        "type": "line",
-        "expressions": [
-          "meter_banyandb_instance_rss_memory_usage/1000"
-        ],
-        "span": 4,
-        "rowSpan": 2
-      },
-      {
-        "id": "network",
-        "title": "Network (KB/s)",
-        "type": "line",
-        "unit": "KB/s",
-        "expressions": [
-          "meter_banyandb_instance_network_usage_recv/1024",
-          "meter_banyandb_instance_network_usage_sent/1024"
-        ],
-        "expressionLabels": [
-          "recv",
-          "sent"
-        ],
-        "span": 4,
-        "rowSpan": 2
-      },
-      {
-        "id": "totals",
-        "title": "Total Data + Series",
-        "type": "line",
-        "expressions": [
-          "meter_banyandb_instance_total_data",
-          "meter_banyandb_instance_total_series"
-        ],
-        "expressionLabels": [
-          "data",
-          "series"
-        ],
-        "span": 6,
-        "rowSpan": 2
-      },
-      {
-        "id": "merge",
-        "title": "Merge File Latency / Data / Partitions",
-        "type": "line",
-        "expressions": [
-          "meter_banyandb_instance_merge_file_latency/1000",
-          "meter_banyandb_instance_merge_file_data",
-          "meter_banyandb_instance_merge_file_partitions"
-        ],
-        "expressionLabels": [
-          "latency",
-          "data",
-          "partitions"
-        ],
-        "span": 6,
-        "rowSpan": 2
-      },
-      {
-        "id": "stream_write",
-        "title": "Stream Write Rate",
-        "type": "line",
-        "expressions": [
-          "meter_banyandb_instance_stream_write_rate"
-        ],
-        "span": 4,
-        "rowSpan": 2
-      },
-      {
-        "id": "group_write",
-        "title": "Group Write Rate",
-        "type": "line",
-        "expressions": [
-          "meter_banyandb_instance_storage_write_rate/1000"
-        ],
-        "span": 4,
-        "rowSpan": 2
-      },
-      {
-        "id": "series_write",
-        "title": "Series Write Rate",
-        "type": "line",
-        "expressions": [
-          "meter_banyandb_instance_series_write_rate/1000"
-        ],
-        "span": 4,
-        "rowSpan": 2
-      },
-      {
-        "id": "query_latency",
-        "title": "Query Latency",
-        "type": "line",
-        "expressions": [
-          "meter_banyandb_instance_query_latency/1000"
-        ],
-        "span": 6,
-        "rowSpan": 2
-      },
-      {
-        "id": "term_search",
-        "title": "Term Search Rate",
-        "type": "line",
-        "expressions": [
-          "meter_banyandb_instance_term_search_rate/1000"
-        ],
-        "span": 6,
-        "rowSpan": 2
-      }
-    ]
-  }
-}
diff --git a/apps/bff/src/bundled_templates/layers/mesh_dp.json 
b/apps/bff/src/bundled_templates/layers/mesh_dp.json
index 48f17a0..1ca48e5 100644
--- a/apps/bff/src/bundled_templates/layers/mesh_dp.json
+++ b/apps/bff/src/bundled_templates/layers/mesh_dp.json
@@ -70,84 +70,156 @@
   "dashboards": {
     "instance": [
       {
-        "id": "connections",
-        "title": "Connections",
-        "tip": "Server-side total + parent connections 
(envoy_total_connections_used / envoy_parent_connections_used).",
+        "id": "up_rq_active",
+        "title": "Upstream Request Active",
+        "tip": "Total active upstream requests from this Envoy sidecar's 
clusters (envoy_cluster_up_rq_active).",
+        "type": "line",
+        "unit": "reqs",
+        "expressions": [
+          "envoy_cluster_up_rq_active"
+        ],
+        "span": 3,
+        "rowSpan": 2,
+        "format": "int"
+      },
+      {
+        "id": "connections_used",
+        "title": "Connections Used",
+        "tip": "Server-side total + parent connections used 
(envoy_total_connections_used / envoy_parent_connections_used).",
         "type": "line",
         "unit": "conns",
         "expressions": [
           "envoy_total_connections_used",
           "envoy_parent_connections_used"
         ],
-        "span": 4,
+        "expressionLabels": [
+          "total",
+          "parent"
+        ],
+        "span": 3,
         "rowSpan": 2,
         "format": "int"
       },
       {
-        "id": "worker_threads",
-        "title": "Worker Threads",
-        "tip": "Concurrent worker threads in use vs the max observed 
(envoy_worker_threads / envoy_worker_threads_max).",
+        "id": "membership_healthy",
+        "title": "Membership Healthy",
+        "tip": "Healthy endpoints across all outbound/inbound clusters 
(envoy_cluster_membership_healthy).",
         "type": "line",
-        "unit": "",
+        "unit": "endpoints",
         "expressions": [
-          "envoy_worker_threads",
-          "envoy_worker_threads_max"
+          "envoy_cluster_membership_healthy"
         ],
-        "span": 4,
+        "span": 3,
         "rowSpan": 2,
         "format": "int"
       },
+      {
+        "id": "up_cx_incr",
+        "title": "Upstream Connection Increase",
+        "tip": "New upstream connections opened per minute 
(envoy_cluster_up_cx_incr).",
+        "type": "line",
+        "unit": "/min",
+        "expressions": [
+          "envoy_cluster_up_cx_incr"
+        ],
+        "span": 3,
+        "rowSpan": 2,
+        "format": "int"
+      },
+      {
+        "id": "up_rq_pending",
+        "title": "Upstream Request Pending",
+        "tip": "Requests pending in upstream queues 
(envoy_cluster_up_rq_pending_active).",
+        "type": "line",
+        "unit": "reqs",
+        "expressions": [
+          "envoy_cluster_up_rq_pending_active"
+        ],
+        "span": 3,
+        "rowSpan": 2,
+        "format": "int"
+      },
+      {
+        "id": "heap_memory",
+        "title": "Server Memory",
+        "tip": "Heap / allocated / physical memory: current + window max 
(envoy_heap_memory_used / envoy_memory_allocated / envoy_memory_physical_size, 
paired with their *_max variants).",
+        "type": "line",
+        "unit": "bytes",
+        "expressions": [
+          "envoy_heap_memory_used",
+          "envoy_heap_memory_max_used",
+          "envoy_memory_allocated",
+          "envoy_memory_allocated_max",
+          "envoy_memory_physical_size",
+          "envoy_memory_physical_size_max"
+        ],
+        "expressionLabels": [
+          "heap",
+          "heap max",
+          "allocated",
+          "allocated max",
+          "physical",
+          "physical max"
+        ],
+        "span": 6,
+        "rowSpan": 3
+      },
       {
         "id": "bug_failures",
         "title": "Bug Failures",
-        "tip": "Envoy internal bug-failure counter (envoy_bug_failures). 
Should normally be zero \u2014 non-zero indicates an Envoy assertion / debug 
check tripped.",
+        "tip": "Envoy internal bug-failure counter \u2014 non-zero indicates 
an assertion / debug check tripped (envoy_bug_failures).",
         "type": "line",
         "unit": "count",
         "expressions": [
           "envoy_bug_failures"
         ],
-        "span": 4,
+        "span": 3,
         "rowSpan": 2,
         "format": "int"
       },
       {
-        "id": "heap_memory",
-        "title": "Heap Memory",
-        "tip": "Server heap size in bytes \u2014 current value + window max 
(envoy_heap_memory_used / envoy_heap_memory_max_used).",
+        "id": "worker_threads",
+        "title": "Worker Threads",
+        "tip": "Concurrent worker threads in use vs the window max 
(envoy_worker_threads / envoy_worker_threads_max).",
         "type": "line",
-        "unit": "bytes",
+        "unit": "threads",
         "expressions": [
-          "envoy_heap_memory_used",
-          "envoy_heap_memory_max_used"
+          "envoy_worker_threads",
+          "envoy_worker_threads_max"
+        ],
+        "expressionLabels": [
+          "current",
+          "max"
         ],
-        "span": 4,
-        "rowSpan": 2
+        "span": 3,
+        "rowSpan": 2,
+        "format": "int"
       },
       {
-        "id": "memory_allocated",
-        "title": "Memory Allocated",
-        "tip": "Server allocated memory (envoy_memory_allocated / 
envoy_memory_allocated_max).",
+        "id": "up_cx_active",
+        "title": "Upstream Connection Active",
+        "tip": "Active upstream connections (envoy_cluster_up_cx_active).",
         "type": "line",
-        "unit": "bytes",
+        "unit": "conns",
         "expressions": [
-          "envoy_memory_allocated",
-          "envoy_memory_allocated_max"
+          "envoy_cluster_up_cx_active"
         ],
-        "span": 4,
-        "rowSpan": 2
+        "span": 3,
+        "rowSpan": 2,
+        "format": "int"
       },
       {
-        "id": "memory_physical",
-        "title": "Physical Memory",
-        "tip": "Server physical memory footprint (envoy_memory_physical_size / 
envoy_memory_physical_size_max).",
+        "id": "up_rq_incr",
+        "title": "Upstream Request Increase",
+        "tip": "New upstream requests per minute (envoy_cluster_up_rq_incr).",
         "type": "line",
-        "unit": "bytes",
+        "unit": "/min",
         "expressions": [
-          "envoy_memory_physical_size",
-          "envoy_memory_physical_size_max"
+          "envoy_cluster_up_rq_incr"
         ],
-        "span": 4,
-        "rowSpan": 2
+        "span": 3,
+        "rowSpan": 2,
+        "format": "int"
       }
     ]
   }
diff --git a/apps/bff/src/bundled_templates/layers/virtual_genai.json 
b/apps/bff/src/bundled_templates/layers/virtual_genai.json
index f56c6f5..fb62934 100644
--- a/apps/bff/src/bundled_templates/layers/virtual_genai.json
+++ b/apps/bff/src/bundled_templates/layers/virtual_genai.json
@@ -6,12 +6,12 @@
   "documentLink": 
"https://skywalking.apache.org/docs/main/next/en/setup/service-agent/virtual-genai/";,
   "aliases": {
     "services": "GenAI Providers",
-    "endpoints": "Models"
+    "instances": "Models"
   },
   "components": {
     "service": true,
-    "instances": false,
-    "endpoints": true,
+    "instances": true,
+    "endpoints": false,
     "topology": false,
     "traces": false,
     "logs": false
@@ -179,7 +179,7 @@
         "rowSpan": 2
       }
     ],
-    "endpoint": [
+    "instance": [
       {
         "id": "cpm",
         "title": "Calls / min",
diff --git a/apps/bff/src/oap/endpoint-routes.ts 
b/apps/bff/src/oap/endpoint-routes.ts
index eb80273..d910341 100644
--- a/apps/bff/src/oap/endpoint-routes.ts
+++ b/apps/bff/src/oap/endpoint-routes.ts
@@ -118,10 +118,11 @@ export function registerEndpointRoute(app: 
FastifyInstance, deps: EndpointRouteD
       const opts = buildOapOpts(cfgCurrent, deps.fetch);
       const window = defaultWindow();
 
-      // Resolve a plain service name to an OAP id when needed (id-shaped
-      // values contain a `.` separator; names don't).
+      // OAP service-id shape: `<base64>.<digits>`. Anything else
+      // (including names like `mesh-svr::r3-load.sample-services` that
+      // embed `.`) needs a `listServices` lookup.
       let serviceId = serviceArg;
-      if (!serviceArg.includes('.') || /\s/.test(serviceArg)) {
+      if (!/^[A-Za-z0-9+/=]+\.\d+$/.test(serviceArg)) {
         try {
           const data = await graphqlPost<{
             services: Array<{ id: string; name: string; normal?: boolean }>;
diff --git a/apps/bff/src/oap/instance-routes.ts 
b/apps/bff/src/oap/instance-routes.ts
index bb17821..457a93b 100644
--- a/apps/bff/src/oap/instance-routes.ts
+++ b/apps/bff/src/oap/instance-routes.ts
@@ -130,11 +130,14 @@ export function registerInstanceRoute(app: 
FastifyInstance, deps: InstanceRouteD
       const cfgCurrent = deps.config.current;
       const opts = buildOapOpts(cfgCurrent, deps.fetch);
       const window = defaultWindow();
-      // OAP-side ids look like base64-ish blobs (e.g. "Y2hlY2tvdXQ=.1");
-      // names are plain words. If we don't see a `.` separator, treat
-      // the arg as a name and resolve to an id first.
+      // OAP service-id shape: `<base64>.<digits>` (e.g.
+      // `Y2hlY2tvdXQ=.1`). Anything else — including names that
+      // happen to contain `.` like `mesh-svr::r3-load.sample-services`
+      // — needs a `listServices` lookup. The earlier "contains `.`"
+      // heuristic was too loose and broke mesh / k8s_service instance
+      // queries whose service names embed dots.
       let serviceId = serviceArg;
-      if (!serviceArg.includes('.') || /\s/.test(serviceArg)) {
+      if (!/^[A-Za-z0-9+/=]+\.\d+$/.test(serviceArg)) {
         try {
           const data = await graphqlPost<ListServicesResp>(opts, 
LIST_SERVICES_FOR_RESOLVE, {
             layer: layerKey.toUpperCase(),
diff --git a/apps/bff/src/oap/log-routes.ts b/apps/bff/src/oap/log-routes.ts
index 7026fef..9813628 100644
--- a/apps/bff/src/oap/log-routes.ts
+++ b/apps/bff/src/oap/log-routes.ts
@@ -121,13 +121,23 @@ interface OapLogRow {
   tags?: LogKeyValue[] | null;
 }
 
+/**
+ * Resolve a service argument to an OAP service id. The arg can be
+ * either a name (`mesh-svr::songs.sample-services`) or an id
+ * (`bWVzaC1zdnI6OnNvbmdzLnNhbXBsZS1zZXJ2aWNlcw==.1`). OAP ids are
+ * `<base64>.<digits>` — match strictly to avoid the previous bug
+ * where a name containing `.` (e.g. `*.sample-services`) was wrongly
+ * accepted as an id, leading to OAP returning empty / "service not
+ * found" on the log query.
+ */
+const OAP_SERVICE_ID_RE = /^[A-Za-z0-9+/=]+\.\d+$/;
 async function resolveServiceId(
   opts: GraphqlOptions,
   layer: string,
   serviceArg: string,
 ): Promise<string | null> {
   if (!serviceArg) return null;
-  if (serviceArg.includes('.') && !/\s/.test(serviceArg)) return serviceArg;
+  if (OAP_SERVICE_ID_RE.test(serviceArg)) return serviceArg;
   const data = await graphqlPost<{ services: Array<{ id: string; name: string 
}> }>(
     opts,
     LIST_SERVICES_FOR_RESOLVE,
diff --git a/apps/bff/src/oap/menu-routes.ts b/apps/bff/src/oap/menu-routes.ts
index aff03a4..8873723 100644
--- a/apps/bff/src/oap/menu-routes.ts
+++ b/apps/bff/src/oap/menu-routes.ts
@@ -38,6 +38,8 @@ import { getLayerTemplate, type LayerComponentFlags } from 
'../layers/loader.js'
 function componentsToCaps(components: LayerComponentFlags): LayerCaps {
   return {
     dashboards: components.service !== false,
+    instances: !!components.instances,
+    endpoints: !!components.endpoints,
     endpointDependency: !!components.endpointDependency,
     serviceMap: !!components.topology,
     instanceTopology: !!components.topology,
diff --git a/apps/bff/src/oap/trace-routes.ts b/apps/bff/src/oap/trace-routes.ts
index 0dc7be3..709145c 100644
--- a/apps/bff/src/oap/trace-routes.ts
+++ b/apps/bff/src/oap/trace-routes.ts
@@ -210,15 +210,18 @@ const QUERY_TRACE_DETAIL = /* GraphQL */ `
 
 // ── Helpers ────────────────────────────────────────────────────────
 
+// OAP service-id shape: `<base64>.<digits>`. Match strictly so we
+// don't mis-classify names containing `.` (e.g. `*.sample-services`)
+// as ids — the earlier "contains `.` and no whitespace" heuristic was
+// too loose and broke trace queries on mesh-layer services.
+const OAP_SERVICE_ID_RE = /^[A-Za-z0-9+/=]+\.\d+$/;
 async function resolveServiceId(
   opts: GraphqlOptions,
   layer: string,
   serviceArg: string,
 ): Promise<string | null> {
   if (!serviceArg) return null;
-  // Ids contain `.`; names rarely do. Short-circuit when it's already
-  // an id.
-  if (serviceArg.includes('.') && !/\s/.test(serviceArg)) return serviceArg;
+  if (OAP_SERVICE_ID_RE.test(serviceArg)) return serviceArg;
   const data = await graphqlPost<{
     services: Array<{ id: string; name: string }>;
   }>(opts, LIST_SERVICES_FOR_RESOLVE, { layer: layer.toUpperCase() });
diff --git a/apps/ui/src/components/shell/AppSidebar.vue 
b/apps/ui/src/components/shell/AppSidebar.vue
index 433d702..f8a973e 100644
--- a/apps/ui/src/components/shell/AppSidebar.vue
+++ b/apps/ui/src/components/shell/AppSidebar.vue
@@ -42,8 +42,14 @@ const orderedLayers = useLandingOrder(availableLayers);
  * page (which IS the dashboard for virtual / cache / database / MQ
  * scopes). */
 type SidebarLayer = (typeof orderedLayers.value)[number];
+function hasInstances(L: SidebarLayer): boolean {
+  return L.caps.instances ?? Boolean(L.slots.instances);
+}
+function hasEndpoints(L: SidebarLayer): boolean {
+  return L.caps.endpoints ?? Boolean(L.slots.endpoints);
+}
 function isSingleFeatureLayer(L: SidebarLayer): boolean {
-  if (L.slots.instances || L.slots.endpoints) return false;
+  if (hasInstances(L) || hasEndpoints(L)) return false;
   if (hasTopology(L)) return false;
   const c = L.caps;
   if (c.traces || c.logs || c.traceProfiling || c.ebpfProfiling || 
c.asyncProfiling || c.events) return false;
@@ -51,20 +57,16 @@ function isSingleFeatureLayer(L: SidebarLayer): boolean {
   return true;
 }
 
-// Default-open the first available layer once data arrives; user clicks
-// thereafter take over.
+// Which layer's row is expanded — the tab strip (Service / Instance /
+// Endpoint / Topology / …) renders only beneath the expanded layer.
+// Auto-driven by the URL: whatever layer the route is on stays
+// expanded so a cold reload of `/layer/<key>/...` shows the tabs +
+// the active tab highlighted. The initial-load watcher only fires
+// when the route ISN'T on a layer page (the top-level / overview) —
+// in that case we pick the first available layer so the sidebar
+// doesn't look closed on first visit.
 const expandedLayer = ref<string | null>(null);
-let userTouched = false;
-watch(
-  orderedLayers,
-  (rows) => {
-    if (userTouched || expandedLayer.value) return;
-    if (rows.length > 0) expandedLayer.value = rows[0].key;
-  },
-  { immediate: true },
-);
 function toggleLayer(key: string): void {
-  userTouched = true;
   expandedLayer.value = expandedLayer.value === key ? null : key;
 }
 
@@ -116,17 +118,35 @@ function isActive(path: string): boolean {
   return route.path === path || route.path.startsWith(path + '/');
 }
 
-// Auto-open the group that contains the active layer so reload-on-URL
-// doesn't hide the user's current location behind a collapsed section.
+// URL-driven sidebar focus. When the route is on a layer page, the
+// containing group expands AND the layer's row expands so the tabs +
+// active-tab highlight are visible. Fires on every route change so
+// deep-linking from outside (bookmark, paste-in) lands the operator
+// with the right rails open. When the route ISN'T on a layer, fall
+// back to the first available layer so the sidebar isn't empty on
+// the overview / setup pages.
 watch(
-  () => route.path,
-  () => {
-    const m = route.path.match(/^\/layer\/([^/]+)/);
-    if (!m) return;
-    const key = m[1];
-    const L = orderedLayers.value.find((l) => l.key === key);
-    if (L?.group && !openGroups.value.has(L.group)) {
-      openGroups.value = new Set([...openGroups.value, L.group]);
+  [() => route.path, orderedLayers],
+  ([path, rows]) => {
+    const m = path.match(/^\/layer\/([^/]+)/);
+    if (m) {
+      const key = m[1];
+      const L = rows.find((l) => l.key === key);
+      if (L) {
+        // Expand this layer's tab strip.
+        expandedLayer.value = key;
+        // Open its parent group if grouped.
+        if (L.group && !openGroups.value.has(L.group)) {
+          openGroups.value = new Set([...openGroups.value, L.group]);
+        }
+      }
+      return;
+    }
+    // No layer in URL — only seed the default expansion if nothing is
+    // currently expanded (don't yank a layer the operator was looking
+    // at on the previous route).
+    if (!expandedLayer.value && rows.length > 0) {
+      expandedLayer.value = rows[0].key;
     }
   },
   { immediate: true },
@@ -291,20 +311,20 @@ const sections: NavSection[] = [
                   <span class="sw-badge" style="margin-left: auto">{{ 
L.serviceCount }}</span>
                 </RouterLink>
                 <RouterLink
-                  v-if="L.slots.instances"
+                  v-if="hasInstances(L)"
                   :to="`/layer/${L.key}/instance`"
                   class="sw-nav-item"
                   :class="{ 'is-active': isActive(`/layer/${L.key}/instance`) 
}"
                 >
-                  <Icon name="prof" /><span>{{ L.slots.instances }}</span>
+                  <Icon name="prof" /><span>{{ L.slots.instances ?? 'Instance' 
}}</span>
                 </RouterLink>
                 <RouterLink
-                  v-if="L.slots.endpoints"
+                  v-if="hasEndpoints(L)"
                   :to="`/layer/${L.key}/endpoint`"
                   class="sw-nav-item"
                   :class="{ 'is-active': isActive(`/layer/${L.key}/endpoint`) 
}"
                 >
-                  <Icon name="ep" /><span>{{ L.slots.endpoints }}</span>
+                  <Icon name="ep" /><span>{{ L.slots.endpoints ?? 'Endpoint' 
}}</span>
                 </RouterLink>
                 <RouterLink
                   v-if="hasTopology(L)"
@@ -412,20 +432,20 @@ const sections: NavSection[] = [
             <span class="sw-badge" style="margin-left: auto">{{ 
E.layer.serviceCount }}</span>
           </RouterLink>
           <RouterLink
-            v-if="E.layer.slots.instances"
+            v-if="hasInstances(E.layer)"
             :to="`/layer/${E.layer.key}/instance`"
             class="sw-nav-item"
             :class="{ 'is-active': isActive(`/layer/${E.layer.key}/instance`) 
}"
           >
-            <Icon name="prof" /><span>{{ E.layer.slots.instances }}</span>
+            <Icon name="prof" /><span>{{ E.layer.slots.instances ?? 'Instance' 
}}</span>
           </RouterLink>
           <RouterLink
-            v-if="E.layer.slots.endpoints"
+            v-if="hasEndpoints(E.layer)"
             :to="`/layer/${E.layer.key}/endpoint`"
             class="sw-nav-item"
             :class="{ 'is-active': isActive(`/layer/${E.layer.key}/endpoint`) 
}"
           >
-            <Icon name="ep" /><span>{{ E.layer.slots.endpoints }}</span>
+            <Icon name="ep" /><span>{{ E.layer.slots.endpoints ?? 'Endpoint' 
}}</span>
           </RouterLink>
           <RouterLink
             v-if="hasTopology(E.layer)"
diff --git a/apps/ui/src/components/shell/AppTopbar.vue 
b/apps/ui/src/components/shell/AppTopbar.vue
index 0e5c466..d0ce812 100644
--- a/apps/ui/src/components/shell/AppTopbar.vue
+++ b/apps/ui/src/components/shell/AppTopbar.vue
@@ -19,15 +19,67 @@ import { computed, ref, watch } from 'vue';
 import { RouterLink, useRoute } from 'vue-router';
 import Icon from '@/components/icons/Icon.vue';
 import { useOapInfo } from '@/composables/useOapInfo';
+import { useLayers } from '@/composables/useLayers';
 import { useAutoRefreshStore } from '@/stores/autoRefresh';
 
 const route = useRoute();
+const { layers } = useLayers();
 
-// Trivial breadcrumb derivation from the path. Real breadcrumb metadata
-// lands when individual views start setting `route.meta.breadcrumbs`.
+/**
+ * Breadcrumb derived from the route path PLUS the layer's display
+ * config so the trail reads the same as the sidebar. For
+ * `/layer/<key>/<scope>` we:
+ *   - Replace the layer key with its alias (`activemq` → `ActiveMQ`).
+ *   - Replace the scope segment with the layer's slot alias when one
+ *     exists (`instance` → `Brokers` for ActiveMQ, `Sidecars` for
+ *     mesh_dp, `Pages` for browser, …). Falls back to the
+ *     capitalized URL segment when no alias applies.
+ *
+ * The mapping lives here (and not in the route definition) because
+ * the layer JSON is the source of truth for the operator-facing
+ * terms; the route segments stay in the canonical `instance` /
+ * `endpoint` / etc. shape for back-compat with bookmarks.
+ */
+const SCOPE_SLOT_KEY: Record<string, 'instances' | 'endpoints' | 'services' | 
'endpointDependency'> = {
+  instance: 'instances',
+  endpoint: 'endpoints',
+  service: 'services',
+  dependency: 'endpointDependency',
+};
+const SCOPE_LITERAL: Record<string, string> = {
+  topology: 'Topology',
+  trace: 'Traces',
+  logs: 'Logs',
+  'trace-profiling': 'Trace Profiling',
+  'ebpf-profiling': 'eBPF Profiling',
+  'async-profiling': 'Async Profiling',
+};
 const crumbs = computed<string[]>(() => {
   const segs = route.path.split('/').filter(Boolean);
   if (segs.length === 0) return ['Home'];
+  // Layer-aware path: `/layer/<key>/<scope?>/...`
+  if (segs[0] === 'layer' && segs[1]) {
+    const layerKey = segs[1];
+    const layer = layers.value.find((l) => l.key === layerKey);
+    const out: string[] = [layer?.name ?? layerKey.replace(/-/g, ' 
').replace(/^./, (c) => c.toUpperCase())];
+    for (let i = 2; i < segs.length; i++) {
+      const seg = segs[i];
+      // Slot alias (services/instances/endpoints/dependency).
+      const slotKey = SCOPE_SLOT_KEY[seg];
+      if (slotKey && layer?.slots?.[slotKey]) {
+        out.push(String(layer.slots[slotKey]));
+        continue;
+      }
+      // Known literal scope (topology / trace / logs / profilings).
+      if (SCOPE_LITERAL[seg]) {
+        out.push(SCOPE_LITERAL[seg]);
+        continue;
+      }
+      // Fallback: capitalize the segment.
+      out.push(seg.replace(/-/g, ' ').replace(/^./, (c) => c.toUpperCase()));
+    }
+    return out;
+  }
   return segs.map((s) => s.replace(/-/g, ' ').replace(/^./, (c) => 
c.toUpperCase()));
 });
 
diff --git a/apps/ui/src/composables/useLayers.ts 
b/apps/ui/src/composables/useLayers.ts
index 7776f7f..daade64 100644
--- a/apps/ui/src/composables/useLayers.ts
+++ b/apps/ui/src/composables/useLayers.ts
@@ -95,8 +95,8 @@ export function useLayers() {
 export function firstLayerTab(L: LayerDef | undefined): string {
   if (!L) return 'service';
   if (L.slots?.services || L.caps?.dashboards) return 'service';
-  if (L.slots?.instances) return 'instance';
-  if (L.slots?.endpoints) return 'endpoint';
+  if (L.caps?.instances ?? Boolean(L.slots?.instances)) return 'instance';
+  if (L.caps?.endpoints ?? Boolean(L.slots?.endpoints)) return 'endpoint';
   if (L.caps?.serviceMap || L.caps?.instanceTopology || 
L.caps?.processTopology) return 'topology';
   if (L.caps?.endpointDependency) return 'dependency';
   if (L.caps?.traces) return 'trace';
diff --git a/apps/ui/src/composables/useSelectedEndpoint.ts 
b/apps/ui/src/composables/useSelectedEndpoint.ts
index 6db5367..df80174 100644
--- a/apps/ui/src/composables/useSelectedEndpoint.ts
+++ b/apps/ui/src/composables/useSelectedEndpoint.ts
@@ -36,6 +36,8 @@ export function useSelectedEndpoint() {
   });
 
   function setSelectedEndpoint(name: string | null): void {
+    const current = typeof route.query.endpoint === 'string' ? 
route.query.endpoint : null;
+    if (name === current) return;
     const next = { ...route.query };
     if (name) next.endpoint = name;
     else delete next.endpoint;
diff --git a/apps/ui/src/composables/useSelectedInstance.ts 
b/apps/ui/src/composables/useSelectedInstance.ts
index 4707e41..f284182 100644
--- a/apps/ui/src/composables/useSelectedInstance.ts
+++ b/apps/ui/src/composables/useSelectedInstance.ts
@@ -42,6 +42,8 @@ export function useSelectedInstance() {
   });
 
   function setSelectedInstance(name: string | null): void {
+    const current = typeof route.query.instance === 'string' ? 
route.query.instance : null;
+    if (name === current) return;
     const next = { ...route.query };
     if (name) next.instance = name;
     else delete next.instance;
diff --git a/apps/ui/src/composables/useSelectedService.ts 
b/apps/ui/src/composables/useSelectedService.ts
index 726144e..a8d84b5 100644
--- a/apps/ui/src/composables/useSelectedService.ts
+++ b/apps/ui/src/composables/useSelectedService.ts
@@ -38,8 +38,15 @@ export function useSelectedService() {
 
   function setSelected(id: string | null): void {
     const next = { ...route.query };
+    const current = typeof route.query.service === 'string' ? 
route.query.service : null;
+    if (id === current) return;
     if (id) next.service = id;
     else delete next.service;
+    // Instance / endpoint choices are derived from the selected service.
+    // When the service changes, drop the narrower entity so each
+    // dashboard can auto-pick from the new service's own list.
+    delete next.instance;
+    delete next.endpoint;
     // `replace` instead of `push` — switching services shouldn't bloat
     // the browser back stack with N entries.
     void router.replace({ path: route.path, query: next });
diff --git a/apps/ui/src/views/layer/LayerDashboardsView.vue 
b/apps/ui/src/views/layer/LayerDashboardsView.vue
index 49c0042..f7995b8 100644
--- a/apps/ui/src/views/layer/LayerDashboardsView.vue
+++ b/apps/ui/src/views/layer/LayerDashboardsView.vue
@@ -125,10 +125,11 @@ const expandedInstance = ref<string | null>(null);
 const { setSelected: setSelectedService } = useSelectedService();
 const landingRows = computed(() => landing.data.value?.sampledRows ?? 
landing.rows.value ?? []);
 watch(landingRows, (rows) => {
-  if (selectedId.value) return;
   const first = rows[0];
   if (!first) return;
-  setSelectedService(first.serviceId);
+  if (!selectedId.value || !rows.some((r) => r.serviceId === 
selectedId.value)) {
+    setSelectedService(first.serviceId);
+  }
 }, { immediate: true });
 // Drop the stale instance whenever the service changes — the new
 // service's instance list almost never matches the previous pick.
@@ -142,7 +143,11 @@ watch(serviceName, (next, prev) => {
 // their URL on every visit).
 watch([instanceList, scope], ([list, s]) => {
   if (s !== 'instance') return;
-  if (!selectedInstance.value && list.length > 0) {
+  if (list.length === 0) {
+    if (selectedInstance.value) setSelectedInstance(null);
+    return;
+  }
+  if (!selectedInstance.value || !list.some((i) => i.name === 
selectedInstance.value)) {
     setSelectedInstance(list[0].name);
   }
 });
diff --git a/apps/ui/src/views/layer/LayerLogsView.vue 
b/apps/ui/src/views/layer/LayerLogsView.vue
index 4af3707..8a589a3 100644
--- a/apps/ui/src/views/layer/LayerLogsView.vue
+++ b/apps/ui/src/views/layer/LayerLogsView.vue
@@ -69,9 +69,11 @@ const landingRows = computed(() => 
landing.data.value?.sampledRows ?? landing.ro
 watch(
   landingRows,
   (rows) => {
-    if (selectedId.value) return;
     const first = rows[0];
-    if (first) setSelectedService(first.serviceId);
+    if (!first) return;
+    if (!selectedId.value || !rows.some((r) => r.serviceId === 
selectedId.value)) {
+      setSelectedService(first.serviceId);
+    }
   },
   { immediate: true },
 );
@@ -99,18 +101,11 @@ const showEndpointSelector = computed(() => logScope.value 
!== 'endpoint');
 //   - `endpoint` scope: optional narrower as well.
 const { selectedInstance, setSelectedInstance } = useSelectedInstance();
 const { instances: instanceList } = useLayerInstances(layerKey, serviceName);
-// Auto-pick the first instance ONLY when the layer pins instance via
-// `log.scope === 'instance'` (mesh_dp sidecar logs). For service /
-// endpoint scopes the picker stays empty by default so the operator's
-// landing view is "all instances of the picked service" — auto-binding
-// to one instance narrows the query and routinely returns zero rows
-// when that specific instance happens to be quiet.
-watch([instanceList, logScope], ([list, scope]) => {
-  if (scope !== 'instance') return;
-  if (selectedInstance.value) return;
-  const first = list[0];
-  if (first) setSelectedInstance(first.name);
-});
+// Logs (and traces) intentionally do NOT auto-select an instance.
+// Default is `All` so the stream starts broad; the operator opts into
+// narrowing by picking from the dropdown. Auto-selection is reserved
+// for metrics-scope pages (instance / endpoint dashboards), where a
+// chosen entity is needed to render the metric widgets at all.
 watch(serviceName, (next, prev) => {
   if (prev !== undefined && next !== prev && selectedInstance.value) {
     setSelectedInstance(null);
@@ -167,16 +162,8 @@ const { endpoints: endpointList, isFetching: 
endpointsLoading } = useLayerEndpoi
   endpointQuery,
   endpointLimit,
 );
-// Auto-pick first endpoint ONLY when the layer pins endpoint via
-// `log.scope === 'endpoint'`. Same reasoning as the instance picker:
-// auto-pinning narrows the query and the operator typically wants the
-// landing view to be "any endpoint of the picked service".
-watch([endpointList, logScope], ([list, scope]) => {
-  if (scope !== 'endpoint') return;
-  if (selectedEndpoint.value) return;
-  const first = list[0];
-  if (first) setSelectedEndpoint(first.name);
-});
+// No endpoint auto-pick on Logs either — same reasoning as the
+// instance picker above. Default is `All`; operator narrows by hand.
 watch(serviceName, (next, prev) => {
   if (prev !== undefined && next !== prev && selectedEndpoint.value) {
     setSelectedEndpoint(null);
@@ -467,15 +454,11 @@ const levelFacet = computed<Record<Level, number>>(() => {
 // reflect it — no client-side narrowing needed.
 const filteredLogs = computed<LogRow[]>(() => logs.value);
 
-// ── Row expand state. Loki / Datadog use inline expand for the
-// detail rather than a separate right pane. ----------------------
-const expandedId = ref<string | null>(null);
+// Row keys for `<template v-for>`. Inline expand is gone — click
+// now opens the full-canvas popout via `onRowClick(r)`.
 function rowKey(r: LogRow, idx: number): string {
   return `${r.timestamp}-${r.traceId ?? ''}-${idx}`;
 }
-function toggleExpand(key: string): void {
-  expandedId.value = expandedId.value === key ? null : key;
-}
 
 function fmtTime(ts: number): string {
   const d = new Date(ts);
@@ -486,10 +469,50 @@ function fmtDate(ts: number): string {
   const d = new Date(ts);
   return d.toLocaleDateString(undefined, { month: 'short', day: '2-digit' });
 }
+/**
+ * Render a one-line inline preview. Length is NOT capped here — the
+ * row's CSS (`.lg-content-body { white-space: nowrap; overflow:
+ * hidden; text-overflow: ellipsis }`) clips at the actual visible
+ * width, so wider viewports surface more of the payload while narrow
+ * ones still get a clean ellipsis. The slicing-at-220-chars heuristic
+ * I had before always cut at the same offset regardless of width.
+ *
+ *   - JSON: re-serialize to the tightest single-line form so the row
+ *     reads as `{"level":"error","msg":"…"}` and not the raw
+ *     whitespace-laden source.
+ *   - YAML: collapse newlines into a single space so the row still
+ *     carries the keys (`apiVersion: v1 kind: Pod spec: containers:
+ *     - name: nginx`). Indentation chars stay, which preserves a
+ *     visual sense of the hierarchy even on one line. The popout is
+ *     the right place to read the proper multi-line structure.
+ *   - Text: whitespace collapsed, same as before.
+ */
 function summariseContent(r: LogRow): string {
   if (!r.content) return '';
-  const oneLine = r.content.replace(/\s+/g, ' ').trim();
-  return oneLine.length > 220 ? oneLine.slice(0, 218) + '…' : oneLine;
+  const fmt = detectFormat(r);
+  if (fmt === 'json') {
+    try {
+      return JSON.stringify(JSON.parse(r.content));
+    } catch {
+      /* fall through to the plain compaction below */
+    }
+  }
+  if (fmt === 'yaml') {
+    // Replace newlines with a single space — keeps the indentation
+    // characters that sit at the start of each line, which gives the
+    // operator a visual cue of nesting depth even when flattened.
+    return r.content.replace(/\n+/g, ' ').trim();
+  }
+  // Plain text — collapse any runs of whitespace.
+  return r.content.replace(/\s+/g, ' ').trim();
+}
+/** With the new flattening rule above, every payload has a usable
+ *  inline preview — JSON, YAML, and multi-line text all flatten to
+ *  one line cleanly. We no longer need a hidden-payload affordance.
+ *  Returns false unconditionally; kept so the template doesn't have
+ *  to change. */
+function hasHiddenPayload(_r: LogRow): boolean {
+  return false;
 }
 function tryPrettyJson(content: string): string {
   try {
@@ -546,6 +569,49 @@ async function copyPopout(): Promise<void> {
     /* clipboard may be blocked; silently no-op */
   }
 }
+// ESC closes the popout. Bound on the window so it works whether or
+// not the popout has keyboard focus (clicking the modal sometimes
+// drops focus into the underlying row).
+function onGlobalKeydown(ev: KeyboardEvent): void {
+  if (ev.key === 'Escape' && popoutRow.value) {
+    ev.preventDefault();
+    closePopout();
+  }
+}
+if (typeof window !== 'undefined') {
+  window.addEventListener('keydown', onGlobalKeydown);
+}
+
+/**
+ * Row click → open popout. The inline expand is intentionally gone —
+ * for multi-line / YAML / JSON payloads it rendered as a cramped strip
+ * inside the row band; the popout has the full canvas. Trace-id chip
+ * and group decoder still propagate stop-events so their own clicks
+ * don't bubble.
+ */
+function onRowClick(r: LogRow): void {
+  openPopout(r);
+}
+
+// Custom hover tooltip state for the density bar. Native browser
+// `title` was making the cursor render as `?` (help-cursor) instead
+// of showing the count, which read like a UI bug.
+const hoveredBin = ref<number | null>(null);
+function fmtBucketRange(idx: number, t0: number, t1: number): string {
+  if (!t0 || !t1) return '';
+  const span = (t1 - t0) || 1;
+  const start = new Date(t0 + (span * idx) / BINS);
+  const end = new Date(t0 + (span * (idx + 1)) / BINS);
+  const pad = (n: number) => String(n).padStart(2, '0');
+  const fmt = (d: Date) => 
`${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
+  return `${fmt(start)} – ${fmt(end)}`;
+}
+function fmtAxisTime(ts: number): string {
+  if (!ts) return '';
+  const d = new Date(ts);
+  const pad = (n: number) => String(n).padStart(2, '0');
+  return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
+}
 
 /** Open the trace in the global popout overlay rather than navigating
  *  to the Traces tab — keeps the operator in the log stream, lets them
@@ -724,23 +790,60 @@ function jumpToTrace(traceId: string): void {
           </span>
         </div>
 
-        <!-- Density bar -->
-        <div class="lg-density" v-if="histogram.bins.length > 0">
-          <div
-            v-for="(bin, i) in histogram.bins"
-            :key="i"
-            class="lg-density-bin"
-            :title="histogram.t0 ? new Date(histogram.t0 + ((histogram.t1 - 
histogram.t0) * (i + 0.5)) / 60).toLocaleString() : ''"
-          >
-            <span
-              v-for="l in LEVEL_ORDER"
-              :key="l"
-              class="lg-density-segment"
-              :style="{
-                background: LEVEL_COLOR[l],
-                height: histogram.max ? (bin[l] / histogram.max * 100) + '%' : 
'0%',
-              }"
-            />
+        <!-- Density bar — x: time, y: count, color: level. Hover a
+             bin: a custom tooltip (NOT the native `title` — the
+             native cursor was rendering as a help cursor `?` instead
+             of the count, which was confusing) shows the bucket time
+             range + per-level counts. Axis tick labels under the bar
+             carry the time scale. -->
+        <div class="lg-density-wrap" v-if="histogram.bins.length > 0" 
@mouseleave="hoveredBin = null">
+          <div class="lg-density">
+            <div
+              v-for="(bin, i) in histogram.bins"
+              :key="i"
+              class="lg-density-bin"
+              @mouseenter="hoveredBin = i"
+            >
+              <span
+                v-for="l in LEVEL_ORDER"
+                :key="l"
+                class="lg-density-segment"
+                :style="{
+                  background: LEVEL_COLOR[l],
+                  height: histogram.max ? (bin[l] / histogram.max * 100) + '%' 
: '0%',
+                }"
+              />
+            </div>
+            <!-- Custom hover tooltip — replaces the native browser
+                 tooltip which was both slow to appear AND coupled to
+                 the `cursor: help` rendering (the `?` cursor was the
+                 thing the operator was reporting). -->
+            <div
+              v-if="hoveredBin !== null"
+              class="lg-density-tip"
+              :style="{ left: ((hoveredBin + 0.5) / 60) * 100 + '%' }"
+            >
+              <div class="lg-density-tip-time">
+                {{ fmtBucketRange(hoveredBin, histogram.t0, histogram.t1) }}
+              </div>
+              <div class="lg-density-tip-total">
+                {{ histogram.bins[hoveredBin].error + 
histogram.bins[hoveredBin].warn + histogram.bins[hoveredBin].info + 
histogram.bins[hoveredBin].debug + histogram.bins[hoveredBin].other }} 
log<template v-if="(histogram.bins[hoveredBin].error + 
histogram.bins[hoveredBin].warn + histogram.bins[hoveredBin].info + 
histogram.bins[hoveredBin].debug + histogram.bins[hoveredBin].other) !== 
1">s</template>
+              </div>
+              <div class="lg-density-tip-rows">
+                <span v-for="l in LEVEL_ORDER" :key="l" 
v-show="histogram.bins[hoveredBin][l] > 0" class="lg-density-tip-row">
+                  <span class="lvl-dot" :style="{ background: LEVEL_COLOR[l] 
}" />
+                  <span class="lg-density-tip-name">{{ l }}</span>
+                  <span class="lg-density-tip-val mono">{{ 
histogram.bins[hoveredBin][l] }}</span>
+                </span>
+              </div>
+            </div>
+          </div>
+          <div class="lg-density-axis">
+            <span class="t-tick">{{ fmtAxisTime(histogram.t0) }}</span>
+            <span class="t-tick">{{ fmtAxisTime(histogram.t0 + (histogram.t1 - 
histogram.t0) * 0.25) }}</span>
+            <span class="t-tick">{{ fmtAxisTime(histogram.t0 + (histogram.t1 - 
histogram.t0) * 0.5) }}</span>
+            <span class="t-tick">{{ fmtAxisTime(histogram.t0 + (histogram.t1 - 
histogram.t0) * 0.75) }}</span>
+            <span class="t-tick">{{ fmtAxisTime(histogram.t1) }}</span>
           </div>
         </div>
 
@@ -750,14 +853,14 @@ function jumpToTrace(traceId: string): void {
         </div>
         <div v-else class="lg-stream">
           <template v-for="(r, idx) in filteredLogs" :key="rowKey(r, idx)">
-            <div class="lg-row" :class="`lv-${levelOf(r)}`" 
@click="toggleExpand(rowKey(r, idx))">
+            <!-- Row click → open the popout. Inline expand removed:
+                 YAML / JSON / multi-line text rendered cramped inside
+                 the row band and reads as truncated. The popout has
+                 the full canvas + format-aware pretty-print. -->
+            <div class="lg-row" :class="`lv-${levelOf(r)}`" 
@click="onRowClick(r)">
               <span class="lg-time mono">{{ fmtTime(r.timestamp) }}</span>
               <span class="lg-date mono dim">{{ fmtDate(r.timestamp) }}</span>
               <span class="lg-lvl" :style="{ color: LEVEL_COLOR[levelOf(r)] 
}">{{ levelOf(r) }}</span>
-              <!-- Decode the OAP `<group>::<base>` convention so the
-                   row reads as "<chip> base" instead of dumping the
-                   raw `agent::songs` string. Falls back to the plain
-                   name when no group prefix is present. -->
               <span class="lg-svc mono dim">
                 <span
                   v-if="r.serviceName && parseServiceName(r.serviceName).group"
@@ -766,30 +869,21 @@ function jumpToTrace(traceId: string): void {
                 {{ r.serviceName ? parseServiceName(r.serviceName).base : '—' 
}}
               </span>
               <span v-if="r.traceId" class="lg-trace mono" 
@click.stop="jumpToTrace(r.traceId!)">↗ trace</span>
-              <span class="lg-content mono">{{ summariseContent(r) }}</span>
-            </div>
-            <div v-if="expandedId === rowKey(r, idx)" class="lg-expand">
-              <pre
-                v-if="detectFormat(r) === 'json'"
-                class="lg-payload json"
-              >{{ prettyForFormat(r, 'json') }}</pre>
-              <pre
-                v-else-if="detectFormat(r) === 'yaml'"
-                class="lg-payload yaml"
-              >{{ r.content }}</pre>
-              <pre v-else class="lg-payload text">{{ r.content }}</pre>
-              <div v-if="r.tags.length > 0" class="lg-tag-row">
-                <span v-for="t in r.tags" :key="`${t.key}=${t.value}`" 
class="lg-tag">
-                  <span class="lg-tag-k">{{ t.key }}</span>
-                  <span class="lg-tag-v mono">{{ t.value }}</span>
+              <!-- Format chip + flat content preview. Chip is always
+                   rendered so the operator can tell at-a-glance which
+                   rows are JSON / YAML / plain text. Preview is single
+                   line, length-capped, and trimmed-with-ellipsis via
+                   `.lg-content` CSS so even when JSON contains long
+                   strings the row stays one line. -->
+              <span class="lg-content mono">
+                <span class="lg-fmt-chip" :class="`fmt-${detectFormat(r)}`">{{ 
detectFormat(r).toUpperCase() }}</span>
+                <span class="lg-content-body">
+                  <template v-if="hasHiddenPayload(r)">
+                    <em class="lg-content-hint">click to view</em>
+                  </template>
+                  <template v-else>{{ summariseContent(r) }}</template>
                 </span>
-              </div>
-              <div class="lg-meta-row">
-                <span v-if="r.serviceInstanceName" class="lg-meta">instance 
<code>{{ r.serviceInstanceName }}</code></span>
-                <span v-if="r.endpointName" class="lg-meta">endpoint <code>{{ 
r.endpointName }}</code></span>
-                <span class="lg-meta">type <code>{{ 
detectFormat(r).toUpperCase() }}</code></span>
-                <button class="sw-btn small" type="button" 
@click.stop="openPopout(r)">View full</button>
-              </div>
+              </span>
             </div>
           </template>
         </div>
@@ -900,7 +994,7 @@ function jumpToTrace(traceId: string): void {
 }
 /* Trace-style toolbar layout (same voice as `LayerTracesView`). */
 .lg-toolbar { padding: 10px 12px; display: flex; flex-direction: column; gap: 
10px; overflow: visible; }
-.lg-toolbar-head { display: flex; align-items: baseline; gap: 10px; }
+.lg-toolbar-head { display: flex; align-items: center; gap: 10px; width: 100%; 
}
 /* Run-query button: SkyWalking orange, sits at the right edge of the
    toolbar head row. Matches `LayerTracesView.tr-run-btn` exactly so the
    two pages read identically. `.sw-btn.primary` is locally scoped per
@@ -1001,7 +1095,7 @@ function jumpToTrace(traceId: string): void {
   overflow: hidden;
   text-overflow: ellipsis;
 }
-.cf-combo-item em { color: var(--sw-fg-3); font-style: italic; }
+.cf-combo-item em { color: var(--sw-fg-1); font-style: normal; font-family: 
var(--sw-mono); }
 .cf-combo-item:hover { background: var(--sw-bg-2); color: var(--sw-fg-0); }
 .cf-combo-item.on { background: var(--sw-accent-soft); color: 
var(--sw-accent-2); font-weight: 600; }
 .cf-combo-empty { padding: 6px 8px; font-size: 10.5px; color: var(--sw-fg-3); }
@@ -1118,15 +1212,20 @@ function jumpToTrace(traceId: string): void {
   flex-direction: column;
   min-height: 0;
 }
+/* Density-bar wrapper: the 60 stacked bin bars on top, x-axis tick
+   strip underneath so the time scale is readable at a glance. */
+.lg-density-wrap {
+  padding: 8px 12px 4px;
+  border-bottom: 1px solid var(--sw-line);
+  background: var(--sw-bg-1);
+}
 .lg-density {
   display: grid;
   grid-template-columns: repeat(60, 1fr);
   align-items: end;
   gap: 1px;
   height: 60px;
-  padding: 8px 12px;
-  border-bottom: 1px solid var(--sw-line);
-  background: var(--sw-bg-1);
+  position: relative; /* anchor for the absolute-positioned tooltip */
 }
 .lg-density-bin {
   display: flex;
@@ -1135,8 +1234,62 @@ function jumpToTrace(traceId: string): void {
   background: var(--sw-bg-2);
   border-radius: 1px;
   overflow: hidden;
+  /* No `cursor: help` — the `?` cursor was misread as a UI error.
+     The bin reads as informational (hover surfaces a count tooltip),
+     so a default pointer is the right affordance. */
 }
+.lg-density-bin:hover { outline: 1px solid var(--sw-accent-line); }
 .lg-density-segment { display: block; }
+/* Custom hover tooltip — anchored to the hovered bin via the
+   `left: <bin-center>%` inline style. Wider than a single bin so it
+   doesn't clip; transforms back by 50% to centre on the bin. */
+.lg-density-tip {
+  position: absolute;
+  bottom: calc(100% + 6px);
+  transform: translateX(-50%);
+  min-width: 160px;
+  padding: 6px 9px;
+  background: var(--sw-bg-0);
+  border: 1px solid var(--sw-line-2);
+  border-radius: 4px;
+  box-shadow: 0 8px 20px rgba(0,0,0,0.45);
+  font-size: 11px;
+  color: var(--sw-fg-1);
+  pointer-events: none;
+  z-index: 5;
+}
+.lg-density-tip-time { color: var(--sw-fg-3); font-family: var(--sw-mono); 
font-size: 10px; margin-bottom: 2px; }
+.lg-density-tip-total { color: var(--sw-fg-0); font-weight: 700; font-size: 
12px; margin-bottom: 4px; }
+.lg-density-tip-rows { display: flex; flex-direction: column; gap: 2px; }
+.lg-density-tip-row { display: inline-flex; align-items: center; gap: 6px; 
font-size: 10.5px; }
+.lg-density-tip-row .lvl-dot { width: 7px; height: 7px; border-radius: 50%; 
flex: 0 0 7px; }
+.lg-density-tip-name { color: var(--sw-fg-2); flex: 1; text-transform: 
capitalize; }
+.lg-density-tip-val { color: var(--sw-fg-0); font-weight: 600; 
font-variant-numeric: tabular-nums; }
+/* X-axis tick strip — 5 evenly-spaced labels (start / 25% / 50% /
+   75% / end) underneath the bars, in tabular nums so they line up. */
+.lg-density-axis {
+  display: flex;
+  justify-content: space-between;
+  font-family: var(--sw-mono);
+  font-size: 9.5px;
+  color: var(--sw-fg-3);
+  font-variant-numeric: tabular-nums;
+  margin-top: 4px;
+  padding: 0 2px;
+}
+.lg-density-axis .t-tick:first-child { text-align: left; }
+.lg-density-axis .t-tick:last-child { text-align: right; }
+
+/* Row-content hint when the inline preview is suppressed (multi-line,
+   YAML, JSON). Italic gray so it reads as "this is a placeholder, not
+   the actual content". */
+.lg-content-hint {
+  color: var(--sw-fg-3);
+  font-style: italic;
+  font-size: 10.5px;
+  letter-spacing: 0.04em;
+}
+.lg-row { cursor: pointer; }
 .lg-empty {
   padding: 32px;
   text-align: center;
@@ -1186,6 +1339,36 @@ function jumpToTrace(traceId: string): void {
   overflow: hidden;
   text-overflow: ellipsis;
   white-space: nowrap;
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  min-width: 0;
+}
+/* Format chip — small uppercase tag rendered before the preview. Per-
+   format color keeps JSON / YAML / TEXT visually distinct without
+   adding chrome. Tabular-nums + monospace so the three chips align. */
+.lg-fmt-chip {
+  flex: 0 0 auto;
+  display: inline-block;
+  padding: 0 5px;
+  height: 14px;
+  line-height: 14px;
+  font-size: 9px;
+  font-weight: 700;
+  letter-spacing: 0.04em;
+  border-radius: 3px;
+  text-transform: uppercase;
+  font-family: var(--sw-mono);
+}
+.lg-fmt-chip.fmt-json { background: var(--sw-info-soft); color: 
var(--sw-info); }
+.lg-fmt-chip.fmt-yaml { background: rgba(251, 191, 36, 0.18); color: #fbbf24; }
+.lg-fmt-chip.fmt-text { background: var(--sw-bg-3); color: var(--sw-fg-2); }
+.lg-content-body {
+  flex: 1;
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
 }
 .lg-expand {
   padding: 10px 14px 14px 28px;
diff --git a/apps/ui/src/views/layer/LayerShell.vue 
b/apps/ui/src/views/layer/LayerShell.vue
index 9c379e6..1b6a5a1 100644
--- a/apps/ui/src/views/layer/LayerShell.vue
+++ b/apps/ui/src/views/layer/LayerShell.vue
@@ -26,7 +26,7 @@
       default entry; the cross-layer Overview lives at `/`.
 -->
 <script setup lang="ts">
-import { computed, ref } from 'vue';
+import { computed, ref, watch } from 'vue';
 import { RouterLink, RouterView, useRoute } from 'vue-router';
 import type { LayerDef } from '@skywalking-horizon-ui/api-client';
 import Icon from '@/components/icons/Icon.vue';
@@ -103,6 +103,23 @@ const selectedName = computed(() => {
 // say) can flip the same meta flag without touching this file.
 const viewOwnsServiceSelector = computed(() => 
Boolean(route.meta?.ownsServiceSelector));
 
+// Keep the URL-backed service selection honest for every page that
+// uses the shell picker. A stale `?service=` can survive navigation or
+// manual URL entry; the switch label used to fall back visually to the
+// first row while the metric query still waited for a valid service.
+watch(
+  [sampledServices, selectedId, viewOwnsServiceSelector],
+  ([rows, id, ownsSelector]) => {
+    if (ownsSelector) return;
+    const first = rows[0];
+    if (!first) return;
+    if (!id || !rows.some((s) => s.serviceId === id)) {
+      setSelected(first.serviceId);
+    }
+  },
+  { immediate: true },
+);
+
 // Picker toggle state. Lives at the shell level so the header's Switch
 // button and the picker section render against the same state.
 const pickerOpen = ref(false);
diff --git a/packages/api-client/src/menu.ts b/packages/api-client/src/menu.ts
index 12e6fed..bb236c1 100644
--- a/packages/api-client/src/menu.ts
+++ b/packages/api-client/src/menu.ts
@@ -41,6 +41,8 @@ export interface LayerSlots {
 export interface LayerCaps {
   serviceMap?: boolean;
   endpointDependency?: boolean;
+  instances?: boolean;
+  endpoints?: boolean;
   instanceTopology?: boolean;
   processTopology?: boolean;
   dashboards?: boolean;

Reply via email to