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>
