You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by wu...@apache.org on 2026/06/23 16:43:34 UTC
(skywalking-horizon-ui) 15/18: feat(explore): Log inspect browser-error source
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 <wu...@foxmail.com>
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>