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 8f161034a0a9fc39118ff533c893b10f6455c5d9
Author: Wu Sheng <[email protected]>
AuthorDate: Wed Jun 24 00:15:54 2026 +0800

    feat(explore): Log inspect browser-error source
    
    Adds the Browser source to cross-layer Log inspect:
    - BFF: fetchBrowserErrors extracted from the per-layer browser route
      (pre-resolved serviceId, no layer); the explore log branch's
      logSource:'browser' resolves the entity + SECOND-precision window +
      category and runs OAP queryBrowserErrorLogs. Cold-stage honored.
    - UI: a BROWSER-layer service picker + a Category condition; a dense
      browser-error list; row-click opens a new BrowserErrorPopout (on the
      shared Modal) showing the error meta, raw stack, and the source-map
      de-obfuscation control (reuses the per-layer resolve flow). The Pod
      logs source is stubbed (disabled) for the next increment.
---
 CHANGELOG.md                                       |   1 +
 apps/bff/src/http/query/browser-errors.ts          | 148 +++++++----
 apps/bff/src/http/query/explore.ts                 |  45 +++-
 .../features/operate/explore/ExploreLogView.vue    | 280 +++++++++++++++++++--
 apps/ui/src/i18n/locales/de.json                   |  11 +
 apps/ui/src/i18n/locales/en.json                   |  11 +
 apps/ui/src/i18n/locales/es.json                   |  11 +
 apps/ui/src/i18n/locales/fr.json                   |  11 +
 apps/ui/src/i18n/locales/ja.json                   |  11 +
 apps/ui/src/i18n/locales/ko.json                   |  11 +
 apps/ui/src/i18n/locales/pt.json                   |  11 +
 apps/ui/src/i18n/locales/zh-CN.json                |  11 +
 apps/ui/src/render/widgets/BrowserErrorPopout.vue  | 249 ++++++++++++++++++
 13 files changed, 730 insertions(+), 81 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 97979eb..d6f1796 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -23,6 +23,7 @@ The version line is shared by every package in the monorepo 
(apps + shared packa
 
 - **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.
+- **Log inspect can now query Browser errors across the page.** A new 
**Browser** source on Log inspect (beside Raw) queries the BROWSER layer's JS 
error logs from anywhere — pick a browser service, filter by category (AJAX / 
RESOURCE / VUE / PROMISE / JS / UNKNOWN) and time window, and read the error 
list (message, category, page path, app version, time, minified `line:col`). 
Clicking a row opens a popout with the error meta, the raw stack, and the same 
source-map de-obfuscation control [...]
 
 ## 0.7.0
 
diff --git a/apps/bff/src/http/query/browser-errors.ts 
b/apps/bff/src/http/query/browser-errors.ts
index 0a86690..de9f141 100644
--- a/apps/bff/src/http/query/browser-errors.ts
+++ b/apps/bff/src/http/query/browser-errors.ts
@@ -30,6 +30,7 @@
 
 import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
 import type {
+  BrowserErrorCategory,
   BrowserErrorRow,
   BrowserErrorsQueryRequest,
   BrowserErrorsResponse,
@@ -39,7 +40,6 @@ import type { ConfigSource } from '../../config/loader.js';
 import type { SessionStore } from '../../user/sessions.js';
 import { requireAuth } from '../../user/middleware.js';
 import { graphqlPost, buildOapOpts, type GraphqlOptions } from 
'../../client/graphql.js';
-import { withColdStage } from '../../util/duration.js';
 import { fmtSecond, getServerOffsetMinutes } from '../../util/window.js';
 
 export interface BrowserErrorsRouteDeps {
@@ -127,6 +127,83 @@ interface OapBrowserErrorRow {
   firstReportedError: boolean;
 }
 
+/** OAP id filters the browser-error query scopes by — all PRE-RESOLVED by
+ *  the caller (no `listServices(layer)` lookup). The per-layer route
+ *  resolves a service name first; explore forwards an id it already
+ *  minted. `category` of `ALL` (or omitted) means "no filter". */
+export interface BrowserErrorScope {
+  serviceId?: string | null;
+  serviceVersionId?: string | null;
+  pagePathId?: string | null;
+  category?: BrowserErrorCategory;
+}
+
+/** Run OAP's `queryBrowserErrorLogs(BrowserErrorLogQueryCondition)` for a
+ *  pre-resolved scope + SECOND-precision window + page, mapping rows to
+ *  {@link BrowserErrorRow} (newest-first). Shared by the per-layer Browser
+ *  Logs route and the cross-layer Log inspect browser branch. Soft-fails to
+ *  `reachable: false` on any OAP error. */
+export async function fetchBrowserErrors(
+  opts: GraphqlOptions,
+  scope: BrowserErrorScope,
+  window: { start: string; end: string },
+  paging: { pageNum: number; pageSize: number },
+  coldStage: boolean,
+): Promise<BrowserErrorsResponse> {
+  const condition = {
+    ...(scope.serviceId ? { serviceId: scope.serviceId } : {}),
+    ...(scope.serviceVersionId ? { serviceVersionId: scope.serviceVersionId } 
: {}),
+    ...(scope.pagePathId ? { pagePathId: scope.pagePathId } : {}),
+    // `ALL` is OAP's "no filter" sentinel — omit it so storage doesn't try
+    // to match a literal category named ALL.
+    ...(scope.category && scope.category !== 'ALL' ? { category: 
scope.category } : {}),
+    queryDuration: {
+      start: window.start,
+      end: window.end,
+      step: 'SECOND',
+      ...(coldStage ? { coldStage: true } : {}),
+    },
+    paging,
+  };
+  try {
+    const env = await graphqlPost<{ data: { logs: OapBrowserErrorRow[] } }>(
+      opts,
+      QUERY_BROWSER_ERRORS,
+      { condition },
+    );
+    const logs: BrowserErrorRow[] = (env.data?.logs ?? []).map((r) => ({
+      service: r.service,
+      serviceVersion: r.serviceVersion,
+      time: r.time,
+      pagePath: r.pagePath,
+      category: r.category,
+      grade: r.grade ?? null,
+      message: r.message ?? null,
+      line: r.line ?? null,
+      col: r.col ?? null,
+      stack: r.stack ?? null,
+      errorUrl: r.errorUrl ?? null,
+      firstReportedError: r.firstReportedError,
+    }));
+    // Normalise to newest-first. OAP's BrowserErrorLog DAO sorts DESC, but
+    // BanyanDB returns it per time-segment (each segment DESC, the segments
+    // concatenated oldest-first), so a multi-segment result is not globally
+    // ordered. Sort by the records' own `time` to guarantee a strictly
+    // newest-first stream.
+    logs.sort((a, b) => b.time - a.time);
+    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),
+    };
+  }
+}
+
 const OAP_SERVICE_ID_RE = /^[A-Za-z0-9+/=]+\.\d+$/;
 async function resolveServiceId(
   opts: GraphqlOptions,
@@ -186,63 +263,24 @@ export function registerBrowserErrorsRoute(app: 
FastifyInstance, deps: BrowserEr
         }
       }
 
-      const condition = {
-        ...(serviceId ? { serviceId } : {}),
-        ...(body.serviceVersionId ? { serviceVersionId: body.serviceVersionId 
} : {}),
-        ...(body.pagePathId ? { pagePathId: body.pagePathId } : {}),
-        // `ALL` is OAP's "no filter" sentinel — omit it so storage doesn't
-        // try to match a literal category named ALL.
-        ...(body.category && body.category !== 'ALL' ? { category: 
body.category } : {}),
-        queryDuration: withColdStage(req, { start: window.start, end: 
window.end, step: 'SECOND' }),
-        paging: {
+      const res = await fetchBrowserErrors(
+        opts,
+        {
+          serviceId,
+          serviceVersionId: body.serviceVersionId,
+          pagePathId: body.pagePathId,
+          category: body.category,
+        },
+        window,
+        {
           pageNum: Math.max(1, Math.round(body.page ?? 1)),
           pageSize: clampPageSize(body.pageSize, 50, 
deps.config.current.performance.limits.maxPageSize.browserLogs),
         },
-      };
-
-      try {
-        const env = await graphqlPost<{ data: { logs: OapBrowserErrorRow[] } 
}>(
-          opts,
-          QUERY_BROWSER_ERRORS,
-          { condition },
-        );
-        const logs: BrowserErrorRow[] = (env.data?.logs ?? []).map((r) => ({
-          service: r.service,
-          serviceVersion: r.serviceVersion,
-          time: r.time,
-          pagePath: r.pagePath,
-          category: r.category,
-          grade: r.grade ?? null,
-          message: r.message ?? null,
-          line: r.line ?? null,
-          col: r.col ?? null,
-          stack: r.stack ?? null,
-          errorUrl: r.errorUrl ?? null,
-          firstReportedError: r.firstReportedError,
-        }));
-        // Normalise to newest-first. OAP's BrowserErrorLog DAO sorts DESC,
-        // but BanyanDB returns it per time-segment (each segment DESC, the
-        // segments concatenated oldest-first), so a multi-segment result is
-        // not globally ordered. Sort by the records' own `time` to guarantee
-        // a strictly newest-first stream.
-        logs.sort((a, b) => b.time - a.time);
-        return reply.send({
-          generatedAt: Date.now(),
-          query: body,
-          total: logs.length,
-          logs,
-          reachable: true,
-        } satisfies BrowserErrorsResponse);
-      } catch (err) {
-        return reply.send({
-          generatedAt: Date.now(),
-          query: body,
-          total: 0,
-          logs: [],
-          reachable: false,
-          error: err instanceof Error ? err.message : String(err),
-        } satisfies BrowserErrorsResponse);
-      }
+        !!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 
BrowserErrorsResponse);
     },
   );
 }
diff --git a/apps/bff/src/http/query/explore.ts 
b/apps/bff/src/http/query/explore.ts
index e108d0c..6c11ee4 100644
--- a/apps/bff/src/http/query/explore.ts
+++ b/apps/bff/src/http/query/explore.ts
@@ -46,6 +46,7 @@ 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 { fetchBrowserErrors } from './browser-errors.js';
 import { zipkinFetchTraces } from '../../client/zipkin.js';
 
 export interface ExploreRouteDeps {
@@ -226,8 +227,8 @@ export function registerExploreRoutes(app: FastifyInstance, 
deps: ExploreRouteDe
         } satisfies ExploreResponse);
       }
 
-      // kind === 'log'. `browser` (BROWSER-layer JS errors) lands in the
-      // next increment; default + only supported source today is `raw`.
+      // kind === 'log'. Two sources: `raw` (queryLogs) and `browser`
+      // (queryBrowserErrorLogs — BROWSER-layer JS errors). Default `raw`.
       const logSource = body.logSource ?? 'raw';
       if (logSource === 'raw') {
         // Entity optional — no service means "all services in the window"
@@ -273,8 +274,44 @@ export function registerExploreRoutes(app: 
FastifyInstance, deps: ExploreRouteDe
         } satisfies ExploreResponse);
       }
 
-      // kind === 'log' && logSource === 'browser' — next increment.
-      return reply.code(501).send({ error: 'browser_log_not_implemented' });
+      // kind === 'log' && logSource === 'browser' — BROWSER-layer JS error
+      // logs. The entity is a browser SERVICE (Pick forwards an id; Type
+      // encodes base64(name).{1|0}). No instance/endpoint — browser errors
+      // scope by serviceVersionId/pagePathId, which Log inspect doesn't
+      // surface, so only the service narrows here. SECOND-precision window
+      // (error logs are event-style — MINUTE rounding chops the newest).
+      const ids = body.entity ? resolveNativeEntity(body.entity) : {};
+      const win = logWindowSecond(body.window ?? {}, offset);
+      const category = body.category ?? 'ALL';
+      const maxBrowser = 
deps.config.current.performance.limits.maxPageSize.browserLogs;
+      const paging = {
+        pageNum: Math.max(1, Math.round(body.pageNum ?? 1)),
+        pageSize: Math.min(maxBrowser, Math.max(1, Math.round(body.pageSize ?? 
50))),
+      };
+      const browser = await fetchBrowserErrors(
+        opts,
+        { serviceId: ids.serviceId, category },
+        win,
+        paging,
+        !!req.coldStage,
+      );
+      const resolved: ExploreResolved = {
+        kind: 'log',
+        source: 'browser',
+        entityId: ids.serviceId,
+        condition: {
+          ...(ids.serviceId ? { serviceId: ids.serviceId } : {}),
+          ...(category && category !== 'ALL' ? { category } : {}),
+          ...win,
+        },
+      };
+      return reply.send({
+        kind: 'log',
+        logSource: 'browser',
+        generatedAt,
+        browser,
+        resolved,
+      } satisfies ExploreResponse);
     },
   );
 }
diff --git a/apps/ui/src/features/operate/explore/ExploreLogView.vue 
b/apps/ui/src/features/operate/explore/ExploreLogView.vue
index 464dd2f..1bc6721 100644
--- a/apps/ui/src/features/operate/explore/ExploreLogView.vue
+++ b/apps/ui/src/features/operate/explore/ExploreLogView.vue
@@ -23,37 +23,79 @@
   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.
+  Two sources: Log · raw (queryLogs) and Log · browser (BROWSER-layer JS
+  errors). The Browser source swaps the entity to a browser SERVICE picker
+  and the conditions to Category + Time + Limit, renders an error list, and
+  the row-click popout adds the source-map de-obfuscation control (the same
+  resolve flow the per-layer Browser Logs tab uses). The next increment adds
+  the k8s pod-logs source — its toggle is present but disabled.
 -->
 <script setup lang="ts">
-import { computed, reactive, ref, watch } from 'vue';
+import { computed, onMounted, reactive, ref, watch } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { bffClient } from '@/api/client';
 import type {
+  BrowserErrorCategory,
+  BrowserErrorRow,
+  BrowserErrorsResponse,
   ExploreEntity,
   ExploreRequest,
   ExploreResolved,
   ExploreWindow,
   LogRow,
   LogsResponse,
+  SourceMapDescriptor,
 } 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 BrowserErrorPopout from '@/render/widgets/BrowserErrorPopout.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) ─────
+// ── source. `raw` = queryLogs (any service across layers); `browser` =
+// BROWSER-layer JS errors. The k8s pod-logs source lands next (disabled). ─
 type LogSource = 'raw' | 'browser';
 const logSource = ref<LogSource>('raw');
 
+// ── browser entity — a BROWSER-layer service. Reuses the Pick metadata
+// feed (bffClient.layer.services) pinned to BROWSER; no instance/endpoint
+// (browser errors scope by version/page, which this view doesn't surface). ─
+const BROWSER_LAYER = 'BROWSER';
+const browserServiceId = ref<string>('');
+const browserServices = ref<Array<{ id: string; name: string; normal: boolean 
| null }>>([]);
+const browserServicesLoading = ref(false);
+const browserServicesReachable = ref(true);
+
+async function loadBrowserServices(): Promise<void> {
+  browserServicesLoading.value = true;
+  try {
+    const res = await bffClient.layer.services(BROWSER_LAYER);
+    browserServicesReachable.value = res.reachable;
+    browserServices.value = res.reachable ? res.services : [];
+  } catch {
+    browserServicesReachable.value = false;
+    browserServices.value = [];
+  } finally {
+    browserServicesLoading.value = false;
+  }
+}
+const browserServiceOptions = computed(() =>
+  browserServices.value.map((s) => ({ value: s.id, label: s.name })),
+);
+// Lazy-load the BROWSER catalog the first time the operator opens that
+// source — the BROWSER layer may not exist on every deployment.
+watch(logSource, (src) => {
+  if (src === 'browser' && browserServices.value.length === 0 && 
!browserServicesLoading.value) {
+    void loadBrowserServices();
+  }
+});
+
 // ── entity (OPTIONAL): pick (layer-filtered) vs type (name + real) ────
 type EntityMode = 'pick' | 'type';
 const entityMode = ref<EntityMode>('pick');
@@ -186,6 +228,11 @@ const cond = reactive({
 const WINDOWS = [15, 30, 60, 180, 360, 720, 1440];
 const LIMITS = [20, 50, 100, 200];
 
+// ── browser condition: error category (ALL = no filter; the rest mirror
+// OAP's ErrorCategory enum verbatim). Time + Limit are shared with raw. ─
+const BROWSER_CATEGORIES: BrowserErrorCategory[] = ['ALL', 'AJAX', 'RESOURCE', 
'VUE', 'PROMISE', 'JS', 'UNKNOWN'];
+const browserCategory = ref<BrowserErrorCategory>('ALL');
+
 const CUSTOM_RANGE_SENTINEL = -1;
 const customStart = ref<string | null>(null);
 const customEnd = ref<string | null>(null);
@@ -232,10 +279,14 @@ const running = ref(false);
 const hasQueried = ref(false);
 const errorMsg = ref<string | null>(null);
 const logsResp = ref<LogsResponse | null>(null);
+const browserResp = ref<BrowserErrorsResponse | null>(null);
 const resolved = ref<ExploreResolved | null>(null);
 const showResolved = ref(false);
 
-const canRun = computed(() => logSource.value === 'raw');
+const canRun = computed(() => {
+  if (logSource.value === 'browser') return !!browserServiceId.value;
+  return true;
+});
 
 /** Explicit epoch-ms bounds in Custom mode (datetime-local strings are
  *  browser-local; `Date.parse` reads them as local), else rolling. */
@@ -254,6 +305,17 @@ function resolveWindow(): ExploreWindow {
 }
 
 function buildRequest(): ExploreRequest {
+  if (logSource.value === 'browser') {
+    // Pick forwards a pre-resolved BROWSER service id; no instance/endpoint.
+    return {
+      kind: 'log',
+      logSource: 'browser',
+      entity: { mode: 'pick', serviceId: browserServiceId.value },
+      window: resolveWindow(),
+      pageSize: cond.limit,
+      ...(browserCategory.value !== 'ALL' ? { category: browserCategory.value 
} : {}),
+    };
+  }
   const entity = currentEntity();
   const keywords = parseKeywords(cond.keywords);
   const tags = parseTags(cond.tags);
@@ -270,6 +332,7 @@ function buildRequest(): ExploreRequest {
 }
 
 async function runQuery(): Promise<void> {
+  if (!canRun.value) return;
   running.value = true;
   hasQueried.value = true;
   errorMsg.value = null;
@@ -277,6 +340,7 @@ async function runQuery(): Promise<void> {
   // Cascade-clear: drop the prior result before the new query lands so
   // operators never read stale rows as the new state.
   logsResp.value = null;
+  browserResp.value = null;
   const req = buildRequest();
   try {
     const res = await bffClient.explore.query(req);
@@ -284,16 +348,24 @@ async function runQuery(): Promise<void> {
       logsResp.value = res.logs;
       resolved.value = res.resolved;
       if (!res.logs.reachable) errorMsg.value = res.logs.error ?? t('OAP 
unreachable');
+    } else if (res.kind === 'log' && res.logSource === 'browser') {
+      browserResp.value = res.browser;
+      resolved.value = res.resolved;
+      if (!res.browser.reachable) errorMsg.value = res.browser.error ?? t('OAP 
unreachable');
     }
   } catch (e) {
     errorMsg.value = e instanceof Error ? e.message : String(e);
     logsResp.value = null;
+    browserResp.value = null;
   } finally {
     running.value = false;
   }
 }
 
 const rows = computed<LogRow[]>(() => (hasQueried.value ? logsResp.value?.logs 
?? [] : []));
+const browserRows = computed<BrowserErrorRow[]>(() =>
+  hasQueried.value && logSource.value === 'browser' ? browserResp.value?.logs 
?? [] : [],
+);
 
 // ── detail — clicking a row opens the shared full-payload popout. The
 // stream stays full-width; the popout owns its own Escape / close +
@@ -301,13 +373,21 @@ const rows = computed<LogRow[]>(() => (hasQueried.value ? 
logsResp.value?.logs ?
 const selectedKey = ref<string | null>(null);
 const selectedRow = ref<LogRow | null>(null);
 
+// ── browser detail — a BrowserErrorRow row-click opens the browser-error
+// popout (raw stack + source-map de-obfuscation). One open at a time. ──
+const selectedBrowserRow = ref<BrowserErrorRow | null>(null);
+
 function openRow(payload: { row: LogRow; key: string }): void {
   selectedKey.value = payload.key;
   selectedRow.value = payload.row;
 }
+function openBrowserRow(row: BrowserErrorRow): void {
+  selectedBrowserRow.value = row;
+}
 function closeDetail(): void {
   selectedKey.value = null;
   selectedRow.value = null;
+  selectedBrowserRow.value = null;
 }
 function jumpToTrace(traceId: string, ts: number): void {
   openTrace(traceId, ts);
@@ -320,6 +400,64 @@ watch(rows, (next) => {
   const stillThere = next.some((r, idx) => logRowKey(r, idx) === 
selectedKey.value);
   if (!stillThere) closeDetail();
 });
+watch(browserRows, () => {
+  if (selectedBrowserRow.value) selectedBrowserRow.value = null;
+});
+
+// ── source-map cache — loaded once for the browser popout's resolve
+// control. The popout owns the per-row resolve; the host owns the list. ─
+const sourceMaps = ref<SourceMapDescriptor[]>([]);
+async function loadSourceMaps(): Promise<void> {
+  try {
+    const res = await bffClient.browserErrors.listSourceMaps();
+    sourceMaps.value = res.enabled ? res.maps : [];
+  } catch {
+    sourceMaps.value = [];
+  }
+}
+onMounted(loadSourceMaps);
+
+// ── presentation helpers (browser list rows) ──────────────────────────
+const CATEGORY_COLOR: Record<string, string> = {
+  js: 'var(--sw-err)',
+  promise: 'var(--sw-warn)',
+  vue: 'var(--sw-info)',
+  ajax: 'var(--sw-accent-2)',
+  resource: 'var(--sw-cyan)',
+  unknown: 'var(--sw-fg-3)',
+};
+function catColor(r: BrowserErrorRow): string {
+  return CATEGORY_COLOR[(r.category ?? '').toLowerCase()] ?? 'var(--sw-fg-3)';
+}
+function fmtRowTime(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())}`;
+}
+function fmtRowDate(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 rowLoc(r: BrowserErrorRow): string {
+  if (!r.line) return '';
+  return `${r.line}:${r.col ?? 0}`;
+}
+function browserRowKey(r: BrowserErrorRow, idx: number): string {
+  return `${r.time}-${r.category}-${idx}`;
+}
+
+// Cascade-clear on source switch: drop the prior result + resolved-query
+// echo so the new source starts on its own "run a query" empty state rather
+// than the other source's stale rows / wrong empty message.
+watch(logSource, () => {
+  hasQueried.value = false;
+  errorMsg.value = null;
+  logsResp.value = null;
+  browserResp.value = null;
+  resolved.value = null;
+  closeDetail();
+});
 </script>
 
 <template>
@@ -333,14 +471,34 @@ watch(rows, (next) => {
       <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>
+        <button :class="{ on: logSource === 'browser' }" @click="logSource = 
'browser'">{{ t('Browser') }}</button>
+        <button disabled :title="t('coming next')">{{ t('Pod logs') }}</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">
+      <!-- Browser source: a single BROWSER-layer service picker (required —
+           browser errors always scope by service). -->
+      <div v-if="logSource === 'browser'" class="iq-target">
+        <div class="iq-target-h">
+          <span>{{ t('Browser app') }} <small class="dim">{{ t('required — 
pick a BROWSER service') }}</small></span>
+        </div>
+        <div class="iq-grid">
+          <label class="cf cf-wide">
+            <span>{{ t('Service') }}</span>
+            <TypeaheadSelect
+              v-model="browserServiceId" :aria-label="t('Service')" 
:options="browserServiceOptions"
+              :disabled="browserServicesLoading"
+              :placeholder="browserServicesLoading ? t('Reading…') : 
(browserServicesReachable ? t('Pick a browser service') : t('No BROWSER 
layer'))"
+              class="cf-tas"
+            />
+          </label>
+        </div>
+      </div>
+
+      <div v-else 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">
@@ -396,7 +554,7 @@ watch(rows, (next) => {
 
         <div class="iq-conditions">
           <div class="iq-conditions-h">
-            <span class="kicker iq-cond-kicker">Logs</span>
+            <span class="kicker iq-cond-kicker">{{ logSource === 'browser' ? 
t('Browser errors') : 'Logs' }}</span>
             <button v-if="resolved" class="iq-resolved-tog" 
@click="showResolved = !showResolved">
               {{ showResolved ? '▾' : '▸' }} {{ t('Resolved query') }}
               <span class="dim">{{ resolved.source }}</span>
@@ -406,18 +564,28 @@ watch(rows, (next) => {
             </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>
+            <template v-if="logSource === 'browser'">
+              <label class="cf cf-wide">
+                <span>{{ t('Category') }}</span>
+                <select v-model="browserCategory" class="cf-input">
+                  <option v-for="c in BROWSER_CATEGORIES" :key="c" 
:value="c">{{ c === 'ALL' ? t('All categories') : c }}</option>
+                </select>
+              </label>
+            </template>
+            <template v-else>
+              <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>
+            </template>
             <label class="cf iq-time" :class="{ 'cf-wide': isCustomRange }">
               <span>{{ t('Time') }}</span>
               <template v-if="isCustomRange">
@@ -444,7 +612,45 @@ watch(rows, (next) => {
       </div>
     </div>
 
-    <div class="iq-result">
+    <!-- Browser source: a dense error list in the same dark vocabulary as
+         the per-layer Browser Logs stream; row-click opens the popout. -->
+    <div v-if="logSource === 'browser'" class="iq-result">
+      <div v-if="!hasQueried" class="iq-empty">{{ t('Pick a browser service 
and run a query.') }}</div>
+      <div v-else-if="running && browserRows.length === 0" class="iq-empty">{{ 
t('Reading data…') }}</div>
+      <div v-else-if="errorMsg" class="iq-err">{{ errorMsg }}</div>
+      <div v-else-if="browserRows.length === 0" class="iq-empty">{{ t('No 
browser logs in this window.') }}</div>
+
+      <article v-else class="iq-list-card sw-card">
+        <header class="iq-list-head">
+          <h4>{{ t('Browser errors') }}</h4>
+          <span class="hint">{{ browserRows.length }} {{ t('errors') }}</span>
+        </header>
+        <div class="iq-stream-scroll">
+          <div class="be-stream">
+            <div
+              v-for="(r, idx) in browserRows"
+              :key="browserRowKey(r, idx)"
+              class="be-row"
+              :class="{ 'is-open': selectedBrowserRow === r }"
+              :style="{ boxShadow: `inset 3px 0 0 ${catColor(r)}` }"
+              @click="openBrowserRow(r)"
+            >
+              <span class="be-time mono">{{ fmtRowTime(r.time) }}</span>
+              <span class="be-date mono dim">{{ fmtRowDate(r.time) }}</span>
+              <span class="be-cat" :style="{ color: catColor(r) }">{{ 
r.category }}</span>
+              <span class="be-page mono dim" :title="r.pagePath">{{ r.pagePath 
|| '—' }}</span>
+              <span class="be-ver mono dim" :title="r.serviceVersion">{{ 
r.serviceVersion || '—' }}</span>
+              <span class="be-msg mono">
+                <span v-if="rowLoc(r)" class="be-loc">{{ rowLoc(r) }}</span>
+                <span class="be-msg-body">{{ r.message || t('(no message)') 
}}</span>
+              </span>
+            </div>
+          </div>
+        </div>
+      </article>
+    </div>
+
+    <div v-else 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>
@@ -464,6 +670,8 @@ watch(rows, (next) => {
     <!-- 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)" />
+    <!-- Browser row → browser-error popout (raw stack + source-map resolve). 
-->
+    <BrowserErrorPopout :row="selectedBrowserRow" :maps="sourceMaps" 
@close="closeDetail" />
   </div>
 </template>
 
@@ -530,4 +738,32 @@ watch(rows, (next) => {
 .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); }
+.dim { color: var(--sw-fg-3); }
+
+/* Browser-error list rows — same dense grid vocabulary as the per-layer
+   Browser Logs stream so the two read identically. */
+.be-stream { font-size: 11.5px; }
+.be-row {
+  display: grid;
+  grid-template-columns: 76px 40px 74px 150px 90px 1fr;
+  gap: 10px;
+  align-items: center;
+  padding: 4px 12px;
+  border-bottom: 1px solid var(--sw-line);
+  cursor: pointer;
+}
+.be-row:hover, .be-row.is-open { background: var(--sw-bg-2); }
+.be-time { color: var(--sw-fg-1); }
+.be-date { font-size: 10px; }
+.be-cat { font-size: 9.5px; text-transform: uppercase; letter-spacing: 0.06em; 
font-weight: 700; }
+.be-page, .be-ver { font-size: 10.5px; overflow: hidden; text-overflow: 
ellipsis; white-space: nowrap; }
+.be-msg {
+  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;
+}
+.be-loc {
+  flex: 0 0 auto; font-size: 9.5px; color: var(--sw-fg-3); background: 
var(--sw-bg-3);
+  border-radius: 3px; padding: 0 5px; font-variant-numeric: tabular-nums;
+}
+.be-msg-body { flex: 1; min-width: 0; overflow: hidden; text-overflow: 
ellipsis; white-space: nowrap; }
 </style>
diff --git a/apps/ui/src/i18n/locales/de.json b/apps/ui/src/i18n/locales/de.json
index 773280d..acc4ad3 100644
--- a/apps/ui/src/i18n/locales/de.json
+++ b/apps/ui/src/i18n/locales/de.json
@@ -1456,6 +1456,17 @@
   "Any layer": "Beliebiger Layer",
   "Any service": "Beliebiger Service",
   "Browser": "Browser",
+  "Browser app": "Browser-App",
+  "Browser error": "Browser-Fehler",
+  "Browser errors": "Browser-Fehler",
+  "Category": "Kategorie",
+  "All categories": "Alle Kategorien",
+  "No BROWSER layer": "Keine BROWSER-Ebene",
+  "Pick a browser service": "Browser-Service auswählen",
+  "Pick a browser service and run a query.": "Wähle einen Browser-Service und 
führe eine Abfrage aus.",
+  "Pod logs": "Pod-Logs",
+  "required — pick a BROWSER service": "erforderlich — BROWSER-Service 
auswählen",
+  "errors": "Fehler",
   "Copy": "Kopieren",
   "Key": "Schlüssel",
   "Keywords": "Schlüsselwörter",
diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json
index 74cee77..add41c4 100644
--- a/apps/ui/src/i18n/locales/en.json
+++ b/apps/ui/src/i18n/locales/en.json
@@ -1463,12 +1463,23 @@
   "Any layer": "Any layer",
   "Any service": "Any service",
   "Browser": "Browser",
+  "Browser app": "Browser app",
+  "Browser error": "Browser error",
+  "Browser errors": "Browser errors",
+  "Category": "Category",
+  "All categories": "All categories",
   "Copy": "Copy",
   "Key": "Key",
   "Keywords": "Keywords",
   "Log entry": "Log entry",
   "Log inspect": "Log inspect",
+  "No BROWSER layer": "No BROWSER layer",
   "No logs in this window.": "No logs in this window.",
+  "Pick a browser service": "Pick a browser service",
+  "Pick a browser service and run a query.": "Pick a browser service and run a 
query.",
+  "Pod logs": "Pod logs",
+  "required — pick a BROWSER service": "required — pick a BROWSER service",
+  "errors": "errors",
   "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",
diff --git a/apps/ui/src/i18n/locales/es.json b/apps/ui/src/i18n/locales/es.json
index 5b94171..d663893 100644
--- a/apps/ui/src/i18n/locales/es.json
+++ b/apps/ui/src/i18n/locales/es.json
@@ -1456,6 +1456,17 @@
   "Any layer": "Cualquier Layer",
   "Any service": "Cualquier Service",
   "Browser": "Browser",
+  "Browser app": "Aplicación de Browser",
+  "Browser error": "Error de Browser",
+  "Browser errors": "Errores de Browser",
+  "Category": "Categoría",
+  "All categories": "Todas las categorías",
+  "No BROWSER layer": "Sin capa BROWSER",
+  "Pick a browser service": "Elige un servicio de browser",
+  "Pick a browser service and run a query.": "Elige un servicio de browser y 
ejecuta una consulta.",
+  "Pod logs": "Logs de pod",
+  "required — pick a BROWSER service": "obligatorio — elige un servicio 
BROWSER",
+  "errors": "errores",
   "Copy": "Copiar",
   "Key": "Clave",
   "Keywords": "Palabras clave",
diff --git a/apps/ui/src/i18n/locales/fr.json b/apps/ui/src/i18n/locales/fr.json
index 8e34963..b914685 100644
--- a/apps/ui/src/i18n/locales/fr.json
+++ b/apps/ui/src/i18n/locales/fr.json
@@ -1456,6 +1456,17 @@
   "Any layer": "N'importe quel Layer",
   "Any service": "N'importe quel Service",
   "Browser": "Browser",
+  "Browser app": "Application Browser",
+  "Browser error": "Erreur Browser",
+  "Browser errors": "Erreurs Browser",
+  "Category": "Catégorie",
+  "All categories": "Toutes les catégories",
+  "No BROWSER layer": "Aucune couche BROWSER",
+  "Pick a browser service": "Choisir un service browser",
+  "Pick a browser service and run a query.": "Choisissez un service browser et 
lancez une requête.",
+  "Pod logs": "Logs de pod",
+  "required — pick a BROWSER service": "requis — choisir un service BROWSER",
+  "errors": "erreurs",
   "Copy": "Copier",
   "Key": "Clé",
   "Keywords": "Mots-clés",
diff --git a/apps/ui/src/i18n/locales/ja.json b/apps/ui/src/i18n/locales/ja.json
index ed9e529..bc21b28 100644
--- a/apps/ui/src/i18n/locales/ja.json
+++ b/apps/ui/src/i18n/locales/ja.json
@@ -1456,6 +1456,17 @@
   "Any layer": "任意の Layer",
   "Any service": "任意の Service",
   "Browser": "Browser",
+  "Browser app": "Browser アプリ",
+  "Browser error": "Browser エラー",
+  "Browser errors": "Browser エラー",
+  "Category": "カテゴリ",
+  "All categories": "すべてのカテゴリ",
+  "No BROWSER layer": "BROWSER レイヤーがありません",
+  "Pick a browser service": "browser サービスを選択",
+  "Pick a browser service and run a query.": "browser サービスを選択してクエリを実行してください。",
+  "Pod logs": "Pod ログ",
+  "required — pick a BROWSER service": "必須 — BROWSER サービスを選択",
+  "errors": "件のエラー",
   "Copy": "コピー",
   "Key": "キー",
   "Keywords": "キーワード",
diff --git a/apps/ui/src/i18n/locales/ko.json b/apps/ui/src/i18n/locales/ko.json
index 590be15..d435f86 100644
--- a/apps/ui/src/i18n/locales/ko.json
+++ b/apps/ui/src/i18n/locales/ko.json
@@ -1456,6 +1456,17 @@
   "Any layer": "모든 Layer",
   "Any service": "모든 Service",
   "Browser": "Browser",
+  "Browser app": "Browser 앱",
+  "Browser error": "Browser 오류",
+  "Browser errors": "Browser 오류",
+  "Category": "카테고리",
+  "All categories": "모든 카테고리",
+  "No BROWSER layer": "BROWSER 레이어 없음",
+  "Pick a browser service": "browser 서비스 선택",
+  "Pick a browser service and run a query.": "browser 서비스를 선택하고 쿼리를 실행하세요.",
+  "Pod logs": "Pod 로그",
+  "required — pick a BROWSER service": "필수 — BROWSER 서비스를 선택하세요",
+  "errors": "개 오류",
   "Copy": "복사",
   "Key": "키",
   "Keywords": "키워드",
diff --git a/apps/ui/src/i18n/locales/pt.json b/apps/ui/src/i18n/locales/pt.json
index 38d7de1..f4dd05f 100644
--- a/apps/ui/src/i18n/locales/pt.json
+++ b/apps/ui/src/i18n/locales/pt.json
@@ -1456,6 +1456,17 @@
   "Any layer": "Qualquer Layer",
   "Any service": "Qualquer Service",
   "Browser": "Browser",
+  "Browser app": "Aplicação de Browser",
+  "Browser error": "Erro de Browser",
+  "Browser errors": "Erros de Browser",
+  "Category": "Categoria",
+  "All categories": "Todas as categorias",
+  "No BROWSER layer": "Sem camada BROWSER",
+  "Pick a browser service": "Escolha um serviço de browser",
+  "Pick a browser service and run a query.": "Escolha um serviço de browser e 
execute uma consulta.",
+  "Pod logs": "Logs de pod",
+  "required — pick a BROWSER service": "obrigatório — escolha um serviço 
BROWSER",
+  "errors": "erros",
   "Copy": "Copiar",
   "Key": "Chave",
   "Keywords": "Palavras-chave",
diff --git a/apps/ui/src/i18n/locales/zh-CN.json 
b/apps/ui/src/i18n/locales/zh-CN.json
index 8df8bef..9f8d8af 100644
--- a/apps/ui/src/i18n/locales/zh-CN.json
+++ b/apps/ui/src/i18n/locales/zh-CN.json
@@ -1456,6 +1456,17 @@
   "Any layer": "任意 Layer",
   "Any service": "任意 Service",
   "Browser": "Browser",
+  "Browser app": "Browser 应用",
+  "Browser error": "Browser 错误",
+  "Browser errors": "Browser 错误",
+  "Category": "类别",
+  "All categories": "全部类别",
+  "No BROWSER layer": "无 BROWSER 层",
+  "Pick a browser service": "选择一个 browser 服务",
+  "Pick a browser service and run a query.": "选择一个 browser 服务并运行查询。",
+  "Pod logs": "Pod 日志",
+  "required — pick a BROWSER service": "必填 — 选择一个 BROWSER 服务",
+  "errors": "条错误",
   "Copy": "复制",
   "Key": "键",
   "Keywords": "关键字",
diff --git a/apps/ui/src/render/widgets/BrowserErrorPopout.vue 
b/apps/ui/src/render/widgets/BrowserErrorPopout.vue
new file mode 100644
index 0000000..7cb2567
--- /dev/null
+++ b/apps/ui/src/render/widgets/BrowserErrorPopout.vue
@@ -0,0 +1,249 @@
+<!--
+  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.
+-->
+<!--
+  Browser-error detail popout — the Browser sibling of LogDetailPopout, for
+  the cross-layer Log inspect view. Shows one BrowserErrorRow's meta + the
+  raw stack alongside the source-map de-obfuscation control (pick a hosted
+  map → resolve → original frames + source snippet). The resolve flow mirrors
+  the per-layer Browser Logs tab; the host owns the maps list (so it can also
+  render the SourceMapManager), this popout owns the per-row resolve call.
+
+  Built on `_shared/Modal.vue` (backdrop + Escape + close).
+
+  Props:
+    row     — the BrowserErrorRow to show; `null` keeps the popout closed.
+    maps    — the hosted source maps the operator can resolve against.
+    enabled — whether source-map de-obfuscation is on (server flag).
+  Emits:
+    close — dismiss (× button, Escape, backdrop).
+-->
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import type { BrowserErrorRow, ResolveResponse, SourceMapDescriptor } from 
'@/api/client';
+import { bffClient, describeApiError } from '@/api/client';
+import Modal from '@/features/operate/_shared/Modal.vue';
+
+const { t } = useI18n();
+
+const props = defineProps<{
+  row: BrowserErrorRow | null;
+  maps: SourceMapDescriptor[];
+}>();
+const emit = defineEmits<{ (e: 'close'): void }>();
+
+const selectedMapId = ref<string>('');
+const resolved = ref<ResolveResponse | null>(null);
+const resolveBusy = ref(false);
+const resolveErr = ref<string | null>(null);
+
+// A fresh row drops the prior resolution and auto-seeds the first map so
+// the operator can resolve in one click. Same default as the per-layer tab.
+watch(
+  () => props.row,
+  (row) => {
+    resolved.value = null;
+    resolveErr.value = null;
+    if (row && !selectedMapId.value && props.maps.length > 0) 
selectedMapId.value = props.maps[0].id;
+  },
+);
+
+async function resolveRow(): Promise<void> {
+  const row = props.row;
+  if (!row || !selectedMapId.value) return;
+  resolveBusy.value = true;
+  resolveErr.value = null;
+  resolved.value = null;
+  try {
+    const res = await bffClient.browserErrors.resolve({
+      stack: row.stack ?? undefined,
+      line: row.line ?? undefined,
+      col: row.col ?? undefined,
+      errorUrl: row.errorUrl ?? undefined,
+      category: row.category,
+      sourceMapId: selectedMapId.value,
+    });
+    resolved.value = res;
+    if (!res.ok) resolveErr.value = t('Could not resolve: {reason}', { reason: 
res.error ?? t('unknown') });
+  } catch (err) {
+    resolveErr.value = describeApiError(err);
+  } finally {
+    resolveBusy.value = false;
+  }
+}
+
+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())}`;
+}
+// Browser line/col are 1-based; OAP reports 0 for categories carrying no
+// top-level position (PROMISE/AJAX/…), so a positive line is the signal.
+const loc = computed<string>(() => {
+  const r = props.row;
+  if (!r?.line) return '';
+  return `${r.line}:${r.col ?? 0}`;
+});
+</script>
+
+<template>
+  <Modal :open="row != null" :title="t('Browser error')" width="min(1100px, 
92vw)" fit-body @close="emit('close')">
+    <div v-if="row" class="be">
+      <div class="be-head">
+        <div class="be-head-l">
+          <span class="be-time mono">{{ fmtTime(row.time) }} · {{ 
fmtDate(row.time) }}</span>
+          <span class="be-cat">{{ row.category }}</span>
+          <span v-if="loc" class="be-loc mono">{{ loc }}</span>
+        </div>
+      </div>
+      <div class="be-meta">
+        <span class="be-msg">{{ row.message || t('(no message)') }}</span>
+      </div>
+      <div class="be-meta be-meta-sub">
+        <span v-if="row.pagePath" class="be-meta-item">{{ t('Page') }} <code 
class="mono" :title="row.pagePath">{{ row.pagePath }}</code></span>
+        <span v-if="row.serviceVersion" class="be-meta-item">{{ t('Version') 
}} <code class="mono">{{ row.serviceVersion }}</code></span>
+        <span v-if="row.errorUrl" class="be-meta-item">{{ t('URL') }} <code 
class="mono" :title="row.errorUrl">{{ row.errorUrl }}</code></span>
+        <span v-if="row.grade" class="be-meta-item">{{ t('grade') }} <code>{{ 
row.grade }}</code></span>
+        <span v-if="row.firstReportedError" class="be-meta-item be-first">{{ 
t('first occurrence') }}</span>
+      </div>
+
+      <div class="be-cols">
+        <div class="be-col">
+          <div class="be-col-head">{{ t('Raw stack') }}</div>
+          <pre class="be-pre">{{ row.stack || t('(no stack)') }}</pre>
+        </div>
+        <div class="be-col">
+          <div class="be-col-head">
+            <span>{{ t('Resolved') }}</span>
+            <select v-model="selectedMapId" class="be-map-pick" 
:disabled="maps.length === 0">
+              <option value="" disabled>{{ maps.length ? t('Pick a source 
map…') : t('No maps loaded') }}</option>
+              <option v-for="m in maps" :key="m.id" :value="m.id">
+                {{ m.label }}{{ m.origin === 'upload' ? ' ' + t('(temp)') : '' 
}}
+              </option>
+            </select>
+            <button class="be-btn primary" type="button" 
:disabled="!selectedMapId || resolveBusy" @click="resolveRow">{{ t('Resolve') 
}}</button>
+          </div>
+          <p v-if="resolveErr" class="be-err">{{ resolveErr }}</p>
+          <p v-else-if="resolveBusy" class="be-dim">{{ t('Resolving…') }}</p>
+          <div v-else-if="resolved && resolved.ok" class="be-frames">
+            <div v-for="(f, fi) in resolved.frames" :key="fi" class="be-frame">
+              <div class="be-frame-line">
+                <span class="be-frame-fn">{{ f.generated.fn || 
t('(anonymous)') }}</span>
+                <span v-if="f.original" class="be-frame-orig">
+                  {{ f.original.source }}:{{ f.original.line }}:{{ 
f.original.col }}
+                  <span v-if="f.original.name" class="be-frame-name">→ {{ 
f.original.name }}</span>
+                </span>
+                <span v-else class="be-frame-unmapped">{{ t('unmapped 
({line}:{col})', { line: f.generated.line, col: f.generated.col }) }}</span>
+              </div>
+              <pre v-if="f.snippet" class="be-snippet"><code v-for="(ln, li) 
in f.snippet.lines" :key="li" class="be-snip-line" :class="{ 'is-hit': 
f.original && f.snippet.startLine + li === f.original.line }"><span 
class="be-snip-no">{{ f.snippet.startLine + li }}</span><span 
class="be-snip-code">{{ ln }}</span></code></pre>
+            </div>
+          </div>
+          <p v-else class="be-dim">{{ t('Pick a source map and resolve to 
de-obfuscate this stack.') }}</p>
+        </div>
+      </div>
+    </div>
+  </Modal>
+</template>
+
+<style scoped>
+.be { flex: 1; display: flex; flex-direction: column; min-height: 0; gap: 8px; 
}
+.be-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;
+}
+.be-head-l { display: inline-flex; align-items: center; gap: 10px; min-width: 
0; }
+.be-time { font-size: 11px; color: var(--sw-fg-2); }
+.be-cat {
+  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;
+}
+.be-loc {
+  font-size: 10px; color: var(--sw-fg-3); background: var(--sw-bg-3);
+  border-radius: 3px; padding: 0 5px; font-variant-numeric: tabular-nums;
+}
+.be-meta { display: flex; flex-wrap: wrap; gap: 12px; flex: 0 0 auto; }
+.be-msg { font-family: var(--sw-mono); font-size: 12px; color: var(--sw-fg-0); 
word-break: break-word; }
+.be-meta-sub {
+  padding-bottom: 8px; border-bottom: 1px dashed var(--sw-line);
+  font-size: 11px; color: var(--sw-fg-3);
+}
+.be-meta-item code {
+  font-family: var(--sw-mono); color: var(--sw-fg-1);
+  padding: 0 4px; background: var(--sw-bg-2); border-radius: 3px;
+  max-width: 360px; overflow: hidden; text-overflow: ellipsis; white-space: 
nowrap;
+  display: inline-block; vertical-align: bottom;
+}
+.be-first { color: var(--sw-warn); }
+.be-cols { flex: 1; min-height: 0; display: grid; grid-template-columns: 1fr 
1fr; gap: 12px; }
+.be-col { display: flex; flex-direction: column; gap: 4px; min-width: 0; 
min-height: 0; }
+.be-col-head { display: flex; align-items: center; gap: 6px; min-height: 28px; 
font-size: 11px; font-weight: 600; color: var(--sw-fg-2); }
+.be-map-pick {
+  margin-left: auto; max-width: 220px; height: 26px; padding: 0 6px;
+  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;
+}
+.be-map-pick:disabled { opacity: 0.5; cursor: not-allowed; }
+.be-pre {
+  flex: 1; min-height: 0; margin: 0; background: var(--sw-bg-0);
+  border: 1px solid var(--sw-line); border-radius: 4px; padding: 8px;
+  font-family: var(--sw-mono); font-size: 11px; line-height: 1.5; color: 
var(--sw-fg-1);
+  overflow: auto; white-space: pre;
+}
+.be-frames { flex: 1; min-height: 0; overflow-y: auto; display: flex; 
flex-direction: column; gap: 8px; }
+.be-frame { display: flex; flex-direction: column; border: 1px solid 
var(--sw-line); border-radius: 5px; overflow: hidden; background: 
var(--sw-bg-0); }
+.be-frame-line { font-family: var(--sw-mono); font-size: 11px; display: flex; 
flex-wrap: wrap; gap: 8px; align-items: baseline; padding: 5px 9px; background: 
var(--sw-bg-1); }
+.be-frame-fn { color: var(--sw-fg-0); font-weight: 600; }
+.be-frame-orig { color: var(--sw-cyan); }
+.be-frame-name { color: var(--sw-info); }
+.be-frame-unmapped { color: var(--sw-fg-3); margin-left: auto; font-style: 
italic; }
+.be-snippet {
+  display: flex; flex-direction: column; margin: 0;
+  font-family: var(--sw-mono); font-size: 11px; line-height: 1.6;
+  border-top: 1px solid var(--sw-line); overflow-x: auto;
+}
+.be-snip-line { display: flex; white-space: pre; }
+.be-snip-no {
+  flex: 0 0 auto; width: 30px; text-align: right; padding: 0 8px;
+  color: var(--sw-fg-3); background: var(--sw-bg-1); border-right: 1px solid 
var(--sw-line);
+  user-select: none;
+}
+.be-snip-code { padding: 0 10px; color: var(--sw-fg-1); white-space: pre; }
+.be-snip-line.is-hit { background: color-mix(in srgb, var(--sw-accent) 13%, 
transparent); }
+.be-snip-line.is-hit .be-snip-no { color: var(--sw-accent-2); background: 
color-mix(in srgb, var(--sw-accent) 22%, transparent); font-weight: 700; }
+.be-snip-line.is-hit .be-snip-code { color: var(--sw-fg-0); }
+.be-err { margin: 0; font-size: 11.5px; color: var(--sw-err); }
+.be-dim { color: var(--sw-fg-3); font-size: 11px; padding: 4px 0; }
+.be-btn {
+  height: 26px; padding: 0 12px; border-radius: 4px; font: inherit; font-size: 
11px;
+  font-weight: 500; border: 1px solid var(--sw-line-2); background: 
var(--sw-bg-2);
+  color: var(--sw-fg-1); cursor: pointer;
+}
+.be-btn:hover { color: var(--sw-fg-0); border-color: var(--sw-line); }
+.be-btn:disabled { opacity: 0.5; cursor: not-allowed; }
+.be-btn.primary { background: var(--sw-accent); color: var(--sw-bg-0); border: 
none; font-weight: 600; }
+.be-btn.primary:hover { background: var(--sw-accent-2); color: var(--sw-bg-0); 
}
+.mono { font-family: var(--sw-mono); }
+@media (max-width: 900px) {
+  .be-cols { grid-template-columns: 1fr; }
+}
+</style>

Reply via email to