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

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

commit e3b96c2415bd144f632a6939222c6e4ac152f8af
Author: Wu Sheng <[email protected]>
AuthorDate: Tue Jun 23 23:57:55 2026 +0800

    feat(explore): Log inspect (raw source) + trace-inspect review fixes
    
    Log inspect — raw logs:
    - New ExploreLogView: cross-layer raw-log query with the optional
      Pick/Type/blank entity, keywords/tags/trace-id/time conditions, a
      full-width form, the shared LogStreamPanel, and a row-click popout
      (LogDetailPopout, built on the shared Modal) — also adopted by the
      per-layer Logs tab, replacing its bespoke popout.
    - BFF: fetchLogs extracted from the per-layer logs route; the explore
      route's log branch resolves the entity + SECOND-precision window and
      runs OAP queryLogs. Validated against the demo.
    
    Trace-inspect review fixes (adversarial review):
    - loadEndpoints() resets the picked endpoint on service change (was a
      silent OAP endpoint/service scope mismatch).
    - runQuery() clears the prior result before the await + shows
      "Reading data…" (cascade-clear-then-load).
    - RBAC: Trace/Log inspect gate on explore:read (the route's verb), now
      registered in verbs.ts.
    - TraceDistribution brush works again — the fat click hit-targets no
      longer swallow drag-start (click-vs-drag disambiguation).
    - Trace ID condition hidden for the zipkin source (unsupported there).
    - Native resolved-query echo includes the duration + tag filters.
---
 CHANGELOG.md                                       |   5 +
 apps/bff/src/http/query/explore.ts                 |  73 ++-
 apps/bff/src/http/query/log.ts                     | 138 ++++--
 apps/bff/src/rbac/verbs.ts                         |   2 +
 .../features/operate/explore/ExploreLogView.vue    | 533 ++++++++++++++++++++
 .../src/features/operate/explore/ExploreView.vue   |  32 +-
 apps/ui/src/i18n/locales/de.json                   |  34 +-
 apps/ui/src/i18n/locales/en.json                   |  34 +-
 apps/ui/src/i18n/locales/es.json                   |  34 +-
 apps/ui/src/i18n/locales/fr.json                   |  34 +-
 apps/ui/src/i18n/locales/ja.json                   |  34 +-
 apps/ui/src/i18n/locales/ko.json                   |  34 +-
 apps/ui/src/i18n/locales/pt.json                   |  34 +-
 apps/ui/src/i18n/locales/zh-CN.json                |  34 +-
 apps/ui/src/layer/logs/LayerLogsView.vue           | 536 +--------------------
 apps/ui/src/render/widgets/LogDetailPopout.vue     | 186 +++++++
 apps/ui/src/render/widgets/LogStreamPanel.vue      | 233 +++++++++
 apps/ui/src/render/widgets/TraceDistribution.vue   |  48 +-
 apps/ui/src/shell/AppSidebar.vue                   |   4 +-
 apps/ui/src/shell/router/index.ts                  |   7 +-
 apps/ui/src/utils/logRow.ts                        |  25 +
 21 files changed, 1477 insertions(+), 617 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9e43cb5..97979eb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,11 @@ The version line is shared by every package in the monorepo 
(apps + shared packa
 - **Shareable trace links are unified.** Native and Zipkin traces both open 
from a single `?traceId=` link under the layer's trace tab; the viewer 
auto-selects native vs Zipkin by the trace-ID shape, so 
`/layer/<layer>/trace?traceId=…` always opens the right one.
 - **Trace filters are searchable, on-theme dropdowns.** The native Service / 
Instance / Endpoint pickers and the Zipkin Service / Remote service / Span name 
pickers use a dark type-to-filter dropdown that reopens correctly after a pick.
 
+### Logs
+
+- **Log inspect uses the full width.** The cross-layer Log inspect form 
(Target + Keywords / Tags / Trace ID / Time / Limit conditions) now spans the 
whole page instead of sharing a two-column strip with empty space.
+- **Clicking a log row opens a centered popout.** Both the cross-layer Log 
inspect and the per-layer Logs tab now open the same full-payload popout on row 
click — format-aware pretty-print (JSON pretty-printed by content type), the 
tags table, service / instance / endpoint / time meta, a copy button, and the 
trace link. Escape or the close button dismisses it.
+
 ## 0.7.0
 
 ### Browser errors & source maps
diff --git a/apps/bff/src/http/query/explore.ts 
b/apps/bff/src/http/query/explore.ts
index 7cc3ed1..e108d0c 100644
--- a/apps/bff/src/http/query/explore.ts
+++ b/apps/bff/src/http/query/explore.ts
@@ -35,15 +35,17 @@ import type {
   ExploreResponse,
   ExploreWindow,
   FetchLike,
+  LogTagFilter,
   ZipkinTraceListResponse,
 } from '@skywalking-horizon-ui/api-client';
 import type { ConfigSource } from '../../config/loader.js';
 import type { SessionStore } from '../../user/sessions.js';
 import { requireAuth } from '../../user/middleware.js';
 import { buildOapOpts } from '../../client/graphql.js';
-import { getServerOffsetMinutes } from '../../util/window.js';
+import { fmtSecond, getServerOffsetMinutes } from '../../util/window.js';
 import { buildEndpointId, buildInstanceId, buildServiceId } from 
'../../util/entityId.js';
 import { fetchNativeList, type TraceListBody } from './trace.js';
+import { fetchLogs } from './log.js';
 import { zipkinFetchTraces } from '../../client/zipkin.js';
 
 export interface ExploreRouteDeps {
@@ -88,6 +90,21 @@ function zipkinWindow(w: ExploreWindow): { endTs: number; 
lookback: number } {
   return { endTs: Date.now(), lookback: Math.max(1, w.windowMinutes ?? 30) * 
60_000 };
 }
 
+const DEFAULT_LOG_WINDOW_MIN = 30;
+const MAX_LOG_WINDOW_MIN = 60 * 24 * 7; // 1 week guard, mirrors the per-layer 
route
+/** Log queries are SECOND-precision RECORD reads (same as the per-layer
+ *  Logs tab) — MINUTE rounding would chop off the most recent lines.
+ *  Explicit epoch-ms bounds win; otherwise the rolling minutes preset
+ *  anchors at "now". Both formatted OAP-local via the cached offset. */
+function logWindowSecond(w: ExploreWindow, offsetMinutes: number): { start: 
string; end: string } {
+  if (typeof w.startMs === 'number' && typeof w.endMs === 'number' && w.endMs 
> w.startMs) {
+    return { start: fmtSecond(w.startMs, offsetMinutes), end: 
fmtSecond(w.endMs, offsetMinutes) };
+  }
+  const m = Math.max(1, Math.min(MAX_LOG_WINDOW_MIN, 
Math.round(w.windowMinutes ?? DEFAULT_LOG_WINDOW_MIN)));
+  const endMs = Date.now();
+  return { start: fmtSecond(endMs - m * 60_000, offsetMinutes), end: 
fmtSecond(endMs, offsetMinutes) };
+}
+
 export function registerExploreRoutes(app: FastifyInstance, deps: 
ExploreRouteDeps): void {
   const auth = requireAuth(deps);
 
@@ -138,6 +155,9 @@ export function registerExploreRoutes(app: FastifyInstance, 
deps: ExploreRouteDe
               ...(body.traceId ? { traceId: body.traceId } : {}),
               traceState: body.traceState ?? 'ALL',
               queryOrder: body.queryOrder ?? 'BY_START_TIME',
+              ...(typeof body.minTraceDuration === 'number' ? { 
minTraceDuration: body.minTraceDuration } : {}),
+              ...(typeof body.maxTraceDuration === 'number' ? { 
maxTraceDuration: body.maxTraceDuration } : {}),
+              ...(body.tags && body.tags.length ? { tags: body.tags } : {}),
               ...win,
             },
           };
@@ -206,8 +226,55 @@ export function registerExploreRoutes(app: 
FastifyInstance, deps: ExploreRouteDe
         } satisfies ExploreResponse);
       }
 
-      // kind === 'log' — raw + browser, added in the log increment.
-      return reply.code(501).send({ error: 'log_not_implemented' });
+      // kind === 'log'. `browser` (BROWSER-layer JS errors) lands in the
+      // next increment; default + only supported source today is `raw`.
+      const logSource = body.logSource ?? 'raw';
+      if (logSource === 'raw') {
+        // Entity optional — no service means "all services in the window"
+        // (OAP's LogQueryCondition.serviceId is nullable).
+        const ids = body.entity ? resolveNativeEntity(body.entity) : {};
+        const win = logWindowSecond(body.window ?? {}, offset);
+        const keywords = (body.keywordsOfContent ?? []).filter((k) => k.length 
> 0);
+        const tags = (body.tags ?? []) as LogTagFilter[];
+        const maxLogs = 
deps.config.current.performance.limits.maxPageSize.logs;
+        const paging = {
+          pageNum: Math.max(1, Math.round(body.pageNum ?? 1)),
+          pageSize: Math.min(maxLogs, Math.max(1, Math.round(body.pageSize ?? 
50))),
+        };
+        const scope = {
+          serviceId: ids.serviceId,
+          serviceInstanceId: ids.instanceId,
+          endpointId: ids.endpointId,
+          traceId: body.relatedTraceId,
+          keywordsOfContent: keywords,
+          tags,
+        };
+        const logs = await fetchLogs(opts, scope, win, paging, 
!!req.coldStage);
+        const resolved: ExploreResolved = {
+          kind: 'log',
+          source: 'raw',
+          entityId: ids.serviceId,
+          condition: {
+            ...(ids.serviceId ? { serviceId: ids.serviceId } : {}),
+            ...(ids.instanceId ? { serviceInstanceId: ids.instanceId } : {}),
+            ...(ids.endpointId ? { endpointId: ids.endpointId } : {}),
+            ...(body.relatedTraceId ? { traceId: body.relatedTraceId } : {}),
+            ...(keywords.length > 0 ? { keywordsOfContent: keywords } : {}),
+            ...(tags.length > 0 ? { tags } : {}),
+            ...win,
+          },
+        };
+        return reply.send({
+          kind: 'log',
+          logSource: 'raw',
+          generatedAt,
+          logs,
+          resolved,
+        } satisfies ExploreResponse);
+      }
+
+      // kind === 'log' && logSource === 'browser' — next increment.
+      return reply.code(501).send({ error: 'browser_log_not_implemented' });
     },
   );
 }
diff --git a/apps/bff/src/http/query/log.ts b/apps/bff/src/http/query/log.ts
index 74cc6cf..9d693eb 100644
--- a/apps/bff/src/http/query/log.ts
+++ b/apps/bff/src/http/query/log.ts
@@ -36,6 +36,7 @@ import type {
   LogKeyValue,
   LogQueryRequest,
   LogRow,
+  LogTagFilter,
   LogsResponse,
 } from '@skywalking-horizon-ui/api-client';
 import type { ConfigSource } from '../../config/loader.js';
@@ -143,6 +144,79 @@ interface OapLogRow {
   tags?: LogKeyValue[] | null;
 }
 
+function mapLogRow(r: OapLogRow): LogRow {
+  return {
+    serviceName: r.serviceName ?? null,
+    serviceId: r.serviceId ?? null,
+    serviceInstanceName: r.serviceInstanceName ?? null,
+    serviceInstanceId: r.serviceInstanceId ?? null,
+    endpointName: r.endpointName ?? null,
+    endpointId: r.endpointId ?? null,
+    traceId: r.traceId ?? null,
+    timestamp: r.timestamp,
+    contentType: r.contentType,
+    content: r.content,
+    tags: r.tags ?? [],
+  };
+}
+
+/** Entity ids the log query scopes by — all PRE-RESOLVED by the caller
+ *  (no `listServices(layer)` lookup, no name → id resolution). The
+ *  per-layer route resolves a name first; explore forwards ids it
+ *  already minted. */
+export interface LogFetchScope {
+  serviceId?: string | null;
+  serviceInstanceId?: string | null;
+  endpointId?: string | null;
+  traceId?: string | null;
+  keywordsOfContent?: string[];
+  tags?: LogTagFilter[];
+}
+
+/** Run OAP's `queryLogs(LogQueryCondition)` for a pre-resolved scope +
+ *  SECOND-precision window + page, and map the rows to {@link LogRow}.
+ *  Shared by the per-layer Logs route and the cross-layer Log inspect
+ *  branch. Soft-fails to `reachable: false` on any OAP error. */
+export async function fetchLogs(
+  opts: GraphqlOptions,
+  scope: LogFetchScope,
+  window: { start: string; end: string },
+  paging: { pageNum: number; pageSize: number },
+  coldStage: boolean,
+): Promise<LogsResponse> {
+  const condition = {
+    ...(scope.serviceId ? { serviceId: scope.serviceId } : {}),
+    ...(scope.serviceInstanceId ? { serviceInstanceId: scope.serviceInstanceId 
} : {}),
+    ...(scope.endpointId ? { endpointId: scope.endpointId } : {}),
+    ...(scope.traceId ? { relatedTrace: { traceId: scope.traceId } } : {}),
+    ...(scope.keywordsOfContent && scope.keywordsOfContent.length > 0
+      ? { keywordsOfContent: scope.keywordsOfContent }
+      : {}),
+    ...(scope.tags && scope.tags.length > 0 ? { tags: scope.tags } : {}),
+    queryDuration: {
+      start: window.start,
+      end: window.end,
+      step: 'SECOND',
+      ...(coldStage ? { coldStage: true } : {}),
+    },
+    paging,
+  };
+  try {
+    const env = await graphqlPost<{ data: { logs: OapLogRow[] } }>(opts, 
QUERY_LOGS, { condition });
+    const logs = (env.data?.logs ?? []).map(mapLogRow);
+    return { generatedAt: Date.now(), query: {}, total: logs.length, logs, 
reachable: true };
+  } catch (err) {
+    return {
+      generatedAt: Date.now(),
+      query: {},
+      total: 0,
+      logs: [],
+      reachable: false,
+      error: err instanceof Error ? err.message : String(err),
+    };
+  }
+}
+
 /**
  * Resolve a service argument to an OAP service id. The arg can be
  * either a name (`mesh-svr::songs.sample-services`) or an id
@@ -211,56 +285,26 @@ export function registerLogRoute(app: FastifyInstance, 
deps: LogRouteDeps): void
           } satisfies LogsResponse);
         }
       }
-      const condition = {
-        ...(serviceId ? { serviceId } : {}),
-        ...(body.serviceInstanceId ? { serviceInstanceId: 
body.serviceInstanceId } : {}),
-        ...(body.endpointId ? { endpointId: body.endpointId } : {}),
-        ...(body.traceId ? { relatedTrace: { traceId: body.traceId } } : {}),
-        ...(body.keywordsOfContent && body.keywordsOfContent.length > 0
-          ? { keywordsOfContent: body.keywordsOfContent }
-          : {}),
-        ...(body.tags && body.tags.length > 0 ? { tags: body.tags } : {}),
-        queryDuration: withColdStage(req, { start: window.start, end: 
window.end, step: 'SECOND' }),
-        paging: {
+      const res = await fetchLogs(
+        opts,
+        {
+          serviceId,
+          serviceInstanceId: body.serviceInstanceId,
+          endpointId: body.endpointId,
+          traceId: body.traceId,
+          keywordsOfContent: body.keywordsOfContent,
+          tags: body.tags,
+        },
+        window,
+        {
           pageNum: Math.max(1, Math.round(body.page ?? 1)),
           pageSize: clampPageSize(body.pageSize, 50, 
deps.config.current.performance.limits.maxPageSize.logs),
         },
-      };
-
-      try {
-        const env = await graphqlPost<{
-          data: { logs: OapLogRow[] };
-        }>(opts, QUERY_LOGS, { condition });
-        const logs: LogRow[] = (env.data?.logs ?? []).map((r) => ({
-          serviceName: r.serviceName ?? null,
-          serviceId: r.serviceId ?? null,
-          serviceInstanceName: r.serviceInstanceName ?? null,
-          serviceInstanceId: r.serviceInstanceId ?? null,
-          endpointName: r.endpointName ?? null,
-          endpointId: r.endpointId ?? null,
-          traceId: r.traceId ?? null,
-          timestamp: r.timestamp,
-          contentType: r.contentType,
-          content: r.content,
-          tags: r.tags ?? [],
-        }));
-        return reply.send({
-          generatedAt: Date.now(),
-          query: body,
-          total: logs.length,
-          logs,
-          reachable: true,
-        } satisfies LogsResponse);
-      } catch (err) {
-        return reply.send({
-          generatedAt: Date.now(),
-          query: body,
-          total: 0,
-          logs: [],
-          reachable: false,
-          error: err instanceof Error ? err.message : String(err),
-        } satisfies LogsResponse);
-      }
+        !!req.coldStage,
+      );
+      // Echo the operator's query (the shared helper returns an empty
+      // echo since it's entity-agnostic).
+      return reply.send({ ...res, query: body } satisfies LogsResponse);
     },
   );
 
diff --git a/apps/bff/src/rbac/verbs.ts b/apps/bff/src/rbac/verbs.ts
index 43ce4a8..d1d2bc4 100644
--- a/apps/bff/src/rbac/verbs.ts
+++ b/apps/bff/src/rbac/verbs.ts
@@ -29,6 +29,8 @@ export const VERBS = {
   tracesRead: 'traces:read',
   logsRead: 'logs:read',
   browserErrorsRead: 'browser-errors:read',
+  /** Cross-layer Trace/Log inspect (the /api/explore/query route). */
+  exploreRead: 'explore:read',
   topologyRead: 'topology:read',
   profileRead: 'profile:read',
   infra3dRead: 'infra-3d:read',
diff --git a/apps/ui/src/features/operate/explore/ExploreLogView.vue 
b/apps/ui/src/features/operate/explore/ExploreLogView.vue
new file mode 100644
index 0000000..464dd2f
--- /dev/null
+++ b/apps/ui/src/features/operate/explore/ExploreLogView.vue
@@ -0,0 +1,533 @@
+<!--
+  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.
+-->
+<!--
+  Log inspect — cross-layer raw-log query power-tool. The Log sibling of
+  ExploreView (Trace inspect): same dark-dense spine, same OPTIONAL
+  entity (pick a layer-filtered service, type its name + the real flag,
+  or leave it blank to query every service). One query → one full-width
+  log stream, rendered with the shared LogStreamPanel; clicking a row
+  opens the shared LogDetailPopout with the full payload. The
+  resolved-query panel surfaces the exact condition the BFF ran.
+
+  This pass implements Log · raw. The Browser source (BROWSER-layer JS
+  errors) lands next on the same spine — its toggle is present but
+  disabled.
+-->
+<script setup lang="ts">
+import { computed, reactive, ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { bffClient } from '@/api/client';
+import type {
+  ExploreEntity,
+  ExploreRequest,
+  ExploreResolved,
+  ExploreWindow,
+  LogRow,
+  LogsResponse,
+} from '@/api/client';
+import { useLayers } from '@/shell/useLayers';
+import { useTracePopout } from '@/layer/traces/useTracePopout';
+import TypeaheadSelect from '@/components/primitives/TypeaheadSelect.vue';
+import LogStreamPanel from '@/render/widgets/LogStreamPanel.vue';
+import LogDetailPopout from '@/render/widgets/LogDetailPopout.vue';
+import { logRowKey } from '@/utils/logRow';
+
+const { t } = useI18n();
+const { availableLayers } = useLayers();
+const { openTrace } = useTracePopout();
+
+// ── source (browser-error log source lands in the next increment) ─────
+type LogSource = 'raw' | 'browser';
+const logSource = ref<LogSource>('raw');
+
+// ── entity (OPTIONAL): pick (layer-filtered) vs type (name + real) ────
+type EntityMode = 'pick' | 'type';
+const entityMode = ref<EntityMode>('pick');
+
+const pickLayer = ref<string>('');
+const pickServiceId = ref<string>('');
+const pickInstanceId = ref<string>('');
+const pickEndpointId = ref<string>('');
+const services = ref<Array<{ id: string; name: string; normal: boolean | null 
}>>([]);
+const instances = ref<Array<{ id: string; name: string }>>([]);
+const endpoints = ref<Array<{ id: string; name: string }>>([]);
+const servicesLoading = ref(false);
+
+const pickServiceName = computed(
+  () => services.value.find((s) => s.id === pickServiceId.value)?.name ?? '',
+);
+
+async function loadServices(): Promise<void> {
+  services.value = [];
+  instances.value = [];
+  endpoints.value = [];
+  pickServiceId.value = '';
+  pickInstanceId.value = '';
+  pickEndpointId.value = '';
+  if (!pickLayer.value) return;
+  servicesLoading.value = true;
+  try {
+    const res = await bffClient.layer.services(pickLayer.value);
+    services.value = res.reachable ? res.services : [];
+  } catch {
+    services.value = [];
+  } finally {
+    servicesLoading.value = false;
+  }
+}
+
+async function loadInstances(): Promise<void> {
+  instances.value = [];
+  pickInstanceId.value = '';
+  const name = pickServiceName.value;
+  if (!pickLayer.value || !name) return;
+  try {
+    const res = await bffClient.layer.instances(pickLayer.value, name);
+    instances.value = res.reachable ? res.instances : [];
+  } catch {
+    instances.value = [];
+  }
+}
+
+async function loadEndpoints(): Promise<void> {
+  const name = pickServiceName.value;
+  if (!pickLayer.value || !name) {
+    endpoints.value = [];
+    return;
+  }
+  try {
+    const res = await bffClient.layer.endpoints(pickLayer.value, name, '', 50);
+    endpoints.value = res.reachable ? res.endpoints : [];
+  } catch {
+    endpoints.value = [];
+  }
+}
+
+watch(pickLayer, () => void loadServices());
+watch(pickServiceId, () => {
+  void loadInstances();
+  void loadEndpoints();
+});
+
+const layerOptions = computed(() => availableLayers.value.map((l) => ({ value: 
l.key, label: l.name || l.key })));
+const serviceOptions = computed(() =>
+  services.value.map((s) => ({ value: s.id, label: s.name, hint: s.normal === 
false ? 'virtual' : undefined })),
+);
+const instanceOptions = computed(() => [
+  { value: '', label: t('All instances') },
+  ...instances.value.map((i) => ({ value: i.id, label: i.name })),
+]);
+const endpointOptions = computed(() => [
+  { value: '', label: t('All endpoints') },
+  ...endpoints.value.map((e) => ({ value: e.id, label: e.name })),
+]);
+const instanceSel = computed<string>({ get: () => pickInstanceId.value, set: 
(v) => (pickInstanceId.value = v ?? '') });
+const endpointSel = computed<string>({ get: () => pickEndpointId.value, set: 
(v) => (pickEndpointId.value = v ?? '') });
+
+const typeService = ref<string>('');
+const typeReal = ref<boolean>(true);
+const typeInstance = ref<string>('');
+const typeEndpoint = ref<string>('');
+
+/** Seed the Type form from the current Pick selection — pick to discover,
+ *  then tweak the name/flag by hand. */
+function seedTypeFromPick(): void {
+  if (!pickServiceName.value) return;
+  typeService.value = pickServiceName.value;
+  typeReal.value = services.value.find((s) => s.id === 
pickServiceId.value)?.normal !== false;
+  typeInstance.value = instances.value.find((i) => i.id === 
pickInstanceId.value)?.name ?? '';
+  typeEndpoint.value = endpoints.value.find((e) => e.id === 
pickEndpointId.value)?.name ?? '';
+  entityMode.value = 'type';
+}
+
+function currentEntity(): ExploreEntity | null {
+  if (entityMode.value === 'pick') {
+    if (!pickServiceId.value) return null;
+    return {
+      mode: 'pick',
+      serviceId: pickServiceId.value,
+      instanceId: pickInstanceId.value || undefined,
+      endpointId: pickEndpointId.value || undefined,
+    };
+  }
+  const name = typeService.value.trim();
+  if (!name) return null;
+  return {
+    mode: 'type',
+    serviceName: name,
+    isReal: typeReal.value,
+    instanceName: typeInstance.value.trim() || undefined,
+    endpointName: typeEndpoint.value.trim() || undefined,
+  };
+}
+
+// ── log conditions ────────────────────────────────────────────────────
+const cond = reactive({
+  keywords: '' as string,
+  tags: '' as string,
+  traceId: '' as string,
+  windowMinutes: 30,
+  limit: 50,
+});
+const WINDOWS = [15, 30, 60, 180, 360, 720, 1440];
+const LIMITS = [20, 50, 100, 200];
+
+const CUSTOM_RANGE_SENTINEL = -1;
+const customStart = ref<string | null>(null);
+const customEnd = ref<string | null>(null);
+const isCustomRange = computed(() => cond.windowMinutes === 
CUSTOM_RANGE_SENTINEL);
+function fmtLocalInput(d: Date): string {
+  const pad = (n: number) => String(n).padStart(2, '0');
+  return `${d.getFullYear()}-${pad(d.getMonth() + 
1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
+}
+watch(isCustomRange, (custom) => {
+  if (custom) {
+    if (customStart.value && customEnd.value) return;
+    const end = new Date();
+    const start = new Date(end.getTime() - 30 * 60_000);
+    customStart.value = fmtLocalInput(start);
+    customEnd.value = fmtLocalInput(end);
+  } else {
+    customStart.value = null;
+    customEnd.value = null;
+  }
+});
+
+/** Free-text keywords → OAP's content-keyword list. Split on whitespace
+ *  and commas; each token is AND-joined server-side. */
+function parseKeywords(s: string): string[] {
+  return s
+    .split(/[\s,]+/)
+    .map((p) => p.trim())
+    .filter(Boolean);
+}
+function parseTags(s: string): Array<{ key: string; value: string }> {
+  return s
+    .split(',')
+    .map((p) => p.trim())
+    .filter(Boolean)
+    .map((p) => {
+      const i = p.indexOf('=');
+      return i < 0 ? { key: p, value: '' } : { key: p.slice(0, i).trim(), 
value: p.slice(i + 1).trim() };
+    })
+    .filter((kv) => kv.key);
+}
+
+// ── run + result ──────────────────────────────────────────────────────
+const running = ref(false);
+const hasQueried = ref(false);
+const errorMsg = ref<string | null>(null);
+const logsResp = ref<LogsResponse | null>(null);
+const resolved = ref<ExploreResolved | null>(null);
+const showResolved = ref(false);
+
+const canRun = computed(() => logSource.value === 'raw');
+
+/** Explicit epoch-ms bounds in Custom mode (datetime-local strings are
+ *  browser-local; `Date.parse` reads them as local), else rolling. */
+function resolveWindow(): ExploreWindow {
+  if (isCustomRange.value) {
+    if (customStart.value && customEnd.value) {
+      const startMs = Date.parse(customStart.value);
+      const endMs = Date.parse(customEnd.value);
+      if (Number.isFinite(startMs) && Number.isFinite(endMs) && endMs > 
startMs) {
+        return { startMs, endMs };
+      }
+    }
+    return { windowMinutes: 30 };
+  }
+  return { windowMinutes: cond.windowMinutes };
+}
+
+function buildRequest(): ExploreRequest {
+  const entity = currentEntity();
+  const keywords = parseKeywords(cond.keywords);
+  const tags = parseTags(cond.tags);
+  return {
+    kind: 'log',
+    logSource: 'raw',
+    ...(entity ? { entity } : {}),
+    window: resolveWindow(),
+    pageSize: cond.limit,
+    ...(keywords.length > 0 ? { keywordsOfContent: keywords } : {}),
+    ...(tags.length > 0 ? { tags } : {}),
+    ...(cond.traceId.trim() ? { relatedTraceId: cond.traceId.trim() } : {}),
+  };
+}
+
+async function runQuery(): Promise<void> {
+  running.value = true;
+  hasQueried.value = true;
+  errorMsg.value = null;
+  closeDetail();
+  // Cascade-clear: drop the prior result before the new query lands so
+  // operators never read stale rows as the new state.
+  logsResp.value = null;
+  const req = buildRequest();
+  try {
+    const res = await bffClient.explore.query(req);
+    if (res.kind === 'log' && res.logSource === 'raw') {
+      logsResp.value = res.logs;
+      resolved.value = res.resolved;
+      if (!res.logs.reachable) errorMsg.value = res.logs.error ?? t('OAP 
unreachable');
+    }
+  } catch (e) {
+    errorMsg.value = e instanceof Error ? e.message : String(e);
+    logsResp.value = null;
+  } finally {
+    running.value = false;
+  }
+}
+
+const rows = computed<LogRow[]>(() => (hasQueried.value ? logsResp.value?.logs 
?? [] : []));
+
+// ── detail — clicking a row opens the shared full-payload popout. The
+// stream stays full-width; the popout owns its own Escape / close +
+// format-aware pretty-print + copy + tag table.
+const selectedKey = ref<string | null>(null);
+const selectedRow = ref<LogRow | null>(null);
+
+function openRow(payload: { row: LogRow; key: string }): void {
+  selectedKey.value = payload.key;
+  selectedRow.value = payload.row;
+}
+function closeDetail(): void {
+  selectedKey.value = null;
+  selectedRow.value = null;
+}
+function jumpToTrace(traceId: string, ts: number): void {
+  openTrace(traceId, ts);
+}
+
+// Drop the selection across result changes — a row that's no longer in
+// the new result closes the popout.
+watch(rows, (next) => {
+  if (selectedKey.value == null) return;
+  const stillThere = next.some((r, idx) => logRowKey(r, idx) === 
selectedKey.value);
+  if (!stillThere) closeDetail();
+});
+</script>
+
+<template>
+  <div class="iq">
+    <header class="iq-head">
+      <h1>{{ t('Log inspect') }}</h1>
+      <p class="iq-sub">{{ t('Query any service across layers — pick it, type 
its name, or leave it blank.') }}</p>
+    </header>
+
+    <div class="iq-bar">
+      <span class="iq-bar-l">{{ t('Source') }}</span>
+      <div class="seg">
+        <button :class="{ on: logSource === 'raw' }" @click="logSource = 
'raw'">{{ t('Raw') }}</button>
+        <button disabled :title="t('coming next')">{{ t('Browser') }}</button>
+      </div>
+    </div>
+
+    <!-- Logs have no distribution square — the form spans the full width
+         (a 3-column condition grid reads well across it). -->
+    <div class="iq-form">
+      <div class="iq-target">
+        <div class="iq-target-h">
+            <span>{{ t('Target') }} <small class="dim">{{ t('optional — blank 
queries all services') }}</small></span>
+            <div class="seg sm">
+              <button :class="{ on: entityMode === 'pick' }" 
@click="entityMode = 'pick'">{{ t('Pick') }}</button>
+              <button :class="{ on: entityMode === 'type' }" 
@click="entityMode = 'type'">{{ t('Type') }}</button>
+            </div>
+            <button v-if="entityMode === 'pick'" class="iq-link" 
:disabled="!pickServiceId" @click="seedTypeFromPick">
+              {{ t('→ edit as text') }}
+            </button>
+          </div>
+
+          <div class="iq-grid" v-if="entityMode === 'pick'">
+            <label class="cf">
+              <span>{{ t('Layer') }}</span>
+              <TypeaheadSelect v-model="pickLayer" :aria-label="t('Layer')" 
:options="layerOptions" :placeholder="t('Any layer')" class="cf-tas" />
+            </label>
+            <label class="cf">
+              <span>{{ t('Service') }}</span>
+              <TypeaheadSelect
+                v-model="pickServiceId" :aria-label="t('Service')" 
:options="serviceOptions" :disabled="!pickLayer || servicesLoading"
+                :placeholder="servicesLoading ? t('Reading…') : t('Any 
service')" class="cf-tas"
+              />
+            </label>
+            <label class="cf">
+              <span>{{ t('Instance') }}</span>
+              <TypeaheadSelect v-model="instanceSel" 
:aria-label="t('Instance')" :options="instanceOptions" 
:disabled="!pickServiceId" :placeholder="t('All instances')" class="cf-tas" />
+            </label>
+            <label class="cf">
+              <span>{{ t('Endpoint') }}</span>
+              <TypeaheadSelect v-model="endpointSel" 
:aria-label="t('Endpoint')" :options="endpointOptions" 
:disabled="!pickServiceId" :placeholder="t('All endpoints')" class="cf-tas" />
+            </label>
+          </div>
+
+          <div class="iq-grid" v-else>
+            <label class="cf">
+              <span>{{ t('Service name') }}</span>
+              <input v-model="typeService" class="cf-input mono" type="text" 
:placeholder="t('e.g. agent::checkout')" />
+            </label>
+            <label class="cf cf-chk">
+              <span>{{ t('Real') }}</span>
+              <span class="iq-chk"><input v-model="typeReal" type="checkbox" 
/> <small class="dim">{{ t('off = virtual / peer') }}</small></span>
+            </label>
+            <label class="cf">
+              <span>{{ t('Instance') }}</span>
+              <input v-model="typeInstance" class="cf-input" type="text" 
:placeholder="t('optional')" />
+            </label>
+            <label class="cf">
+              <span>{{ t('Endpoint') }}</span>
+              <input v-model="typeEndpoint" class="cf-input" type="text" 
:placeholder="t('optional')" />
+            </label>
+          </div>
+        </div>
+
+        <div class="iq-conditions">
+          <div class="iq-conditions-h">
+            <span class="kicker iq-cond-kicker">Logs</span>
+            <button v-if="resolved" class="iq-resolved-tog" 
@click="showResolved = !showResolved">
+              {{ showResolved ? '▾' : '▸' }} {{ t('Resolved query') }}
+              <span class="dim">{{ resolved.source }}</span>
+            </button>
+            <button class="iq-run" :disabled="!canRun || running" 
@click="runQuery">
+              {{ running ? t('Running…') : t('Run query') }}
+            </button>
+          </div>
+          <div class="iq-grid">
+            <label class="cf cf-wide">
+              <span>{{ t('Keywords') }}</span>
+              <input v-model="cond.keywords" class="cf-input mono" type="text" 
:placeholder="t('space- or comma-separated, AND-joined')" />
+            </label>
+            <label class="cf cf-wide">
+              <span>{{ t('Tags') }}</span>
+              <input v-model="cond.tags" class="cf-input mono" type="text" 
:placeholder="t('level=ERROR, …')" />
+            </label>
+            <label class="cf cf-wide">
+              <span>{{ t('Trace ID') }}</span>
+              <input v-model="cond.traceId" class="cf-input mono" type="text" 
:placeholder="t('paste a trace id')" />
+            </label>
+            <label class="cf iq-time" :class="{ 'cf-wide': isCustomRange }">
+              <span>{{ t('Time') }}</span>
+              <template v-if="isCustomRange">
+                <span class="cf-range">
+                  <input v-model="customStart" type="datetime-local" 
class="cf-input cf-range-num" />
+                  <span class="cf-range-sep">–</span>
+                  <input v-model="customEnd" type="datetime-local" 
class="cf-input cf-range-num" />
+                  <button class="iq-range-reset" type="button" :title="t('Back 
to presets')" @click="cond.windowMinutes = 30">×</button>
+                </span>
+              </template>
+              <select v-else v-model.number="cond.windowMinutes" 
class="cf-input">
+                <option v-for="w in WINDOWS" :key="w" :value="w">{{ w < 60 ? 
`${w}m` : `${w / 60}h` }}</option>
+                <option :value="CUSTOM_RANGE_SENTINEL">{{ t('Custom…') 
}}</option>
+              </select>
+            </label>
+            <label class="cf">
+              <span>{{ t('Limit') }}</span>
+              <select v-model.number="cond.limit" class="cf-input">
+                <option v-for="l in LIMITS" :key="l" :value="l">{{ l 
}}</option>
+              </select>
+            </label>
+          </div>
+        <pre v-if="resolved && showResolved" class="iq-resolved-body">{{ 
JSON.stringify(resolved.condition, null, 2) }}</pre>
+      </div>
+    </div>
+
+    <div class="iq-result">
+      <div v-if="!hasQueried" class="iq-empty">{{ t('Run a query — name a 
service or leave it blank.') }}</div>
+      <div v-else-if="running && rows.length === 0" class="iq-empty">{{ 
t('Reading data…') }}</div>
+      <div v-else-if="errorMsg" class="iq-err">{{ errorMsg }}</div>
+      <div v-else-if="rows.length === 0" class="iq-empty">{{ t('No logs in 
this window.') }}</div>
+
+      <article v-else class="iq-list-card sw-card">
+        <header class="iq-list-head">
+          <h4>Logs</h4>
+          <span class="hint">{{ rows.length }} {{ t('logs') }}</span>
+        </header>
+        <div class="iq-stream-scroll">
+          <LogStreamPanel :rows="rows" :selected-key="selectedKey" 
@select="openRow" @jump-trace="jumpToTrace($event.traceId, $event.ts)" />
+        </div>
+      </article>
+    </div>
+
+    <!-- Row click → shared full-payload popout (format-aware pretty-print
+         + copy + tag table + trace link). Escape / × closes it. -->
+    <LogDetailPopout :row="selectedRow" @close="closeDetail" 
@jump-trace="jumpToTrace($event.traceId, $event.ts)" />
+  </div>
+</template>
+
+<style scoped>
+/* Mirrors ExploreView's `.iq-*` vocab — see the comment there for the
+   viewport-anchored min-height rationale. */
+.iq { display: flex; flex-direction: column; gap: 10px; min-height: calc(100vh 
- 52px); padding: 14px 16px; }
+.iq-head h1 { font-size: 16px; margin: 0; }
+.iq-sub { color: var(--sw-fg-3); font-size: 12px; margin: 2px 0 0; }
+.iq-bar { display: flex; align-items: center; gap: 10px; }
+.iq-bar-l { font-size: 11px; color: var(--sw-fg-3); font-weight: 500; }
+.seg { display: inline-flex; border: 1px solid var(--sw-line-2); 
border-radius: 5px; overflow: hidden; }
+.seg button { background: var(--sw-bg-2); color: var(--sw-fg-2); border: none; 
padding: 4px 14px; font: inherit; font-size: 12px; cursor: pointer; }
+.seg button:disabled { opacity: 0.45; cursor: not-allowed; }
+.seg.sm button { padding: 3px 10px; }
+.seg button + button { border-left: 1px solid var(--sw-line-2); }
+.seg button.on { background: var(--sw-accent); color: #fff; }
+.iq-target { border: 1px solid var(--sw-line); border-radius: 6px; padding: 
8px 10px; }
+.iq-target-h { display: flex; align-items: center; gap: 12px; margin-bottom: 
8px; font-size: 11px; color: var(--sw-fg-2); font-weight: 600; }
+.iq-target-h .dim { color: var(--sw-fg-4); font-weight: 400; }
+.iq-link { background: none; border: none; color: var(--sw-accent); font-size: 
11px; cursor: pointer; padding: 0; margin-left: auto; }
+.iq-link:disabled { color: var(--sw-fg-4); cursor: not-allowed; }
+.iq-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); 
gap: 8px 10px; }
+@media (max-width: 900px) { .iq-grid { grid-template-columns: repeat(2, 
minmax(0, 1fr)); } }
+@media (max-width: 560px) { .iq-grid { grid-template-columns: 1fr; } }
+.cf { display: flex; flex-direction: column; gap: 3px; font-size: 11px; color: 
var(--sw-fg-3); font-weight: 500; min-width: 0; }
+.cf.cf-wide { grid-column: span 2; }
+.cf.iq-time { grid-column-start: 1; }
+.cf.iq-time.cf-wide { grid-column: 1 / span 2; }
+.cf.cf-chk { justify-content: flex-end; }
+.cf small { font-weight: 400; font-size: 9.5px; margin-left: 4px; font-style: 
italic; }
+.iq-chk { display: inline-flex; align-items: center; gap: 6px; height: 28px; }
+.iq-chk .dim { color: var(--sw-fg-4); }
+.cf-input {
+  height: 28px; padding: 0 8px; background: var(--sw-bg-2); border: 1px solid 
var(--sw-line-2);
+  border-radius: 4px; color: var(--sw-fg-0); font: inherit; font-size: 11px; 
width: 100%; box-sizing: border-box;
+}
+.cf-input.mono { font-family: var(--sw-mono); }
+.cf-input:disabled { opacity: 0.5; cursor: not-allowed; }
+.cf-tas { display: block; width: 100%; }
+.cf-tas :deep(.tas__trigger) { width: 100%; max-width: none; min-width: 0; 
height: 28px; padding: 0 8px; font-size: 11px; background: var(--sw-bg-2); 
border-radius: 4px; }
+.cf-range { display: flex; align-items: center; gap: 4px; }
+.cf-range-num { flex: 1; min-width: 0; }
+.cf-range-sep { color: var(--sw-fg-3); font-size: 12px; flex: 0 0 auto; }
+.iq-conditions-h { display: flex; align-items: center; gap: 12px; }
+.iq-cond-kicker { margin-right: auto; }
+.iq-run { background: var(--sw-accent); color: #fff; border: none; 
border-radius: 4px; padding: 5px 18px; font: inherit; font-size: 12px; cursor: 
pointer; height: 28px; order: 2; }
+.iq-run:disabled { opacity: 0.5; cursor: not-allowed; }
+.iq-resolved-tog { background: none; border: none; color: var(--sw-fg-3); 
font-size: 11px; cursor: pointer; }
+.iq-resolved-tog .dim { color: var(--sw-fg-4); margin-left: 6px; }
+.iq-resolved-body { margin: 0; padding: 8px 12px; font-family: var(--sw-mono); 
font-size: 11px; color: var(--sw-fg-2); background: var(--sw-bg-0); overflow: 
auto; max-height: 160px; border: 1px solid var(--sw-line); border-radius: 5px; }
+.kicker { font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; 
color: var(--sw-accent); font-weight: 600; }
+
+.iq-form { display: flex; flex-direction: column; gap: 10px; min-width: 0; }
+.iq-conditions { display: flex; flex-direction: column; gap: 10px; min-width: 
0; }
+
+.iq-result { flex: 1; min-height: 0; display: flex; flex-direction: column; }
+.iq-result > .iq-list-card { flex: 1; }
+.iq-empty, .iq-err { padding: 24px; text-align: center; color: var(--sw-fg-3); 
font-size: 12px; }
+.iq-err { color: var(--sw-err); }
+.iq-list-card { padding: 0; display: flex; flex-direction: column; min-height: 
0; max-height: calc(100vh - 80px); overflow: hidden; }
+.iq-list-head { display: flex; align-items: baseline; gap: 8px; padding: 10px 
14px; border-bottom: 1px solid var(--sw-line); flex: 0 0 auto; }
+.iq-list-head h4 { margin: 0; font-size: 12px; font-weight: 600; color: 
var(--sw-fg-0); }
+.iq-list-head .hint { margin-left: auto; font-size: 10.5px; color: 
var(--sw-fg-3); }
+.iq-stream-scroll { flex: 1; overflow-y: auto; min-height: 0; }
+.mono { font-family: var(--sw-mono); }
+</style>
diff --git a/apps/ui/src/features/operate/explore/ExploreView.vue 
b/apps/ui/src/features/operate/explore/ExploreView.vue
index dc0c466..682621c 100644
--- a/apps/ui/src/features/operate/explore/ExploreView.vue
+++ b/apps/ui/src/features/operate/explore/ExploreView.vue
@@ -15,8 +15,8 @@
   limitations under the License.
 -->
 <!--
-  Trace inspect / Log inspect — cross-layer trace/log query power-tools
-  (the `kind` prop picks which; two sidebar menus, one component).
+  Trace inspect — cross-layer trace query power-tool. (Log inspect is the
+  sibling ExploreLogView; both share the same `.iq-*` spine.)
 
   Layer-less by design: the entity is OPTIONAL. Name a service by PICKING
   it (a layer-filtered dropdown), TYPING it (service name + the real/
@@ -25,8 +25,7 @@
   with the same waterfall the per-layer Traces tab uses; the resolved-
   query panel surfaces the exact condition the BFF ran.
 
-  This pass implements Trace · native. Trace · zipkin and the Log kinds
-  (raw / browser-error) land next on the same spine.
+  Source switches between SkyWalking-native and Zipkin traces.
 -->
 <script setup lang="ts">
 import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 
'vue';
@@ -53,13 +52,10 @@ import ZipkinTraceDetailCard from 
'@/render/widgets/ZipkinTraceDetailCard.vue';
 import TraceDistribution from '@/render/widgets/TraceDistribution.vue';
 import TypeaheadSelect from '@/components/primitives/TypeaheadSelect.vue';
 
-const props = defineProps<{ kind: 'trace' | 'log' }>();
 const { t } = useI18n();
 const { availableLayers } = useLayers();
 
-const title = computed(() => (props.kind === 'trace' ? t('Trace inspect') : 
t('Log inspect')));
-
-// ── source (zipkin / log sources land in the next increments) ─────────
+// ── source (native / zipkin) ──────────────────────────────────────────
 type TraceSource = 'native' | 'zipkin';
 const traceSource = ref<TraceSource>('native');
 
@@ -113,6 +109,7 @@ async function loadInstances(): Promise<void> {
 }
 
 async function loadEndpoints(): Promise<void> {
+  pickEndpointId.value = '';
   const name = pickServiceName.value;
   if (!pickLayer.value || !name) {
     endpoints.value = [];
@@ -314,8 +311,6 @@ const zipkinRows = ref<ZipkinTraceListRow[]>([]);
 const resolved = ref<ExploreResolved | null>(null);
 const showResolved = ref(false);
 
-const canRun = computed(() => props.kind === 'trace');
-
 /** Build the window the request carries: explicit epoch-ms bounds in
  *  Custom mode (datetime-local strings are browser-local; `Date.parse`
  *  reads them as local), else the rolling minutes preset. */
@@ -376,6 +371,9 @@ async function runQuery(): Promise<void> {
   errorMsg.value = null;
   closeDetail();
   brushedKeys.value = [];
+  native.value = null;
+  zipkinRows.value = [];
+  resolved.value = null;
   const zipkin = traceSource.value === 'zipkin';
   const req = zipkin ? buildZipkinRequest() : buildNativeRequest();
   try {
@@ -500,14 +498,11 @@ onBeforeUnmount(() => 
window.removeEventListener('keydown', onPageKeyDown, true)
 <template>
   <div class="iq">
     <header class="iq-head">
-      <h1>{{ title }}</h1>
+      <h1>{{ t('Trace inspect') }}</h1>
       <p class="iq-sub">{{ t('Query any service across layers — pick it, type 
its name, or leave it blank.') }}</p>
     </header>
 
-    <div v-if="props.kind === 'log'" class="iq-coming">{{ t('Log inspect — 
coming in the next pass.') }}</div>
-
-    <template v-else>
-      <div class="iq-bar">
+    <div class="iq-bar">
         <span class="iq-bar-l">{{ t('Source') }}</span>
         <div class="seg">
           <button :class="{ on: traceSource === 'native' }" 
@click="traceSource = 'native'">{{ t('Native') }}</button>
@@ -595,12 +590,12 @@ onBeforeUnmount(() => 
window.removeEventListener('keydown', onPageKeyDown, true)
                 {{ showResolved ? '▾' : '▸' }} {{ t('Resolved query') }}
                 <span class="dim">{{ resolved.source }}{{ resolved.backend ? ` 
· ${resolved.backend}` : '' }}</span>
               </button>
-              <button class="iq-run" :disabled="!canRun || running" 
@click="runQuery">
+              <button class="iq-run" :disabled="running" @click="runQuery">
                 {{ running ? t('Running…') : t('Run query') }}
               </button>
             </div>
             <div class="iq-grid">
-              <label class="cf">
+              <label v-if="!isZipkin" class="cf">
                 <span>{{ t('Trace ID') }}</span>
                 <input v-model="cond.traceId" class="cf-input mono" 
type="text" :placeholder="t('paste a trace id')" />
               </label>
@@ -693,6 +688,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', 
onPageKeyDown, true)
 
       <div class="iq-result">
         <div v-if="!hasQueried" class="iq-empty">{{ t('Run a query — name a 
service or leave it blank.') }}</div>
+        <div v-else-if="running && rows.length === 0" class="iq-empty">{{ 
t('Reading data…') }}</div>
         <div v-else-if="errorMsg" class="iq-err">{{ errorMsg }}</div>
         <div v-else-if="rows.length === 0" class="iq-empty">{{ t('No traces in 
this window.') }}</div>
 
@@ -750,7 +746,6 @@ onBeforeUnmount(() => window.removeEventListener('keydown', 
onPageKeyDown, true)
           </div>
         </section>
       </div>
-    </template>
   </div>
 </template>
 
@@ -764,7 +759,6 @@ onBeforeUnmount(() => window.removeEventListener('keydown', 
onPageKeyDown, true)
 .iq { display: flex; flex-direction: column; gap: 10px; min-height: calc(100vh 
- 52px); padding: 14px 16px; }
 .iq-head h1 { font-size: 16px; margin: 0; }
 .iq-sub { color: var(--sw-fg-3); font-size: 12px; margin: 2px 0 0; }
-.iq-coming { padding: 40px; text-align: center; color: var(--sw-fg-3); 
font-size: 13px; }
 .iq-bar { display: flex; align-items: center; gap: 10px; }
 .iq-bar-l { font-size: 11px; color: var(--sw-fg-3); font-weight: 500; }
 .seg { display: inline-flex; border: 1px solid var(--sw-line-2); 
border-radius: 5px; overflow: hidden; }
diff --git a/apps/ui/src/i18n/locales/de.json b/apps/ui/src/i18n/locales/de.json
index 98a0799..773280d 100644
--- a/apps/ui/src/i18n/locales/de.json
+++ b/apps/ui/src/i18n/locales/de.json
@@ -1450,5 +1450,37 @@
   "Use this to clear a stuck apply or coax laggard nodes to re-confirm the 
schema.": "Damit lässt sich ein hängendes Anwenden lösen oder säumige Nodes zur 
erneuten Bestätigung des Schemas bewegen.",
   "Apply status is no longer tracked — reload to see the stored rule.": 
"Apply-Status wird nicht mehr verfolgt — neu laden, um die gespeicherte Regel 
anzuzeigen.",
   "An ACTIVE rule is inactivated first, then reverted.": "Eine ACTIVE-Regel 
wird zuerst inaktiviert und dann zurückgesetzt.",
-  "rule is ACTIVE — inactivate first, then revert to bundled": "Regel ist 
ACTIVE — zuerst inaktivieren, dann auf Bundled zurücksetzen"
+  "rule is ACTIVE — inactivate first, then revert to bundled": "Regel ist 
ACTIVE — zuerst inaktivieren, dann auf Bundled zurücksetzen",
+  "All endpoints": "Alle Endpoints",
+  "All instances": "Alle Instanzen",
+  "Any layer": "Beliebiger Layer",
+  "Any service": "Beliebiger Service",
+  "Browser": "Browser",
+  "Copy": "Kopieren",
+  "Key": "Schlüssel",
+  "Keywords": "Schlüsselwörter",
+  "Log entry": "Log-Eintrag",
+  "Log inspect": "Log-Inspektion",
+  "No logs in this window.": "Keine Logs in diesem Zeitfenster.",
+  "Pick": "Auswählen",
+  "Query any service across layers — pick it, type its name, or leave it 
blank.": "Beliebigen Service über Layer hinweg abfragen — auswählen, Namen 
eingeben oder leer lassen.",
+  "Raw": "Roh",
+  "Reading…": "Lädt…",
+  "Real": "Real",
+  "Resolved query": "Aufgelöste Abfrage",
+  "Run a query — name a service or leave it blank.": "Abfrage ausführen — 
einen Service benennen oder leer lassen.",
+  "Running…": "Läuft…",
+  "Service name": "Service-Name",
+  "Time": "Zeit",
+  "Value": "Wert",
+  "coming next": "demnächst",
+  "e.g. agent::checkout": "z. B. agent::checkout",
+  "level=ERROR, …": "level=ERROR, …",
+  "logs": "Logs",
+  "off = virtual / peer": "aus = virtuell / peer",
+  "optional": "optional",
+  "optional — blank queries all services": "optional — leer fragt alle 
Services ab",
+  "paste a trace id": "Trace-ID einfügen",
+  "space- or comma-separated, AND-joined": "durch Leerzeichen oder Komma 
getrennt, mit AND verknüpft",
+  "→ edit as text": "→ als Text bearbeiten"
 }
diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json
index 72e8054..74cee77 100644
--- a/apps/ui/src/i18n/locales/en.json
+++ b/apps/ui/src/i18n/locales/en.json
@@ -1457,5 +1457,37 @@
   "server send": "server send",
   "wire send": "wire send",
   "wire receive": "wire receive",
-  "error": "error"
+  "error": "error",
+  "All endpoints": "All endpoints",
+  "All instances": "All instances",
+  "Any layer": "Any layer",
+  "Any service": "Any service",
+  "Browser": "Browser",
+  "Copy": "Copy",
+  "Key": "Key",
+  "Keywords": "Keywords",
+  "Log entry": "Log entry",
+  "Log inspect": "Log inspect",
+  "No logs in this window.": "No logs in this window.",
+  "Pick": "Pick",
+  "Query any service across layers — pick it, type its name, or leave it 
blank.": "Query any service across layers — pick it, type its name, or leave it 
blank.",
+  "Raw": "Raw",
+  "Reading…": "Reading…",
+  "Real": "Real",
+  "Resolved query": "Resolved query",
+  "Run a query — name a service or leave it blank.": "Run a query — name a 
service or leave it blank.",
+  "Running…": "Running…",
+  "Service name": "Service name",
+  "Time": "Time",
+  "Value": "Value",
+  "coming next": "coming next",
+  "e.g. agent::checkout": "e.g. agent::checkout",
+  "level=ERROR, …": "level=ERROR, …",
+  "logs": "logs",
+  "off = virtual / peer": "off = virtual / peer",
+  "optional": "optional",
+  "optional — blank queries all services": "optional — blank queries all 
services",
+  "paste a trace id": "paste a trace id",
+  "space- or comma-separated, AND-joined": "space- or comma-separated, 
AND-joined",
+  "→ edit as text": "→ edit as text"
 }
diff --git a/apps/ui/src/i18n/locales/es.json b/apps/ui/src/i18n/locales/es.json
index 3193901..5b94171 100644
--- a/apps/ui/src/i18n/locales/es.json
+++ b/apps/ui/src/i18n/locales/es.json
@@ -1450,5 +1450,37 @@
   "Use this to clear a stuck apply or coax laggard nodes to re-confirm the 
schema.": "Úsalo para desatascar una aplicación bloqueada o para que los nodos 
rezagados vuelvan a confirmar el esquema.",
   "Apply status is no longer tracked — reload to see the stored rule.": "El 
estado de la aplicación ya no se rastrea — recarga para ver la regla 
almacenada.",
   "An ACTIVE rule is inactivated first, then reverted.": "Una regla ACTIVE se 
inactiva primero y luego se revierte.",
-  "rule is ACTIVE — inactivate first, then revert to bundled": "la regla está 
ACTIVE — inactívala primero y luego revierte a la bundled"
+  "rule is ACTIVE — inactivate first, then revert to bundled": "la regla está 
ACTIVE — inactívala primero y luego revierte a la bundled",
+  "All endpoints": "Todos los endpoints",
+  "All instances": "Todas las instancias",
+  "Any layer": "Cualquier Layer",
+  "Any service": "Cualquier Service",
+  "Browser": "Browser",
+  "Copy": "Copiar",
+  "Key": "Clave",
+  "Keywords": "Palabras clave",
+  "Log entry": "Entrada de log",
+  "Log inspect": "Inspección de logs",
+  "No logs in this window.": "No hay logs en esta ventana.",
+  "Pick": "Elegir",
+  "Query any service across layers — pick it, type its name, or leave it 
blank.": "Consulta cualquier Service entre Layers: selecciónalo, escribe su 
nombre o déjalo en blanco.",
+  "Raw": "Sin procesar",
+  "Reading…": "Leyendo…",
+  "Real": "Real",
+  "Resolved query": "Consulta resuelta",
+  "Run a query — name a service or leave it blank.": "Ejecuta una consulta: 
nombra un Service o déjalo en blanco.",
+  "Running…": "Ejecutando…",
+  "Service name": "Nombre del Service",
+  "Time": "Tiempo",
+  "Value": "Valor",
+  "coming next": "próximamente",
+  "e.g. agent::checkout": "p. ej. agent::checkout",
+  "level=ERROR, …": "level=ERROR, …",
+  "logs": "logs",
+  "off = virtual / peer": "desactivado = virtual / peer",
+  "optional": "opcional",
+  "optional — blank queries all services": "opcional: en blanco consulta todos 
los Services",
+  "paste a trace id": "pega un Trace ID",
+  "space- or comma-separated, AND-joined": "separadas por espacio o coma, 
unidas con AND",
+  "→ edit as text": "→ editar como texto"
 }
diff --git a/apps/ui/src/i18n/locales/fr.json b/apps/ui/src/i18n/locales/fr.json
index 030b322..8e34963 100644
--- a/apps/ui/src/i18n/locales/fr.json
+++ b/apps/ui/src/i18n/locales/fr.json
@@ -1450,5 +1450,37 @@
   "Use this to clear a stuck apply or coax laggard nodes to re-confirm the 
schema.": "À utiliser pour débloquer une application figée ou inciter les nœuds 
en retard à reconfirmer le schéma.",
   "Apply status is no longer tracked — reload to see the stored rule.": 
"L’état de l’application n’est plus suivi — rechargez pour voir la règle 
enregistrée.",
   "An ACTIVE rule is inactivated first, then reverted.": "Une règle ACTIVE est 
d’abord désactivée, puis rétablie.",
-  "rule is ACTIVE — inactivate first, then revert to bundled": "la règle est 
ACTIVE — désactivez-la d’abord, puis rétablissez la version bundled"
+  "rule is ACTIVE — inactivate first, then revert to bundled": "la règle est 
ACTIVE — désactivez-la d’abord, puis rétablissez la version bundled",
+  "All endpoints": "Tous les endpoints",
+  "All instances": "Toutes les instances",
+  "Any layer": "N'importe quel Layer",
+  "Any service": "N'importe quel Service",
+  "Browser": "Browser",
+  "Copy": "Copier",
+  "Key": "Clé",
+  "Keywords": "Mots-clés",
+  "Log entry": "Entrée de log",
+  "Log inspect": "Inspection des logs",
+  "No logs in this window.": "Aucun log dans cette fenêtre.",
+  "Pick": "Choisir",
+  "Query any service across layers — pick it, type its name, or leave it 
blank.": "Interrogez n'importe quel Service entre les Layers — sélectionnez-le, 
saisissez son nom ou laissez vide.",
+  "Raw": "Brut",
+  "Reading…": "Lecture…",
+  "Real": "Réel",
+  "Resolved query": "Requête résolue",
+  "Run a query — name a service or leave it blank.": "Lancez une requête — 
nommez un Service ou laissez vide.",
+  "Running…": "En cours…",
+  "Service name": "Nom du Service",
+  "Time": "Temps",
+  "Value": "Valeur",
+  "coming next": "à venir",
+  "e.g. agent::checkout": "p. ex. agent::checkout",
+  "level=ERROR, …": "level=ERROR, …",
+  "logs": "logs",
+  "off = virtual / peer": "désactivé = virtuel / peer",
+  "optional": "facultatif",
+  "optional — blank queries all services": "facultatif — vide interroge tous 
les Services",
+  "paste a trace id": "collez un Trace ID",
+  "space- or comma-separated, AND-joined": "séparés par un espace ou une 
virgule, joints par AND",
+  "→ edit as text": "→ éditer en texte"
 }
diff --git a/apps/ui/src/i18n/locales/ja.json b/apps/ui/src/i18n/locales/ja.json
index 7bb58cb..ed9e529 100644
--- a/apps/ui/src/i18n/locales/ja.json
+++ b/apps/ui/src/i18n/locales/ja.json
@@ -1450,5 +1450,37 @@
   "Use this to clear a stuck apply or coax laggard nodes to re-confirm the 
schema.": "停止した適用の解消や、追従が遅れているノードにスキーマの再確認を促すために使用します。",
   "Apply status is no longer tracked — reload to see the stored rule.": 
"適用ステータスは追跡されていません — 再読み込みして保存済みのルールを確認してください。",
   "An ACTIVE rule is inactivated first, then reverted.": "ACTIVE 
のルールは、まず無効化してから元に戻します。",
-  "rule is ACTIVE — inactivate first, then revert to bundled": "ルールは ACTIVE です 
— 先に無効化してから bundled に戻してください"
+  "rule is ACTIVE — inactivate first, then revert to bundled": "ルールは ACTIVE です 
— 先に無効化してから bundled に戻してください",
+  "All endpoints": "すべてのエンドポイント",
+  "All instances": "すべてのインスタンス",
+  "Any layer": "任意の Layer",
+  "Any service": "任意の Service",
+  "Browser": "Browser",
+  "Copy": "コピー",
+  "Key": "キー",
+  "Keywords": "キーワード",
+  "Log entry": "ログエントリ",
+  "Log inspect": "ログ調査",
+  "No logs in this window.": "この時間範囲にログはありません。",
+  "Pick": "選択",
+  "Query any service across layers — pick it, type its name, or leave it 
blank.": "Layer をまたいで任意の Service を検索 — 選択するか、名前を入力するか、空のままにします。",
+  "Raw": "生ログ",
+  "Reading…": "読み込み中…",
+  "Real": "実体",
+  "Resolved query": "解決済みクエリ",
+  "Run a query — name a service or leave it blank.": "クエリを実行 — Service 
を指定するか、空のままにします。",
+  "Running…": "実行中…",
+  "Service name": "Service 名",
+  "Time": "時間",
+  "Value": "値",
+  "coming next": "近日対応",
+  "e.g. agent::checkout": "例:agent::checkout",
+  "level=ERROR, …": "level=ERROR, …",
+  "logs": "件のログ",
+  "off = virtual / peer": "オフ = 仮想 / peer",
+  "optional": "任意",
+  "optional — blank queries all services": "任意 — 空の場合はすべての Service を検索",
+  "paste a trace id": "Trace ID を貼り付け",
+  "space- or comma-separated, AND-joined": "スペースまたはカンマ区切り、AND 結合",
+  "→ edit as text": "→ テキストで編集"
 }
diff --git a/apps/ui/src/i18n/locales/ko.json b/apps/ui/src/i18n/locales/ko.json
index dc67187..590be15 100644
--- a/apps/ui/src/i18n/locales/ko.json
+++ b/apps/ui/src/i18n/locales/ko.json
@@ -1450,5 +1450,37 @@
   "Use this to clear a stuck apply or coax laggard nodes to re-confirm the 
schema.": "멈춘 적용을 정리하거나 뒤처진 노드가 스키마를 다시 확인하도록 유도할 때 사용하세요.",
   "Apply status is no longer tracked — reload to see the stored rule.": "적용 
상태가 더 이상 추적되지 않습니다 — 다시 로드하여 저장된 규칙을 확인하세요.",
   "An ACTIVE rule is inactivated first, then reverted.": "ACTIVE 규칙은 먼저 비활성화한 
뒤 되돌립니다.",
-  "rule is ACTIVE — inactivate first, then revert to bundled": "규칙이 ACTIVE 
상태입니다 — 먼저 비활성화한 뒤 bundled로 되돌리세요"
+  "rule is ACTIVE — inactivate first, then revert to bundled": "규칙이 ACTIVE 
상태입니다 — 먼저 비활성화한 뒤 bundled로 되돌리세요",
+  "All endpoints": "모든 엔드포인트",
+  "All instances": "모든 인스턴스",
+  "Any layer": "모든 Layer",
+  "Any service": "모든 Service",
+  "Browser": "Browser",
+  "Copy": "복사",
+  "Key": "키",
+  "Keywords": "키워드",
+  "Log entry": "로그 항목",
+  "Log inspect": "로그 조회",
+  "No logs in this window.": "이 기간에 로그가 없습니다.",
+  "Pick": "선택",
+  "Query any service across layers — pick it, type its name, or leave it 
blank.": "Layer 전반에서 임의의 Service를 조회 — 선택하거나 이름을 입력하거나 비워 둡니다.",
+  "Raw": "원본",
+  "Reading…": "읽는 중…",
+  "Real": "실제",
+  "Resolved query": "해석된 쿼리",
+  "Run a query — name a service or leave it blank.": "쿼리 실행 — Service를 지정하거나 
비워 둡니다.",
+  "Running…": "실행 중…",
+  "Service name": "Service 이름",
+  "Time": "시간",
+  "Value": "값",
+  "coming next": "곧 제공",
+  "e.g. agent::checkout": "예: agent::checkout",
+  "level=ERROR, …": "level=ERROR, …",
+  "logs": "개 로그",
+  "off = virtual / peer": "꺼짐 = 가상 / peer",
+  "optional": "선택",
+  "optional — blank queries all services": "선택 — 비워 두면 모든 Service 조회",
+  "paste a trace id": "Trace ID 붙여넣기",
+  "space- or comma-separated, AND-joined": "공백 또는 쉼표로 구분, AND 결합",
+  "→ edit as text": "→ 텍스트로 편집"
 }
diff --git a/apps/ui/src/i18n/locales/pt.json b/apps/ui/src/i18n/locales/pt.json
index 7d163eb..38d7de1 100644
--- a/apps/ui/src/i18n/locales/pt.json
+++ b/apps/ui/src/i18n/locales/pt.json
@@ -1450,5 +1450,37 @@
   "Use this to clear a stuck apply or coax laggard nodes to re-confirm the 
schema.": "Use isto para destravar uma aplicação presa ou induzir nodes 
atrasados a reconfirmar o schema.",
   "Apply status is no longer tracked — reload to see the stored rule.": "O 
status da aplicação não é mais rastreado — recarregue para ver a regra 
armazenada.",
   "An ACTIVE rule is inactivated first, then reverted.": "Uma regra ACTIVE é 
inativada primeiro e depois revertida.",
-  "rule is ACTIVE — inactivate first, then revert to bundled": "a regra está 
ACTIVE — inative-a primeiro e depois reverta para a bundled"
+  "rule is ACTIVE — inactivate first, then revert to bundled": "a regra está 
ACTIVE — inative-a primeiro e depois reverta para a bundled",
+  "All endpoints": "Todos os endpoints",
+  "All instances": "Todas as instâncias",
+  "Any layer": "Qualquer Layer",
+  "Any service": "Qualquer Service",
+  "Browser": "Browser",
+  "Copy": "Copiar",
+  "Key": "Chave",
+  "Keywords": "Palavras-chave",
+  "Log entry": "Entrada de log",
+  "Log inspect": "Inspeção de logs",
+  "No logs in this window.": "Nenhum log nesta janela.",
+  "Pick": "Escolher",
+  "Query any service across layers — pick it, type its name, or leave it 
blank.": "Consulte qualquer Service entre Layers — selecione, digite o nome ou 
deixe em branco.",
+  "Raw": "Brutos",
+  "Reading…": "Lendo…",
+  "Real": "Real",
+  "Resolved query": "Consulta resolvida",
+  "Run a query — name a service or leave it blank.": "Execute uma consulta — 
nomeie um Service ou deixe em branco.",
+  "Running…": "Executando…",
+  "Service name": "Nome do Service",
+  "Time": "Tempo",
+  "Value": "Valor",
+  "coming next": "em breve",
+  "e.g. agent::checkout": "ex. agent::checkout",
+  "level=ERROR, …": "level=ERROR, …",
+  "logs": "logs",
+  "off = virtual / peer": "desligado = virtual / peer",
+  "optional": "opcional",
+  "optional — blank queries all services": "opcional — em branco consulta 
todos os Services",
+  "paste a trace id": "cole um Trace ID",
+  "space- or comma-separated, AND-joined": "separadas por espaço ou vírgula, 
unidas com AND",
+  "→ edit as text": "→ editar como texto"
 }
diff --git a/apps/ui/src/i18n/locales/zh-CN.json 
b/apps/ui/src/i18n/locales/zh-CN.json
index 80fd191..8df8bef 100644
--- a/apps/ui/src/i18n/locales/zh-CN.json
+++ b/apps/ui/src/i18n/locales/zh-CN.json
@@ -1450,5 +1450,37 @@
   "Use this to clear a stuck apply or coax laggard nodes to re-confirm the 
schema.": "用于清除卡住的应用,或促使滞后的节点重新确认 schema。",
   "Apply status is no longer tracked — reload to see the stored rule.": 
"应用状态已不再被跟踪 —— 重新加载以查看已存储的规则。",
   "An ACTIVE rule is inactivated first, then reverted.": "ACTIVE 
规则会先被停用,然后再回退。",
-  "rule is ACTIVE — inactivate first, then revert to bundled": "规则处于 ACTIVE 状态 
—— 请先停用,然后再回退到 bundled"
+  "rule is ACTIVE — inactivate first, then revert to bundled": "规则处于 ACTIVE 状态 
—— 请先停用,然后再回退到 bundled",
+  "All endpoints": "所有端点",
+  "All instances": "所有实例",
+  "Any layer": "任意 Layer",
+  "Any service": "任意 Service",
+  "Browser": "Browser",
+  "Copy": "复制",
+  "Key": "键",
+  "Keywords": "关键字",
+  "Log entry": "日志条目",
+  "Log inspect": "日志查询",
+  "No logs in this window.": "此时间范围内没有日志。",
+  "Pick": "选择",
+  "Query any service across layers — pick it, type its name, or leave it 
blank.": "跨 Layer 查询任意 Service —— 选择、输入名称,或留空。",
+  "Raw": "原始日志",
+  "Reading…": "读取中…",
+  "Real": "真实",
+  "Resolved query": "解析后的查询",
+  "Run a query — name a service or leave it blank.": "执行查询 —— 指定一个 Service 
或留空。",
+  "Running…": "执行中…",
+  "Service name": "Service 名称",
+  "Time": "时间",
+  "Value": "值",
+  "coming next": "即将推出",
+  "e.g. agent::checkout": "例如 agent::checkout",
+  "level=ERROR, …": "level=ERROR, …",
+  "logs": "条日志",
+  "off = virtual / peer": "关闭 = 虚拟 / peer",
+  "optional": "可选",
+  "optional — blank queries all services": "可选 —— 留空则查询所有 Service",
+  "paste a trace id": "粘贴 Trace ID",
+  "space- or comma-separated, AND-joined": "以空格或逗号分隔,按 AND 组合",
+  "→ edit as text": "→ 改为文本编辑"
 }
diff --git a/apps/ui/src/layer/logs/LayerLogsView.vue 
b/apps/ui/src/layer/logs/LayerLogsView.vue
index 6d21655..fd91562 100644
--- a/apps/ui/src/layer/logs/LayerLogsView.vue
+++ b/apps/ui/src/layer/logs/LayerLogsView.vue
@@ -40,7 +40,8 @@ import { useSelectedEndpoint } from 
'@/layer/useSelectedEndpoint';
 import { useLayerServiceName } from '@/layer/useLayerServiceName';
 import { useSetupStore } from '@/state/setup';
 import { useTracePopout } from '@/layer/traces/useTracePopout';
-import { parseServiceName } from '@/utils/serviceName';
+import LogStreamPanel from '@/render/widgets/LogStreamPanel.vue';
+import LogDetailPopout from '@/render/widgets/LogDetailPopout.vue';
 
 const route = useRoute();
 const layerKey = computed(() => String(route.params.layerKey ?? ''));
@@ -455,149 +456,12 @@ const levelFacet = computed<Record<Level, number>>(() => 
{
 // reflect it — no client-side narrowing needed.
 const filteredLogs = computed<LogRow[]>(() => logs.value);
 
-// 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 fmtTime(ts: number): string {
-  const d = new Date(ts);
-  const pad = (n: number) => String(n).padStart(2, '0');
-  return 
`${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${String(d.getMilliseconds()).padStart(3,
 '0')}`;
-}
-function fmtDate(ts: number): string {
-  // Numeric MM-DD in the operator's browser-local TZ. Matches the
-  // topbar's compact date chip (`05-12 03h → 06h`) and stays
-  // locale-independent — `toLocaleDateString` without an explicit
-  // locale picks up the system locale (e.g. `5月08日` on zh-CN), which
-  // looked inconsistent next to the rest of the dense English UI.
-  const d = new Date(ts);
-  const pad = (n: number) => String(n).padStart(2, '0');
-  return `${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
-}
-/**
- * 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 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 {
-    return JSON.stringify(JSON.parse(content), null, 2);
-  } catch {
-    return content;
-  }
-}
-
-/**
- * Detect the rendered format of a log payload. OAP only labels
- * JSON / TEXT, so we sniff for YAML markers (leading `---`, top-level
- * `key:` mappings) and JSON-ness as a fallback so operators get the
- * right pretty-printer in the popout. The detection is best-effort —
- * an ambiguous payload falls back to TEXT.
- */
-type LogFormat = 'json' | 'yaml' | 'text';
-function detectFormat(r: LogRow): LogFormat {
-  if (r.contentType === 'application/json') return 'json';
-  const trimmed = r.content?.trim() ?? '';
-  if (!trimmed) return 'text';
-  // JSON sniff — content-type missing but body parses cleanly.
-  if ((trimmed.startsWith('{') && trimmed.endsWith('}')) ||
-      (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
-    try { JSON.parse(trimmed); return 'json'; } catch { /* fallthrough */ }
-  }
-  // YAML sniff — document marker or 2+ top-level `key:` mappings.
-  if (trimmed.startsWith('---') || trimmed.startsWith('apiVersion:')) return 
'yaml';
-  const lines = trimmed.split('\n');
-  if (lines.length >= 2) {
-    const topLevelMaps = lines.filter((l) => 
/^[A-Za-z_][\w.-]*\s*:\s*(\S|$)/.test(l)).length;
-    if (topLevelMaps >= 2) return 'yaml';
-  }
-  return 'text';
-}
-function prettyForFormat(r: LogRow, fmt: LogFormat): string {
-  if (fmt === 'json') return tryPrettyJson(r.content);
-  return r.content;
-}
-
-// ── Log payload popout — operator-triggered modal for inspecting a
-// single row's full payload at maximum size, with format-aware
-// pretty-print + a copy button + key/value tag table. The inline
-// expand below the row stays as a quick peek; the popout is the
-// "give me the whole thing" view.
+// ── Log payload popout — a row click opens the shared LogDetailPopout
+// (format-aware pretty-print + copy + key/value tag table + trace link).
+// The popout owns its own Escape / close + format detection.
 const popoutRow = ref<LogRow | null>(null);
-function openPopout(r: LogRow): void { popoutRow.value = r; }
-function closePopout(): void { popoutRow.value = null; }
-async function copyPopout(): Promise<void> {
-  if (!popoutRow.value) return;
-  try {
-    await navigator.clipboard.writeText(popoutRow.value.content);
-  } catch {
-    /* 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);
+  popoutRow.value = r;
 }
 
 // Custom hover tooltip state for the density bar. Native browser
@@ -862,48 +726,16 @@ function jumpToTrace(traceId: string, ts?: number): void {
         <div v-if="filteredLogs.length === 0" class="lg-empty">
           {{ logs.length === 0 ? 'No logs returned for this scope.' : 'No logs 
match the active filters.' }}
         </div>
-        <div v-else class="lg-stream">
-          <template v-for="(r, idx) in filteredLogs" :key="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>
-              <span class="lg-svc mono dim">
-                <span
-                  v-if="r.serviceName && parseServiceName(r.serviceName).group"
-                  class="lg-svc-group"
-                >{{ parseServiceName(r.serviceName).group }}</span>
-                {{ r.serviceName ? parseServiceName(r.serviceName).base : '—' 
}}
-              </span>
-              <!-- The trace-link slot is ALWAYS rendered, even when the
-                   row has no traceId — its 60px grid column is fixed,
-                   and skipping the slot collapses every subsequent
-                   column leftward (the 1fr content cell ends up sized
-                   at 60px, truncating the preview to two characters). -->
-              <span v-if="r.traceId" class="lg-trace mono" 
@click.stop="jumpToTrace(r.traceId!, r.timestamp)">↗ trace</span>
-              <span v-else class="lg-trace-spacer" aria-hidden="true"></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>
-              </span>
-            </div>
-          </template>
-        </div>
+        <!-- Row click → open the full-payload popout. The dense row
+             rendering is the shared `LogStreamPanel` (same markup the
+             cross-layer Log inspect uses); the popout + density bar +
+             facets stay in this view. -->
+        <LogStreamPanel
+          v-else
+          :rows="filteredLogs"
+          @select="onRowClick($event.row)"
+          @jump-trace="jumpToTrace($event.traceId, $event.ts)"
+        />
         <div class="lg-pager">
           <span class="hint">page {{ page }} · showing {{ filteredLogs.length 
}} of {{ total }} total</span>
           <div class="lg-pager-ctrls">
@@ -919,58 +751,13 @@ function jumpToTrace(traceId: string, ts?: number): void {
       </div>
     </section>
 
-    <!-- Full-payload popout. Triggered from a row's expanded panel.
-         Format-aware pretty-print + copy button + tag table. Escape
-         or backdrop click closes. -->
-    <div v-if="popoutRow" class="lg-popout-backdrop" @click.self="closePopout">
-      <article class="lg-popout sw-card">
-        <header class="lg-popout-head">
-          <div>
-            <span class="kicker">Log entry</span>
-            <span class="lg-popout-time mono">{{ fmtTime(popoutRow.timestamp) 
}} · {{ fmtDate(popoutRow.timestamp) }}</span>
-            <span class="lg-popout-fmt">{{ 
detectFormat(popoutRow).toUpperCase() }}</span>
-          </div>
-          <div class="lg-popout-ctrls">
-            <button class="sw-btn small" type="button" 
@click="copyPopout">Copy</button>
-            <button
-              v-if="popoutRow.traceId"
-              class="sw-btn small"
-              type="button"
-              @click="jumpToTrace(popoutRow.traceId!, popoutRow.timestamp)"
-            >↗ trace</button>
-            <button class="sw-btn small ghost" type="button" 
@click="closePopout">×</button>
-          </div>
-        </header>
-        <div class="lg-popout-meta">
-          <span v-if="popoutRow.serviceName" class="lg-meta">service <code>{{ 
popoutRow.serviceName }}</code></span>
-          <span v-if="popoutRow.serviceInstanceName" class="lg-meta">instance 
<code>{{ popoutRow.serviceInstanceName }}</code></span>
-          <span v-if="popoutRow.endpointName" class="lg-meta">endpoint 
<code>{{ popoutRow.endpointName }}</code></span>
-          <span v-if="popoutRow.traceId" class="lg-meta">trace <code 
class="mono">{{ popoutRow.traceId }}</code></span>
-        </div>
-        <div class="lg-popout-split">
-          <pre
-            v-if="detectFormat(popoutRow) === 'json'"
-            class="lg-popout-body json"
-          >{{ prettyForFormat(popoutRow, 'json') }}</pre>
-          <pre
-            v-else-if="detectFormat(popoutRow) === 'yaml'"
-            class="lg-popout-body yaml"
-          >{{ popoutRow.content }}</pre>
-          <pre v-else class="lg-popout-body text">{{ popoutRow.content }}</pre>
-          <aside v-if="popoutRow.tags.length > 0" class="lg-popout-tags">
-            <table class="lg-popout-tag-tbl">
-              <thead><tr><th>Key</th><th>Value</th></tr></thead>
-              <tbody>
-                <tr v-for="t in popoutRow.tags" :key="`${t.key}=${t.value}`">
-                  <td class="mono">{{ t.key }}</td>
-                  <td class="mono">{{ t.value }}</td>
-                </tr>
-              </tbody>
-            </table>
-          </aside>
-        </div>
-      </article>
-    </div>
+    <!-- Full-payload popout. Format-aware pretty-print + copy button +
+         tag table + trace link. Escape or backdrop click closes. -->
+    <LogDetailPopout
+      :row="popoutRow"
+      @close="popoutRow = null"
+      @jump-trace="jumpToTrace($event.traceId, $event.ts)"
+    />
   </div>
 </template>
 
@@ -1119,21 +906,6 @@ function jumpToTrace(traceId: string, ts?: number): void {
 .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); }
 
-/* Group-prefix chip in a log row's service column. Decodes
-   `<group>::<base>` so the eye lands on the base name first. */
-.lg-svc-group {
-  display: inline-block;
-  padding: 0 5px;
-  margin-right: 4px;
-  background: var(--sw-bg-2);
-  border: 1px solid var(--sw-line-2);
-  border-radius: 3px;
-  font-size: 9.5px;
-  font-weight: 600;
-  letter-spacing: 0.04em;
-  color: var(--sw-fg-2);
-  text-transform: uppercase;
-}
 .f-field {
   display: inline-flex;
   align-items: baseline;
@@ -1299,149 +1071,12 @@ function jumpToTrace(traceId: string, ts?: number): 
void {
 .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;
   color: var(--sw-fg-3);
   font-size: 11.5px;
 }
-.lg-stream {
-  flex: 1;
-  overflow-y: auto;
-  font-size: 11.5px;
-}
-.lg-row {
-  display: grid;
-  grid-template-columns: 80px 60px 56px 140px 60px 1fr;
-  gap: 10px;
-  align-items: center;
-  padding: 4px 12px;
-  border-bottom: 1px solid var(--sw-line);
-  cursor: pointer;
-}
-.lg-row:hover { background: var(--sw-bg-2); }
-.lg-row.lv-error { box-shadow: inset 3px 0 0 var(--sw-err); }
-.lg-row.lv-warn { box-shadow: inset 3px 0 0 var(--sw-warn); }
-.lg-time { font-family: var(--sw-mono); color: var(--sw-fg-1); }
-.lg-date { font-size: 10px; }
-.lg-lvl {
-  font-size: 9.5px;
-  text-transform: uppercase;
-  letter-spacing: 0.06em;
-  font-weight: 700;
-}
-.lg-svc { font-size: 10.5px; }
-.lg-trace {
-  font-size: 10px;
-  color: var(--sw-accent-2);
-  cursor: pointer;
-  padding: 1px 5px;
-  background: var(--sw-accent-soft);
-  border: 1px solid var(--sw-accent-line);
-  border-radius: 3px;
-}
-.lg-trace:hover { color: var(--sw-fg-0); }
-.lg-content {
-  font-family: var(--sw-mono);
-  font-size: 11px;
-  color: var(--sw-fg-1);
-  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;
-  border-bottom: 1px solid var(--sw-line);
-  background: var(--sw-bg-2);
-}
-.lg-payload {
-  margin: 0 0 8px;
-  padding: 8px 10px;
-  background: var(--sw-bg-1);
-  border: 1px solid var(--sw-line);
-  border-radius: 4px;
-  font-family: var(--sw-mono);
-  font-size: 11px;
-  color: var(--sw-fg-1);
-  white-space: pre-wrap;
-  word-break: break-word;
-  max-height: 280px;
-  overflow: auto;
-}
-.lg-payload.json { color: var(--sw-cyan); }
-.lg-tag-row {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 5px;
-  margin-bottom: 6px;
-}
-.lg-tag {
-  display: inline-flex;
-  align-items: baseline;
-  gap: 4px;
-  font-size: 10.5px;
-  padding: 1px 6px;
-  background: var(--sw-bg-1);
-  border: 1px solid var(--sw-line);
-  border-radius: 3px;
-}
-.lg-tag-k { color: var(--sw-fg-3); }
-.lg-tag-v { color: var(--sw-fg-1); }
-.lg-meta-row {
-  display: flex;
-  gap: 14px;
-  flex-wrap: wrap;
-  font-size: 10.5px;
-  color: var(--sw-fg-3);
-}
-.lg-meta code {
-  font-family: var(--sw-mono);
-  color: var(--sw-fg-2);
-  background: var(--sw-bg-1);
-  padding: 1px 4px;
-  border-radius: 3px;
-}
-
 .lg-pager {
   display: flex;
   align-items: center;
@@ -1467,32 +1102,6 @@ function jumpToTrace(traceId: string, ts?: number): void 
{
   .lg-legend-chip { padding: 2px 7px; font-size: 11px; }
 }
 
-/* Trace-ID + tag-key=value inputs on the conditions bar. */
-.lg-trace-input {
-  height: 26px;
-  width: 220px;
-  padding: 0 8px;
-  background: var(--sw-bg-2);
-  border: 1px solid var(--sw-line-2);
-  border-radius: 4px;
-  color: var(--sw-fg-0);
-  font: inherit;
-  font-size: 11px;
-}
-.lg-trace-input:focus { outline: none; border-color: var(--sw-accent-line); }
-.lg-tag-input {
-  height: 26px;
-  width: 220px;
-  padding: 0 8px;
-  background: var(--sw-bg-2);
-  border: 1px solid var(--sw-line-2);
-  border-radius: 4px;
-  color: var(--sw-fg-0);
-  font: inherit;
-  font-size: 11px;
-}
-.lg-tag-input:focus { outline: none; border-color: var(--sw-accent-line); }
-
 /* Active tag chips — markup + visuals lifted from `LayerTracesView`
    so the two pages read identically. */
 .tr-tag-row {
@@ -1527,103 +1136,4 @@ function jumpToTrace(traceId: string, ts?: number): 
void {
 }
 .tag-x:hover { color: var(--sw-err); }
 
-/* YAML payload colour (cyan-leaning to distinguish from JSON which
-   is also cyan; warm-yellow scheme conveys "config / declarative"). */
-.lg-payload.yaml { color: #fbbf24; }
-
-/* Full-payload popout — fixed, centred, modal-like overlay. */
-.lg-popout-backdrop {
-  position: fixed;
-  inset: 0;
-  background: rgba(0,0,0,0.55);
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  z-index: 100;
-  padding: 24px;
-}
-.lg-popout {
-  width: min(1100px, 92vw);
-  min-height: 70vh;
-  max-height: 92vh;
-  display: flex;
-  flex-direction: column;
-  overflow: hidden;
-}
-.lg-popout-head {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  gap: 12px;
-  padding: 10px 14px;
-  border-bottom: 1px solid var(--sw-line);
-}
-.lg-popout-head .kicker { margin-right: 8px; }
-.lg-popout-time { font-size: 11px; color: var(--sw-fg-2); margin-right: 10px; }
-.lg-popout-fmt {
-  display: inline-block;
-  padding: 1px 8px;
-  border-radius: 10px;
-  background: var(--sw-accent-soft);
-  color: var(--sw-accent-2);
-  font-size: 10px;
-  font-weight: 600;
-  letter-spacing: 0.04em;
-}
-.lg-popout-ctrls { display: inline-flex; gap: 6px; }
-.lg-popout-meta {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 12px;
-  padding: 8px 14px;
-  border-bottom: 1px dashed var(--sw-line);
-  font-size: 11px;
-  color: var(--sw-fg-3);
-}
-.lg-popout-meta code {
-  font-family: var(--sw-mono);
-  color: var(--sw-fg-1);
-  padding: 0 4px;
-  background: var(--sw-bg-2);
-  border-radius: 3px;
-}
-.lg-popout-split { flex: 1; min-height: 0; display: flex; }
-.lg-popout-body {
-  flex: 1;
-  min-height: 0;
-  min-width: 0;
-  margin: 0;
-  padding: 12px 14px;
-  background: var(--sw-bg-0);
-  font-family: var(--sw-mono);
-  font-size: 12px;
-  line-height: 1.55;
-  color: var(--sw-fg-0);
-  white-space: pre-wrap;
-  word-break: break-all;
-  overflow: auto;
-}
-.lg-popout-body.json { color: var(--sw-cyan); }
-.lg-popout-body.yaml { color: #fbbf24; }
-.lg-popout-tags {
-  flex: 0 0 340px;
-  border-left: 1px solid var(--sw-line);
-  padding: 8px 14px 12px;
-  overflow: auto;
-  background: var(--sw-bg-1);
-}
-.lg-popout-tag-tbl {
-  width: 100%;
-  table-layout: fixed;
-  border-collapse: collapse;
-  font-size: 11px;
-}
-.lg-popout-tag-tbl td { word-break: break-all; vertical-align: top; }
-.lg-popout-tag-tbl th, .lg-popout-tag-tbl td {
-  padding: 4px 10px;
-  text-align: left;
-  border-bottom: 1px solid var(--sw-line);
-}
-.lg-popout-tag-tbl th { color: var(--sw-fg-3); font-weight: 500; }
-.lg-popout-tag-tbl td { color: var(--sw-fg-1); font-family: var(--sw-mono); }
 </style>
diff --git a/apps/ui/src/render/widgets/LogDetailPopout.vue 
b/apps/ui/src/render/widgets/LogDetailPopout.vue
new file mode 100644
index 0000000..7a0a669
--- /dev/null
+++ b/apps/ui/src/render/widgets/LogDetailPopout.vue
@@ -0,0 +1,186 @@
+<!--
+  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.
+-->
+<!--
+  Shared full-payload log popout. Centred modal showing one log row's
+  whole payload at max size: format-aware pretty-print of `content` by
+  `contentType` (JSON re-serialised; YAML / text verbatim), a key/value
+  tags table, the service / instance / endpoint / time meta strip, a
+  copy button, and the trace-id link.
+
+  Built on `_shared/Modal.vue` (backdrop + Escape + close). Used by BOTH
+  the per-layer Logs tab and the cross-layer Log inspect view. The host
+  owns the trace jump — the popout just emits `jump-trace`.
+
+  Props:
+    row  — the LogRow to show; `null` keeps the popout closed.
+  Emits:
+    close      — dismiss (× button, Escape, backdrop).
+    jump-trace — { traceId, ts } when the operator clicks the trace link.
+-->
+<script setup lang="ts">
+import { computed } from 'vue';
+import { useI18n } from 'vue-i18n';
+import type { LogRow } from '@/api/client';
+import Modal from '@/features/operate/_shared/Modal.vue';
+
+const { t } = useI18n();
+
+const props = defineProps<{ row: LogRow | null }>();
+const emit = defineEmits<{
+  (e: 'close'): void;
+  (e: 'jump-trace', payload: { traceId: string; ts: number }): void;
+}>();
+
+type LogFormat = 'json' | 'yaml' | 'text';
+function detectFormat(r: LogRow): LogFormat {
+  if (r.contentType === 'application/json') return 'json';
+  const trimmed = r.content?.trim() ?? '';
+  if (!trimmed) return 'text';
+  if ((trimmed.startsWith('{') && trimmed.endsWith('}')) ||
+      (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
+    try { JSON.parse(trimmed); return 'json'; } catch { /* fallthrough */ }
+  }
+  if (trimmed.startsWith('---') || trimmed.startsWith('apiVersion:')) return 
'yaml';
+  const lines = trimmed.split('\n');
+  if (lines.length >= 2) {
+    const topLevelMaps = lines.filter((l) => 
/^[A-Za-z_][\w.-]*\s*:\s*(\S|$)/.test(l)).length;
+    if (topLevelMaps >= 2) return 'yaml';
+  }
+  return 'text';
+}
+function prettyContent(r: LogRow): string {
+  if (detectFormat(r) === 'json') {
+    try { return JSON.stringify(JSON.parse(r.content), null, 2); } catch { /* 
fall through */ }
+  }
+  return r.content;
+}
+function fmtTime(ts: number): string {
+  const d = new Date(ts);
+  const pad = (n: number) => String(n).padStart(2, '0');
+  return 
`${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${String(d.getMilliseconds()).padStart(3,
 '0')}`;
+}
+function fmtDate(ts: number): string {
+  const d = new Date(ts);
+  const pad = (n: number) => String(n).padStart(2, '0');
+  return `${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
+}
+
+const fmt = computed<LogFormat | null>(() => (props.row ? 
detectFormat(props.row) : null));
+
+async function copyContent(): Promise<void> {
+  if (!props.row) return;
+  try {
+    await navigator.clipboard.writeText(props.row.content);
+  } catch {
+    /* clipboard may be blocked; silently no-op */
+  }
+}
+function onJumpTrace(): void {
+  if (props.row?.traceId) emit('jump-trace', { traceId: props.row.traceId, ts: 
props.row.timestamp });
+}
+</script>
+
+<template>
+  <Modal :open="row != null" :title="t('Log entry')" width="min(1100px, 92vw)" 
fit-body @close="emit('close')">
+    <div v-if="row" class="ld">
+      <div class="ld-head">
+        <div class="ld-head-l">
+          <span class="ld-time mono">{{ fmtTime(row.timestamp) }} · {{ 
fmtDate(row.timestamp) }}</span>
+          <span class="ld-fmt">{{ fmt!.toUpperCase() }}</span>
+        </div>
+        <div class="ld-ctrls">
+          <button class="sw-btn small" type="button" @click="copyContent">{{ 
t('Copy') }}</button>
+          <button v-if="row.traceId" class="sw-btn small" type="button" 
@click="onJumpTrace">↗ {{ t('trace') }}</button>
+        </div>
+      </div>
+      <div class="ld-meta">
+        <span v-if="row.serviceName" class="ld-meta-item">{{ t('Service') }} 
<code>{{ row.serviceName }}</code></span>
+        <span v-if="row.serviceInstanceName" class="ld-meta-item">{{ 
t('Instance') }} <code>{{ row.serviceInstanceName }}</code></span>
+        <span v-if="row.endpointName" class="ld-meta-item">{{ t('Endpoint') }} 
<code>{{ row.endpointName }}</code></span>
+        <span v-if="row.traceId" class="ld-meta-item">{{ t('Trace ID') }} 
<code class="mono">{{ row.traceId }}</code></span>
+      </div>
+      <div class="ld-split">
+        <pre class="ld-body" :class="`fmt-${fmt}`">{{ prettyContent(row) 
}}</pre>
+        <aside v-if="row.tags.length > 0" class="ld-tags">
+          <table class="ld-tag-tbl">
+            <thead><tr><th>{{ t('Key') }}</th><th>{{ t('Value') 
}}</th></tr></thead>
+            <tbody>
+              <tr v-for="tg in row.tags" :key="`${tg.key}=${tg.value}`">
+                <td class="mono">{{ tg.key }}</td>
+                <td class="mono">{{ tg.value }}</td>
+              </tr>
+            </tbody>
+          </table>
+        </aside>
+      </div>
+    </div>
+  </Modal>
+</template>
+
+<style scoped>
+.ld { flex: 1; display: flex; flex-direction: column; min-height: 0; }
+.ld-head {
+  display: flex; align-items: center; justify-content: space-between; gap: 
12px;
+  padding-bottom: 8px; border-bottom: 1px solid var(--sw-line); flex: 0 0 auto;
+}
+.ld-head-l { display: inline-flex; align-items: center; gap: 10px; min-width: 
0; }
+.ld-time { font-size: 11px; color: var(--sw-fg-2); }
+.ld-fmt {
+  display: inline-block; padding: 1px 8px; border-radius: 10px;
+  background: var(--sw-accent-soft); color: var(--sw-accent-2);
+  font-size: 10px; font-weight: 600; letter-spacing: 0.04em;
+}
+.ld-ctrls { display: inline-flex; gap: 6px; flex: 0 0 auto; }
+.ld-meta {
+  display: flex; flex-wrap: wrap; gap: 12px; padding: 8px 0;
+  border-bottom: 1px dashed var(--sw-line); font-size: 11px; color: 
var(--sw-fg-3); flex: 0 0 auto;
+}
+.ld-meta-item code {
+  font-family: var(--sw-mono); color: var(--sw-fg-1);
+  padding: 0 4px; background: var(--sw-bg-2); border-radius: 3px;
+}
+.ld-split { flex: 1; min-height: 0; display: flex; margin-top: 8px; }
+.ld-body {
+  flex: 1; min-height: 0; min-width: 0; margin: 0; padding: 12px 14px;
+  background: var(--sw-bg-0); font-family: var(--sw-mono); font-size: 12px; 
line-height: 1.55;
+  color: var(--sw-fg-0); white-space: pre-wrap; word-break: break-all; 
overflow: auto;
+  border-radius: 4px;
+}
+.ld-body.fmt-json { color: var(--sw-cyan); }
+.ld-body.fmt-yaml { color: #fbbf24; }
+.ld-tags {
+  flex: 0 0 340px; border-left: 1px solid var(--sw-line); padding: 8px 14px 
12px;
+  overflow: auto; background: var(--sw-bg-1);
+}
+.ld-tag-tbl { width: 100%; table-layout: fixed; border-collapse: collapse; 
font-size: 11px; }
+.ld-tag-tbl td { word-break: break-all; vertical-align: top; }
+.ld-tag-tbl th, .ld-tag-tbl td {
+  padding: 4px 10px; text-align: left; border-bottom: 1px solid var(--sw-line);
+}
+.ld-tag-tbl th { color: var(--sw-fg-3); font-weight: 500; }
+.ld-tag-tbl td { color: var(--sw-fg-1); font-family: var(--sw-mono); }
+.sw-btn.small {
+  height: 24px; padding: 0 10px; background: var(--sw-bg-2); border: 1px solid 
var(--sw-line-2);
+  color: var(--sw-fg-1); border-radius: 4px; font: inherit; font-size: 11px; 
cursor: pointer;
+}
+.sw-btn.small:hover { color: var(--sw-fg-0); border-color: var(--sw-fg-3); }
+.mono { font-family: var(--sw-mono); }
+@media (max-width: 900px) {
+  .ld-split { flex-direction: column; }
+  .ld-tags { flex: 0 0 auto; border-left: none; border-top: 1px solid 
var(--sw-line); }
+}
+</style>
diff --git a/apps/ui/src/render/widgets/LogStreamPanel.vue 
b/apps/ui/src/render/widgets/LogStreamPanel.vue
new file mode 100644
index 0000000..a1e61be
--- /dev/null
+++ b/apps/ui/src/render/widgets/LogStreamPanel.vue
@@ -0,0 +1,233 @@
+<!--
+  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.
+-->
+<!--
+  Shared raw-log STREAM. One dense row per log line — time / date /
+  level stripe / service (group-decoded) / trace-id chip / format chip
+  + flattened single-line content preview. Used by BOTH the per-layer
+  Logs tab and the cross-layer Log inspect view (both click → the shared
+  LogDetailPopout). The component is purely presentational: it owns row
+  rendering + level/format detection and emits `select`; the host decides
+  what a click opens.
+
+  Props:
+    rows         — LogRow[] to render.
+    selectedKey  — `rowKey(...)` of the active row (null when none); the
+                   host computes the same key via the exported helper.
+
+  Emits:
+    select       — { row, key } for a clicked row. The trace-id chip
+                   emits `jump-trace` instead (and stops propagation) so
+                   the host can open the trace without selecting the row.
+-->
+<script setup lang="ts">
+import type { LogRow } from '@/api/client';
+import { parseServiceName } from '@/utils/serviceName';
+import { logRowKey } from '@/utils/logRow';
+
+defineProps<{
+  rows: LogRow[];
+  selectedKey?: string | null;
+}>();
+const emit = defineEmits<{
+  (e: 'select', payload: { row: LogRow; key: string }): void;
+  (e: 'jump-trace', payload: { traceId: string; ts: number }): void;
+}>();
+
+type Level = 'error' | 'warn' | 'info' | 'debug' | 'other';
+const LEVEL_COLOR: Record<Level, string> = {
+  error: 'var(--sw-err)',
+  warn: 'var(--sw-warn)',
+  info: 'var(--sw-info)',
+  debug: 'var(--sw-fg-3)',
+  other: 'var(--sw-fg-3)',
+};
+
+function levelOf(r: LogRow): Level {
+  const tag = (r.tags ?? []).find((t) => t.key.toLowerCase() === 'level');
+  const raw = (tag?.value ?? '').toLowerCase();
+  if (raw.includes('error') || raw === 'err' || raw === 'fatal') return 
'error';
+  if (raw.includes('warn')) return 'warn';
+  if (raw.includes('info')) return 'info';
+  if (raw.includes('debug') || raw.includes('trace')) return 'debug';
+  return 'other';
+}
+
+type LogFormat = 'json' | 'yaml' | 'text';
+function detectFormat(r: LogRow): LogFormat {
+  if (r.contentType === 'application/json') return 'json';
+  const trimmed = r.content?.trim() ?? '';
+  if (!trimmed) return 'text';
+  if ((trimmed.startsWith('{') && trimmed.endsWith('}')) ||
+      (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
+    try { JSON.parse(trimmed); return 'json'; } catch { /* fallthrough */ }
+  }
+  if (trimmed.startsWith('---') || trimmed.startsWith('apiVersion:')) return 
'yaml';
+  const lines = trimmed.split('\n');
+  if (lines.length >= 2) {
+    const topLevelMaps = lines.filter((l) => 
/^[A-Za-z_][\w.-]*\s*:\s*(\S|$)/.test(l)).length;
+    if (topLevelMaps >= 2) return 'yaml';
+  }
+  return 'text';
+}
+
+/** Single-line preview. JSON re-serialises tight; YAML/text collapse
+ *  whitespace so the row stays one line (full payload lives in the
+ *  host's detail / popout). */
+function summariseContent(r: LogRow): string {
+  if (!r.content) return '';
+  const fmt = detectFormat(r);
+  if (fmt === 'json') {
+    try { return JSON.stringify(JSON.parse(r.content)); } catch { /* fall 
through */ }
+  }
+  if (fmt === 'yaml') return r.content.replace(/\n+/g, ' ').trim();
+  return r.content.replace(/\s+/g, ' ').trim();
+}
+
+function fmtTime(ts: number): string {
+  const d = new Date(ts);
+  const pad = (n: number) => String(n).padStart(2, '0');
+  return 
`${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${String(d.getMilliseconds()).padStart(3,
 '0')}`;
+}
+function fmtDate(ts: number): string {
+  const d = new Date(ts);
+  const pad = (n: number) => String(n).padStart(2, '0');
+  return `${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
+}
+
+function keyOf(r: LogRow, idx: number): string {
+  return logRowKey(r, idx);
+}
+</script>
+
+<template>
+  <div class="lg-stream">
+    <div
+      v-for="(r, idx) in rows"
+      :key="keyOf(r, idx)"
+      class="lg-row"
+      :class="[`lv-${levelOf(r)}`, { on: selectedKey != null && selectedKey 
=== keyOf(r, idx) }]"
+      @click="emit('select', { row: r, key: keyOf(r, idx) })"
+    >
+      <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>
+      <span class="lg-svc mono dim">
+        <span
+          v-if="r.serviceName && parseServiceName(r.serviceName).group"
+          class="lg-svc-group"
+        >{{ parseServiceName(r.serviceName).group }}</span>
+        {{ r.serviceName ? parseServiceName(r.serviceName).base : '—' }}
+      </span>
+      <span
+        v-if="r.traceId"
+        class="lg-trace mono"
+        @click.stop="emit('jump-trace', { traceId: r.traceId, ts: r.timestamp 
})"
+      >↗ trace</span>
+      <span v-else class="lg-trace-spacer" aria-hidden="true"></span>
+      <span class="lg-content mono">
+        <span class="lg-fmt-chip" :class="`fmt-${detectFormat(r)}`">{{ 
detectFormat(r).toUpperCase() }}</span>
+        <span class="lg-content-body">{{ summariseContent(r) }}</span>
+      </span>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.lg-stream { font-size: 11.5px; }
+.lg-row {
+  display: grid;
+  grid-template-columns: 80px 60px 56px 140px 60px 1fr;
+  gap: 10px;
+  align-items: center;
+  padding: 4px 12px;
+  border-bottom: 1px solid var(--sw-line);
+  cursor: pointer;
+}
+.lg-row:hover { background: var(--sw-bg-2); }
+.lg-row.on { background: var(--sw-accent-soft); }
+.lg-row.lv-error { box-shadow: inset 3px 0 0 var(--sw-err); }
+.lg-row.lv-warn { box-shadow: inset 3px 0 0 var(--sw-warn); }
+.lg-time { font-family: var(--sw-mono); color: var(--sw-fg-1); }
+.lg-date { font-size: 10px; }
+.lg-lvl {
+  font-size: 9.5px;
+  text-transform: uppercase;
+  letter-spacing: 0.06em;
+  font-weight: 700;
+}
+.lg-svc { font-size: 10.5px; }
+.lg-svc-group {
+  display: inline-block;
+  padding: 0 5px;
+  margin-right: 4px;
+  background: var(--sw-bg-2);
+  border: 1px solid var(--sw-line-2);
+  border-radius: 3px;
+  font-size: 9.5px;
+  font-weight: 600;
+  letter-spacing: 0.04em;
+  color: var(--sw-fg-2);
+  text-transform: uppercase;
+}
+.lg-trace {
+  font-size: 10px;
+  color: var(--sw-accent-2);
+  cursor: pointer;
+  padding: 1px 5px;
+  background: var(--sw-accent-soft);
+  border: 1px solid var(--sw-accent-line);
+  border-radius: 3px;
+}
+.lg-trace:hover { color: var(--sw-fg-0); }
+.lg-content {
+  font-family: var(--sw-mono);
+  font-size: 11px;
+  color: var(--sw-fg-1);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  min-width: 0;
+}
+.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;
+}
+.mono { font-family: var(--sw-mono); }
+.dim { color: var(--sw-fg-3); }
+</style>
diff --git a/apps/ui/src/render/widgets/TraceDistribution.vue 
b/apps/ui/src/render/widgets/TraceDistribution.vue
index 8a7b364..c2b9a78 100644
--- a/apps/ui/src/render/widgets/TraceDistribution.vue
+++ b/apps/ui/src/render/widgets/TraceDistribution.vue
@@ -136,8 +136,9 @@ const scatterXTicks = computed(() => {
 const scatterSvgRef = ref<SVGSVGElement | null>(null);
 const dragState = ref<{
   active: boolean;
+  pending: boolean;
   startVx: number; startVy: number; curVx: number; curVy: number;
-}>({ active: false, startVx: 0, startVy: 0, curVx: 0, curVy: 0 });
+}>({ active: false, pending: false, startVx: 0, startVy: 0, curVx: 0, curVy: 0 
});
 function clientToViewbox(ev: PointerEvent): { vx: number; vy: number } | null {
   const svg = scatterSvgRef.value;
   if (!svg) return null;
@@ -150,29 +151,36 @@ function clientToViewbox(ev: PointerEvent): { vx: number; 
vy: number } | null {
 function onScatterDown(ev: PointerEvent): void {
   const pt = clientToViewbox(ev);
   if (!pt) return;
-  const target = ev.target as Element | null;
-  // Click on a dot is handled by its own click handler; the drag is
-  // for the empty plot area.
-  if (target?.classList.contains('scatter-dot')) return;
-  ev.preventDefault();
-  dragState.value = { active: true, startVx: pt.vx, startVy: pt.vy, curVx: 
pt.vx, curVy: pt.vy };
-  (ev.currentTarget as SVGSVGElement).setPointerCapture(ev.pointerId);
+  // Record a potential drag; do NOT preventDefault yet, so a plain click on
+  // a dot still fires its @click. The brush activates only once the pointer
+  // actually moves (onScatterMove) — the fat hit-circles otherwise swallow
+  // the drag-start.
+  dragState.value = { active: false, pending: true, startVx: pt.vx, startVy: 
pt.vy, curVx: pt.vx, curVy: pt.vy };
 }
 function onScatterMove(ev: PointerEvent): void {
-  if (!dragState.value.active) return;
+  const s = dragState.value;
+  if (!s.pending && !s.active) return;
   const pt = clientToViewbox(ev);
   if (!pt) return;
-  dragState.value = { ...dragState.value, curVx: pt.vx, curVy: pt.vy };
+  if (!s.active) {
+    if (Math.abs(pt.vx - s.startVx) < 6 && Math.abs(pt.vy - s.startVy) < 6) {
+      dragState.value = { ...s, curVx: pt.vx, curVy: pt.vy };
+      return;
+    }
+    ev.preventDefault();
+    try { (ev.currentTarget as SVGSVGElement).setPointerCapture(ev.pointerId); 
} catch { /* noop */ }
+    dragState.value = { ...s, active: true, curVx: pt.vx, curVy: pt.vy };
+    return;
+  }
+  dragState.value = { ...s, curVx: pt.vx, curVy: pt.vy };
 }
 function onScatterUp(ev: PointerEvent): void {
-  if (!dragState.value.active) return;
-  const { startVx, startVy, curVx, curVy } = dragState.value;
-  dragState.value = { active: false, startVx: 0, startVy: 0, curVx: 0, curVy: 
0 };
-  try {
-    (ev.currentTarget as SVGSVGElement).releasePointerCapture(ev.pointerId);
-  } catch { /* noop */ }
-  // No real drag (click without move) → don't change selection.
-  if (Math.abs(curVx - startVx) < 4 && Math.abs(curVy - startVy) < 4) return;
+  const s = dragState.value;
+  const wasActive = s.active;
+  dragState.value = { active: false, pending: false, startVx: 0, startVy: 0, 
curVx: 0, curVy: 0 };
+  if (!wasActive) return;
+  try { (ev.currentTarget as 
SVGSVGElement).releasePointerCapture(ev.pointerId); } catch { /* noop */ }
+  const { startVx, startVy, curVx, curVy } = s;
   const b = scatterBounds.value;
   if (!b) return;
   const vxMin = Math.min(startVx, curVx);
@@ -183,9 +191,7 @@ function onScatterUp(ev: PointerEvent): void {
   for (const p of scatterPoints.value) {
     const cx = b.xMax === b.xMin ? 500 : ((p.x - b.xMin) / (b.xMax - b.xMin)) 
* 1000;
     const cy = 1000 - ((p.y - b.yMin) / (b.yMax - b.yMin || 1)) * 990;
-    if (cx >= vxMin && cx <= vxMax && cy >= vyMin && cy <= vyMax) {
-      keys.push(p.rowKey);
-    }
+    if (cx >= vxMin && cx <= vxMax && cy >= vyMin && cy <= vyMax) 
keys.push(p.rowKey);
   }
   if (keys.length > 0) emit('brush', keys);
 }
diff --git a/apps/ui/src/shell/AppSidebar.vue b/apps/ui/src/shell/AppSidebar.vue
index bca3b92..b168006 100644
--- a/apps/ui/src/shell/AppSidebar.vue
+++ b/apps/ui/src/shell/AppSidebar.vue
@@ -272,8 +272,8 @@ const sections = computed<NavSection[]>(() => [
       },
       { icon: 'event', label: t('Capture history'), to: 
'/operate/live-debug/history', verb: 'live-debug:read' },
       { icon: 'metric', label: t('Metrics inspect'), to: '/operate/inspect', 
verb: 'inspect:read' },
-      { icon: 'trace', label: t('Trace inspect'), to: 
'/operate/trace-inspect', verb: 'traces:read' },
-      { icon: 'log', label: t('Log inspect'), to: '/operate/log-inspect', 
verb: 'logs:read' },
+      { icon: 'trace', label: t('Trace inspect'), to: 
'/operate/trace-inspect', verb: 'explore:read' },
+      { icon: 'log', label: t('Log inspect'), to: '/operate/log-inspect', 
verb: 'explore:read' },
     ],
   },
   {
diff --git a/apps/ui/src/shell/router/index.ts 
b/apps/ui/src/shell/router/index.ts
index f8124e1..94ba3af 100644
--- a/apps/ui/src/shell/router/index.ts
+++ b/apps/ui/src/shell/router/index.ts
@@ -221,14 +221,13 @@ const shellRoutes: RouteRecordRaw[] = [
     name: 'trace-inspect',
     component: () => import('@/features/operate/explore/ExploreView.vue'),
     props: { kind: 'trace' },
-    meta: { verb: 'traces:read' },
+    meta: { verb: 'explore:read' },
   },
   {
     path: 'operate/log-inspect',
     name: 'log-inspect',
-    component: () => import('@/features/operate/explore/ExploreView.vue'),
-    props: { kind: 'log' },
-    meta: { verb: 'logs:read' },
+    component: () => import('@/features/operate/explore/ExploreLogView.vue'),
+    meta: { verb: 'explore:read' },
   },
   // Data retention (TTL) — query-port read of getRecordsTTL/getMetricsTTL.
   {
diff --git a/apps/ui/src/utils/logRow.ts b/apps/ui/src/utils/logRow.ts
new file mode 100644
index 0000000..7fe2355
--- /dev/null
+++ b/apps/ui/src/utils/logRow.ts
@@ -0,0 +1,25 @@
+/*
+ * 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.
+ */
+
+import type { LogRow } from '@/api/client';
+
+/** Stable per-row key for the shared log stream. Shared by LogStreamPanel
+ *  and its hosts so a host's `selectedKey` lines up with the panel's rows.
+ *  Timestamp + trace id + index disambiguate rows that share a timestamp. */
+export function logRowKey(r: LogRow, idx: number): string {
+  return `${r.timestamp}-${r.traceId ?? ''}-${idx}`;
+}

Reply via email to