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/11 02:41:45 UTC

(skywalking-horizon-ui) 01/01: feat(layer): Browser Logs tab — JS error logs + source-map de-obfuscation

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

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

commit 20f4f06c40dd541d486399eb089a3f1153f7bd21
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Thu Jun 11 10:41:20 2026 +0800

    feat(layer): Browser Logs tab — JS error logs + source-map de-obfuscation
    
    Add a Browser Logs tab to the BROWSER layer that lists queryBrowserErrorLogs
    rows and resolves minified JS stacks against source maps. Maps are held in BFF
    process memory: uploaded .map files are temporary (evicted least-recently-used
    past a configurable budget, lost on restart), while a static mount provides
    durable provisioning. Resolution runs server-side via @jridgewell/trace-mapping,
    so maps and their sourcesContent never ship to the browser.
    
    Implements apache/skywalking#6784 entirely in Horizon UI — no OAP, agent, or
    query-protocol change.
    
    - BFF: POST /api/layer/:key/browser-errors (SECOND step, OAP-server-local time,
      per-page range, DESC re-sort for BanyanDB segment order); source-map admin
      routes (multipart upload / list / delete / resolve); a process-global
      SourceMapStore with budget accounting, LRU eviction, lazy mount reads, and a
      bounded parsed-map cache.
    - UI: layer/browser-errors view + composable + embedded source-map manager; tab
      wiring across sidebar / router / caps / topbar; the tab owns its time range
      and pauses auto-refresh.
    - Config: new sourceMaps block (defaults 64 MiB/file, 512 MiB total); RBAC verbs
      browser-errors:read (list/resolve) and source-map:write (upload/delete).
    - i18n: every operator string wrapped; 44 keys added to English and translated
      into the 7 other catalogs.
    - Docs: operate/browser-source-maps.md (+ menu entry), plus horizon.yaml,
      container-image, and RBAC updates. The bundled BROWSER template gains the
      browserErrors widget; the generic logs tab is disabled there.
---
 .claude/skills/local-boot/horizon.demo.yaml        |    5 +
 .claude/skills/local-boot/horizon.local.yaml       |    4 +
 CHANGELOG.md                                       |   19 +
 Dockerfile                                         |    8 +-
 apps/bff/package.json                              |    3 +
 apps/bff/src/bundled_templates/layers/browser.json |    3 +-
 apps/bff/src/config/schema.ts                      |   43 +
 apps/bff/src/http/admin/source-maps.test.ts        |  189 ++++
 apps/bff/src/http/admin/source-maps.ts             |  167 ++++
 apps/bff/src/http/query/browser-errors.ts          |  246 +++++
 apps/bff/src/http/query/menu.ts                    |    3 +-
 apps/bff/src/logic/browser-errors/resolve.test.ts  |  136 +++
 apps/bff/src/logic/browser-errors/resolve.ts       |  122 +++
 apps/bff/src/logic/browser-errors/store.test.ts    |  181 ++++
 apps/bff/src/logic/browser-errors/store.ts         |  382 ++++++++
 apps/bff/src/logic/layers/loader.ts                |    2 +
 apps/bff/src/rbac/route-policy.ts                  |    9 +
 apps/bff/src/rbac/verbs.ts                         |    3 +
 apps/bff/src/server.ts                             |   21 +
 apps/ui/src/api/client.ts                          |   61 ++
 apps/ui/src/api/scopes/browser-errors.ts           |   67 ++
 .../admin/layer-templates/LayerDashboardsAdmin.vue |    6 +-
 apps/ui/src/i18n/locales/de.json                   |   46 +-
 apps/ui/src/i18n/locales/en.json                   |   46 +-
 apps/ui/src/i18n/locales/es.json                   |   46 +-
 apps/ui/src/i18n/locales/fr.json                   |   46 +-
 apps/ui/src/i18n/locales/ja.json                   |   46 +-
 apps/ui/src/i18n/locales/ko.json                   |   46 +-
 apps/ui/src/i18n/locales/pt.json                   |   46 +-
 apps/ui/src/i18n/locales/zh-CN.json                |   46 +-
 apps/ui/src/layer/LayerShell.vue                   |    1 +
 .../browser-errors/LayerBrowserErrorsView.vue      | 1005 ++++++++++++++++++++
 .../src/layer/browser-errors/SourceMapManager.vue  |  268 ++++++
 .../layer/browser-errors/useLayerBrowserErrors.ts  |   88 ++
 apps/ui/src/shell/AppSidebar.vue                   |   18 +-
 apps/ui/src/shell/AppTopbar.vue                    |    3 +
 apps/ui/src/shell/layerFromTemplate.ts             |    1 +
 apps/ui/src/shell/router/index.ts                  |    2 +
 apps/ui/src/shell/useLayers.ts                     |    1 +
 docs/access-control/rbac.md                        |    5 +-
 docs/menu.yml                                      |    2 +
 docs/operate/browser-source-maps.md                |  140 +++
 docs/setup/container-image.md                      |    2 +
 docs/setup/horizon-yaml.md                         |    7 +-
 horizon.example.yaml                               |   22 +
 packages/api-client/src/browser-errors.ts          |  168 ++++
 packages/api-client/src/index.ts                   |   14 +
 packages/api-client/src/menu.ts                    |    3 +
 pnpm-lock.yaml                                     |   44 +
 49 files changed, 3826 insertions(+), 16 deletions(-)

diff --git a/.claude/skills/local-boot/horizon.demo.yaml b/.claude/skills/local-boot/horizon.demo.yaml
index 2185a52..f68f222 100644
--- a/.claude/skills/local-boot/horizon.demo.yaml
+++ b/.claude/skills/local-boot/horizon.demo.yaml
@@ -51,6 +51,7 @@ rbac:
       - "alarms:read"
       - "traces:read"
       - "logs:read"
+      - "browser-errors:read"
       - "topology:read"
       - "profile:read"
       - "overview:read"
@@ -59,6 +60,7 @@ rbac:
       - "alarms:read"
       - "traces:read"
       - "logs:read"
+      - "browser-errors:read"
       - "topology:read"
       - "profile:read"
       - "overview:read"
@@ -71,6 +73,8 @@ rbac:
       - "alarms:read"
       - "traces:read"
       - "logs:read"
+      - "browser-errors:read"
+      - "source-map:write"
       - "topology:read"
       - "profile:read"
       - "cluster:read"
@@ -112,3 +116,4 @@ audit:    { file: ./horizon-audit.jsonl }
 setup:    { file: ./horizon-setup.json }
 alarms:   { file: ./horizon-alarms.json }
 debugLog: { enabled: false, file: ./horizon-wire.jsonl, maxBodyChars: 8192, redactAuthHeaders: true }
+sourceMaps: { enabled: true, maxFileBytes: 67108864, maxTotalBytes: 536870912, maxFileCount: 128, bootMountDir: "" }
diff --git a/.claude/skills/local-boot/horizon.local.yaml b/.claude/skills/local-boot/horizon.local.yaml
index 448d1b6..c21c19b 100644
--- a/.claude/skills/local-boot/horizon.local.yaml
+++ b/.claude/skills/local-boot/horizon.local.yaml
@@ -55,6 +55,7 @@ rbac:
       - "alarms:read"
       - "traces:read"
       - "logs:read"
+      - "browser-errors:read"
       - "topology:read"
       - "profile:read"
       - "overview:read"
@@ -63,6 +64,7 @@ rbac:
       - "alarms:read"
       - "traces:read"
       - "logs:read"
+      - "browser-errors:read"
       - "topology:read"
       - "profile:read"
       - "overview:read"
@@ -75,6 +77,8 @@ rbac:
       - "alarms:read"
       - "traces:read"
       - "logs:read"
+      - "browser-errors:read"
+      - "source-map:write"
       - "topology:read"
       - "profile:read"
       - "cluster:read"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ae6451c..136d5af 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,25 @@ packages) plus the BFF's `HORIZON_VERSION` default.
 
 ## 0.7.0
 
+### Browser errors & source maps
+
+- **New "Browser Logs" tab on the BROWSER layer** — lists the JS error logs
+  the browser agent reports (message, category, page, app version, time, and
+  the minified `line:col`), filterable by category and time window. Expanding a
+  row shows the raw stack alongside a de-obfuscated view.
+- **Source-map de-obfuscation (issue [#6784](https://github.com/apache/skywalking/issues/6784)).**
+  Upload a `.map` file from the tab and resolve any error's minified stack back
+  to the original source — file, line, column, symbol name, and a source
+  snippet — by picking which map to apply. Maps are held in the BFF's **memory
+  only** (no backend storage): they're surfaced as *temporary*, evicted
+  least-recently-used when the configured budget is hit, and lost on restart. For durable
+  provisioning, mount `.map` files into the server's static source-map directory
+  (`HORIZON_SOURCEMAPS_DIR`, `/app/sourcemaps` in the image) — those reload
+  automatically and can't be deleted from the UI. Budgets are configurable via
+  the new `sourceMaps` block in `horizon.yaml` (per-file and total in-memory
+  caps; defaults 64 MiB / 512 MiB). Upload/delete require the new
+  `source-map:write` permission; viewing + resolving ride on `browser-errors:read`.
+
 ### Layers
 
 - **Every layer OAP reports now appears in the sidebar**, including ones
diff --git a/Dockerfile b/Dockerfile
index b54ffb2..d77e34b 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -65,6 +65,11 @@ COPY --from=build --chown=horizon:horizon /src/dist/bundled_templates  ./bundled
 RUN mkdir -p /data && chown horizon:horizon /data
 VOLUME ["/data"]
 
+# Static source-map mount point for the Browser Errors tab (#6784). Drop
+# (or bind-mount) `.map` files here and they're indexed at boot — durable
+# across restarts, unlike runtime uploads which live in memory only.
+RUN mkdir -p /app/sourcemaps && chown horizon:horizon /app/sourcemaps
+
 ENV NODE_ENV=production \
     HORIZON_SERVER_HOST=0.0.0.0 \
     HORIZON_SERVER_PORT=8081 \
@@ -73,7 +78,8 @@ ENV NODE_ENV=production \
     HORIZON_AUDIT_FILE=/data/horizon-audit.jsonl \
     HORIZON_SETUP_FILE=/data/horizon-setup.json \
     HORIZON_ALARMS_FILE=/data/horizon-alarms.json \
-    HORIZON_WIRE_LOG_FILE=/data/horizon-wire.jsonl
+    HORIZON_WIRE_LOG_FILE=/data/horizon-wire.jsonl \
+    HORIZON_SOURCEMAPS_DIR=/app/sourcemaps
 
 USER horizon
 EXPOSE 8081
diff --git a/apps/bff/package.json b/apps/bff/package.json
index f7136da..abfeb5a 100644
--- a/apps/bff/package.json
+++ b/apps/bff/package.json
@@ -21,7 +21,9 @@
   },
   "dependencies": {
     "@fastify/cookie": "^11.0.1",
+    "@fastify/multipart": "^10.0.0",
     "@fastify/static": "^9.1.1",
+    "@jridgewell/trace-mapping": "^0.3.31",
     "@skywalking-horizon-ui/api-client": "workspace:*",
     "argon2": "^0.41.1",
     "chokidar": "^4.0.1",
@@ -29,6 +31,7 @@
     "ldapts": "^8.1.7",
     "pino": "^9.5.0",
     "pino-pretty": "^11.3.0",
+    "stacktrace-parser": "^0.1.11",
     "yaml": "^2.6.0",
     "zod": "^3.23.8"
   },
diff --git a/apps/bff/src/bundled_templates/layers/browser.json b/apps/bff/src/bundled_templates/layers/browser.json
index f10dd31..b5246e1 100644
--- a/apps/bff/src/bundled_templates/layers/browser.json
+++ b/apps/bff/src/bundled_templates/layers/browser.json
@@ -14,7 +14,8 @@
     "endpoints": true,
     "topology": false,
     "traces": true,
-    "logs": true
+    "logs": false,
+    "browserErrors": true
   },
   "layer-header": {
     "orderBy": "pv",
diff --git a/apps/bff/src/config/schema.ts b/apps/bff/src/config/schema.ts
index ed17d7c..51f6fd6 100644
--- a/apps/bff/src/config/schema.ts
+++ b/apps/bff/src/config/schema.ts
@@ -174,6 +174,7 @@ const rbacSchema = z
           'alarms:read',
           'traces:read',
           'logs:read',
+          'browser-errors:read',
           'topology:read',
           'profile:read',
           'overview:read',
@@ -187,6 +188,7 @@ const rbacSchema = z
           'alarms:read',
           'traces:read',
           'logs:read',
+          'browser-errors:read',
           'topology:read',
           'profile:read',
           'overview:read',
@@ -204,6 +206,8 @@ const rbacSchema = z
           'alarms:read',
           'traces:read',
           'logs:read',
+          'browser-errors:read',
+          'source-map:write',
           'topology:read',
           'profile:read',
           'cluster:read',
@@ -319,6 +323,43 @@ const querySchema = z
   .strict()
   .default({ landingServiceCap: 100 });
 
+// JS source maps for de-obfuscating BROWSER-layer error stacks (#6784).
+// Maps live in the BFF process heap — there is NO OAP-side storage — so
+// the budgets below bound a per-instance, intentionally-ephemeral cache.
+// `HORIZON_SOURCEMAPS_DIR` is set by the Docker image to /app/sourcemaps
+// so a mounted maps directory is picked up with zero YAML.
+const sourceMapsDirDefault = process.env.HORIZON_SOURCEMAPS_DIR ?? '';
+const sourceMapsSchema = z
+  .object({
+    /** Master switch for the upload / static-mount / resolve capability.
+     *  When false the Browser Errors tab still lists errors, but the map
+     *  controls are hidden and the source-map routes reject. */
+    enabled: z.boolean().default(true),
+    /** Reject any single `.map` larger than this (upload or mount). Maps
+     *  carrying `sourcesContent` are commonly 5–40 MiB; large bundles run
+     *  bigger. Default 64 MiB. */
+    maxFileBytes: z.number().int().positive().default(64 * 1024 * 1024),
+    /** Budget for the resident UPLOADED maps (raw `.map` bytes in the Node
+     *  heap). An upload bigger than this is rejected; past it, least-recently-
+     *  used uploads are evicted. A small parsed-map cache rides on top
+     *  (bounded by count + this budget), so plan ~2x headroom; mounted maps
+     *  are disk-backed and don't count. Lowering it trims on the next
+     *  upload / resolve / list. Default 512 MiB. */
+    maxTotalBytes: z.number().int().positive().default(512 * 1024 * 1024),
+    /** Cap on the NUMBER of maps held, independent of bytes — bounds the
+     *  in-memory uploaded set (LRU-evicted past it) and the count of
+     *  statically-mounted maps indexed at boot (the rest are skipped).
+     *  Default 128. */
+    maxFileCount: z.number().int().positive().default(128),
+    /** Directory scanned at boot for statically-provisioned `.map` files
+     *  (Docker/k8s mount). Disk-backed: evictable from memory but reloaded
+     *  on demand, so they survive restarts and aren't deletable from the
+     *  UI. Empty disables the static mount. */
+    bootMountDir: z.string().default(sourceMapsDirDefault),
+  })
+  .strict()
+  .default({});
+
 // Layers hidden from the sidebar / menu even when OAP reports them in
 // `listLayers`. Config-driven (replaces a former hard-coded hide list): an
 // operator can clear `excluded` to surface every reported layer, or add keys
@@ -357,6 +398,7 @@ export const configSchema = z
     alarms: alarmsSchema,
     debugLog: debugLogSchema,
     query: querySchema,
+    sourceMaps: sourceMapsSchema,
     // Deprecated + ignored. The 3D-map config moved to OAP (a template kind);
     // the old file-backed `infra3d.file` knob is gone. Accepted here (rather
     // than rejected by `.strict()`) so an existing config carrying the block
@@ -366,6 +408,7 @@ export const configSchema = z
   .strict();
 
 export type HorizonConfig = z.infer<typeof configSchema>;
+export type SourceMapsConfig = z.infer<typeof sourceMapsSchema>;
 export type LdapConfig = z.infer<typeof ldapSchema>;
 export type LocalUser = z.infer<typeof localUserSchema>;
 export type BreakGlassConfig = z.infer<typeof breakGlassSchema>;
diff --git a/apps/bff/src/http/admin/source-maps.test.ts b/apps/bff/src/http/admin/source-maps.test.ts
new file mode 100644
index 0000000..b431831
--- /dev/null
+++ b/apps/bff/src/http/admin/source-maps.test.ts
@@ -0,0 +1,189 @@
+/*
+ * 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.
+ */
+
+/**
+ * HTTP-surface acceptance for the source-map routes — the literal "the BFF
+ * api accepts a js + .map analysis" check: upload a real esbuild-emitted
+ * map over multipart, then resolve a stack against it and get original
+ * positions back. Also pins the RBAC gate (read vs write verb).
+ */
+
+import { describe, it, expect, beforeAll } from 'vitest';
+import Fastify, { type FastifyInstance } from 'fastify';
+import cookie from '@fastify/cookie';
+import fastifyMultipart from '@fastify/multipart';
+import { transform } from 'esbuild';
+import { configSchema } from '../../config/schema.js';
+import type { ConfigSource } from '../../config/loader.js';
+import { SessionStore } from '../../user/sessions.js';
+import { makeRouteAuthHook } from '../../rbac/route-policy.js';
+import { SourceMapStore } from '../../logic/browser-errors/store.js';
+import { registerSourceMapRoutes } from './source-maps.js';
+
+const SOURCE = [
+  'function greet(name) {',
+  '  const greeting = "HELLO_MARKER";',
+  '  return greeting + name;',
+  '}',
+  'globalThis.__greet = greet;',
+].join('\n');
+
+let MAP_JSON: string;
+let MARKER_COL0: number;
+
+beforeAll(async () => {
+  const out = await transform(SOURCE, {
+    loader: 'js',
+    sourcemap: true,
+    minify: true,
+    sourcesContent: true,
+    sourcefile: 'greet.js',
+  });
+  MAP_JSON = out.map;
+  MARKER_COL0 = out.code.indexOf('HELLO_MARKER');
+});
+
+function fakeConfig(): ConfigSource {
+  const cfg = configSchema.parse({});
+  return { current: cfg, current_: () => cfg, path: '', onChange: () => () => {}, close: async () => {} };
+}
+
+async function buildApp(
+  store: SourceMapStore,
+  fileSizeLimit = 64 * 1024 * 1024,
+): Promise<{ app: FastifyInstance; sessions: SessionStore }> {
+  const config = fakeConfig();
+  const sessions = new SessionStore({ ttlMinutes: 60 });
+  const app = Fastify();
+  await app.register(cookie);
+  await app.register(fastifyMultipart, { limits: { fileSize: fileSizeLimit, files: 1 } });
+  app.addHook('onRoute', makeRouteAuthHook({ config, sessions }));
+  registerSourceMapRoutes(app, { config, sessions, store });
+  await app.ready();
+  return { app, sessions };
+}
+
+function multipartBody(filename: string, content: string): { payload: string; contentType: string } {
+  const boundary = '----horizontest';
+  const payload = [
+    `--${boundary}`,
+    `Content-Disposition: form-data; name="file"; filename="${filename}"`,
+    'Content-Type: application/json',
+    '',
+    content,
+    `--${boundary}--`,
+    '',
+  ].join('\r\n');
+  return { payload, contentType: `multipart/form-data; boundary=${boundary}` };
+}
+
+describe('source-map routes — RBAC + upload', () => {
+  it('401 without a session', async () => {
+    const { app } = await buildApp(new SourceMapStore(() => ({ enabled: true, maxFileBytes: 1 << 20, maxTotalBytes: 1 << 30, maxFileCount: 100 })));
+    const { payload, contentType } = multipartBody('app.js.map', MAP_JSON);
+    const res = await app.inject({
+      method: 'POST',
+      url: '/api/browser-errors/source-maps',
+      headers: { 'content-type': contentType },
+      payload,
+    });
+    expect(res.statusCode).toBe(401);
+    await app.close();
+  });
+
+  it('403 for a reader without source-map:write (viewer)', async () => {
+    const { app, sessions } = await buildApp(new SourceMapStore(() => ({ enabled: true, maxFileBytes: 1 << 20, maxTotalBytes: 1 << 30, maxFileCount: 100 })));
+    const sid = sessions.create('v', ['viewer']).sid;
+    const { payload, contentType } = multipartBody('app.js.map', MAP_JSON);
+    const res = await app.inject({
+      method: 'POST',
+      url: '/api/browser-errors/source-maps',
+      headers: { 'content-type': contentType, cookie: `horizon_sid=${sid}` },
+      payload,
+    });
+    expect(res.statusCode).toBe(403);
+    // ...but a viewer can still list (read verb).
+    const list = await app.inject({ method: 'GET', url: '/api/browser-errors/source-maps', headers: { cookie: `horizon_sid=${sid}` } });
+    expect(list.statusCode).toBe(200);
+    await app.close();
+  });
+
+  it('413 when the upload exceeds the multipart fileSize limit', async () => {
+    const { app, sessions } = await buildApp(new SourceMapStore(() => ({ enabled: true, maxFileBytes: 16, maxTotalBytes: 1 << 30, maxFileCount: 100 })), 16);
+    const sid = sessions.create('op', ['operator']).sid;
+    const { payload, contentType } = multipartBody('app.js.map', MAP_JSON);
+    const res = await app.inject({
+      method: 'POST',
+      url: '/api/browser-errors/source-maps',
+      headers: { 'content-type': contentType, cookie: `horizon_sid=${sid}` },
+      payload,
+    });
+    expect(res.statusCode).toBe(413);
+    await app.close();
+  });
+});
+
+describe('source-map routes — upload then resolve (js + .map)', () => {
+  it('operator uploads a real map and resolves a stack to original source', async () => {
+    const store = new SourceMapStore(() => ({ enabled: true, maxFileBytes: 1 << 20, maxTotalBytes: 1 << 30, maxFileCount: 100 }));
+    const { app, sessions } = await buildApp(store);
+    const sid = sessions.create('op', ['operator']).sid;
+
+    const { payload, contentType } = multipartBody('app.js.map', MAP_JSON);
+    const up = await app.inject({
+      method: 'POST',
+      url: '/api/browser-errors/source-maps',
+      headers: { 'content-type': contentType, cookie: `horizon_sid=${sid}` },
+      payload,
+    });
+    expect(up.statusCode).toBe(200);
+    const upBody = up.json() as { ok: boolean; map: { id: string; label: string; origin: string } };
+    expect(upBody.ok).toBe(true);
+    expect(upBody.map.origin).toBe('upload');
+    const mapId = upBody.map.id;
+
+    const stack = `Error: boom\n    at greet (app.js:1:${MARKER_COL0 + 1})`;
+    const res = await app.inject({
+      method: 'POST',
+      url: '/api/browser-errors/resolve',
+      headers: { 'content-type': 'application/json', cookie: `horizon_sid=${sid}` },
+      payload: JSON.stringify({ stack, sourceMapId: mapId }),
+    });
+    expect(res.statusCode).toBe(200);
+    const body = res.json() as { ok: boolean; sourceMapLabel: string; frames: Array<{ original: { source: string; line: number } | null }> };
+    expect(body.ok).toBe(true);
+    expect(body.sourceMapLabel).toBe('app.js.map');
+    expect(body.frames[0]?.original?.line).toBe(2);
+    expect(body.frames[0]?.original?.source).toBe('greet.js');
+    await app.close();
+  });
+
+  it('resolve against an unknown map id returns a typed error, not a throw', async () => {
+    const store = new SourceMapStore(() => ({ enabled: true, maxFileBytes: 1 << 20, maxTotalBytes: 1 << 30, maxFileCount: 100 }));
+    const { app, sessions } = await buildApp(store);
+    const sid = sessions.create('op', ['operator']).sid;
+    const res = await app.inject({
+      method: 'POST',
+      url: '/api/browser-errors/resolve',
+      headers: { 'content-type': 'application/json', cookie: `horizon_sid=${sid}` },
+      payload: JSON.stringify({ stack: 'Error\n    at x (a.js:1:1)', sourceMapId: 'nope' }),
+    });
+    expect(res.statusCode).toBe(200);
+    expect((res.json() as { error: string }).error).toBe('unknown_map');
+    await app.close();
+  });
+});
diff --git a/apps/bff/src/http/admin/source-maps.ts b/apps/bff/src/http/admin/source-maps.ts
new file mode 100644
index 0000000..8f84e7d
--- /dev/null
+++ b/apps/bff/src/http/admin/source-maps.ts
@@ -0,0 +1,167 @@
+/*
+ * 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.
+ */
+
+/**
+ * Source-map management for the Browser Errors tab (#6784):
+ *
+ *   POST   /api/browser-errors/source-maps      multipart upload  (source-map:write)
+ *   GET    /api/browser-errors/source-maps      list + usage      (browser-errors:read)
+ *   DELETE /api/browser-errors/source-maps/:id  drop an upload    (source-map:write)
+ *   POST   /api/browser-errors/resolve          de-obfuscate      (browser-errors:read)
+ *
+ * Maps live in the in-memory `SourceMapStore`. Resolution is intentionally
+ * a query-time, operator-driven action: the caller picks the map id, so a
+ * build/version mismatch is the operator's call, not a silent guess.
+ */
+
+import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
+// Importing the type also pulls in @fastify/multipart's FastifyRequest
+// augmentation (`req.file()`), so the plugin's API is typed here.
+import type { MultipartFile } from '@fastify/multipart';
+import type {
+  ResolveRequest,
+  ResolveResponse,
+  SourceMapListResponse,
+  SourceMapUploadResponse,
+} from '@skywalking-horizon-ui/api-client';
+import type { ConfigSource } from '../../config/loader.js';
+import type { SessionStore } from '../../user/sessions.js';
+import { requireAuth } from '../../user/middleware.js';
+import type { SourceMapStore } from '../../logic/browser-errors/store.js';
+import { resolveStack } from '../../logic/browser-errors/resolve.js';
+
+export interface SourceMapRouteDeps {
+  config: ConfigSource;
+  sessions: SessionStore;
+  store: SourceMapStore;
+}
+
+export function registerSourceMapRoutes(app: FastifyInstance, deps: SourceMapRouteDeps): void {
+  const auth = requireAuth(deps);
+  const { store } = deps;
+
+  app.post(
+    '/api/browser-errors/source-maps',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      if (!store.enabled) {
+        return reply.code(403).send({ ok: false, error: 'disabled' } satisfies SourceMapUploadResponse);
+      }
+      let file: MultipartFile | undefined;
+      try {
+        file = await req.file();
+      } catch {
+        // The multipart fileSize limit trips here for oversized streams.
+        return reply.code(413).send({ ok: false, error: 'too_large' } satisfies SourceMapUploadResponse);
+      }
+      if (!file) {
+        return reply.code(400).send({ ok: false, error: 'no_file' } satisfies SourceMapUploadResponse);
+      }
+      let buf: Buffer;
+      try {
+        buf = await file.toBuffer();
+      } catch {
+        return reply.code(413).send({ ok: false, error: 'too_large' } satisfies SourceMapUploadResponse);
+      }
+      if (file.file.truncated) {
+        return reply.code(413).send({ ok: false, error: 'too_large' } satisfies SourceMapUploadResponse);
+      }
+      const result = store.addUpload(file.filename, buf);
+      if (!result.ok) {
+        const code = result.error === 'too_large' ? 413 : result.error === 'disabled' ? 403 : 400;
+        return reply.code(code).send({ ok: false, error: result.error } satisfies SourceMapUploadResponse);
+      }
+      return reply.send({ ok: true, map: result.map } satisfies SourceMapUploadResponse);
+    },
+  );
+
+  app.get(
+    '/api/browser-errors/source-maps',
+    { preHandler: auth },
+    async (_req: FastifyRequest, reply: FastifyReply) => {
+      return reply.send({
+        enabled: store.enabled,
+        maps: store.enabled ? store.list() : [],
+        usage: store.usage(),
+      } satisfies SourceMapListResponse);
+    },
+  );
+
+  app.delete(
+    '/api/browser-errors/source-maps/:id',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      const { id } = req.params as { id: string };
+      const removed = store.remove(id);
+      if (!removed) {
+        // Either unknown, or a durable mount (delete the file on disk to
+        // remove those — they reload at boot).
+        return reply.code(404).send({ ok: false, error: 'not_removable' });
+      }
+      return reply.send({ ok: true });
+    },
+  );
+
+  app.post(
+    '/api/browser-errors/resolve',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      const body = (req.body ?? {}) as ResolveRequest;
+      const sourceMapId = body.sourceMapId ?? '';
+      if (!store.enabled) {
+        return reply.send({
+          ok: false,
+          sourceMapId,
+          sourceMapLabel: null,
+          frames: [],
+          error: 'disabled',
+        } satisfies ResolveResponse);
+      }
+      if (!body.stack && (body.line === undefined || body.line === null)) {
+        return reply.send({
+          ok: false,
+          sourceMapId,
+          sourceMapLabel: null,
+          frames: [],
+          error: 'no_input',
+        } satisfies ResolveResponse);
+      }
+      const map = await store.getTraceMap(sourceMapId);
+      if (!map) {
+        return reply.send({
+          ok: false,
+          sourceMapId,
+          sourceMapLabel: store.labelOf(sourceMapId),
+          frames: [],
+          error: 'unknown_map',
+        } satisfies ResolveResponse);
+      }
+      const frames = resolveStack(map, {
+        stack: body.stack ?? null,
+        line: body.line ?? null,
+        col: body.col ?? null,
+        errorUrl: body.errorUrl ?? null,
+      });
+      return reply.send({
+        ok: true,
+        sourceMapId,
+        sourceMapLabel: store.labelOf(sourceMapId),
+        frames,
+      } satisfies ResolveResponse);
+    },
+  );
+}
diff --git a/apps/bff/src/http/query/browser-errors.ts b/apps/bff/src/http/query/browser-errors.ts
new file mode 100644
index 0000000..5e78455
--- /dev/null
+++ b/apps/bff/src/http/query/browser-errors.ts
@@ -0,0 +1,246 @@
+/*
+ * 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.
+ */
+
+/**
+ * `POST /api/layer/:key/browser-errors`
+ *
+ * Wraps OAP's `queryBrowserErrorLogs(BrowserErrorLogQueryCondition)` for
+ * the BROWSER-layer "Browser Errors" tab (#6784). Body shape is
+ * `BrowserErrorsQueryRequest` from `@skywalking-horizon-ui/api-client`.
+ *
+ * Like the logs feed, we accept a `service` NAME on the body and resolve
+ * it to an OAP id server-side, query at SECOND precision (error logs are
+ * event-style — MINUTE rounding would drop the most recent rows), and the
+ * source-map resolution is a separate concern (see admin/source-maps.ts).
+ */
+
+import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
+import type {
+  BrowserErrorRow,
+  BrowserErrorsQueryRequest,
+  BrowserErrorsResponse,
+  FetchLike,
+} from '@skywalking-horizon-ui/api-client';
+import type { ConfigSource } from '../../config/loader.js';
+import type { SessionStore } from '../../user/sessions.js';
+import { requireAuth } from '../../user/middleware.js';
+import { graphqlPost, buildOapOpts, type GraphqlOptions } from '../../client/graphql.js';
+import { withColdStage } from '../../util/duration.js';
+import { fmtSecond, getServerOffsetMinutes } from '../../util/window.js';
+
+export interface BrowserErrorsRouteDeps {
+  config: ConfigSource;
+  sessions: SessionStore;
+  fetch?: FetchLike;
+}
+
+const DEFAULT_WINDOW_MIN = 30;
+const MAX_PAGE_SIZE = 100;
+function clampPageSize(requested: number | undefined, fallback: number): number {
+  if (!Number.isFinite(requested as number) || (requested as number) < 1) return fallback;
+  return Math.min(MAX_PAGE_SIZE, Math.round(requested as number));
+}
+
+function defaultWindow(
+  offsetMinutes: number,
+  minutes?: number,
+  explicit?: { startMs?: number; endMs?: number },
+): { start: string; end: string } {
+  // Absolute range: the UI sends epoch MS (TZ-unambiguous) and the BFF
+  // renders them in OAP-server-local using the OAP offset — same path as
+  // the rolling window. (Sending bare browser-local strings would be read
+  // by OAP as OAP-local and miss the data by the browser↔OAP TZ delta.)
+  if (
+    typeof explicit?.startMs === 'number' &&
+    typeof explicit.endMs === 'number' &&
+    explicit.startMs < explicit.endMs
+  ) {
+    return { start: fmtSecond(explicit.startMs, offsetMinutes), end: fmtSecond(explicit.endMs, offsetMinutes) };
+  }
+  const m =
+    Number.isFinite(minutes) && (minutes as number) > 0
+      ? Math.min(60 * 24 * 7, Math.round(minutes as number))
+      : DEFAULT_WINDOW_MIN;
+  const endMs = Date.now();
+  const startMs = endMs - m * 60_000;
+  return { start: fmtSecond(startMs, offsetMinutes), end: fmtSecond(endMs, offsetMinutes) };
+}
+
+const LIST_SERVICES_FOR_RESOLVE = /* GraphQL */ `
+  query ListServicesForBrowserErrors($layer: String!) {
+    services: listServices(layer: $layer) {
+      id
+      name
+    }
+  }
+`;
+
+const QUERY_BROWSER_ERRORS = /* GraphQL */ `
+  query QueryBrowserErrorLogs($condition: BrowserErrorLogQueryCondition) {
+    data: queryBrowserErrorLogs(condition: $condition) {
+      logs {
+        service
+        serviceVersion
+        time
+        pagePath
+        category
+        grade
+        message
+        line
+        col
+        stack
+        errorUrl
+        firstReportedError
+      }
+    }
+  }
+`;
+
+interface OapBrowserErrorRow {
+  service: string;
+  serviceVersion: string;
+  time: number;
+  pagePath: string;
+  category: BrowserErrorRow['category'];
+  grade?: string | null;
+  message?: string | null;
+  line?: number | null;
+  col?: number | null;
+  stack?: string | null;
+  errorUrl?: string | null;
+  firstReportedError: boolean;
+}
+
+const OAP_SERVICE_ID_RE = /^[A-Za-z0-9+/=]+\.\d+$/;
+async function resolveServiceId(
+  opts: GraphqlOptions,
+  layer: string,
+  serviceArg: string,
+): Promise<string | null> {
+  if (!serviceArg) return null;
+  if (OAP_SERVICE_ID_RE.test(serviceArg)) return serviceArg;
+  const data = await graphqlPost<{ services: Array<{ id: string; name: string }> }>(
+    opts,
+    LIST_SERVICES_FOR_RESOLVE,
+    { layer: layer.toUpperCase() },
+  );
+  return (
+    data.services.find((s) => s.name === serviceArg)?.id ??
+    data.services.find((s) => s.id === serviceArg)?.id ??
+    null
+  );
+}
+
+interface Body extends BrowserErrorsQueryRequest {
+  service?: string;
+}
+
+export function registerBrowserErrorsRoute(app: FastifyInstance, deps: BrowserErrorsRouteDeps): void {
+  const auth = requireAuth(deps);
+  app.post(
+    '/api/layer/:key/browser-errors',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      const params = req.params as { key: string };
+      const layerKey = params.key;
+      if (!layerKey || !/^[a-z0-9_]+$/i.test(layerKey)) {
+        return reply.code(400).send({ error: 'invalid_layer_key' });
+      }
+      const body = (req.body ?? {}) as Body;
+      const opts = buildOapOpts(deps.config.current, deps.fetch);
+      const offset = await getServerOffsetMinutes(deps.config, deps.fetch);
+      const window = defaultWindow(offset, body.windowMinutes, {
+        startMs: body.startMs,
+        endMs: body.endMs,
+      });
+
+      let serviceId = body.serviceId ?? null;
+      if (!serviceId && body.service) {
+        try {
+          serviceId = await resolveServiceId(opts, layerKey, body.service);
+        } 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);
+        }
+      }
+
+      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: {
+          pageNum: Math.max(1, Math.round(body.page ?? 1)),
+          pageSize: clampPageSize(body.pageSize, 50),
+        },
+      };
+
+      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);
+      }
+    },
+  );
+}
diff --git a/apps/bff/src/http/query/menu.ts b/apps/bff/src/http/query/menu.ts
index 2d8399e..4a5c3cd 100644
--- a/apps/bff/src/http/query/menu.ts
+++ b/apps/bff/src/http/query/menu.ts
@@ -62,6 +62,7 @@ function componentsToCaps(components: LayerComponentFlags): LayerCaps {
     processTopology: !!components.topology,
     traces: !!components.traces,
     logs: !!components.logs,
+    browserErrors: !!components.browserErrors,
     traceProfiling: !!components.traceProfiling,
     ebpfProfiling: !!components.ebpfProfiling,
     asyncProfiling: !!components.asyncProfiling,
@@ -202,7 +203,7 @@ const LAYER_DEFAULTS: Record<string, { color: string; slots: LayerSlots; caps: L
   MESH_DP: { color: 'var(--sw-info)', slots: { services: 'Data-plane services', instances: 'Sidecars' }, caps: { dashboards: true } },
   K8S: { color: 'var(--sw-purple)', slots: { services: 'Workloads', instances: 'Pods' }, caps: { serviceMap: true, dashboards: true, events: true } },
   K8S_SERVICE: { color: 'var(--sw-purple)', slots: { services: 'K8s services', instances: 'Pods' }, caps: { serviceMap: true, dashboards: true } },
-  BROWSER: { color: 'var(--sw-cyan)', slots: { services: 'Applications', instances: 'Versions', endpoints: 'Pages' }, caps: { dashboards: true, traces: true, logs: true } },
+  BROWSER: { color: 'var(--sw-cyan)', slots: { services: 'Applications', instances: 'Versions', endpoints: 'Pages' }, caps: { dashboards: true, traces: true, browserErrors: true } },
   MYSQL: { color: 'var(--sw-warn)', slots: { services: 'Instances' }, caps: { dashboards: true } },
   POSTGRESQL: { color: 'var(--sw-warn)', slots: { services: 'Instances' }, caps: { dashboards: true } },
   ELASTICSEARCH: { color: 'var(--sw-warn)', slots: { services: 'Clusters', instances: 'Nodes' }, caps: { dashboards: true } },
diff --git a/apps/bff/src/logic/browser-errors/resolve.test.ts b/apps/bff/src/logic/browser-errors/resolve.test.ts
new file mode 100644
index 0000000..f5c7ee4
--- /dev/null
+++ b/apps/bff/src/logic/browser-errors/resolve.test.ts
@@ -0,0 +1,136 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { describe, it, expect, beforeAll } from 'vitest';
+import { transform } from 'esbuild';
+import { TraceMap } from '@jridgewell/trace-mapping';
+import { parseFrames, resolveFrame, resolveStack } from './resolve.js';
+
+// Original source — one meaningful token per line so a resolved position
+// maps to an unambiguous original line. "HELLO_MARKER" is unique so we can
+// find its column in the minified output.
+const SOURCE = [
+  'function greet(name) {', //                         line 1
+  '  const greeting = "HELLO_MARKER";', //              line 2
+  '  return greeting + name;', //                       line 3
+  '}', //                                               line 4
+  'globalThis.__greet = greet;', //                     line 5
+].join('\n');
+
+let map: TraceMap;
+let minified: string;
+let markerCol0: number; // 0-based column of the marker in the minified line
+
+beforeAll(async () => {
+  // A REAL toolchain-emitted Source Map v3 — esbuild is the build tool, so
+  // this proves the resolver against an authentic map, not a hand fixture.
+  const out = await transform(SOURCE, {
+    loader: 'js',
+    sourcemap: true,
+    minify: true,
+    sourcesContent: true,
+    sourcefile: 'greet.js',
+  });
+  minified = out.code;
+  map = new TraceMap(out.map);
+  markerCol0 = minified.indexOf('HELLO_MARKER');
+  expect(markerCol0).toBeGreaterThanOrEqual(0);
+});
+
+describe('resolveFrame — real esbuild map', () => {
+  it('maps a minified position back to the original source line', () => {
+    // Frames carry 1-based columns (browser convention); resolveFrame
+    // shifts to the 0-based column the map indexes.
+    const frame = { file: 'greet.js', line: 1, col: markerCol0 + 1, fn: 'greet' };
+    const resolved = resolveFrame(map, frame);
+    expect(resolved.original).not.toBeNull();
+    expect(resolved.original?.source).toBe('greet.js');
+    expect(resolved.original?.line).toBe(2);
+  });
+
+  it('includes a source snippet around the mapped line (sourcesContent)', () => {
+    const resolved = resolveFrame(map, { file: 'greet.js', line: 1, col: markerCol0 + 1, fn: null });
+    expect(resolved.snippet).toBeTruthy();
+    expect(resolved.snippet?.lines.join('\n')).toContain('HELLO_MARKER');
+  });
+
+  it('returns original=null for a position outside the map', () => {
+    const resolved = resolveFrame(map, { file: 'greet.js', line: 999_999, col: 1, fn: null });
+    expect(resolved.original).toBeNull();
+  });
+
+  it('returns original=null when the frame has no line', () => {
+    expect(resolveFrame(map, { file: 'x', line: null, col: null, fn: null }).original).toBeNull();
+  });
+});
+
+describe('parseFrames — engine-specific stack formats', () => {
+  const URL = 'https://app.example.com/assets/main.abc123.js';
+
+  it('parses a V8 / Chrome stack', () => {
+    const stack = [
+      'Error: boom',
+      `    at greet (${URL}:1:845)`,
+      `    at ${URL}:1:870`,
+    ].join('\n');
+    const frames = parseFrames(stack);
+    expect(frames.length).toBe(2);
+    expect(frames[0]).toMatchObject({ file: URL, line: 1, col: 845, fn: 'greet' });
+    expect(frames[1]).toMatchObject({ file: URL, line: 1, col: 870 });
+  });
+
+  it('parses a Firefox / Gecko stack', () => {
+    const stack = [`greet@${URL}:1:845`, `@${URL}:1:870`].join('\n');
+    const frames = parseFrames(stack);
+    expect(frames.length).toBe(2);
+    expect(frames[0]).toMatchObject({ file: URL, line: 1, col: 845 });
+  });
+
+  it('parses a Safari / JSC stack', () => {
+    const stack = [`greet@${URL}:1:845`, `global code@${URL}:1:870`].join('\n');
+    const frames = parseFrames(stack);
+    expect(frames.length).toBe(2);
+    expect(frames[0].file).toBe(URL);
+    expect(frames[0].line).toBe(1);
+  });
+
+  it('returns [] for empty / garbage input', () => {
+    expect(parseFrames(undefined)).toEqual([]);
+    expect(parseFrames('')).toEqual([]);
+    expect(parseFrames('not a stack trace at all')).toEqual([]);
+  });
+});
+
+describe('resolveStack — full pipeline', () => {
+  it('resolves frames parsed from a V8 stack against the map', () => {
+    const stack = `Error: boom\n    at greet (main.js:1:${markerCol0 + 1})`;
+    const frames = resolveStack(map, { stack });
+    expect(frames.length).toBe(1);
+    expect(frames[0].original?.line).toBe(2);
+    expect(frames[0].original?.source).toBe('greet.js');
+  });
+
+  it('falls back to the top-level line/col when the stack has no frames (JS category)', () => {
+    const frames = resolveStack(map, { stack: null, line: 1, col: markerCol0 + 1, errorUrl: 'main.js' });
+    expect(frames.length).toBe(1);
+    expect(frames[0].original?.line).toBe(2);
+  });
+
+  it('returns [] when there is neither a parseable stack nor a top-level line', () => {
+    expect(resolveStack(map, { stack: 'garbage', line: null })).toEqual([]);
+  });
+});
diff --git a/apps/bff/src/logic/browser-errors/resolve.ts b/apps/bff/src/logic/browser-errors/resolve.ts
new file mode 100644
index 0000000..2365eda
--- /dev/null
+++ b/apps/bff/src/logic/browser-errors/resolve.ts
@@ -0,0 +1,122 @@
+/*
+ * 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.
+ */
+
+/**
+ * Pure source-map resolution: minified `(file,line,col)` → original
+ * `(source,line,col,name)` + a source snippet. No IO, no store — takes a
+ * parsed `TraceMap` and the error's stack/position, returns frames. This
+ * is the piece that gets fixture-tested with a real esbuild-emitted map.
+ *
+ * The hard part is NOT the mapping — it's that the browser agent ships a
+ * raw `error.stack` STRING whose format is engine-specific (V8 / Gecko /
+ * JSC). We lean on `stacktrace-parser` to normalise those into frames,
+ * then map each frame. When there are no parseable frames we fall back to
+ * the top-level `line`/`col` (only the `JS` category populates those).
+ */
+
+import { originalPositionFor, sourceContentFor, type TraceMap } from '@jridgewell/trace-mapping';
+import { parse as parseStackTrace } from 'stacktrace-parser';
+import type { ResolvedFrame } from '@skywalking-horizon-ui/api-client';
+
+/** A minified frame, columns as they appear in the wire (1-based). */
+export interface GeneratedFrame {
+  file: string | null;
+  line: number | null;
+  col: number | null;
+  fn: string | null;
+}
+
+export interface ResolveInput {
+  stack?: string | null;
+  line?: number | null;
+  col?: number | null;
+  errorUrl?: string | null;
+}
+
+/** Lines of original source kept on each side of the mapped line. */
+const SNIPPET_RADIUS = 2;
+
+/** Split a raw `error.stack` into frames. Returns [] for empty/garbage
+ *  input — never throws to the caller. */
+export function parseFrames(stack: string | null | undefined): GeneratedFrame[] {
+  if (!stack) return [];
+  try {
+    return parseStackTrace(stack).map((f) => ({
+      file: f.file ?? null,
+      line: f.lineNumber ?? null,
+      col: f.column ?? null,
+      fn: f.methodName ?? null,
+    }));
+  } catch {
+    return [];
+  }
+}
+
+function snippetFor(
+  map: TraceMap,
+  source: string | null,
+  line: number | null,
+): ResolvedFrame['snippet'] {
+  if (!source || line == null || line < 1) return null;
+  const content = sourceContentFor(map, source);
+  if (!content) return null;
+  const all = content.split('\n');
+  const start = Math.max(1, line - SNIPPET_RADIUS);
+  const end = Math.min(all.length, line + SNIPPET_RADIUS);
+  if (start > all.length) return null;
+  return { startLine: start, lines: all.slice(start - 1, end) };
+}
+
+/** Map one generated frame to its original position. `original` is null
+ *  when the frame has no line or falls outside the map. */
+export function resolveFrame(map: TraceMap, frame: GeneratedFrame): ResolvedFrame {
+  if (frame.line == null) return { generated: frame, original: null };
+  // Source maps index columns 0-based; browser stack traces (V8) and the
+  // `window.onerror` `colno` are 1-based. Shift before the lookup or every
+  // result lands one column early.
+  const column = frame.col != null && frame.col > 0 ? frame.col - 1 : 0;
+  const pos = originalPositionFor(map, { line: frame.line, column });
+  if (pos.source == null) return { generated: frame, original: null };
+  return {
+    generated: frame,
+    original: {
+      source: pos.source,
+      line: pos.line ?? null,
+      col: pos.column ?? null,
+      name: pos.name ?? null,
+    },
+    snippet: snippetFor(map, pos.source, pos.line ?? null),
+  };
+}
+
+/** Resolve a whole error: prefer the parsed stack frames; fall back to the
+ *  single top-level position when the stack yields nothing. */
+export function resolveStack(map: TraceMap, input: ResolveInput): ResolvedFrame[] {
+  const frames = parseFrames(input.stack);
+  if (frames.length > 0) return frames.map((f) => resolveFrame(map, f));
+  if (input.line != null) {
+    return [
+      resolveFrame(map, {
+        file: input.errorUrl ?? null,
+        line: input.line,
+        col: input.col ?? null,
+        fn: null,
+      }),
+    ];
+  }
+  return [];
+}
diff --git a/apps/bff/src/logic/browser-errors/store.test.ts b/apps/bff/src/logic/browser-errors/store.test.ts
new file mode 100644
index 0000000..31ce60a
--- /dev/null
+++ b/apps/bff/src/logic/browser-errors/store.test.ts
@@ -0,0 +1,181 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { mkdtemp, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { SourceMapStore } from './store.js';
+
+/** Distinct, valid Source Map v3 JSON of a controllable size. `seed`
+ *  varies the content so each map gets a distinct content-addressed id. */
+function makeMap(seed: string, padBytes = 0): string {
+  return JSON.stringify({
+    version: 3,
+    file: `${seed}.js`,
+    sources: [`${seed}.ts`],
+    names: [],
+    mappings: 'AAAA',
+    sourcesContent: ['x'.repeat(Math.max(1, padBytes))],
+  });
+}
+
+const ONE_MAP = makeMap('a', 40);
+const MAP_BYTES = Buffer.byteLength(ONE_MAP, 'utf8');
+
+function bigBudget() {
+  return { enabled: true, maxFileBytes: 1 << 20, maxTotalBytes: 1 << 30, maxFileCount: 1000 };
+}
+
+describe('SourceMapStore — validation', () => {
+  it('rejects when disabled', () => {
+    const s = new SourceMapStore(() => ({ ...bigBudget(), enabled: false }));
+    expect(s.addUpload('a.map', makeMap('a', 40))).toEqual({ ok: false, error: 'disabled' });
+  });
+
+  it('rejects non-JSON / non-v3 content', () => {
+    const s = new SourceMapStore(() => bigBudget());
+    expect(s.addUpload('a.map', 'not json').ok).toBe(false);
+    expect(s.addUpload('a.map', JSON.stringify({ version: 2, mappings: '' })).ok).toBe(false);
+    expect(s.addUpload('a.map', '').ok).toBe(false);
+  });
+
+  it('rejects a file larger than maxFileBytes', () => {
+    const s = new SourceMapStore(() => ({ ...bigBudget(), maxFileBytes: 20 }));
+    expect(s.addUpload('a.map', makeMap('a', 200))).toEqual({ ok: false, error: 'too_large' });
+  });
+
+  it('content-addresses ids — re-uploading the same file dedups', () => {
+    const s = new SourceMapStore(() => bigBudget());
+    const first = s.addUpload('a.map', ONE_MAP);
+    const again = s.addUpload('a.map', ONE_MAP);
+    expect(first.ok && again.ok && first.map.id === again.map.id).toBe(true);
+    expect(s.list().length).toBe(1);
+  });
+});
+
+describe('SourceMapStore — eviction', () => {
+  it('evicts the least-recently-used upload past the byte budget', () => {
+    // Budget fits two maps but not three.
+    const s = new SourceMapStore(() => ({ ...bigBudget(), maxTotalBytes: MAP_BYTES * 2 + 10 }));
+    const a = s.addUpload('a.map', makeMap('a', 40));
+    const b = s.addUpload('b.map', makeMap('b', 40));
+    const c = s.addUpload('c.map', makeMap('c', 40));
+    expect(a.ok && b.ok && c.ok).toBe(true);
+    if (!a.ok || !b.ok || !c.ok) return;
+    expect(s.has(a.map.id)).toBe(false); // oldest evicted
+    expect(s.has(b.map.id)).toBe(true);
+    expect(s.has(c.map.id)).toBe(true);
+    expect(s.usage().usedBytes).toBeLessThanOrEqual(MAP_BYTES * 2 + 10);
+  });
+
+  it('evicts past the file-count cap independent of bytes', () => {
+    const s = new SourceMapStore(() => ({ ...bigBudget(), maxFileCount: 2 }));
+    const a = s.addUpload('a.map', makeMap('a', 10));
+    s.addUpload('b.map', makeMap('b', 10));
+    s.addUpload('c.map', makeMap('c', 10));
+    expect(a.ok).toBe(true);
+    if (!a.ok) return;
+    expect(s.list().length).toBe(2);
+    expect(s.has(a.map.id)).toBe(false);
+  });
+
+  it('rejects an upload larger than maxTotalBytes (even under maxFileBytes)', () => {
+    const s = new SourceMapStore(() => ({ enabled: true, maxFileBytes: 1 << 20, maxTotalBytes: 50, maxFileCount: 100 }));
+    expect(s.addUpload('a.map', makeMap('a', 200))).toEqual({ ok: false, error: 'too_large' });
+  });
+
+  it('lowering maxTotalBytes via hot reload trims resident uploads on next read', () => {
+    const cfg = { enabled: true, maxFileBytes: 1 << 20, maxTotalBytes: 1 << 30, maxFileCount: 100 };
+    const s = new SourceMapStore(() => cfg);
+    s.addUpload('a.map', makeMap('a', 40));
+    s.addUpload('b.map', makeMap('b', 40));
+    s.addUpload('c.map', makeMap('c', 40));
+    expect(s.list().length).toBe(3);
+    cfg.maxTotalBytes = MAP_BYTES + 5; // room for ~1 map
+    expect(s.list().length).toBe(1); // trimmed on read
+    expect(s.usage().usedBytes).toBeLessThanOrEqual(MAP_BYTES + 5);
+  });
+});
+
+describe('SourceMapStore — uploads vs mounts', () => {
+  it('removes an upload but not a mount; getTraceMap parses both', async () => {
+    const s = new SourceMapStore(() => bigBudget());
+    const up = s.addUpload('app.js.map', ONE_MAP);
+    expect(up.ok).toBe(true);
+    if (!up.ok) return;
+    expect(await s.getTraceMap(up.map.id)).toBeTruthy();
+    expect(s.remove(up.map.id)).toBe(true);
+    expect(s.has(up.map.id)).toBe(false);
+    expect(await s.getTraceMap(up.map.id)).toBeNull();
+
+    const dir = await mkdtemp(join(tmpdir(), 'sm-store-'));
+    await writeFile(join(dir, 'mounted.js.map'), makeMap('mounted', 10), 'utf8');
+    await writeFile(join(dir, 'ignore.txt'), 'not a map', 'utf8');
+    await s.loadMountDir(dir);
+    const mount = s.list().find((d) => d.origin === 'mount');
+    expect(mount).toBeTruthy();
+    if (!mount) return;
+    expect(mount.label).toBe('mounted.js.map');
+    expect(s.remove(mount.id)).toBe(false); // mounts are durable, not deletable
+    expect(await s.getTraceMap(mount.id)).toBeTruthy(); // read from disk on demand
+  });
+
+  it('tolerates a missing mount dir', async () => {
+    const s = new SourceMapStore(() => bigBudget());
+    await expect(s.loadMountDir(join(tmpdir(), 'does-not-exist-xyz'))).resolves.toBeUndefined();
+    expect(s.list().length).toBe(0);
+  });
+
+  it('validates mounted maps at index time — invalid/non-v3 are skipped', async () => {
+    const s = new SourceMapStore(() => bigBudget());
+    const dir = await mkdtemp(join(tmpdir(), 'sm-mount-validate-'));
+    await writeFile(join(dir, 'good.js.map'), makeMap('good', 10), 'utf8');
+    await writeFile(join(dir, 'garbage.js.map'), 'not json at all', 'utf8');
+    await writeFile(join(dir, 'v2.js.map'), JSON.stringify({ version: 2, mappings: '' }), 'utf8');
+    await s.loadMountDir(dir);
+    const mounts = s.list().filter((d) => d.origin === 'mount');
+    expect(mounts.map((m) => m.label)).toEqual(['good.js.map']);
+  });
+
+  it('caps the mounted set at maxFileCount', async () => {
+    const s = new SourceMapStore(() => ({ ...bigBudget(), maxFileCount: 2 }));
+    const dir = await mkdtemp(join(tmpdir(), 'sm-mount-cap-'));
+    for (const seed of ['m1', 'm2', 'm3', 'm4']) {
+      await writeFile(join(dir, `${seed}.js.map`), makeMap(seed, 10), 'utf8');
+    }
+    await s.loadMountDir(dir);
+    expect(s.list().filter((d) => d.origin === 'mount').length).toBe(2);
+  });
+});
+
+describe('SourceMapStore — live config (hot reload)', () => {
+  it('reads enabled + budgets through the getter on every call', () => {
+    const cfg = { enabled: true, maxFileBytes: 1 << 20, maxTotalBytes: 1 << 30, maxFileCount: 100 };
+    const s = new SourceMapStore(() => cfg);
+    expect(s.enabled).toBe(true);
+    expect(s.addUpload('a.map', ONE_MAP).ok).toBe(true);
+    // Flip config live — no reconstruction.
+    cfg.enabled = false;
+    expect(s.enabled).toBe(false);
+    expect(s.addUpload('b.map', makeMap('b', 40))).toEqual({ ok: false, error: 'disabled' });
+    cfg.enabled = true;
+    expect(s.usage().maxFileCount).toBe(100);
+    cfg.maxFileCount = 7;
+    expect(s.usage().maxFileCount).toBe(7);
+  });
+});
diff --git a/apps/bff/src/logic/browser-errors/store.ts b/apps/bff/src/logic/browser-errors/store.ts
new file mode 100644
index 0000000..3197884
--- /dev/null
+++ b/apps/bff/src/logic/browser-errors/store.ts
@@ -0,0 +1,382 @@
+/*
+ * 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.
+ */
+
+/**
+ * Process-global, in-memory store of JS source maps used to de-obfuscate
+ * BROWSER-layer error stacks (#6784). There is deliberately NO OAP-side
+ * storage — this is a per-BFF-instance cache, and the budgets make it
+ * explicitly evictable so a busy operator can't OOM the relay.
+ *
+ * Two provisioning paths, with different durability:
+ *   - `upload` — a `.map` POSTed at runtime. Held in memory only: counts
+ *     against the byte/count budget, is LRU-evicted under pressure, and
+ *     is gone on restart. This is the "temporary" set the UI warns about.
+ *   - `mount`  — a `.map` discovered at boot under `bootMountDir`. Indexed
+ *     (id + path + size) but NOT held resident; read from disk on demand
+ *     and dropped from the parsed cache freely. Durable across restarts,
+ *     and not deletable from the UI.
+ *
+ * Resolution itself lives in `resolve.ts` (pure). This module only owns
+ * bytes, eviction, disk IO, and a small parsed-map cache.
+ */
+
+import { readFile, readdir, stat } from 'node:fs/promises';
+import { createHash } from 'node:crypto';
+import { basename, join } from 'node:path';
+import { TraceMap } from '@jridgewell/trace-mapping';
+import type {
+  SourceMapDescriptor,
+  SourceMapOrigin,
+  SourceMapUsage,
+} from '@skywalking-horizon-ui/api-client';
+import { logger } from '../../logger.js';
+
+export interface SourceMapStoreOptions {
+  enabled: boolean;
+  maxFileBytes: number;
+  maxTotalBytes: number;
+  maxFileCount: number;
+}
+
+/** Parsed maps run larger than their raw bytes and are only needed during
+ *  an active resolve, so they ride a small LRU bounded BOTH by this count
+ *  AND by `maxTotalBytes` (using each map's raw bytes as the proxy for its
+ *  parsed footprint). That keeps repeated resolves from growing the heap
+ *  past the configured budget while the usage bar shows only raw bytes. */
+const PARSED_CACHE_MAX = 8;
+
+interface Entry {
+  id: string;
+  label: string;
+  origin: SourceMapOrigin;
+  bytes: number;
+  addedAt: number;
+  lastUsedAt: number;
+  /** Resident map JSON. Always set for uploads; null for mounts (lazy). */
+  raw: string | null;
+  /** Source path, mounts only. */
+  mountPath?: string;
+}
+
+export type UploadResult =
+  | { ok: true; map: SourceMapDescriptor }
+  | { ok: false; error: 'disabled' | 'too_large' | 'invalid_map' | 'no_file' };
+
+function descriptorOf(e: Entry): SourceMapDescriptor {
+  return {
+    id: e.id,
+    label: e.label,
+    origin: e.origin,
+    bytes: e.bytes,
+    addedAt: e.addedAt,
+    lastUsedAt: e.lastUsedAt,
+  };
+}
+
+/** Cheap structural check that a buffer is a Source Map v3 document.
+ *  Returns the decoded JSON on success so callers don't parse twice. */
+function validateSourceMap(text: string): Record<string, unknown> | null {
+  let json: unknown;
+  try {
+    json = JSON.parse(text);
+  } catch {
+    return null;
+  }
+  if (typeof json !== 'object' || json === null) return null;
+  const obj = json as Record<string, unknown>;
+  if (obj.version !== 3) return null;
+  if (typeof obj.mappings !== 'string') return null;
+  return obj;
+}
+
+export class SourceMapStore {
+  /** Config is read through a live getter so `sourceMaps.enabled` + the
+   *  budgets hot-reload with horizon.yaml (the repo's general contract).
+   *  CAVEAT: the multipart upload hard-cap is registered once at boot in
+   *  server.ts, so RAISING `maxFileBytes` at runtime only takes effect for
+   *  the in-memory budget — an upload above the startup cap still needs a
+   *  restart. Lowering it, and toggling `enabled`, take effect live. */
+  private readonly cfg: () => SourceMapStoreOptions;
+  private readonly entries = new Map<string, Entry>();
+  private readonly parsed = new Map<string, TraceMap>();
+
+  constructor(cfg: () => SourceMapStoreOptions) {
+    this.cfg = cfg;
+  }
+
+  get enabled(): boolean {
+    return this.cfg().enabled;
+  }
+
+  /** Bytes currently resident in memory — uploads (always resident) only.
+   *  Mounts are disk-backed and don't count against the runtime budget. */
+  private residentUploadBytes(): number {
+    let sum = 0;
+    for (const e of this.entries.values()) if (e.origin === 'upload') sum += e.bytes;
+    return sum;
+  }
+
+  private uploadCount(): number {
+    let n = 0;
+    for (const e of this.entries.values()) if (e.origin === 'upload') n++;
+    return n;
+  }
+
+  private mountCount(): number {
+    let n = 0;
+    for (const e of this.entries.values()) if (e.origin === 'mount') n++;
+    return n;
+  }
+
+  /** Sum of the RAW bytes of the maps currently held parsed — the proxy
+   *  used to bound the parsed cache against `maxTotalBytes`. */
+  private parsedRawBytes(): number {
+    let sum = 0;
+    for (const id of this.parsed.keys()) sum += this.entries.get(id)?.bytes ?? 0;
+    return sum;
+  }
+
+  /** Evict least-recently-used UPLOADS until adding `incomingBytes` keeps
+   *  both the byte budget and the count cap. Mounts are never evicted
+   *  (they reload from disk). */
+  private evictForUpload(incomingBytes: number): void {
+    const o = this.cfg();
+    const overBudget = (): boolean =>
+      this.residentUploadBytes() + incomingBytes > o.maxTotalBytes;
+    const overCount = (): boolean => this.uploadCount() + 1 > o.maxFileCount;
+    while (overBudget() || overCount()) {
+      let victim: Entry | null = null;
+      for (const e of this.entries.values()) {
+        if (e.origin !== 'upload') continue;
+        if (!victim || e.lastUsedAt < victim.lastUsedAt) victim = e;
+      }
+      if (!victim) break; // nothing left to evict
+      this.drop(victim.id);
+    }
+  }
+
+  private drop(id: string): void {
+    this.entries.delete(id);
+    this.parsed.delete(id);
+  }
+
+  /** Trim resident uploads (LRU) + the parsed cache down to the CURRENT
+   *  budgets. Called on the read paths so a hot-reload that LOWERS a budget
+   *  takes effect on the next interaction (eviction otherwise only ran on
+   *  upload). */
+  private enforceBudget(): void {
+    const o = this.cfg();
+    while (this.residentUploadBytes() > o.maxTotalBytes || this.uploadCount() > o.maxFileCount) {
+      let victim: Entry | null = null;
+      for (const e of this.entries.values()) {
+        if (e.origin !== 'upload') continue;
+        if (!victim || e.lastUsedAt < victim.lastUsedAt) victim = e;
+      }
+      if (!victim) break;
+      this.drop(victim.id);
+    }
+    while (this.parsed.size > PARSED_CACHE_MAX || (this.parsed.size > 0 && this.parsedRawBytes() > o.maxTotalBytes)) {
+      const oldest = this.parsed.keys().next().value as string | undefined;
+      if (oldest === undefined) break;
+      this.parsed.delete(oldest);
+    }
+  }
+
+  /** Add (or refresh) an uploaded map. Id is content-addressed so the
+   *  same file re-uploaded dedups rather than piling up. */
+  addUpload(label: string, content: Buffer | string): UploadResult {
+    const o = this.cfg();
+    if (!o.enabled) return { ok: false, error: 'disabled' };
+    const text = typeof content === 'string' ? content : content.toString('utf8');
+    const bytes = Buffer.byteLength(text, 'utf8');
+    if (bytes === 0) return { ok: false, error: 'no_file' };
+    // Reject anything that can't fit the budget at all — a single file
+    // larger than maxFileBytes, or (even alone) larger than maxTotalBytes.
+    if (bytes > o.maxFileBytes || bytes > o.maxTotalBytes) return { ok: false, error: 'too_large' };
+    if (!validateSourceMap(text)) return { ok: false, error: 'invalid_map' };
+
+    const id = createHash('sha1').update(text).digest('hex').slice(0, 12);
+    const now = Date.now();
+    const existing = this.entries.get(id);
+    if (existing) {
+      existing.lastUsedAt = now;
+      existing.label = label || existing.label;
+      return { ok: true, map: descriptorOf(existing) };
+    }
+    this.evictForUpload(bytes);
+    const entry: Entry = {
+      id,
+      label: label || `${id}.map`,
+      origin: 'upload',
+      bytes,
+      addedAt: now,
+      lastUsedAt: now,
+      raw: text,
+    };
+    this.entries.set(id, entry);
+    return { ok: true, map: descriptorOf(entry) };
+  }
+
+  /** Scan `dir` for `.map` files and index them as durable `mount`
+   *  entries. Each candidate is read + validated as Source Map v3 at index
+   *  time so only real maps surface in the UI (an invalid file used to
+   *  appear and only fail at resolve). `maxFileCount` bounds the mounted
+   *  set too — once hit, the rest are skipped with a warning (the operator
+   *  controls the directory; this just bounds boot I/O + the list). The
+   *  validated content is NOT retained — mounts stay disk-backed and are
+   *  re-read lazily on resolve, so they don't occupy the byte budget.
+   *  Tolerant: a missing dir is a no-op. Safe to call once at boot. */
+  async loadMountDir(dir: string): Promise<void> {
+    const o = this.cfg();
+    if (!o.enabled || !dir) return;
+    let names: string[];
+    try {
+      names = await readdir(dir);
+    } catch (err) {
+      if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
+        logger.info({ dir }, 'source-map mount dir absent; skipping static load');
+        return;
+      }
+      logger.warn({ err, dir }, 'source-map mount dir unreadable');
+      return;
+    }
+    let loaded = 0;
+    let skipped = 0;
+    for (const name of names) {
+      if (!name.endsWith('.map')) continue;
+      if (this.mountCount() >= o.maxFileCount) {
+        skipped++;
+        continue;
+      }
+      const path = join(dir, name);
+      try {
+        const info = await stat(path);
+        if (!info.isFile()) continue;
+        if (info.size > o.maxFileBytes) {
+          logger.warn({ path, size: info.size, cap: o.maxFileBytes }, 'mounted source map exceeds maxFileBytes; skipping');
+          continue;
+        }
+        const text = await readFile(path, 'utf8');
+        if (!validateSourceMap(text)) {
+          logger.warn({ path }, 'mounted file is not a valid Source Map v3; skipping');
+          continue;
+        }
+        const id = createHash('sha1').update(`mount:${path}`).digest('hex').slice(0, 12);
+        const now = Date.now();
+        this.entries.set(id, {
+          id,
+          label: basename(name),
+          origin: 'mount',
+          bytes: info.size,
+          addedAt: now,
+          lastUsedAt: now,
+          raw: null,
+          mountPath: path,
+        });
+        loaded++;
+      } catch (err) {
+        logger.warn({ err, path }, 'failed to index mounted source map');
+      }
+    }
+    if (loaded > 0 || skipped > 0) {
+      logger.info({ dir, loaded, skipped }, 'indexed statically-mounted source maps');
+    }
+  }
+
+  list(): SourceMapDescriptor[] {
+    this.enforceBudget();
+    return [...this.entries.values()]
+      .sort((a, b) => b.lastUsedAt - a.lastUsedAt)
+      .map(descriptorOf);
+  }
+
+  usage(): SourceMapUsage {
+    this.enforceBudget();
+    const o = this.cfg();
+    return {
+      usedBytes: this.residentUploadBytes(),
+      maxTotalBytes: o.maxTotalBytes,
+      maxFileBytes: o.maxFileBytes,
+      maxFileCount: o.maxFileCount,
+    };
+  }
+
+  has(id: string): boolean {
+    return this.entries.has(id);
+  }
+
+  labelOf(id: string): string | null {
+    return this.entries.get(id)?.label ?? null;
+  }
+
+  /** Remove an UPLOADED map. Mounts are durable (reload from disk) and
+   *  are not deletable. Returns false if absent or a mount. */
+  remove(id: string): boolean {
+    const e = this.entries.get(id);
+    if (!e || e.origin === 'mount') return false;
+    this.drop(id);
+    return true;
+  }
+
+  /** Parse (and cache) the map for `id`, reading a mount file from disk on
+   *  demand. Touches `lastUsedAt` so the LRU reflects real usage. Returns
+   *  null when the id is unknown or the source is unreadable/invalid. */
+  async getTraceMap(id: string): Promise<TraceMap | null> {
+    const e = this.entries.get(id);
+    if (!e) return null;
+    e.lastUsedAt = Date.now();
+    // Apply any hot-reloaded budget lowering. The just-touched entry is now
+    // MRU, so it's never the eviction victim here.
+    this.enforceBudget();
+    if (!this.entries.has(id)) return null;
+    const cached = this.parsed.get(id);
+    if (cached) {
+      // refresh LRU position
+      this.parsed.delete(id);
+      this.parsed.set(id, cached);
+      return cached;
+    }
+    let text = e.raw;
+    if (text === null && e.mountPath) {
+      try {
+        text = await readFile(e.mountPath, 'utf8');
+      } catch (err) {
+        logger.warn({ err, path: e.mountPath }, 'failed to read mounted source map');
+        return null;
+      }
+    }
+    if (!text) return null;
+    let map: TraceMap;
+    try {
+      map = new TraceMap(text);
+    } catch (err) {
+      logger.warn({ err, id }, 'failed to parse source map');
+      return null;
+    }
+    this.parsed.set(id, map);
+    // Bound the parsed cache by count AND by the raw-byte budget (raw size
+    // as the proxy for parsed footprint), so repeated resolves can't grow
+    // the heap past maxTotalBytes. Never evict the entry we just inserted.
+    const maxTotal = this.cfg().maxTotalBytes;
+    while (this.parsed.size > 1 && (this.parsed.size > PARSED_CACHE_MAX || this.parsedRawBytes() > maxTotal)) {
+      const oldest = this.parsed.keys().next().value as string | undefined;
+      if (oldest === undefined || oldest === id) break;
+      this.parsed.delete(oldest);
+    }
+    return map;
+  }
+}
diff --git a/apps/bff/src/logic/layers/loader.ts b/apps/bff/src/logic/layers/loader.ts
index d1eb9a0..6f90172 100644
--- a/apps/bff/src/logic/layers/loader.ts
+++ b/apps/bff/src/logic/layers/loader.ts
@@ -59,6 +59,8 @@ export interface LayerComponentFlags {
   topology?: boolean;
   traces?: boolean;
   logs?: boolean;
+  /** BROWSER-layer JS error logs + source-map de-obfuscation (#6784). */
+  browserErrors?: boolean;
   /** Trace-driven thread profiling (original SkyWalking profile). */
   traceProfiling?: boolean;
   /** Kernel-level CPU / off-CPU profiling via eBPF agents. */
diff --git a/apps/bff/src/rbac/route-policy.ts b/apps/bff/src/rbac/route-policy.ts
index 59dc6bf..4b27c31 100644
--- a/apps/bff/src/rbac/route-policy.ts
+++ b/apps/bff/src/rbac/route-policy.ts
@@ -97,6 +97,15 @@ export const ROUTE_POLICY: Record<string, RoutePolicy> = {
   'GET /api/layer/:key/pod-logs/containers':       'logs:read',
   'POST /api/layer/:key/pod-logs':                 'logs:read',
 
+  // ── Browser errors + source maps (#6784) ─────────────────────────
+  // Listing + resolving rides on the read verb; upload/delete of maps
+  // (which sit in BFF memory) needs the dedicated write verb.
+  'POST /api/layer/:key/browser-errors':           'browser-errors:read',
+  'GET /api/browser-errors/source-maps':           'browser-errors:read',
+  'POST /api/browser-errors/resolve':              'browser-errors:read',
+  'POST /api/browser-errors/source-maps':          'source-map:write',
+  'DELETE /api/browser-errors/source-maps/:id':    'source-map:write',
+
   // ── Topology (read) ──────────────────────────────────────────────
   'GET /api/layer/:key/topology':                  'topology:read',
   'GET /api/layer/:key/instance-topology':         'topology:read',
diff --git a/apps/bff/src/rbac/verbs.ts b/apps/bff/src/rbac/verbs.ts
index 04da2c3..6692401 100644
--- a/apps/bff/src/rbac/verbs.ts
+++ b/apps/bff/src/rbac/verbs.ts
@@ -28,6 +28,7 @@ export const VERBS = {
   alarmsRead: 'alarms:read',
   tracesRead: 'traces:read',
   logsRead: 'logs:read',
+  browserErrorsRead: 'browser-errors:read',
   topologyRead: 'topology:read',
   profileRead: 'profile:read',
 
@@ -49,6 +50,8 @@ export const VERBS = {
   alarmRuleWrite: 'alarm-rule:write',
   liveDebugRead: 'live-debug:read',
   liveDebugWrite: 'live-debug:write',
+  /** Upload / delete browser-error source maps held in BFF memory. */
+  sourceMapWrite: 'source-map:write',
   /** Single verb covering task-creation across all profiling families
    *  (agent / async / pprof / eBPF cpu / eBPF network). Reads ride on
    *  `*:read` via `profile:read`. */
diff --git a/apps/bff/src/server.ts b/apps/bff/src/server.ts
index d65ee7a..0e42096 100644
--- a/apps/bff/src/server.ts
+++ b/apps/bff/src/server.ts
@@ -20,6 +20,7 @@ import { resolve as resolvePath } from 'node:path';
 import Fastify from 'fastify';
 import cookie from '@fastify/cookie';
 import fastifyStatic from '@fastify/static';
+import fastifyMultipart from '@fastify/multipart';
 import { AuditLogger } from './audit/logger.js';
 import { SessionStore } from './user/sessions.js';
 import { LdapHealth } from './user/ldap-health.js';
@@ -43,6 +44,7 @@ import { registerTraceRoutes } from './http/query/trace.js';
 import { registerTraceTagRoutes } from './http/query/trace-tag.js';
 import { registerZipkinRoutes } from './http/query/zipkin.js';
 import { registerLogRoute } from './http/query/log.js';
+import { registerBrowserErrorsRoute } from './http/query/browser-errors.js';
 import { registerPodLogRoutes } from './http/query/pod-log.js';
 import { registerDashboardQueryRoute } from './http/query/dashboard.js';
 import { registerAlarmsQueryRoutes } from './http/query/alarms.js';
@@ -78,11 +80,13 @@ import { registerAlarmRulesRoutes } from './http/admin/alarm-rules.js';
 import { registerOverviewTemplatesAdminRoutes } from './http/admin/overview-templates.js';
 import { registerAuthStatusRoutes } from './http/admin/auth-status.js';
 import { registerAdminUsersRoute } from './http/admin/users.js';
+import { registerSourceMapRoutes } from './http/admin/source-maps.js';
 import { registerAuthHealthRoute } from './http/auth-health.js';
 import { registerColdStageHook } from './util/duration.js';
 // Logic / stores
 import { AlarmsStore } from './logic/alarms/store.js';
 import { SetupStore } from './logic/setup/store.js';
+import { SourceMapStore } from './logic/browser-errors/store.js';
 import { serviceLayerCatalog } from './logic/services/service-layer-catalog.js';
 import { HttpError } from './errors.js';
 import { logger, loggerOptions } from './logger.js';
@@ -133,6 +137,14 @@ const setupStore = new SetupStore(source.current.setup.file);
 await setupStore.load();
 const alarmsStore = new AlarmsStore(source.current.alarms.file);
 await alarmsStore.load();
+// In-memory source-map cache for the Browser Errors tab (#6784). Process-
+// global (NOT per-session); statically-mounted maps are indexed once here.
+// The store reads config through a live getter so `enabled` + the budgets
+// hot-reload with horizon.yaml. (The multipart hard-cap below is the one
+// exception — it's fixed at registration, so raising maxFileBytes needs a
+// restart; the store still enforces the live value for everything else.)
+const sourceMapStore = new SourceMapStore(() => source.current.sourceMaps);
+await sourceMapStore.loadMountDir(source.current.sourceMaps.bootMountDir);
 // Server-global service-by-layer index — shared by the sidebar menu, the
 // alarms tagger, and any other surface that needs the service ↔ layer
 // mapping. 60s TTL + single-flight dedup; one OAP fan-out per minute
@@ -141,6 +153,13 @@ const serviceLayer = serviceLayerCatalog({ config: source });
 
 await app.register(cookie);
 
+// Multipart for source-map (.map) uploads on the Browser Errors tab. The
+// fileSize cap mirrors `sourceMaps.maxFileBytes` so an oversized stream is
+// rejected before it's buffered; the store re-checks the live value too.
+await app.register(fastifyMultipart, {
+  limits: { fileSize: source.current.sourceMaps.maxFileBytes, files: 1 },
+});
+
 // Text/plain body parser — the rule editor sends raw YAML to /api/rule.
 app.addContentTypeParser('text/plain', { parseAs: 'string' }, (_req, body, done) => done(null, body));
 
@@ -199,6 +218,7 @@ registerTraceRoutes(app, {
 registerTraceTagRoutes(app, { config: source, sessions });
 registerZipkinRoutes(app, { config: source, sessions });
 registerLogRoute(app, { config: source, sessions });
+registerBrowserErrorsRoute(app, { config: source, sessions });
 registerPodLogRoutes(app, { config: source, sessions });
 registerDashboardQueryRoute(app, {
   config: source,
@@ -265,6 +285,7 @@ registerAlarmRulesRoutes(app, { config: source, sessions });
 registerOverviewTemplatesAdminRoutes(app, { config: source, sessions });
 registerAuthStatusRoutes(app, { config: source, ldapHealth, sessions });
 registerAdminUsersRoute(app, { config: source, seenCache });
+registerSourceMapRoutes(app, { config: source, sessions, store: sourceMapStore });
 registerTemplateSyncAdminRoutes(app, {
   config: source,
   sessions,
diff --git a/apps/ui/src/api/client.ts b/apps/ui/src/api/client.ts
index 9bee153..af7efad 100644
--- a/apps/ui/src/api/client.ts
+++ b/apps/ui/src/api/client.ts
@@ -79,6 +79,7 @@ import { LayerApi } from './scopes/layer';
 import { TraceApi } from './scopes/trace';
 import { ZipkinApi } from './scopes/zipkin';
 import { LogApi } from './scopes/log';
+import { BrowserErrorsApi } from './scopes/browser-errors';
 import { ProfileApi } from './scopes/profile';
 import { EbpfApi } from './scopes/ebpf';
 import { NetworkProfileApi } from './scopes/network-profile';
@@ -167,6 +168,17 @@ export type {
   LogQueryRequest,
   LogsResponse,
   LogFacetsResponse,
+  BrowserErrorCategory,
+  BrowserErrorRow,
+  BrowserErrorsQueryRequest,
+  BrowserErrorsResponse,
+  SourceMapDescriptor,
+  SourceMapUsage,
+  SourceMapListResponse,
+  SourceMapUploadResponse,
+  ResolveRequest,
+  ResolvedFrame,
+  ResolveResponse,
   ProfileTask,
   ProfileTaskLog,
   ProfileTaskListResponse,
@@ -280,6 +292,7 @@ export interface AdminLayerTemplate {
     deployment?: boolean;
     traces?: boolean;
     logs?: boolean;
+    browserErrors?: boolean;
     podLogs?: boolean;
     profiling?: boolean;
     traceProfiling?: boolean;
@@ -802,6 +815,53 @@ export class BffClient {
     return (await res.text()) as unknown as T;
   }
 
+  /** Multipart variant of {@link request} for file uploads (source-map
+   *  `.map` files). Sends a FormData body WITHOUT a content-type header so
+   *  the browser sets the multipart boundary; otherwise mirrors request()'s
+   *  401 hook + error envelope. */
+  async requestForm<T>(method: string, path: string, form: FormData): Promise<T> {
+    const init: RequestInit = {
+      method,
+      credentials: 'include',
+      headers: {
+        ...(readColdStageHeader() ? { [COLD_STAGE_HEADER]: '1' } : {}),
+        [LOCALE_HEADER]: currentLocale(),
+      },
+      body: form,
+    };
+    const url = withBase(path);
+    let res: Response;
+    try {
+      res = await fetch(url, init);
+    } catch (err) {
+      const detail = err instanceof Error ? err.message : String(err);
+      pushEvent('api', 'err', `${method} ${path} · network ${detail}`);
+      throw new BffApiError(0, `Cannot reach the server — the BFF is unreachable (${detail}).`, null);
+    }
+    if (res.status === 401) {
+      pushEvent('api', 'info', `${method} ${path} · 401 (re-auth)`);
+      this.on401?.();
+      throw new BffApiError(401, 'unauthenticated', null);
+    }
+    if (!res.ok) {
+      const raw = await res.text().catch(() => '');
+      let parsed: unknown = raw;
+      if (raw) {
+        try {
+          parsed = JSON.parse(raw);
+        } catch {
+          parsed = raw;
+        }
+      }
+      pushEvent('api', 'err', `${method} ${path} · ${res.status}`);
+      throw new BffApiError(res.status, `${method} ${path} failed (${res.status})`, parsed);
+    }
+    if (res.status === 204) return undefined as T;
+    const ct = res.headers.get('content-type') ?? '';
+    if (ct.includes('application/json')) return (await res.json()) as T;
+    return (await res.text()) as unknown as T;
+  }
+
   // ── Sub-clients (one per scope) ───────────────────────────────────
   readonly session = new SessionApi(this);
   readonly menu = new MenuApi(this);
@@ -811,6 +871,7 @@ export class BffClient {
   readonly trace = new TraceApi(this);
   readonly zipkin = new ZipkinApi(this);
   readonly log = new LogApi(this);
+  readonly browserErrors = new BrowserErrorsApi(this);
   readonly profile = new ProfileApi(this);
   readonly ebpf = new EbpfApi(this);
   readonly networkProfile = new NetworkProfileApi(this);
diff --git a/apps/ui/src/api/scopes/browser-errors.ts b/apps/ui/src/api/scopes/browser-errors.ts
new file mode 100644
index 0000000..9035708
--- /dev/null
+++ b/apps/ui/src/api/scopes/browser-errors.ts
@@ -0,0 +1,67 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { BffClient } from '../client';
+import type {
+  BrowserErrorsQueryRequest,
+  BrowserErrorsResponse,
+  ResolveRequest,
+  ResolveResponse,
+  SourceMapListResponse,
+  SourceMapUploadResponse,
+} from '@skywalking-horizon-ui/api-client';
+
+/**
+ * BROWSER-layer error logs + their in-memory source-map cache (#6784).
+ * `list` is the error feed; the source-map calls manage the BFF-resident
+ * maps and `resolve` de-obfuscates a stack against a chosen map.
+ */
+export class BrowserErrorsApi {
+  constructor(private readonly bff: BffClient) {}
+
+  list(
+    layerKey: string,
+    body: BrowserErrorsQueryRequest & { service?: string } = {},
+  ): Promise<BrowserErrorsResponse> {
+    return this.bff.request<BrowserErrorsResponse>(
+      'POST',
+      `/api/layer/${encodeURIComponent(layerKey)}/browser-errors`,
+      body,
+    );
+  }
+
+  listSourceMaps(): Promise<SourceMapListResponse> {
+    return this.bff.request<SourceMapListResponse>('GET', '/api/browser-errors/source-maps');
+  }
+
+  uploadSourceMap(file: File): Promise<SourceMapUploadResponse> {
+    const form = new FormData();
+    form.append('file', file, file.name);
+    return this.bff.requestForm<SourceMapUploadResponse>('POST', '/api/browser-errors/source-maps', form);
+  }
+
+  deleteSourceMap(id: string): Promise<{ ok: boolean }> {
+    return this.bff.request<{ ok: boolean }>(
+      'DELETE',
+      `/api/browser-errors/source-maps/${encodeURIComponent(id)}`,
+    );
+  }
+
+  resolve(body: ResolveRequest): Promise<ResolveResponse> {
+    return this.bff.request<ResolveResponse>('POST', '/api/browser-errors/resolve', body);
+  }
+}
diff --git a/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue b/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
index e63299d..b0a5cb4 100644
--- a/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
+++ b/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
@@ -1652,6 +1652,7 @@ const COMPONENT_TOGGLES: Array<{ key: ComponentKey; label: string; hint: string
   { key: 'endpointDependency', label: 'API dependency', hint: 'Endpoint-to-endpoint dependency view.' },
   { key: 'traces', label: 'Traces', hint: 'Trace explorer scoped to this layer.' },
   { key: 'logs', label: 'Logs', hint: 'Log explorer scoped to this layer.' },
+  { key: 'browserErrors', label: 'Browser Logs', hint: 'BROWSER-layer JS error logs with source-map de-obfuscation of the minified stack.' },
   { key: 'podLogs', label: 'Pod Logs', hint: 'On-demand Kubernetes pod-log live tail. Only K8s-deployed layers (k8s_service, mesh) carry pods that resolve.' },
   { key: 'traceProfiling', label: 'Trace Profiling', hint: 'Trace-driven thread profiling — the original SkyWalking profile.' },
   { key: 'ebpfProfiling', label: 'eBPF Profiling', hint: 'Kernel-level CPU / off-CPU profiling via eBPF agents.' },
@@ -1682,8 +1683,9 @@ const COMPONENT_SCOPE: Record<ComponentKey, AdminScope> = {
   deployment: 'deployment',
   traces: 'trace',
   logs: 'logs',
-  // Pod Logs has no editable widget grid — filler to satisfy the
-  // exhaustive Record; the menu-preview click no-ops for it.
+  // Browser Errors + Pod Logs have no editable widget grid — filler to
+  // satisfy the exhaustive Record; the menu-preview click no-ops for them.
+  browserErrors: 'logs',
   podLogs: 'logs',
   traceProfiling: 'traceProfiling',
   ebpfProfiling: 'ebpfProfiling',
diff --git a/apps/ui/src/i18n/locales/de.json b/apps/ui/src/i18n/locales/de.json
index 678f88a..d001392 100644
--- a/apps/ui/src/i18n/locales/de.json
+++ b/apps/ui/src/i18n/locales/de.json
@@ -1357,5 +1357,49 @@
   "ungrouped": "ohne Gruppe",
   "Scale": "Maßstab",
   "Pick a service to see its deployment topology.": "Wählen Sie einen Service, um seine Deployment-Topologie zu sehen.",
-  "No deployment topology in this window.": "Keine Deployment-Topologie in diesem Zeitfenster."
+  "No deployment topology in this window.": "Keine Deployment-Topologie in diesem Zeitfenster.",
+  "(anonymous)": "(anonym)",
+  "(no message)": "(keine Meldung)",
+  "(no stack)": "(kein Stack)",
+  "(temp)": "(temp)",
+  "All pages": "Alle Seiten",
+  "All versions": "Alle Versionen",
+  "Backend unreachable.": "Backend nicht erreichbar.",
+  "Browser Logs": "Browser-Logs",
+  "Categories": "Kategorien",
+  "Clear page": "Seite leeren",
+  "Mounted": "Eingebunden",
+  "Next": "Weiter",
+  "No browser logs in this window.": "Keine Browser-Logs in diesem Fenster.",
+  "No logs match the selected category.": "Keine Logs passen zur ausgewählten Kategorie.",
+  "No maps loaded": "Keine Maps geladen",
+  "No source maps loaded. Upload a .map to de-obfuscate stacks.": "Keine Source-Maps geladen. Laden Sie eine .map hoch, um Stacks zu entschlüsseln.",
+  "Page": "Seite",
+  "Pick a source map and resolve to de-obfuscate this stack.": "Wählen Sie eine Source-Map und lösen Sie auf, um diesen Stack zu entschlüsseln.",
+  "Pick a source map…": "Source-Map wählen…",
+  "Prev": "Zurück",
+  "Raw stack": "Roher Stack",
+  "Remove": "Entfernen",
+  "Remove from memory": "Aus Speicher entfernen",
+  "Resolved": "Aufgelöst",
+  "Resolving…": "Wird aufgelöst…",
+  "Search page path": "Seitenpfad suchen",
+  "Select an app to view its browser logs.": "Wählen Sie eine App, um deren Browser-Logs anzuzeigen.",
+  "Service version": "Service-Version",
+  "Source maps": "Source-Maps",
+  "Source-map de-obfuscation is disabled on this server (sourceMaps.enabled: false).": "Die Source-Map-Entschlüsselung ist auf diesem Server deaktiviert (sourceMaps.enabled: false).",
+  "Upload .map": ".map hochladen",
+  "Uploaded maps are held in the server memory only — evicted least-recently-used under memory pressure and lost on restart. For durable provisioning, mount .map files into the server's static source-map directory; those are reloaded automatically and can't be deleted here.": "Hochgeladene Maps liegen nur im Serverspeicher — bei Speicherdruck werden zuerst die am längsten ungenutzten verworfen, und beim Neustart gehen sie verloren. Für dauerhafte Bereitstellung binden Sie .map-Dateien in [...]
+  "Uploaded · temporary": "Hochgeladen · temporär",
+  "durable": "dauerhaft",
+  "first occurrence": "erstes Auftreten",
+  "no matches": "keine Treffer",
+  "page {page} · showing {shown} of {total} loaded": "Seite {page} · {shown} von {total} geladenen angezeigt",
+  "unmapped ({line}:{col})": "nicht zugeordnet ({line}:{col})",
+  "{count} in window": "{count} im Fenster",
+  "{used} / {total} · {count} maps · max {perFile}/file": "{used} / {total} · {count} Maps · max. {perFile}/Datei",
+  "Upload rejected: {reason}": "Upload abgelehnt: {reason}",
+  "Could not resolve: {reason}": "Konnte nicht aufgelöst werden: {reason}",
+  "{count} logs": "{count} Logs",
+  "grade": "Stufe"
 }
diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json
index acb3206..91723dc 100644
--- a/apps/ui/src/i18n/locales/en.json
+++ b/apps/ui/src/i18n/locales/en.json
@@ -1357,5 +1357,49 @@
   "ungrouped": "ungrouped",
   "Scale": "Scale",
   "Pick a service to see its deployment topology.": "Pick a service to see its deployment topology.",
-  "No deployment topology in this window.": "No deployment topology in this window."
+  "No deployment topology in this window.": "No deployment topology in this window.",
+  "(anonymous)": "(anonymous)",
+  "(no message)": "(no message)",
+  "(no stack)": "(no stack)",
+  "(temp)": "(temp)",
+  "All pages": "All pages",
+  "All versions": "All versions",
+  "Backend unreachable.": "Backend unreachable.",
+  "Browser Logs": "Browser Logs",
+  "Categories": "Categories",
+  "Clear page": "Clear page",
+  "Mounted": "Mounted",
+  "Next": "Next",
+  "No browser logs in this window.": "No browser logs in this window.",
+  "No logs match the selected category.": "No logs match the selected category.",
+  "No maps loaded": "No maps loaded",
+  "No source maps loaded. Upload a .map to de-obfuscate stacks.": "No source maps loaded. Upload a .map to de-obfuscate stacks.",
+  "Page": "Page",
+  "Pick a source map and resolve to de-obfuscate this stack.": "Pick a source map and resolve to de-obfuscate this stack.",
+  "Pick a source map…": "Pick a source map…",
+  "Prev": "Prev",
+  "Raw stack": "Raw stack",
+  "Remove": "Remove",
+  "Remove from memory": "Remove from memory",
+  "Resolved": "Resolved",
+  "Resolving…": "Resolving…",
+  "Search page path": "Search page path",
+  "Select an app to view its browser logs.": "Select an app to view its browser logs.",
+  "Service version": "Service version",
+  "Source maps": "Source maps",
+  "Source-map de-obfuscation is disabled on this server (sourceMaps.enabled: false).": "Source-map de-obfuscation is disabled on this server (sourceMaps.enabled: false).",
+  "Upload .map": "Upload .map",
+  "Uploaded maps are held in the server memory only — evicted least-recently-used under memory pressure and lost on restart. For durable provisioning, mount .map files into the server's static source-map directory; those are reloaded automatically and can't be deleted here.": "Uploaded maps are held in the server memory only — evicted least-recently-used under memory pressure and lost on restart. For durable provisioning, mount .map files into the server's static source-map directory; th [...]
+  "Uploaded · temporary": "Uploaded · temporary",
+  "durable": "durable",
+  "first occurrence": "first occurrence",
+  "no matches": "no matches",
+  "page {page} · showing {shown} of {total} loaded": "page {page} · showing {shown} of {total} loaded",
+  "unmapped ({line}:{col})": "unmapped ({line}:{col})",
+  "{count} in window": "{count} in window",
+  "{used} / {total} · {count} maps · max {perFile}/file": "{used} / {total} · {count} maps · max {perFile}/file",
+  "Upload rejected: {reason}": "Upload rejected: {reason}",
+  "Could not resolve: {reason}": "Could not resolve: {reason}",
+  "{count} logs": "{count} logs",
+  "grade": "grade"
 }
diff --git a/apps/ui/src/i18n/locales/es.json b/apps/ui/src/i18n/locales/es.json
index 24490fc..3d2dd20 100644
--- a/apps/ui/src/i18n/locales/es.json
+++ b/apps/ui/src/i18n/locales/es.json
@@ -1357,5 +1357,49 @@
   "ungrouped": "sin agrupar",
   "Scale": "Escala",
   "Pick a service to see its deployment topology.": "Elige un servicio para ver su topología de despliegue.",
-  "No deployment topology in this window.": "No hay topología de despliegue en esta ventana."
+  "No deployment topology in this window.": "No hay topología de despliegue en esta ventana.",
+  "(anonymous)": "(anónimo)",
+  "(no message)": "(sin mensaje)",
+  "(no stack)": "(sin stack)",
+  "(temp)": "(temp)",
+  "All pages": "Todas las páginas",
+  "All versions": "Todas las versiones",
+  "Backend unreachable.": "Backend no accesible.",
+  "Browser Logs": "Logs del navegador",
+  "Categories": "Categorías",
+  "Clear page": "Limpiar página",
+  "Mounted": "Montado",
+  "Next": "Siguiente",
+  "No browser logs in this window.": "No hay logs del navegador en esta ventana.",
+  "No logs match the selected category.": "Ningún log coincide con la categoría seleccionada.",
+  "No maps loaded": "No hay source maps cargados",
+  "No source maps loaded. Upload a .map to de-obfuscate stacks.": "No hay source maps cargados. Sube un .map para restaurar las posiciones originales del stack.",
+  "Page": "Página",
+  "Pick a source map and resolve to de-obfuscate this stack.": "Elige un source map y resuélvelo para restaurar las posiciones originales de este stack.",
+  "Pick a source map…": "Elige un source map…",
+  "Prev": "Anterior",
+  "Raw stack": "Stack original",
+  "Remove": "Quitar",
+  "Remove from memory": "Quitar de la memoria",
+  "Resolved": "Resuelto",
+  "Resolving…": "Resolviendo…",
+  "Search page path": "Buscar ruta de página",
+  "Select an app to view its browser logs.": "Selecciona una app para ver sus logs del navegador.",
+  "Service version": "Versión del servicio",
+  "Source maps": "Source maps",
+  "Source-map de-obfuscation is disabled on this server (sourceMaps.enabled: false).": "La restauración de posiciones mediante source maps está desactivada en este servidor (sourceMaps.enabled: false).",
+  "Upload .map": "Subir .map",
+  "Uploaded maps are held in the server memory only — evicted least-recently-used under memory pressure and lost on restart. For durable provisioning, mount .map files into the server's static source-map directory; those are reloaded automatically and can't be deleted here.": "Los source maps subidos se guardan solo en la memoria del servidor: se eliminan los menos usados recientemente cuando hay presión de memoria y se pierden al reiniciar. Para un aprovisionamiento duradero, monta los  [...]
+  "Uploaded · temporary": "Subido · temporal",
+  "durable": "duradero",
+  "first occurrence": "primera aparición",
+  "no matches": "sin coincidencias",
+  "page {page} · showing {shown} of {total} loaded": "página {page} · mostrando {shown} de {total} cargados",
+  "unmapped ({line}:{col})": "sin mapear ({line}:{col})",
+  "{count} in window": "{count} en la ventana",
+  "{used} / {total} · {count} maps · max {perFile}/file": "{used} / {total} · {count} maps · máx {perFile}/archivo",
+  "Upload rejected: {reason}": "Subida rechazada: {reason}",
+  "Could not resolve: {reason}": "No se pudo resolver: {reason}",
+  "{count} logs": "{count} logs",
+  "grade": "grado"
 }
diff --git a/apps/ui/src/i18n/locales/fr.json b/apps/ui/src/i18n/locales/fr.json
index 371db9c..da2de2b 100644
--- a/apps/ui/src/i18n/locales/fr.json
+++ b/apps/ui/src/i18n/locales/fr.json
@@ -1357,5 +1357,49 @@
   "ungrouped": "non groupé",
   "Scale": "Échelle",
   "Pick a service to see its deployment topology.": "Choisissez un service pour voir sa topologie de déploiement.",
-  "No deployment topology in this window.": "Aucune topologie de déploiement dans cette fenêtre."
+  "No deployment topology in this window.": "Aucune topologie de déploiement dans cette fenêtre.",
+  "(anonymous)": "(anonyme)",
+  "(no message)": "(aucun message)",
+  "(no stack)": "(aucune pile)",
+  "(temp)": "(temp)",
+  "All pages": "Toutes les pages",
+  "All versions": "Toutes les versions",
+  "Backend unreachable.": "Backend injoignable.",
+  "Browser Logs": "Journaux navigateur",
+  "Categories": "Catégories",
+  "Clear page": "Vider la page",
+  "Mounted": "Monté",
+  "Next": "Suivant",
+  "No browser logs in this window.": "Aucun journal navigateur dans cette fenêtre.",
+  "No logs match the selected category.": "Aucun journal ne correspond à la catégorie sélectionnée.",
+  "No maps loaded": "Aucun source map chargé",
+  "No source maps loaded. Upload a .map to de-obfuscate stacks.": "Aucun source map chargé. Importez un .map pour restaurer les positions d'origine des piles.",
+  "Page": "Page",
+  "Pick a source map and resolve to de-obfuscate this stack.": "Choisissez un source map et résolvez-le pour restaurer cette pile.",
+  "Pick a source map…": "Choisir un source map…",
+  "Prev": "Préc.",
+  "Raw stack": "Pile brute",
+  "Remove": "Supprimer",
+  "Remove from memory": "Retirer de la mémoire",
+  "Resolved": "Résolu",
+  "Resolving…": "Résolution…",
+  "Search page path": "Rechercher un chemin de page",
+  "Select an app to view its browser logs.": "Sélectionnez une application pour voir ses journaux navigateur.",
+  "Service version": "Version du service",
+  "Source maps": "Source maps",
+  "Source-map de-obfuscation is disabled on this server (sourceMaps.enabled: false).": "La restauration des positions d'origine est désactivée sur ce serveur (sourceMaps.enabled: false).",
+  "Upload .map": "Importer un .map",
+  "Uploaded maps are held in the server memory only — evicted least-recently-used under memory pressure and lost on restart. For durable provisioning, mount .map files into the server's static source-map directory; those are reloaded automatically and can't be deleted here.": "Les source maps importés ne sont conservés qu'en mémoire serveur — les moins récemment utilisés sont évincés en cas de pression mémoire, et tout est perdu au redémarrage. Pour un approvisionnement durable, montez l [...]
+  "Uploaded · temporary": "Importé · temporaire",
+  "durable": "durable",
+  "first occurrence": "première occurrence",
+  "no matches": "aucune correspondance",
+  "page {page} · showing {shown} of {total} loaded": "page {page} · affichage de {shown} sur {total} chargés",
+  "unmapped ({line}:{col})": "non mappé ({line}:{col})",
+  "{count} in window": "{count} dans la fenêtre",
+  "{used} / {total} · {count} maps · max {perFile}/file": "{used} / {total} · {count} maps · max {perFile}/fichier",
+  "Upload rejected: {reason}": "Import refusé : {reason}",
+  "Could not resolve: {reason}": "Résolution impossible : {reason}",
+  "{count} logs": "{count} logs",
+  "grade": "niveau"
 }
diff --git a/apps/ui/src/i18n/locales/ja.json b/apps/ui/src/i18n/locales/ja.json
index 173f22a..9d981d4 100644
--- a/apps/ui/src/i18n/locales/ja.json
+++ b/apps/ui/src/i18n/locales/ja.json
@@ -1357,5 +1357,49 @@
   "ungrouped": "未分類",
   "Scale": "表示倍率",
   "Pick a service to see its deployment topology.": "サービスを選択すると、デプロイメントトポロジーが表示されます。",
-  "No deployment topology in this window.": "この時間範囲にデプロイメントトポロジーはありません。"
+  "No deployment topology in this window.": "この時間範囲にデプロイメントトポロジーはありません。",
+  "(anonymous)": "(匿名)",
+  "(no message)": "(メッセージなし)",
+  "(no stack)": "(スタックなし)",
+  "(temp)": "(一時)",
+  "All pages": "すべてのページ",
+  "All versions": "すべてのバージョン",
+  "Backend unreachable.": "バックエンドに到達できません。",
+  "Browser Logs": "ブラウザログ",
+  "Categories": "カテゴリ",
+  "Clear page": "ページをクリア",
+  "Mounted": "マウント済み",
+  "Next": "次へ",
+  "No browser logs in this window.": "このウィンドウにブラウザログはありません。",
+  "No logs match the selected category.": "選択したカテゴリに一致するログはありません。",
+  "No maps loaded": "読み込み済みの source map なし",
+  "No source maps loaded. Upload a .map to de-obfuscate stacks.": "source map が読み込まれていません。スタックを復元するには .map をアップロードしてください。",
+  "Page": "ページ",
+  "Pick a source map and resolve to de-obfuscate this stack.": "source map を選択して解決し、このスタックを復元します。",
+  "Pick a source map…": "source map を選択…",
+  "Prev": "前へ",
+  "Raw stack": "生のスタック",
+  "Remove": "削除",
+  "Remove from memory": "メモリから削除",
+  "Resolved": "解決済み",
+  "Resolving…": "解決しています…",
+  "Search page path": "ページパスを検索",
+  "Select an app to view its browser logs.": "ブラウザログを表示するアプリを選択してください。",
+  "Service version": "サービスバージョン",
+  "Source maps": "Source map",
+  "Source-map de-obfuscation is disabled on this server (sourceMaps.enabled: false).": "このサーバーでは source map による復元が無効です(sourceMaps.enabled: false)。",
+  "Upload .map": ".map をアップロード",
+  "Uploaded maps are held in the server memory only — evicted least-recently-used under memory pressure and lost on restart. For durable provisioning, mount .map files into the server's static source-map directory; those are reloaded automatically and can't be deleted here.": "アップロードした map はサーバーのメモリ上にのみ保持されます — メモリ逼迫時には最近最も使われていないものから削除され、再起動で失われます。永続的に用意するには、.map ファイルをサーバーの静的 source map ディレクトリにマウントしてください。それらは自動的に再読み込みされ、ここからは削除できません。",
+  "Uploaded · temporary": "アップロード済み · 一時",
+  "durable": "永続",
+  "first occurrence": "初回発生",
+  "no matches": "該当なし",
+  "page {page} · showing {shown} of {total} loaded": "ページ {page} · 読み込み済み {total} 件中 {shown} 件を表示",
+  "unmapped ({line}:{col})": "未マップ ({line}:{col})",
+  "{count} in window": "ウィンドウ内に {count} 件",
+  "{used} / {total} · {count} maps · max {perFile}/file": "{used} / {total} · {count} 個の map · ファイルあたり最大 {perFile}",
+  "Upload rejected: {reason}": "アップロードが拒否されました:{reason}",
+  "Could not resolve: {reason}": "解決できませんでした:{reason}",
+  "{count} logs": "{count} 件のログ",
+  "grade": "グレード"
 }
diff --git a/apps/ui/src/i18n/locales/ko.json b/apps/ui/src/i18n/locales/ko.json
index f74d400..d700d21 100644
--- a/apps/ui/src/i18n/locales/ko.json
+++ b/apps/ui/src/i18n/locales/ko.json
@@ -1357,5 +1357,49 @@
   "ungrouped": "미분류",
   "Scale": "배율",
   "Pick a service to see its deployment topology.": "서비스를 선택하면 배포 토폴로지가 표시됩니다.",
-  "No deployment topology in this window.": "이 기간에 배포 토폴로지가 없습니다."
+  "No deployment topology in this window.": "이 기간에 배포 토폴로지가 없습니다.",
+  "(anonymous)": "(익명)",
+  "(no message)": "(메시지 없음)",
+  "(no stack)": "(스택 없음)",
+  "(temp)": "(임시)",
+  "All pages": "전체 페이지",
+  "All versions": "전체 버전",
+  "Backend unreachable.": "백엔드에 연결할 수 없습니다.",
+  "Browser Logs": "브라우저 로그",
+  "Categories": "카테고리",
+  "Clear page": "페이지 비우기",
+  "Mounted": "마운트됨",
+  "Next": "다음",
+  "No browser logs in this window.": "이 구간에 브라우저 로그가 없습니다.",
+  "No logs match the selected category.": "선택한 카테고리에 해당하는 로그가 없습니다.",
+  "No maps loaded": "로드된 맵 없음",
+  "No source maps loaded. Upload a .map to de-obfuscate stacks.": "로드된 소스 맵이 없습니다. .map을 업로드해 스택의 원본 위치를 복원하세요.",
+  "Page": "페이지",
+  "Pick a source map and resolve to de-obfuscate this stack.": "소스 맵을 선택하고 변환해 이 스택의 원본 위치를 복원하세요.",
+  "Pick a source map…": "소스 맵 선택…",
+  "Prev": "이전",
+  "Raw stack": "원본 스택",
+  "Remove": "제거",
+  "Remove from memory": "메모리에서 제거",
+  "Resolved": "변환됨",
+  "Resolving…": "변환 중…",
+  "Search page path": "페이지 경로 검색",
+  "Select an app to view its browser logs.": "브라우저 로그를 보려면 앱을 선택하세요.",
+  "Service version": "서비스 버전",
+  "Source maps": "소스 맵",
+  "Source-map de-obfuscation is disabled on this server (sourceMaps.enabled: false).": "이 서버에서는 소스 맵 기반 원본 위치 복원이 비활성화되어 있습니다(sourceMaps.enabled: false).",
+  "Upload .map": ".map 업로드",
+  "Uploaded maps are held in the server memory only — evicted least-recently-used under memory pressure and lost on restart. For durable provisioning, mount .map files into the server's static source-map directory; those are reloaded automatically and can't be deleted here.": "업로드된 맵은 서버 메모리에만 보관되며, 메모리가 부족하면 가장 오래 사용되지 않은 것부터 제거되고 재시작 시 사라집니다. 영구적으로 보관하려면 .map 파일을 서버의 정적 소스 맵 디렉터리에 마운트하세요. 해당 파일은 자동으로 다시 로드되며 여기서 삭제할 수 없습니다.",
+  "Uploaded · temporary": "업로드됨 · 임시",
+  "durable": "영구",
+  "first occurrence": "최초 발생",
+  "no matches": "일치 항목 없음",
+  "page {page} · showing {shown} of {total} loaded": "{page}페이지 · 로드된 {total}개 중 {shown}개 표시",
+  "unmapped ({line}:{col})": "매핑 안 됨 ({line}:{col})",
+  "{count} in window": "구간 내 {count}개",
+  "{used} / {total} · {count} maps · max {perFile}/file": "{used} / {total} · 맵 {count}개 · 파일당 최대 {perFile}",
+  "Upload rejected: {reason}": "업로드가 거부되었습니다: {reason}",
+  "Could not resolve: {reason}": "변환할 수 없습니다: {reason}",
+  "{count} logs": "로그 {count}개",
+  "grade": "등급"
 }
diff --git a/apps/ui/src/i18n/locales/pt.json b/apps/ui/src/i18n/locales/pt.json
index 829e027..5c16445 100644
--- a/apps/ui/src/i18n/locales/pt.json
+++ b/apps/ui/src/i18n/locales/pt.json
@@ -1357,5 +1357,49 @@
   "ungrouped": "sem grupo",
   "Scale": "Escala",
   "Pick a service to see its deployment topology.": "Escolha um serviço para ver sua topologia de implantação.",
-  "No deployment topology in this window.": "Nenhuma topologia de implantação nesta janela."
+  "No deployment topology in this window.": "Nenhuma topologia de implantação nesta janela.",
+  "(anonymous)": "(anônimo)",
+  "(no message)": "(sem mensagem)",
+  "(no stack)": "(sem stack)",
+  "(temp)": "(temp)",
+  "All pages": "Todas as páginas",
+  "All versions": "Todas as versões",
+  "Backend unreachable.": "Backend inacessível.",
+  "Browser Logs": "Logs do navegador",
+  "Categories": "Categorias",
+  "Clear page": "Limpar página",
+  "Mounted": "Montado",
+  "Next": "Próximo",
+  "No browser logs in this window.": "Nenhum log de navegador nesta janela.",
+  "No logs match the selected category.": "Nenhum log corresponde à categoria selecionada.",
+  "No maps loaded": "Nenhum mapa carregado",
+  "No source maps loaded. Upload a .map to de-obfuscate stacks.": "Nenhum source map carregado. Envie um .map para restaurar as posições originais dos stacks.",
+  "Page": "Página",
+  "Pick a source map and resolve to de-obfuscate this stack.": "Escolha um source map e resolva para restaurar as posições originais deste stack.",
+  "Pick a source map…": "Escolha um source map…",
+  "Prev": "Anterior",
+  "Raw stack": "Stack bruto",
+  "Remove": "Remover",
+  "Remove from memory": "Remover da memória",
+  "Resolved": "Resolvido",
+  "Resolving…": "Resolvendo…",
+  "Search page path": "Buscar caminho da página",
+  "Select an app to view its browser logs.": "Selecione um app para ver seus logs de navegador.",
+  "Service version": "Versão do serviço",
+  "Source maps": "Source maps",
+  "Source-map de-obfuscation is disabled on this server (sourceMaps.enabled: false).": "A restauração de posições via source map está desativada neste servidor (sourceMaps.enabled: false).",
+  "Upload .map": "Enviar .map",
+  "Uploaded maps are held in the server memory only — evicted least-recently-used under memory pressure and lost on restart. For durable provisioning, mount .map files into the server's static source-map directory; those are reloaded automatically and can't be deleted here.": "Os mapas enviados ficam apenas na memória do servidor — os menos usados recentemente são descartados sob pressão de memória e perdidos ao reiniciar. Para provisionamento durável, monte os arquivos .map no diretório [...]
+  "Uploaded · temporary": "Enviado · temporário",
+  "durable": "durável",
+  "first occurrence": "primeira ocorrência",
+  "no matches": "nenhuma correspondência",
+  "page {page} · showing {shown} of {total} loaded": "página {page} · mostrando {shown} de {total} carregados",
+  "unmapped ({line}:{col})": "não mapeado ({line}:{col})",
+  "{count} in window": "{count} na janela",
+  "{used} / {total} · {count} maps · max {perFile}/file": "{used} / {total} · {count} mapas · máx {perFile}/arquivo",
+  "Upload rejected: {reason}": "Envio rejeitado: {reason}",
+  "Could not resolve: {reason}": "Não foi possível resolver: {reason}",
+  "{count} logs": "{count} logs",
+  "grade": "grau"
 }
diff --git a/apps/ui/src/i18n/locales/zh-CN.json b/apps/ui/src/i18n/locales/zh-CN.json
index 47352a8..427a98e 100644
--- a/apps/ui/src/i18n/locales/zh-CN.json
+++ b/apps/ui/src/i18n/locales/zh-CN.json
@@ -1357,5 +1357,49 @@
   "ungrouped": "未分组",
   "Scale": "缩放比例",
   "Pick a service to see its deployment topology.": "选择一个服务以查看其部署拓扑。",
-  "No deployment topology in this window.": "此时间窗口内没有部署拓扑。"
+  "No deployment topology in this window.": "此时间窗口内没有部署拓扑。",
+  "(anonymous)": "(匿名)",
+  "(no message)": "(无消息)",
+  "(no stack)": "(无调用栈)",
+  "(temp)": "(临时)",
+  "All pages": "全部页面",
+  "All versions": "全部版本",
+  "Backend unreachable.": "后端不可达。",
+  "Browser Logs": "浏览器日志",
+  "Categories": "类别",
+  "Clear page": "清空页面",
+  "Mounted": "已挂载",
+  "Next": "下一页",
+  "No browser logs in this window.": "此时间窗口内无浏览器日志。",
+  "No logs match the selected category.": "没有日志匹配所选类别。",
+  "No maps loaded": "未加载 source map",
+  "No source maps loaded. Upload a .map to de-obfuscate stacks.": "尚未加载 source map。上传 .map 文件以还原调用栈的原始位置。",
+  "Page": "页面",
+  "Pick a source map and resolve to de-obfuscate this stack.": "选择一个 source map 并解析,以还原此调用栈的原始位置。",
+  "Pick a source map…": "选择一个 source map…",
+  "Prev": "上一页",
+  "Raw stack": "原始调用栈",
+  "Remove": "移除",
+  "Remove from memory": "从内存中移除",
+  "Resolved": "已解析",
+  "Resolving…": "解析中…",
+  "Search page path": "搜索页面路径",
+  "Select an app to view its browser logs.": "选择一个应用以查看其浏览器日志。",
+  "Service version": "服务版本",
+  "Source maps": "Source maps",
+  "Source-map de-obfuscation is disabled on this server (sourceMaps.enabled: false).": "此服务器已禁用 source map 原始位置还原(sourceMaps.enabled: false)。",
+  "Upload .map": "上传 .map",
+  "Uploaded maps are held in the server memory only — evicted least-recently-used under memory pressure and lost on restart. For durable provisioning, mount .map files into the server's static source-map directory; those are reloaded automatically and can't be deleted here.": "上传的 map 仅保存在服务器内存中 —— 内存紧张时按最近最少使用优先淘汰,重启后丢失。如需持久化配置,请将 .map 文件挂载到服务器的静态 source-map 目录;这些文件会自动重新加载,且无法在此删除。",
+  "Uploaded · temporary": "已上传 · 临时",
+  "durable": "持久",
+  "first occurrence": "首次出现",
+  "no matches": "无匹配",
+  "page {page} · showing {shown} of {total} loaded": "第 {page} 页 · 显示已加载 {total} 条中的 {shown} 条",
+  "unmapped ({line}:{col})": "未映射({line}:{col})",
+  "{count} in window": "时间窗口内 {count} 条",
+  "{used} / {total} · {count} maps · max {perFile}/file": "{used} / {total} · {count} 个 map · 每文件上限 {perFile}",
+  "Upload rejected: {reason}": "上传被拒绝:{reason}",
+  "Could not resolve: {reason}": "无法解析:{reason}",
+  "{count} logs": "{count} 条日志",
+  "grade": "级别"
 }
diff --git a/apps/ui/src/layer/LayerShell.vue b/apps/ui/src/layer/LayerShell.vue
index db5eebe..2c4a06e 100644
--- a/apps/ui/src/layer/LayerShell.vue
+++ b/apps/ui/src/layer/LayerShell.vue
@@ -184,6 +184,7 @@ const SCOPE_CAP_PREDICATE: Record<string, (L: LayerDef) => boolean> = {
   dependency: (L) => Boolean(L.caps?.endpointDependency),
   trace: (L) => Boolean(L.caps?.traces),
   logs: (L) => Boolean(L.caps?.logs),
+  'browser-errors': (L) => Boolean(L.caps?.browserErrors),
   'trace-profiling': (L) => Boolean(L.caps?.traceProfiling),
   'ebpf-profiling': (L) => Boolean(L.caps?.ebpfProfiling),
   'async-profiling': (L) => Boolean(L.caps?.asyncProfiling),
diff --git a/apps/ui/src/layer/browser-errors/LayerBrowserErrorsView.vue b/apps/ui/src/layer/browser-errors/LayerBrowserErrorsView.vue
new file mode 100644
index 0000000..003e454
--- /dev/null
+++ b/apps/ui/src/layer/browser-errors/LayerBrowserErrorsView.vue
@@ -0,0 +1,1005 @@
+<!--
+  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.
+-->
+<!--
+  Per-layer BROWSER "Browser Logs" tab (#6784). Lists JS error logs from
+  OAP and de-obfuscates the minified stack against an operator-chosen
+  source map (held in BFF memory). Visual vocabulary is deliberately the
+  SAME as the general Logs tab — category legend + counts, time-bucketed
+  density histogram, dense stream rows — so the two read identically. The
+  one addition is the row-expand panel: raw stack + source-map resolution.
+-->
+<script setup lang="ts">
+import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
+import { useRoute } from 'vue-router';
+import { useI18n } from 'vue-i18n';
+import type {
+  BrowserErrorCategory,
+  BrowserErrorRow,
+  LayerDef,
+  ResolveResponse,
+  SourceMapDescriptor,
+  SourceMapUsage,
+} from '@/api/client';
+import { bffClient, describeApiError } from '@/api/client';
+import { useLayers } from '@/shell/useLayers';
+import { useSetupStore } from '@/state/setup';
+import { useSelectedService } from '@/layer/useSelectedService';
+import { useLayerLanding } from '@/layer/useLayerLanding';
+import { useLayerServiceName } from '@/layer/useLayerServiceName';
+import { useLayerInstances } from '@/layer/useLayerInstances';
+import { useLayerEndpoints } from '@/layer/useLayerEndpoints';
+import { useLayerBrowserErrors } from '@/layer/browser-errors/useLayerBrowserErrors';
+import SourceMapManager from '@/layer/browser-errors/SourceMapManager.vue';
+
+const route = useRoute();
+const { t } = useI18n({ useScope: 'global' });
+const layerKey = computed(() => String(route.params.layerKey ?? ''));
+
+const { layers } = useLayers();
+const layer = computed<LayerDef | null>(() => layers.value.find((l) => l.key === layerKey.value) ?? null);
+const setup = useSetupStore();
+const { selectedId, setSelected } = useSelectedService();
+const safeLayer = computed<LayerDef>(
+  () =>
+    layer.value ?? {
+      key: layerKey.value,
+      name: layerKey.value,
+      color: 'var(--sw-fg-2)',
+      serviceCount: -1,
+      active: false,
+      level: null,
+      slots: {},
+      caps: {},
+    },
+);
+const safeCfg = computed(() => {
+  if (!layer.value) return { priority: 99, topN: 5, orderBy: 'cpm', columns: [], style: 'table' as const };
+  return setup.ensure(layer.value.key, {
+    slots: layer.value.slots,
+    caps: layer.value.caps,
+    metrics: layer.value.metrics,
+    overview: layer.value.overview,
+  }).landing;
+});
+const landing = useLayerLanding(safeLayer, safeCfg);
+const serviceName = useLayerServiceName(layerKey, landing);
+const landingRows = computed(() => landing.data.value?.sampledRows ?? landing.rows.value ?? []);
+watch(
+  landingRows,
+  (rows) => {
+    const first = rows[0];
+    if (first && !selectedId.value) setSelected(first.serviceId);
+  },
+  { immediate: true },
+);
+
+// ── Time range (own to this triage page — the topbar pauses the global
+// ticker here) + paging. Preset window OR a Custom absolute range, exactly
+// like the Logs conditions bar. ─────────────────────────────────────────
+const TIME_RANGE_PRESETS: Array<{ label: string; minutes: number }> = [
+  { label: 'Last 15 min', minutes: 15 },
+  { label: 'Last 30 min', minutes: 30 },
+  { label: 'Last 1 hour', minutes: 60 },
+  { label: 'Last 3 hours', minutes: 180 },
+  { label: 'Last 6 hours', minutes: 360 },
+  { label: 'Last 12 hours', minutes: 720 },
+  { label: 'Last 24 hours', minutes: 1440 },
+];
+const CUSTOM_RANGE_SENTINEL = -1;
+const windowMinutes = ref<number>(30);
+const customStart = ref<string | null>(null);
+const customEnd = ref<string | null>(null);
+const isCustomRange = computed(() => windowMinutes.value === CUSTOM_RANGE_SENTINEL);
+function fmtDateTimeLocal(d: Date): string {
+  const pad = (n: number) => String(n).padStart(2, '0');
+  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
+}
+watch(isCustomRange, (custom) => {
+  if (custom) {
+    if (!customStart.value || !customEnd.value) {
+      const end = new Date();
+      const start = new Date(end.getTime() - 30 * 60_000);
+      customStart.value = fmtDateTimeLocal(start);
+      customEnd.value = fmtDateTimeLocal(end);
+    }
+  } else {
+    customStart.value = null;
+    customEnd.value = null;
+  }
+});
+// Send the custom range as epoch MS — the datetime-local inputs are
+// browser-local, so `new Date(value)` yields the correct absolute instant.
+// The BFF renders it in OAP-server-local using the OAP offset; sending a
+// bare local string would be misread as OAP-local and miss by the TZ delta.
+const startMsRef = computed<number | null>(() =>
+  isCustomRange.value && customStart.value ? new Date(customStart.value).getTime() : null,
+);
+const endMsRef = computed<number | null>(() =>
+  isCustomRange.value && customEnd.value ? new Date(customEnd.value).getTime() : null,
+);
+const windowMinutesEffective = computed<number>(() => (isCustomRange.value ? 0 : windowMinutes.value));
+const page = ref(1);
+const pageSize = ref(100);
+// The query always pulls every category; the legend filters the stream
+// client-side (mirrors the Logs legend) so the chips can show full
+// per-category counts regardless of which one is selected.
+const allCategories = ref<BrowserErrorCategory>('ALL');
+
+// Server-side filters (mirror booster-ui's Version + Page conditions):
+// the BROWSER "Versions" are instances → serviceVersionId; "Pages" are
+// endpoints → pagePathId. Reuse the shared layer instance/endpoint feeds.
+const selectedVersionId = ref('');
+const { instances: versionList } = useLayerInstances(layerKey, serviceName);
+
+// Page (endpoint) is a searchable combobox, not a plain dropdown: OAP
+// returns only a top-N endpoint list, so the typed keyword is forwarded to
+// the endpoint search (`pageQuery`) to surface pages outside the top-N.
+const selectedPageId = ref('');
+const selectedPageLabel = ref('');
+const pageSearchInput = ref('');
+const pageComboOpen = ref(false);
+const pageComboEl = ref<HTMLElement | null>(null);
+const pageQuery = ref('');
+const pageLimit = ref(50);
+const { endpoints: pageList } = useLayerEndpoints(layerKey, serviceName, pageQuery, pageLimit);
+function onPageInput(ev: Event): void {
+  const v = (ev.target as HTMLInputElement).value;
+  pageSearchInput.value = v;
+  pageQuery.value = v.trim();
+  pageComboOpen.value = true;
+}
+function pickPage(p: { id: string; name: string }): void {
+  selectedPageId.value = p.id;
+  selectedPageLabel.value = p.name;
+  pageSearchInput.value = '';
+  pageQuery.value = '';
+  pageComboOpen.value = false;
+}
+function clearPage(): void {
+  selectedPageId.value = '';
+  selectedPageLabel.value = '';
+  pageSearchInput.value = '';
+  pageQuery.value = '';
+  pageComboOpen.value = false;
+}
+function onPageComboClickOutside(ev: MouseEvent): void {
+  if (!pageComboOpen.value) return;
+  const el = pageComboEl.value;
+  if (el && !el.contains(ev.target as Node)) pageComboOpen.value = false;
+}
+onMounted(() => window.addEventListener('click', onPageComboClickOutside));
+onBeforeUnmount(() => window.removeEventListener('click', onPageComboClickOutside));
+
+const { logs, total, reachable, queryError, isFetching, refetch } = useLayerBrowserErrors(layerKey, {
+  service: serviceName,
+  serviceVersionId: selectedVersionId,
+  pagePathId: selectedPageId,
+  category: allCategories,
+  page,
+  pageSize,
+  windowMinutes: windowMinutesEffective,
+  startMs: startMsRef,
+  endMs: endMsRef,
+});
+
+// Reset the version/page filters when the app changes (their ids belong to
+// the previous service), and reset paging on any condition change.
+watch(serviceName, () => {
+  selectedVersionId.value = '';
+  clearPage();
+});
+watch([serviceName, windowMinutes, customStart, customEnd, selectedVersionId, selectedPageId], () => {
+  page.value = 1;
+});
+// Collapse the open row + its resolution whenever a fresh result set
+// lands. (Category-filter toggles reset it too — see toggleCat — because
+// `expanded` is an index into `filteredLogs`, which changes when the
+// filter changes.)
+watch(logs, () => {
+  closeExpanded();
+});
+
+const hasMorePages = computed(() => logs.value.length >= pageSize.value);
+
+// ── Categories: legend + histogram colours (uses the same tokens the
+// Logs view uses for level colours, so the two tabs share a palette). ─
+const CATEGORY_ORDER = ['js', 'promise', 'vue', 'ajax', 'resource', 'unknown'] as const;
+type Cat = (typeof CATEGORY_ORDER)[number];
+const CATEGORY_COLOR: Record<Cat, 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 catOf(r: BrowserErrorRow): Cat {
+  const c = (r.category ?? '').toLowerCase();
+  return (CATEGORY_ORDER as readonly string[]).includes(c) ? (c as Cat) : 'unknown';
+}
+
+const selectedCat = ref<Cat | null>(null);
+function toggleCat(c: Cat): void {
+  selectedCat.value = selectedCat.value === c ? null : c;
+  // `expanded` indexes into filteredLogs, which just changed — drop it.
+  closeExpanded();
+}
+const filteredLogs = computed<BrowserErrorRow[]>(() =>
+  selectedCat.value ? logs.value.filter((r) => catOf(r) === selectedCat.value) : logs.value,
+);
+const catFacet = computed<Record<Cat, number>>(() => {
+  const counts = { js: 0, promise: 0, vue: 0, ajax: 0, resource: 0, unknown: 0 } as Record<Cat, number>;
+  for (const r of logs.value) counts[catOf(r)] += 1;
+  return counts;
+});
+
+// ── Density histogram (60 bins) — counts per category over the loaded
+// rows' time window, same construction as the Logs tab. ──────────────
+const BINS = 60;
+const histogram = computed(() => {
+  const rows = logs.value;
+  if (rows.length === 0) return { bins: [] as Array<Record<Cat, number>>, max: 0, t0: 0, t1: 0 };
+  let t0 = Infinity;
+  let t1 = -Infinity;
+  for (const r of rows) {
+    if (r.time < t0) t0 = r.time;
+    if (r.time > t1) t1 = r.time;
+  }
+  if (!Number.isFinite(t0) || !Number.isFinite(t1) || t0 === t1) {
+    t0 = (t1 || Date.now()) - 60_000;
+    t1 = t1 || Date.now();
+  }
+  const span = t1 - t0 || 1;
+  const bins: Array<Record<Cat, number>> = Array.from({ length: BINS }, () => ({
+    js: 0, promise: 0, vue: 0, ajax: 0, resource: 0, unknown: 0,
+  }));
+  for (const r of rows) {
+    const idx = Math.min(BINS - 1, Math.floor(((r.time - t0) / span) * BINS));
+    bins[idx][catOf(r)] += 1;
+  }
+  let max = 0;
+  for (const b of bins) {
+    const t = b.js + b.promise + b.vue + b.ajax + b.resource + b.unknown;
+    if (t > max) max = t;
+  }
+  return { bins, max, t0, t1 };
+});
+const hoveredBin = ref<number | null>(null);
+function binTotal(b: Record<Cat, number>): number {
+  return b.js + b.promise + b.vue + b.ajax + b.resource + b.unknown;
+}
+
+function pad(n: number): string {
+  return String(n).padStart(2, '0');
+}
+function fmtTime(ts: number): string {
+  const d = new Date(ts);
+  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);
+  return `${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
+}
+function fmtAxisTime(ms: number): string {
+  const d = new Date(ms);
+  return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
+}
+function fmtBucketRange(i: number, t0: number, t1: number): string {
+  const span = (t1 - t0) || 1;
+  const a = t0 + (span * i) / BINS;
+  const b = t0 + (span * (i + 1)) / BINS;
+  const t = (ms: number) => {
+    const d = new Date(ms);
+    return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
+  };
+  return `${t(a)} – ${t(b)}`;
+}
+
+// ── Source-map cache (shared by the manager + the per-row picker) ───
+const showMaps = ref(false);
+const sourceMaps = ref<SourceMapDescriptor[]>([]);
+const usage = ref<SourceMapUsage | null>(null);
+const mapsEnabled = ref(true);
+const mapsBusy = ref(false);
+const mapsError = ref<string | null>(null);
+
+async function loadMaps(): Promise<void> {
+  try {
+    const res = await bffClient.browserErrors.listSourceMaps();
+    sourceMaps.value = res.maps;
+    usage.value = res.usage;
+    mapsEnabled.value = res.enabled;
+    mapsError.value = null;
+  } catch (err) {
+    mapsError.value = describeApiError(err);
+  }
+}
+async function onUpload(file: File): Promise<void> {
+  mapsBusy.value = true;
+  mapsError.value = null;
+  try {
+    const res = await bffClient.browserErrors.uploadSourceMap(file);
+    if (!res.ok) mapsError.value = t('Upload rejected: {reason}', { reason: res.error ?? t('unknown') });
+    await loadMaps();
+    if (res.ok && res.map && !selectedMapId.value) selectedMapId.value = res.map.id;
+  } catch (err) {
+    mapsError.value = describeApiError(err);
+  } finally {
+    mapsBusy.value = false;
+  }
+}
+async function onRemove(id: string): Promise<void> {
+  mapsBusy.value = true;
+  try {
+    await bffClient.browserErrors.deleteSourceMap(id);
+    if (selectedMapId.value === id) selectedMapId.value = '';
+    await loadMaps();
+  } catch (err) {
+    mapsError.value = describeApiError(err);
+  } finally {
+    mapsBusy.value = false;
+  }
+}
+onMounted(loadMaps);
+
+// ── Row expand + source-map resolution (one row open at a time) ─────
+const expanded = ref<number | null>(null);
+const selectedMapId = ref<string>('');
+const resolved = ref<ResolveResponse | null>(null);
+const resolveBusy = ref(false);
+const resolveErr = ref<string | null>(null);
+
+// idx is part of the key so rows stay uniquely keyed even when the demo
+// reports several errors at the identical timestamp+page+version (a
+// data-only key would collide). Same approach as the Logs stream.
+function rowKey(r: BrowserErrorRow, idx: number): string {
+  return `${r.time}-${r.category}-${idx}`;
+}
+function closeExpanded(): void {
+  expanded.value = null;
+  resolved.value = null;
+  resolveErr.value = null;
+}
+function toggleRow(idx: number): void {
+  if (expanded.value === idx) {
+    closeExpanded();
+    return;
+  }
+  expanded.value = idx;
+  resolved.value = null;
+  resolveErr.value = null;
+  if (!selectedMapId.value && sourceMaps.value.length > 0) selectedMapId.value = sourceMaps.value[0].id;
+}
+async function resolveRow(row: BrowserErrorRow): Promise<void> {
+  if (!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 loc(row: BrowserErrorRow): string {
+  // Browser line/col are 1-based; OAP reports 0 for categories that carry
+  // no top-level position (PROMISE/AJAX/…), so a positive line is the
+  // signal that a position is real. Avoids "0:0" littering every row.
+  if (!row.line) return '';
+  return `${row.line}:${row.col ?? 0}`;
+}
+</script>
+
+<template>
+  <div class="lg-tab">
+    <!-- Conditions card — same shape/vocabulary as the Logs conditions
+         bar (kicker + Run query head, label-on-top field grid). Source
+         maps sits on the right of the head; its panel expands below. -->
+    <section class="lg-toolbar sw-card">
+      <div class="lg-toolbar-head">
+        <span class="kicker">{{ t('Browser Logs') }}</span>
+        <div class="be-head-right">
+          <button type="button" class="be-maps-toggle" @click="showMaps = !showMaps">
+            <span class="be-caret">{{ showMaps ? '▾' : '▸' }}</span>
+            {{ t('Source maps') }}
+            <span class="be-maps-count">{{ sourceMaps.length }}</span>
+          </button>
+          <button class="sw-btn primary" type="button" :disabled="isFetching" @click="refetch()">{{ t('Run query') }}</button>
+        </div>
+      </div>
+      <div class="lg-conditions">
+        <label class="cf">
+          <span>{{ t('Version') }}</span>
+          <select v-model="selectedVersionId" class="cf-input" :disabled="!serviceName" :title="t('Service version')">
+            <option value="">{{ t('All versions') }}</option>
+            <option v-for="v in versionList" :key="v.id" :value="v.id">{{ v.name }}</option>
+          </select>
+        </label>
+        <div ref="pageComboEl" class="cf cf-span-3 cf-combo-field">
+          <span>{{ t('Page') }}</span>
+          <div class="be-combo">
+            <input
+              class="cf-input be-combo-input"
+              type="text"
+              :value="pageSearchInput"
+              :placeholder="selectedPageLabel || t('All pages')"
+              :disabled="!serviceName"
+              :title="t('Search page path')"
+              @focus="pageComboOpen = true"
+              @input="onPageInput"
+            />
+            <button
+              v-if="selectedPageId || pageSearchInput"
+              type="button"
+              class="be-combo-clear"
+              :title="t('Clear page')"
+              @click="clearPage"
+            >×</button>
+            <ul v-if="pageComboOpen" class="be-combo-list">
+              <li class="be-combo-item" :class="{ on: !selectedPageId }" @click="clearPage"><em>{{ t('All pages') }}</em></li>
+              <li
+                v-for="p in pageList"
+                :key="p.id"
+                class="be-combo-item"
+                :class="{ on: selectedPageId === p.id }"
+                @click="pickPage(p)"
+              >{{ p.name }}</li>
+              <li v-if="pageList.length === 0" class="be-combo-empty">{{ t('no matches') }}</li>
+            </ul>
+          </div>
+        </div>
+        <label class="cf cf-wide">
+          <span>{{ t('Time range') }}</span>
+          <template v-if="isCustomRange">
+            <div class="cf-range">
+              <input v-model="customStart" type="datetime-local" class="cf-input cf-range-num" />
+              <span class="cf-range-sep">–</span>
+              <input v-model="customEnd" type="datetime-local" class="cf-input cf-range-num" />
+              <button class="sw-btn small ghost" type="button" :title="t('Back to presets')" @click="windowMinutes = 30">×</button>
+            </div>
+          </template>
+          <select v-else v-model.number="windowMinutes" class="cf-input">
+            <option v-for="p in TIME_RANGE_PRESETS" :key="p.minutes" :value="p.minutes">{{ t(p.label) }}</option>
+            <option :value="CUSTOM_RANGE_SENTINEL">{{ t('Custom…') }}</option>
+          </select>
+        </label>
+      </div>
+      <SourceMapManager
+        v-if="showMaps"
+        :maps="sourceMaps"
+        :usage="usage"
+        :enabled="mapsEnabled"
+        :busy="mapsBusy"
+        @upload="onUpload"
+        @remove="onRemove"
+        @refresh="loadMaps"
+      />
+      <p v-if="mapsError" class="be-maps-err">{{ mapsError }}</p>
+    </section>
+
+    <section class="lg-body sw-card">
+      <div class="lg-main">
+        <!-- Category legend — one chip per category with the in-window
+             count; click toggles the stream filter. Mirrors the Logs
+             "Levels" strip. -->
+        <div v-if="logs.length > 0" class="lg-legend">
+          <span class="lg-legend-kicker">{{ t('Categories') }}</span>
+          <button
+            v-for="c in CATEGORY_ORDER"
+            :key="c"
+            type="button"
+            class="lg-legend-chip"
+            :class="{ on: selectedCat === c }"
+            @click="toggleCat(c)"
+          >
+            <span class="lvl-dot" :style="{ background: CATEGORY_COLOR[c] }" />
+            <span class="lg-legend-name">{{ c }}</span>
+            <span v-if="catFacet[c] > 0" class="lg-legend-count">{{ catFacet[c] }}</span>
+          </button>
+          <span class="lg-legend-sample">{{ t('{count} in window', { count: logs.length }) }}</span>
+        </div>
+
+        <!-- Density bar — x: time, y: count, colour: category. -->
+        <div class="lg-density-wrap" v-if="histogram.bins.length > 0" @mouseleave="hoveredBin = null">
+          <div class="lg-density">
+            <div
+              v-for="(bin, i) in histogram.bins"
+              :key="i"
+              class="lg-density-bin"
+              @mouseenter="hoveredBin = i"
+            >
+              <span
+                v-for="c in CATEGORY_ORDER"
+                :key="c"
+                class="lg-density-segment"
+                :style="{
+                  background: CATEGORY_COLOR[c],
+                  height: histogram.max ? (bin[c] / histogram.max * 100) + '%' : '0%',
+                }"
+              />
+            </div>
+            <div
+              v-if="hoveredBin !== null"
+              class="lg-density-tip"
+              :style="{ left: ((hoveredBin + 0.5) / 60) * 100 + '%' }"
+            >
+              <div class="lg-density-tip-time">{{ fmtBucketRange(hoveredBin, histogram.t0, histogram.t1) }}</div>
+              <div class="lg-density-tip-total">
+                {{ t('{count} logs', { count: binTotal(histogram.bins[hoveredBin]) }) }}
+              </div>
+              <div class="lg-density-tip-rows">
+                <span v-for="c in CATEGORY_ORDER" :key="c" v-show="histogram.bins[hoveredBin][c] > 0" class="lg-density-tip-row">
+                  <span class="lvl-dot" :style="{ background: CATEGORY_COLOR[c] }" />
+                  <span class="lg-density-tip-name">{{ c }}</span>
+                  <span class="lg-density-tip-val mono">{{ histogram.bins[hoveredBin][c] }}</span>
+                </span>
+              </div>
+            </div>
+          </div>
+          <div class="lg-density-axis">
+            <span class="t-tick">{{ fmtAxisTime(histogram.t0) }}</span>
+            <span class="t-tick">{{ fmtAxisTime(histogram.t0 + (histogram.t1 - histogram.t0) * 0.25) }}</span>
+            <span class="t-tick">{{ fmtAxisTime(histogram.t0 + (histogram.t1 - histogram.t0) * 0.5) }}</span>
+            <span class="t-tick">{{ fmtAxisTime(histogram.t0 + (histogram.t1 - histogram.t0) * 0.75) }}</span>
+            <span class="t-tick">{{ fmtAxisTime(histogram.t1) }}</span>
+          </div>
+        </div>
+
+        <!-- States + stream -->
+        <div v-if="!serviceName" class="lg-empty">{{ t('Select an app to view its browser logs.') }}</div>
+        <div v-else-if="isFetching && logs.length === 0" class="lg-empty">{{ t('Reading data…') }}</div>
+        <div v-else-if="!reachable" class="lg-empty">{{ t('Backend unreachable.') }}<span v-if="queryError"> {{ queryError }}</span></div>
+        <div v-else-if="filteredLogs.length === 0" class="lg-empty">
+          {{ logs.length === 0 ? t('No browser logs in this window.') : t('No logs match the selected category.') }}
+        </div>
+        <div v-else class="lg-stream">
+          <template v-for="(r, idx) in filteredLogs" :key="rowKey(r, idx)">
+            <div
+              class="lg-row"
+              :class="{ 'is-open': expanded === idx }"
+              :style="{ boxShadow: `inset 3px 0 0 ${CATEGORY_COLOR[catOf(r)]}` }"
+              @click="toggleRow(idx)"
+            >
+              <span class="lg-time mono">{{ fmtTime(r.time) }}</span>
+              <span class="lg-date mono dim">{{ fmtDate(r.time) }}</span>
+              <span class="lg-lvl" :style="{ color: CATEGORY_COLOR[catOf(r)] }">{{ r.category }}</span>
+              <span class="lg-svc mono dim" :title="r.pagePath">{{ r.pagePath || '—' }}</span>
+              <span class="lg-ver mono dim" :title="r.serviceVersion">{{ r.serviceVersion || '—' }}</span>
+              <span class="lg-content mono">
+                <span v-if="loc(r)" class="lg-loc">{{ loc(r) }}</span>
+                <span class="lg-content-body">{{ r.message || t('(no message)') }}</span>
+              </span>
+            </div>
+
+            <div v-if="expanded === idx" class="lg-expand" @click.stop>
+              <div class="be-meta">
+                <span v-if="r.errorUrl" class="be-meta-item mono" :title="r.errorUrl">{{ r.errorUrl }}</span>
+                <span v-if="r.grade" class="be-meta-item">{{ t('grade') }}: {{ r.grade }}</span>
+                <span v-if="r.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">{{ r.stack || t('(no stack)') }}</pre>
+                </div>
+                <div class="be-col">
+                  <div class="be-col-head">
+                    <span>{{ t('Resolved') }}</span>
+                    <select v-model="selectedMapId" class="f-select be-map-pick" :disabled="sourceMaps.length === 0">
+                      <option value="" disabled>{{ sourceMaps.length ? t('Pick a source map…') : t('No maps loaded') }}</option>
+                      <option v-for="m in sourceMaps" :key="m.id" :value="m.id">
+                        {{ m.label }}{{ m.origin === 'upload' ? ' ' + t('(temp)') : '' }}
+                      </option>
+                    </select>
+                    <button class="sw-btn small primary" type="button" :disabled="!selectedMapId || resolveBusy" @click="resolveRow(r)">{{ t('Resolve') }}</button>
+                  </div>
+                  <p v-if="resolveErr" class="be-maps-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>
+          </template>
+        </div>
+
+        <div class="lg-pager">
+          <span class="hint">{{ t('page {page} · showing {shown} of {total} loaded', { page, shown: filteredLogs.length, total }) }}</span>
+          <div class="lg-pager-ctrls">
+            <button class="sw-btn small" type="button" :disabled="page <= 1 || isFetching" @click="page--">{{ t('Prev') }}</button>
+            <button class="sw-btn small" type="button" :disabled="!hasMorePages || isFetching" @click="page++">{{ t('Next') }}</button>
+          </div>
+        </div>
+      </div>
+    </section>
+  </div>
+</template>
+
+<style scoped>
+/* Visual vocabulary copied from the Logs tab so the two read identically
+   (LayerLogsView keeps its own scoped copy — they intentionally don't
+   share a stylesheet so neither can regress the other). */
+.lg-tab { display: flex; flex-direction: column; gap: 12px; padding: 4px 0 0; }
+
+/* Conditions card — mirrors the Logs conditions bar: a padded sw-card
+   with a kicker + Run-query head, then a 4-column grid of label-on-top
+   full-width fields. */
+/* overflow:visible so the Page combobox dropdown isn't clipped by the
+   card's rounded-corner overflow (same as the Logs conditions card). */
+.lg-toolbar { padding: 10px 12px; display: flex; flex-direction: column; gap: 10px; overflow: visible; }
+.lg-toolbar-head { display: flex; align-items: center; gap: 10px; width: 100%; }
+.kicker { font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--sw-accent); font-weight: 600; }
+.be-head-right { margin-left: auto; display: inline-flex; align-items: center; gap: 8px; }
+.lg-conditions { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px 10px; }
+@media (max-width: 900px) { .lg-conditions { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
+.cf { display: flex; flex-direction: column; gap: 3px; font-size: 11px; color: var(--sw-fg-3); font-weight: 500; min-width: 0; }
+.cf-wide { grid-column: span 2; }
+/* Row 1 = Version (1 col) + Page (3 cols, fills the row); Time range
+   (span 2) then flows onto row 2 — always its own second row. */
+.cf-span-3 { grid-column: span 3; }
+.cf-input {
+  height: 28px;
+  width: 100%;
+  padding: 0 8px;
+  background: var(--sw-bg-2);
+  border: 1px solid var(--sw-line-2);
+  border-radius: 4px;
+  color: var(--sw-fg-0);
+  font: inherit;
+  font-size: 11px;
+  box-sizing: border-box;
+}
+.cf-input:focus { outline: none; border-color: var(--sw-accent-line); }
+.cf-input:disabled { opacity: 0.5; cursor: not-allowed; }
+.cf-range { display: flex; align-items: center; gap: 4px; }
+.cf-range-num { flex: 1; min-width: 0; }
+.cf-range-sep { color: var(--sw-fg-3); font-size: 12px; flex: 0 0 auto; }
+/* Searchable Page (endpoint) combobox — typed keyword feeds the OAP
+   endpoint search, so pages outside the top-N list still surface. */
+.cf-combo-field .be-combo { position: relative; width: 100%; }
+.be-combo { position: relative; }
+.be-combo-input { width: 100%; padding-right: 22px; }
+.be-combo-input::placeholder { color: var(--sw-fg-2); }
+.be-combo-clear {
+  position: absolute;
+  right: 6px;
+  top: 50%;
+  transform: translateY(-50%);
+  width: 16px;
+  height: 16px;
+  line-height: 14px;
+  padding: 0;
+  background: transparent;
+  border: none;
+  color: var(--sw-fg-3);
+  font-size: 13px;
+  cursor: pointer;
+}
+.be-combo-clear:hover { color: var(--sw-err); }
+.be-combo-list {
+  position: absolute;
+  top: 100%;
+  left: 0;
+  right: 0;
+  margin: 4px 0 0;
+  padding: 4px;
+  max-height: 240px;
+  overflow-y: auto;
+  list-style: none;
+  background: var(--sw-bg-1);
+  border: 1px solid var(--sw-line-2);
+  border-radius: 4px;
+  z-index: 50;
+  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
+}
+.be-combo-item {
+  padding: 5px 8px;
+  font-size: 11px;
+  font-family: var(--sw-mono);
+  color: var(--sw-fg-1);
+  border-radius: 3px;
+  cursor: pointer;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.be-combo-item:hover { background: var(--sw-bg-2); color: var(--sw-fg-0); }
+.be-combo-item.on { background: var(--sw-accent-soft); color: var(--sw-accent-2); font-weight: 600; }
+.be-combo-item em { font-style: normal; }
+.be-combo-empty { padding: 6px 8px; font-size: 10.5px; color: var(--sw-fg-3); }
+.lg-legend-sample { margin-left: auto; font-size: 10.5px; color: var(--sw-fg-3); font-family: var(--sw-mono); }
+.be-maps-toggle {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  height: 26px;
+  background: transparent;
+  border: 1px solid var(--sw-line-2);
+  border-radius: 6px;
+  color: var(--sw-fg-2);
+  font: inherit;
+  font-size: 11.5px;
+  padding: 4px 10px;
+  cursor: pointer;
+}
+.be-maps-toggle:hover { color: var(--sw-fg-0); border-color: var(--sw-line); }
+.be-caret { color: var(--sw-fg-3); }
+.be-maps-count {
+  font-family: var(--sw-mono);
+  font-size: 10.5px;
+  color: var(--sw-fg-2);
+  background: var(--sw-bg-2);
+  border-radius: 8px;
+  padding: 0 6px;
+}
+.be-maps-err { margin: 0; font-size: 11.5px; color: var(--sw-err); }
+
+.lg-body { padding: 0; min-height: 540px; }
+.lg-main { display: flex; flex-direction: column; min-height: 0; }
+
+.lg-legend {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  padding: 10px 14px;
+  border-bottom: 1px solid var(--sw-line);
+  flex-wrap: wrap;
+}
+.lg-legend-kicker {
+  font-size: 10.5px;
+  text-transform: uppercase;
+  letter-spacing: 0.06em;
+  color: var(--sw-fg-3);
+  margin-right: 6px;
+}
+.lg-legend-chip {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  padding: 3px 9px;
+  background: var(--sw-bg-2);
+  border: 1px solid var(--sw-line-2);
+  border-radius: 12px;
+  color: var(--sw-fg-1);
+  font: inherit;
+  font-size: 11.5px;
+  cursor: pointer;
+}
+.lg-legend-chip:hover { color: var(--sw-fg-0); border-color: var(--sw-line); }
+.lg-legend-chip.on { color: var(--sw-accent-2); background: var(--sw-accent-soft); border-color: var(--sw-accent-line); }
+.lg-legend-name { text-transform: uppercase; letter-spacing: 0.04em; font-size: 10.5px; }
+.lg-legend-count {
+  font-family: var(--sw-mono);
+  font-size: 11px;
+  font-weight: 600;
+  color: var(--sw-fg-2);
+  padding: 0 4px;
+  border-left: 1px solid var(--sw-line-2);
+  margin-left: 2px;
+}
+.lg-legend-chip.on .lg-legend-count { color: var(--sw-accent-2); border-color: var(--sw-accent-line); }
+.lg-legend-tools { margin-left: auto; display: inline-flex; align-items: center; gap: 6px; }
+.lvl-dot { width: 8px; height: 8px; border-radius: 2px; flex: 0 0 auto; }
+
+.f-select {
+  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;
+}
+.f-select:focus { outline: none; border-color: var(--sw-accent-line); }
+.f-select:disabled { opacity: 0.5; cursor: not-allowed; }
+
+.lg-density-wrap { padding: 8px 12px 4px; border-bottom: 1px solid var(--sw-line); background: var(--sw-bg-1); }
+.lg-density { display: grid; grid-template-columns: repeat(60, 1fr); align-items: end; gap: 1px; height: 60px; position: relative; }
+.lg-density-bin { display: flex; flex-direction: column-reverse; height: 100%; background: var(--sw-bg-2); border-radius: 1px; overflow: hidden; }
+.lg-density-bin:hover { outline: 1px solid var(--sw-accent-line); }
+.lg-density-segment { display: block; }
+.lg-density-tip {
+  position: absolute;
+  bottom: calc(100% + 6px);
+  transform: translateX(-50%);
+  min-width: 160px;
+  padding: 6px 9px;
+  background: var(--sw-bg-0);
+  border: 1px solid var(--sw-line-2);
+  border-radius: 4px;
+  box-shadow: 0 8px 20px rgba(0, 0, 0, 0.45);
+  font-size: 11px;
+  color: var(--sw-fg-1);
+  pointer-events: none;
+  z-index: 5;
+}
+.lg-density-tip-time { color: var(--sw-fg-3); font-family: var(--sw-mono); font-size: 10px; margin-bottom: 2px; }
+.lg-density-tip-total { color: var(--sw-fg-0); font-weight: 700; font-size: 12px; margin-bottom: 4px; }
+.lg-density-tip-rows { display: flex; flex-direction: column; gap: 2px; }
+.lg-density-tip-row { display: inline-flex; align-items: center; gap: 6px; font-size: 10.5px; }
+.lg-density-tip-row .lvl-dot { width: 7px; height: 7px; border-radius: 50%; flex: 0 0 7px; }
+.lg-density-tip-name { color: var(--sw-fg-2); flex: 1; text-transform: uppercase; letter-spacing: 0.04em; }
+.lg-density-tip-val { color: var(--sw-fg-0); font-weight: 600; font-variant-numeric: tabular-nums; }
+.lg-density-axis {
+  display: flex;
+  justify-content: space-between;
+  font-family: var(--sw-mono);
+  font-size: 9.5px;
+  color: var(--sw-fg-3);
+  font-variant-numeric: tabular-nums;
+  margin-top: 4px;
+  padding: 0 2px;
+}
+.lg-density-axis .t-tick:first-child { text-align: left; }
+.lg-density-axis .t-tick:last-child { text-align: right; }
+
+.lg-empty { padding: 32px; text-align: center; color: var(--sw-fg-3); font-size: 11.5px; }
+.lg-stream { flex: 1; overflow-y: auto; font-size: 11.5px; }
+.lg-row {
+  display: grid;
+  grid-template-columns: 92px 44px 74px 150px 90px 1fr;
+  gap: 10px;
+  align-items: center;
+  padding: 4px 12px;
+  border-bottom: 1px solid var(--sw-line);
+  cursor: pointer;
+}
+.lg-row:hover { background: var(--sw-bg-2); }
+.lg-row.is-open { background: var(--sw-bg-2); }
+.lg-time { font-family: var(--sw-mono); color: var(--sw-fg-1); }
+.lg-date { font-size: 10px; }
+.lg-lvl { font-size: 9.5px; text-transform: uppercase; letter-spacing: 0.06em; font-weight: 700; }
+.lg-svc, .lg-ver { font-size: 10.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.lg-content {
+  font-family: var(--sw-mono);
+  font-size: 11px;
+  color: var(--sw-fg-1);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  min-width: 0;
+}
+.lg-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;
+}
+.lg-content-body { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+
+.lg-expand { padding: 10px 14px 14px 28px; border-bottom: 1px solid var(--sw-line); background: var(--sw-bg-2); }
+.be-meta { display: flex; gap: 14px; flex-wrap: wrap; font-size: 10.5px; color: var(--sw-fg-3); margin-bottom: 8px; }
+.be-meta-item { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 60%; }
+.be-first { color: var(--sw-warn); }
+.be-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
+.be-col { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
+/* Both column heads share a min-height so "Raw stack" lines up with the
+   "Resolved" head (which carries the map picker + Resolve button) and the
+   two panes below start at the same Y. */
+.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; }
+.be-pre {
+  margin: 0;
+  background: var(--sw-bg-1);
+  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-x: auto;
+  white-space: pre;
+  max-height: 280px;
+}
+.be-frames { display: flex; flex-direction: column; gap: 8px; }
+.be-resolved-by { margin: 0 0 2px; font-size: 11px; color: var(--sw-fg-3); }
+.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; }
+/* Source snippet — a small code block with a line-number gutter; the
+   mapped line is tinted + its number accented. */
+.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-dim { color: var(--sw-fg-3); font-size: 11px; padding: 4px 0; }
+
+.lg-pager { display: flex; align-items: center; gap: 12px; padding: 8px 14px; border-top: 1px solid var(--sw-line); }
+.lg-pager .hint { font-size: 10.5px; color: var(--sw-fg-3); }
+.lg-pager-ctrls { margin-left: auto; display: inline-flex; gap: 6px; }
+.dim { color: var(--sw-fg-3); }
+.mono { font-family: var(--sw-mono); }
+.sw-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;
+}
+.sw-btn:hover { color: var(--sw-fg-0); border-color: var(--sw-line); }
+.sw-btn:disabled { opacity: 0.5; cursor: not-allowed; }
+.sw-btn.small { height: 24px; padding: 0 10px; }
+.sw-btn.ghost { background: transparent; }
+.sw-btn.primary {
+  background: var(--sw-accent);
+  color: var(--sw-bg-0);
+  border: none;
+  padding: 0 14px;
+  font-weight: 600;
+}
+.sw-btn.primary:hover { background: var(--sw-accent-2); color: var(--sw-bg-0); }
+</style>
diff --git a/apps/ui/src/layer/browser-errors/SourceMapManager.vue b/apps/ui/src/layer/browser-errors/SourceMapManager.vue
new file mode 100644
index 0000000..e59e286
--- /dev/null
+++ b/apps/ui/src/layer/browser-errors/SourceMapManager.vue
@@ -0,0 +1,268 @@
+<!--
+  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.
+-->
+<!--
+  Source-map cache manager for the Browser Errors tab. Presentational:
+  the parent owns the data (list + usage) and the API calls; this emits
+  upload / remove / refresh. Surfaces the "maps are temporary, in BFF
+  memory" caveat + a usage bar so operators understand eviction.
+-->
+<script setup lang="ts">
+import { computed, ref } from 'vue';
+import { useI18n } from 'vue-i18n';
+import type { SourceMapDescriptor, SourceMapUsage } from '@/api/client';
+import Btn from '@/components/primitives/Btn.vue';
+import Icon from '@/components/icons/Icon.vue';
+
+const { t } = useI18n({ useScope: 'global' });
+
+const props = defineProps<{
+  maps: SourceMapDescriptor[];
+  usage: SourceMapUsage | null;
+  enabled: boolean;
+  busy?: boolean;
+}>();
+const emit = defineEmits<{
+  (e: 'upload', file: File): void;
+  (e: 'remove', id: string): void;
+  (e: 'refresh'): void;
+}>();
+
+const fileInput = ref<HTMLInputElement | null>(null);
+
+function pick(): void {
+  fileInput.value?.click();
+}
+function onFile(ev: Event): void {
+  const input = ev.target as HTMLInputElement;
+  const file = input.files?.[0];
+  if (file) emit('upload', file);
+  input.value = ''; // allow re-uploading the same filename
+}
+
+function fmtBytes(n: number): string {
+  if (n < 1024) return `${n} B`;
+  if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
+  return `${(n / (1024 * 1024)).toFixed(1)} MB`;
+}
+function fmtAge(ms: number): string {
+  const s = Math.max(0, Math.round((Date.now() - ms) / 1000));
+  if (s < 60) return `${s}s ago`;
+  if (s < 3600) return `${Math.round(s / 60)}m ago`;
+  if (s < 86400) return `${Math.round(s / 3600)}h ago`;
+  return `${Math.round(s / 86400)}d ago`;
+}
+
+const usedPct = computed(() => {
+  if (!props.usage || props.usage.maxTotalBytes <= 0) return 0;
+  return Math.min(100, Math.round((props.usage.usedBytes / props.usage.maxTotalBytes) * 100));
+});
+</script>
+
+<template>
+  <div class="smm">
+    <div class="smm-head">
+      <div class="smm-title"><Icon name="fn" /><span>{{ t('Source maps') }}</span></div>
+      <div class="smm-actions">
+        <Btn size="sm" kind="ghost" :disabled="busy" @click="emit('refresh')"><Icon name="refresh" />{{ t('Refresh') }}</Btn>
+        <Btn size="sm" kind="primary" :disabled="!enabled || busy" @click="pick"><Icon name="plus" />{{ t('Upload .map') }}</Btn>
+        <input ref="fileInput" type="file" accept=".map,application/json" hidden @change="onFile" />
+      </div>
+    </div>
+
+    <p v-if="!enabled" class="smm-note smm-note--off">
+      {{ t('Source-map de-obfuscation is disabled on this server (sourceMaps.enabled: false).') }}
+    </p>
+    <template v-else>
+      <p class="smm-note">
+        {{ t('Uploaded maps are held in the server memory only — evicted least-recently-used under memory pressure and lost on restart. For durable provisioning, mount .map files into the server\'s static source-map directory; those are reloaded automatically and can\'t be deleted here.') }}
+      </p>
+
+      <div v-if="usage" class="smm-usage">
+        <div class="smm-bar"><span class="smm-bar-fill" :style="{ width: usedPct + '%' }" /></div>
+        <span class="smm-usage-txt">
+          {{ t('{used} / {total} · {count} maps · max {perFile}/file', {
+            used: fmtBytes(usage.usedBytes),
+            total: fmtBytes(usage.maxTotalBytes),
+            count: maps.length,
+            perFile: fmtBytes(usage.maxFileBytes),
+          }) }}
+        </span>
+      </div>
+
+      <ul v-if="maps.length > 0" class="smm-list">
+        <li v-for="m in maps" :key="m.id" class="smm-item">
+          <span class="smm-name" :title="m.label">{{ m.label }}</span>
+          <span class="smm-badge" :class="m.origin === 'mount' ? 'is-mount' : 'is-upload'">
+            {{ m.origin === 'mount' ? t('Mounted') : t('Uploaded · temporary') }}
+          </span>
+          <span class="smm-meta">{{ fmtBytes(m.bytes) }}</span>
+          <span class="smm-meta">{{ fmtAge(m.addedAt) }}</span>
+          <button
+            v-if="m.origin === 'upload'"
+            class="smm-del"
+            :disabled="busy"
+            :title="t('Remove from memory')"
+            @click="emit('remove', m.id)"
+          >
+            <Icon name="more" />{{ t('Remove') }}
+          </button>
+          <span v-else class="smm-meta smm-meta--dim">{{ t('durable') }}</span>
+        </li>
+      </ul>
+      <p v-else class="smm-empty">{{ t('No source maps loaded. Upload a .map to de-obfuscate stacks.') }}</p>
+    </template>
+  </div>
+</template>
+
+<style scoped>
+.smm {
+  border: 1px solid var(--sw-line);
+  border-radius: 8px;
+  background: var(--sw-bg-1);
+  padding: 12px;
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+.smm-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+.smm-title {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  font-weight: 600;
+  font-size: 13px;
+  color: var(--sw-fg-1);
+}
+.smm-actions {
+  display: flex;
+  gap: 6px;
+}
+.smm-note {
+  margin: 0;
+  font-size: 12px;
+  line-height: 1.5;
+  color: var(--sw-fg-2);
+}
+.smm-note--off {
+  color: var(--sw-warn);
+}
+.smm-note code,
+.smm-empty code {
+  font-family: var(--sw-mono);
+  font-size: 11px;
+  color: var(--sw-fg-1);
+}
+.smm-usage {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+.smm-bar {
+  flex: 1;
+  height: 6px;
+  border-radius: 3px;
+  background: var(--sw-bg-2);
+  overflow: hidden;
+}
+.smm-bar-fill {
+  display: block;
+  height: 100%;
+  background: var(--sw-cyan);
+  transition: width 0.2s ease;
+}
+.smm-usage-txt {
+  font-size: 11px;
+  color: var(--sw-fg-2);
+  white-space: nowrap;
+}
+.smm-list {
+  list-style: none;
+  margin: 0;
+  padding: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+.smm-item {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 5px 8px;
+  border-radius: 4px;
+  background: var(--sw-bg-2);
+  font-size: 12px;
+}
+.smm-name {
+  font-family: var(--sw-mono);
+  color: var(--sw-fg-1);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  flex: 1;
+  min-width: 0;
+}
+.smm-badge {
+  font-size: 10px;
+  padding: 1px 6px;
+  border-radius: 10px;
+  white-space: nowrap;
+}
+.smm-badge.is-upload {
+  color: var(--sw-warn);
+  background: color-mix(in srgb, var(--sw-warn) 15%, transparent);
+}
+.smm-badge.is-mount {
+  color: var(--sw-info);
+  background: color-mix(in srgb, var(--sw-info) 15%, transparent);
+}
+.smm-meta {
+  font-size: 11px;
+  color: var(--sw-fg-2);
+  white-space: nowrap;
+}
+.smm-meta--dim {
+  opacity: 0.6;
+}
+.smm-del {
+  display: inline-flex;
+  align-items: center;
+  gap: 3px;
+  border: none;
+  background: transparent;
+  color: var(--sw-err);
+  cursor: pointer;
+  font-size: 11px;
+  padding: 2px 4px;
+  border-radius: 4px;
+}
+.smm-del:hover {
+  background: color-mix(in srgb, var(--sw-err) 15%, transparent);
+}
+.smm-del:disabled {
+  opacity: 0.4;
+  cursor: not-allowed;
+}
+.smm-empty {
+  margin: 0;
+  font-size: 12px;
+  color: var(--sw-fg-2);
+}
+</style>
diff --git a/apps/ui/src/layer/browser-errors/useLayerBrowserErrors.ts b/apps/ui/src/layer/browser-errors/useLayerBrowserErrors.ts
new file mode 100644
index 0000000..d128f2a
--- /dev/null
+++ b/apps/ui/src/layer/browser-errors/useLayerBrowserErrors.ts
@@ -0,0 +1,88 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { computed, type Ref } from 'vue';
+import { useQuery } from '@tanstack/vue-query';
+import { bffClient } from '@/api/client';
+import type { BrowserErrorCategory, BrowserErrorsResponse } from '@/api/client';
+
+export interface BrowserErrorParams {
+  service: Ref<string | null>;
+  /** OAP serviceVersionId — the BROWSER "Version" (instance) filter. */
+  serviceVersionId?: Ref<string>;
+  /** OAP pagePathId — the BROWSER "Page" (endpoint) filter. */
+  pagePathId?: Ref<string>;
+  category: Ref<BrowserErrorCategory>;
+  page: Ref<number>;
+  pageSize: Ref<number>;
+  /** Rolling window in minutes (ignored when an explicit start/end pair is
+   *  supplied). Own to this triage page — the global ticker is paused here. */
+  windowMinutes: Ref<number>;
+  /** Explicit absolute range as epoch MS; when both set they win over
+   *  `windowMinutes`. The BFF applies the OAP timezone offset. */
+  startMs?: Ref<number | null>;
+  endMs?: Ref<number | null>;
+}
+
+export function useLayerBrowserErrors(layerKey: Ref<string>, params: BrowserErrorParams) {
+  const q = useQuery<BrowserErrorsResponse>({
+    queryKey: [
+      'layer-browser-errors',
+      layerKey,
+      params.service,
+      params.serviceVersionId ?? computed(() => ''),
+      params.pagePathId ?? computed(() => ''),
+      params.category,
+      params.page,
+      params.pageSize,
+      params.windowMinutes,
+      params.startMs ?? computed(() => null),
+      params.endMs ?? computed(() => null),
+    ],
+    queryFn: () =>
+      bffClient.browserErrors.list(layerKey.value, {
+        ...(params.service.value ? { service: params.service.value } : {}),
+        ...(params.serviceVersionId?.value ? { serviceVersionId: params.serviceVersionId.value } : {}),
+        ...(params.pagePathId?.value ? { pagePathId: params.pagePathId.value } : {}),
+        ...(params.category.value && params.category.value !== 'ALL'
+          ? { category: params.category.value }
+          : {}),
+        ...(params.startMs?.value && params.endMs?.value
+          ? { startMs: params.startMs.value, endMs: params.endMs.value }
+          : params.windowMinutes.value
+            ? { windowMinutes: params.windowMinutes.value }
+            : {}),
+        page: params.page.value,
+        pageSize: params.pageSize.value,
+      }),
+    enabled: computed(() => layerKey.value.length > 0),
+    staleTime: 15_000,
+  });
+  // No auto-refresh subscription: this is a triage page that owns its time
+  // range, and the topbar suspends the global ticker for it ("Paused").
+
+  return {
+    data: computed(() => q.data.value ?? null),
+    logs: computed(() => q.data.value?.logs ?? []),
+    total: computed(() => q.data.value?.total ?? 0),
+    reachable: computed(() => q.data.value?.reachable ?? true),
+    queryError: computed(() => q.data.value?.error ?? null),
+    isFetching: q.isFetching,
+    error: q.error,
+    refetch: q.refetch,
+  };
+}
diff --git a/apps/ui/src/shell/AppSidebar.vue b/apps/ui/src/shell/AppSidebar.vue
index 946e4e7..b2d7912 100644
--- a/apps/ui/src/shell/AppSidebar.vue
+++ b/apps/ui/src/shell/AppSidebar.vue
@@ -82,7 +82,7 @@ function isSingleFeatureLayer(L: SidebarLayer): boolean {
   if (hasInstances(L) || hasEndpoints(L)) return false;
   if (hasTopology(L)) return false;
   const c = L.caps;
-  if (c.traces || c.logs || c.traceProfiling || c.ebpfProfiling || c.asyncProfiling || c.events) return false;
+  if (c.traces || c.logs || c.browserErrors || c.traceProfiling || c.ebpfProfiling || c.asyncProfiling || c.events) return false;
   if (c.endpointDependency || c.serviceMap || c.instanceTopology || c.processTopology || c.deployment) return false;
   return true;
 }
@@ -579,6 +579,14 @@ watch(
                 >
                   <Icon name="log" /><span>Logs</span>
                 </RouterLink>
+                <RouterLink
+                  v-if="L.caps.browserErrors"
+                  :to="`/layer/${L.key}/browser-errors`"
+                  class="sw-nav-item"
+                  :class="{ 'is-active': isActive(`/layer/${L.key}/browser-errors`) }"
+                >
+                  <Icon name="web" /><span>{{ t('Browser Logs') }}</span>
+                </RouterLink>
                 <RouterLink
                   v-if="L.caps.podLogs"
                   :to="`/layer/${L.key}/pod-logs`"
@@ -734,6 +742,14 @@ watch(
           >
             <Icon name="log" /><span>Logs</span>
           </RouterLink>
+          <RouterLink
+            v-if="E.layer.caps.browserErrors"
+            :to="`/layer/${E.layer.key}/browser-errors`"
+            class="sw-nav-item"
+            :class="{ 'is-active': isActive(`/layer/${E.layer.key}/browser-errors`) }"
+          >
+            <Icon name="web" /><span>{{ t('Browser Logs') }}</span>
+          </RouterLink>
           <RouterLink
             v-if="E.layer.caps.podLogs"
             :to="`/layer/${E.layer.key}/pod-logs`"
diff --git a/apps/ui/src/shell/AppTopbar.vue b/apps/ui/src/shell/AppTopbar.vue
index d949a26..c720d81 100644
--- a/apps/ui/src/shell/AppTopbar.vue
+++ b/apps/ui/src/shell/AppTopbar.vue
@@ -207,6 +207,9 @@ const TIME_RANGE_OPT_OUT = [
   // operator is mid-investigation. Block the global picker + pause the
   // auto-refresher whenever the operator is on a Logs tab.
   /^\/layer\/[^/]+\/logs$/,
+  // Browser Logs is a triage view with its own Time range picker (same as
+  // Logs) — auto-refresh would shift the visible window mid-investigation.
+  /^\/layer\/[^/]+\/browser-errors$/,
   // Pod Logs is a live tail driven by its own interval poll — the
   // global ticker would double-fire and the page has no rolling window
   // to refresh. Pause it while on the Pod Logs tab.
diff --git a/apps/ui/src/shell/layerFromTemplate.ts b/apps/ui/src/shell/layerFromTemplate.ts
index 3c99ad5..08e347f 100644
--- a/apps/ui/src/shell/layerFromTemplate.ts
+++ b/apps/ui/src/shell/layerFromTemplate.ts
@@ -69,6 +69,7 @@ export function componentsToCaps(
     endpointDependency: !!c.endpointDependency,
     traces: !!c.traces,
     logs: !!c.logs,
+    browserErrors: !!c.browserErrors,
     podLogs: !!c.podLogs,
     traceProfiling: !!c.traceProfiling,
     ebpfProfiling: !!c.ebpfProfiling,
diff --git a/apps/ui/src/shell/router/index.ts b/apps/ui/src/shell/router/index.ts
index fea777a..98c5504 100644
--- a/apps/ui/src/shell/router/index.ts
+++ b/apps/ui/src/shell/router/index.ts
@@ -80,6 +80,8 @@ function layerRoute(): RouteRecordRaw {
       // this path regardless of source.
       { path: 'zipkin-trace', component: () => import('@/layer/traces/LayerTracesEntry.vue') },
       { path: 'logs', component: () => import('@/layer/logs/LayerLogsView.vue') },
+      // BROWSER-layer JS error logs + source-map de-obfuscation (#6784).
+      { path: 'browser-errors', component: () => import('@/layer/browser-errors/LayerBrowserErrorsView.vue') },
       // On-demand pod logs (live tail). Instance-pinned; only K8s-
       // deployed layers (caps.podLogs) surface the tab in the sidebar.
       { path: 'pod-logs', component: () => import('@/layer/pod-logs/LayerPodLogsView.vue') },
diff --git a/apps/ui/src/shell/useLayers.ts b/apps/ui/src/shell/useLayers.ts
index 645fadc..98afa08 100644
--- a/apps/ui/src/shell/useLayers.ts
+++ b/apps/ui/src/shell/useLayers.ts
@@ -156,6 +156,7 @@ export function firstLayerTab(L: LayerDef | undefined): string {
   if (L.caps?.endpointDependency) return 'dependency';
   if (L.caps?.traces) return 'trace';
   if (L.caps?.logs) return 'logs';
+  if (L.caps?.browserErrors) return 'browser-errors';
   if (L.caps?.podLogs) return 'pod-logs';
   if (L.caps?.traceProfiling) return 'trace-profiling';
   if (L.caps?.ebpfProfiling) return 'ebpf-profiling';
diff --git a/docs/access-control/rbac.md b/docs/access-control/rbac.md
index 0727d50..79eca5e 100644
--- a/docs/access-control/rbac.md
+++ b/docs/access-control/rbac.md
@@ -23,6 +23,7 @@ Known verbs are grouped into areas:
 | `alarms:read` | Alarms page, alarm widgets on overviews. |
 | `traces:read` | Traces tab on any layer, trace detail page. |
 | `logs:read` | Logs tab on any layer, log detail page. |
+| `browser-errors:read` | Browser Logs tab (BROWSER layer): list JS error logs, list source maps, resolve a stack. |
 | `topology:read` | Topology tab, topology widgets on overviews. |
 | `profile:read` | Profiling tab (results read-only). |
 | `overview:read` | Public overview dashboards. |
@@ -43,6 +44,7 @@ Known verbs are grouped into areas:
 | `rule:delete` | DSL Management — delete a rule. |
 | `rule:debug` | DSL Management — debug a rule against sample input. |
 | `live-debug:read` / `live-debug:write` | Live Debugger — observe / start sessions. |
+| `source-map:write` | Browser Logs — upload / remove source maps (held in BFF memory). |
 | `profile:enable` | Create a profiling task on a layer. |
 
 ### Platform monitoring
@@ -94,7 +96,7 @@ Default definitions (used when `rbac.roles` is not overridden):
 Read-only data catalog. Deliberately limited — does not include `*:read` so a viewer cannot peek at rule definitions, live-debug sessions, setup screens, or platform internals.
 
 ```
-metrics:read, alarms:read, traces:read, logs:read, topology:read, profile:read, overview:read
+metrics:read, alarms:read, traces:read, logs:read, browser-errors:read, topology:read, profile:read, overview:read
 ```
 
 ### `maintainer`
@@ -112,6 +114,7 @@ Configures observability. Inherits maintainer's reads + write access to dashboar
 ```
 maintainer baseline +
 overview:read, overview:write,
+source-map:write,
 setup:read, setup:write,
 dashboard:read, dashboard:write,
 alarm-setup:read, alarm-setup:write,
diff --git a/docs/menu.yml b/docs/menu.yml
index 3787efa..c9562ba 100644
--- a/docs/menu.yml
+++ b/docs/menu.yml
@@ -65,6 +65,8 @@ catalog:
         path: "/operate/inspect"
       - name: "3D Infrastructure Map"
         path: "/operate/infra-3d-map"
+      - name: "Browser Logs & Source Maps"
+        path: "/operate/browser-source-maps"
 
   - name: "Access Control"
     catalog:
diff --git a/docs/operate/browser-source-maps.md b/docs/operate/browser-source-maps.md
new file mode 100644
index 0000000..69c8638
--- /dev/null
+++ b/docs/operate/browser-source-maps.md
@@ -0,0 +1,140 @@
+# Browser Logs & Source Maps
+
+The **BROWSER** layer has a **Browser Logs** tab that lists the JavaScript
+errors your browser agent reports — message, category (`JS`, `PROMISE`, `VUE`,
+`AJAX`, `RESOURCE`), the page, the app version, and the minified `line:col`.
+In production those positions point into minified, bundled code, so the stack
+is unreadable. Upload the build's **source map** and Horizon resolves the stack
+back to the original file, line, column, and symbol — with a source snippet.
+
+## Viewing browser logs
+
+Open the **BROWSER** layer and pick the **Browser Logs** tab. Choose an app in
+the selector, then narrow with the **Version**, **Page**, and **Time range**
+conditions, and the **Category** legend above the stream (click a category to
+filter; the counts stay visible). Each row is one reported error, newest first.
+
+The **Time range** is owned by this page — the global topbar picker is paused
+while you're here, so auto-refresh won't shift the window mid-investigation.
+Pick a rolling preset (default **Last 30 min**) or a **Custom** absolute
+start/end.
+
+Click a row to expand it. The left side shows the **raw stack** as the browser
+reported it; the right side is where you resolve it. An error's first
+occurrence is flagged in that expanded detail.
+
+## Resolving a minified stack
+
+1. Make a source map available (upload it, or provision it statically — below).
+2. Expand the error row.
+3. Pick the map from the **source map** dropdown.
+4. Click **Resolve**.
+
+Horizon parses the stack, maps each frame through the chosen map, and shows the
+original `file:line:column`, the original symbol name, and a few lines of
+original source around each frame. The map you used is named above the result,
+because **which map matches which build is your call** — see [Matching maps to
+builds](#matching-maps-to-builds).
+
+Source maps cover anything that compiles to browser JavaScript and ships a
+standard source map: JavaScript, TypeScript, JSX/TSX, Vue, Svelte, and the
+output of webpack, Vite/Rollup, esbuild, Babel, `tsc`, and minifiers. Code with
+no source map, inline/`eval`-ed code, and WebAssembly can't be resolved.
+
+Which **categories** carry a resolvable stack: **`JS`**, **`PROMISE`**, and
+**`VUE`** are real JavaScript errors whose stack points into your bundle —
+these resolve. **`AJAX`** and **`RESOURCE`** are network/load failures (a failed
+request or asset), so their "stack" is an HTTP status or URL, not code — there
+is nothing for a source map to translate. (Only the `JS` category reports a
+top-level `line:col`; for the others the position lives inside the stack string,
+which the resolver parses.)
+
+## Two ways to provide maps
+
+### Upload (temporary)
+
+The **Upload .map** button on the tab loads a map into the server's memory for
+immediate use. These uploads are **temporary by design**:
+
+- They live in the server's memory only — there is no backend storage.
+- They are evicted **least-recently-used** once the configured memory budget is
+  reached, and they are **lost when the server restarts**.
+- In a multi-instance deployment a map you upload is only visible on the
+  instance that received it.
+
+The tab shows current memory usage against the budget and warns that uploaded
+maps are temporary. Use uploads for ad-hoc triage.
+
+### Static mount (durable)
+
+For durable provisioning, place `.map` files in the server's **source-map
+directory** and they're indexed at boot. Each file is validated as a Source Map
+v3 at index time, so only real maps appear in the picker (others are skipped
+with a log warning), and the directory is bounded by `maxFileCount` — beyond it,
+extra files are skipped. Mounted maps survive restarts, reload on demand, and
+can't be deleted from the UI. In the container image the directory is
+`/app/sourcemaps` (set by `HORIZON_SOURCEMAPS_DIR`); bind-mount or copy your
+build's maps there. See [Container Image](../setup/container-image.md).
+
+## Matching maps to builds
+
+A source map only resolves correctly against the **exact build** it was
+generated from. The browser agent reports an app **version** but no build
+fingerprint, so Horizon does not auto-pick a map — **you choose** which map to
+apply, and the resolved view names it so you can confirm. Applying a map from a
+different build yields confidently wrong line numbers, so keep your maps
+labelled by version and pick the one that matches the error's app version.
+
+> Source maps' `sourcesContent` embeds your original source code. Treat the
+> maps you upload or mount as sensitive, and provision them only on servers you
+> trust.
+
+## Configuration
+
+Budgets and the static directory are set in the `sourceMaps` block of
+`horizon.yaml`:
+
+```yaml
+sourceMaps:
+  enabled: true              # turn the upload/resolve controls on or off
+  maxFileBytes: 67108864     # 64 MiB — reject any single .map larger than this
+  maxTotalBytes: 536870912   # 512 MiB — resident-upload budget (LRU-evicted past it)
+  maxFileCount: 128          # per-set map cap — the uploaded set and the mounted set are each bounded by this
+  bootMountDir: /app/sourcemaps   # static .map directory scanned at boot ("" disables it)
+```
+
+`maxTotalBytes` bounds the **resident uploaded maps** (raw `.map` bytes held in
+memory). A single upload larger than it is rejected; past it, the
+least-recently-used uploaded maps are evicted. On top of that, a few
+recently-resolved maps are kept **parsed** (bounded to a small count) — parsed
+structures run larger than the raw file, so size the container with headroom
+(≈2× the budget is comfortable). Mounted maps are disk-backed and don't count
+against this budget. Size `maxTotalBytes` against the memory available to the
+server — maps with `sourcesContent` are commonly tens of MiB each. When
+`enabled` is `false`, the tab still lists errors but the map controls are hidden.
+
+> **Restart-only knobs.** `enabled`, `maxTotalBytes`, and `maxFileCount` apply
+> live on a config reload — lowering a budget trims the **uploaded** set on the
+> next upload / resolve / list (least-recently-used first). Maps already loaded
+> from the mount are fixed by the boot scan: lowering `maxFileCount` afterwards
+> won't shrink the mounted set (restart to re-scan the mount against the lower
+> count). Two changes need a restart: `bootMountDir` (the directory is scanned
+> once at startup, so changes — and newly-dropped `.map` files — take effect on
+> restart), and **raising** `maxFileBytes` (the upload size limit is fixed at
+> startup; lowering it applies live).
+
+**Hot reload.** `enabled` and the budgets take effect immediately on a
+`horizon.yaml` change — with one exception: the upload size limit is fixed when
+the server starts, so *raising* `maxFileBytes` to accept a larger upload needs a
+restart (lowering it, and toggling `enabled`, apply live).
+
+## Permissions
+
+| Action | Permission |
+|---|---|
+| View logs, list maps, resolve a stack | `browser-errors:read` |
+| Upload or remove a source map | `source-map:write` |
+
+The default **viewer**, **maintainer**, and **operator** roles can read and
+resolve; **operator** (and **admin**) can upload and remove. See
+[Roles and Permissions](../access-control/rbac.md).
diff --git a/docs/setup/container-image.md b/docs/setup/container-image.md
index f9c2ff0..3043df5 100644
--- a/docs/setup/container-image.md
+++ b/docs/setup/container-image.md
@@ -32,6 +32,7 @@ The full commit SHA is the canonical, immutable identifier. Moving tags are conv
 | `/app/horizon.yaml` | n/a | n/a | Where the BFF expects the **active** config. **Not present in the image** — provide via mount or `COPY` (see below). |
 | `/app/bundled_templates/` | **horizon** | **yes** | Layer + overview JSON templates. Owned by `horizon` because the admin **Layer-Templates** and **Overview-Templates** editors write into per-key files here. |
 | `/data/` | **horizon** | **yes** | Declared `VOLUME`. Default destination for the audit log, setup state, alarm state, and wire debug log. Mount a PVC / named volume / host bind here for durable storage. |
+| `/app/sourcemaps/` | **horizon** | (read) | Static source maps for the **Browser Logs** tab. Bind-mount or copy `.map` files here and they're loaded at boot — durable across restarts. Optional; runtime uploads work without it. See [Browser Logs & Source Maps](../operate/browser-source-maps.md). |
 
 The runtime stage runs as the non-root user `horizon`. Two locations are owned by `horizon` so writes work without operator intervention: `bundled_templates/` (so the admin editors save) and `/data/` (so state files land somewhere durable).
 
@@ -47,6 +48,7 @@ The runtime stage runs as the non-root user `horizon`. Two locations are owned b
 | `HORIZON_SETUP_FILE` | `/data/horizon-setup.json` | Default for `setup.file`. |
 | `HORIZON_ALARMS_FILE` | `/data/horizon-alarms.json` | Default for `alarms.file`. |
 | `HORIZON_WIRE_LOG_FILE` | `/data/horizon-wire.jsonl` | Default for `debugLog.file`. |
+| `HORIZON_SOURCEMAPS_DIR` | `/app/sourcemaps` | Default for `sourceMaps.bootMountDir` — the directory scanned at boot for statically-provisioned `.map` files. See [Browser Logs & Source Maps](../operate/browser-source-maps.md). |
 
 The four `HORIZON_*_FILE` env vars seed the **defaults** the config schema uses when `horizon.yaml` doesn't supply a value. An explicit value in `horizon.yaml` always wins. The intent: an operator who runs the published image with only a minimal `horizon.yaml` (no `audit/setup/alarms/debugLog` blocks) gets state files routed to `/data/` automatically, no manual path overrides needed.
 
diff --git a/docs/setup/horizon-yaml.md b/docs/setup/horizon-yaml.md
index 7aa916f..99a208c 100644
--- a/docs/setup/horizon-yaml.md
+++ b/docs/setup/horizon-yaml.md
@@ -15,6 +15,7 @@ This page is the top-level map. Each subsection has its own detail page:
 | `setup` / `alarms` | State file paths. | [files](files.md) |
 | `debugLog` | Wire-level request/response log for troubleshooting. | [debugLog](debug-log.md) |
 | `query` | Per-request query limits (the layer-landing service cap). | [below](#query-limits) |
+| `sourceMaps` | In-memory source-map budgets + static mount for the Browser Logs tab. | [Browser Logs & Source Maps](../operate/browser-source-maps.md) |
 | `layers` | Layers to hide from the sidebar. | [below](#excluded-layers) |
 
 ## Top-level shape
@@ -46,6 +47,7 @@ audit:   { file? }
 setup:   { file? }
 alarms:  { file? }
 debugLog: { enabled?, file?, maxBodyChars?, redactAuthHeaders? }
+sourceMaps: { enabled?, maxFileBytes?, maxTotalBytes?, maxFileCount?, bootMountDir? }
 layers:  { excluded?: [{ key, reason? }] }
 ```
 
@@ -90,11 +92,14 @@ The config is re-read on file change and the new values take effect without a re
 - RBAC roles and policy (re-evaluated on next route call).
 - OAP URLs and credentials (used on next outbound call).
 - Session TTL (new sessions use the new TTL; existing sessions keep their original).
+- `sourceMaps.enabled`, `sourceMaps.maxTotalBytes`, `sourceMaps.maxFileCount` — applied on the next source-map upload / resolve / list. Lowering a budget trims the in-memory **uploaded** set then (least-recently-used first). It does **not** shrink maps already loaded from the static mount — see below.
 
-Two changes require a process restart:
+These changes require a process restart:
 
 - `server.host`, `server.port` — the listener already bound.
 - Capability probes — the OAP schema introspection cache is per-process.
+- `sourceMaps.bootMountDir` — the static source-map directory is scanned once at startup, so a new directory (and newly-dropped `.map` files) needs a restart. The count of maps loaded from that mount is fixed by the startup scan as well: lowering `sourceMaps.maxFileCount` afterwards trims only the in-memory uploaded set, never the already-mounted maps — restart to re-scan a mount against a lower count.
+- **Raising** `sourceMaps.maxFileBytes` — the multipart upload size limit is fixed at startup; lowering it applies live.
 
 ## Query limits
 
diff --git a/horizon.example.yaml b/horizon.example.yaml
index 05984c8..1c9d8a3 100644
--- a/horizon.example.yaml
+++ b/horizon.example.yaml
@@ -144,6 +144,7 @@ rbac:
       - alarms:read
       - traces:read
       - logs:read
+      - browser-errors:read
       - topology:read
       - profile:read
       - overview:read
@@ -154,6 +155,7 @@ rbac:
       - alarms:read
       - traces:read
       - logs:read
+      - browser-errors:read
       - topology:read
       - profile:read
       - overview:read
@@ -170,6 +172,8 @@ rbac:
       - alarms:read
       - traces:read
       - logs:read
+      - browser-errors:read
+      - source-map:write
       - topology:read
       - profile:read
       - cluster:read
@@ -225,3 +229,21 @@ debugLog:
   enabled: false
   maxBodyChars: 8192
   redactAuthHeaders: true
+
+# JS source maps for the BROWSER-layer "Browser Errors" tab (#6784).
+# Maps de-obfuscate minified error stacks back to original source. They
+# are held in the BFF process memory ONLY — there is no OAP-side storage —
+# so this cache is per-instance and intentionally temporary: uploaded maps
+# are lost on restart and the least-recently-used are evicted once the
+# budget is hit. For durable provisioning, drop .map files into the static
+# `bootMountDir` (Docker/k8s mount) — those are reloaded on demand and
+# survive restarts.
+sourceMaps:
+  enabled: true
+  maxFileBytes: 67108864      # 64 MiB — reject any single .map larger
+  maxTotalBytes: 536870912    # 512 MiB — total in-memory budget (LRU-evicted)
+  maxFileCount: 128           # cap on number of hosted maps
+  # Directory scanned at boot for statically-provisioned .map files. The
+  # published image sets HORIZON_SOURCEMAPS_DIR=/app/sourcemaps; leave empty
+  # to disable the static mount.
+  bootMountDir: ${HORIZON_SOURCEMAPS_DIR:}
diff --git a/packages/api-client/src/browser-errors.ts b/packages/api-client/src/browser-errors.ts
new file mode 100644
index 0000000..658e843
--- /dev/null
+++ b/packages/api-client/src/browser-errors.ts
@@ -0,0 +1,168 @@
+/*
+ * 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.
+ */
+
+/**
+ * Wire types for the BROWSER-layer "Browser Errors" tab (#6784).
+ *
+ * Two halves:
+ *   - The error log itself — a verbatim mirror of OAP's
+ *     `queryBrowserErrorLogs(BrowserErrorLogQueryCondition)` shape.
+ *   - The source-map cache the BFF holds in memory to de-obfuscate those
+ *     errors' stacks. Maps are NOT stored in OAP; the descriptors below
+ *     describe a per-BFF-instance, intentionally-temporary cache.
+ */
+
+/** OAP `ErrorCategory` enum, verbatim. `ALL` means "no filter". */
+export type BrowserErrorCategory =
+  | 'ALL'
+  | 'AJAX'
+  | 'RESOURCE'
+  | 'VUE'
+  | 'PROMISE'
+  | 'JS'
+  | 'UNKNOWN';
+
+/** One `BrowserErrorLog` row. Fields mirror the GraphQL type 1:1. */
+export interface BrowserErrorRow {
+  service: string;
+  serviceVersion: string;
+  /** Unix millis (OAP `time` Long). */
+  time: number;
+  pagePath: string;
+  category: BrowserErrorCategory;
+  grade: string | null;
+  message: string | null;
+  /** Top-level error position. OAP only populates these for the `JS`
+   *  category (the `window.onerror` path); `PROMISE` / `VUE` / `AJAX`
+   *  report 0 and carry their real frames inside `stack`. */
+  line: number | null;
+  col: number | null;
+  stack: string | null;
+  errorUrl: string | null;
+  firstReportedError: boolean;
+}
+
+export interface BrowserErrorsQueryRequest {
+  /** OAP service id (`<base64>.<digits>`). */
+  serviceId?: string;
+  /** Service NAME — resolved to an id server-side (mirror of the logs route). */
+  service?: string;
+  serviceVersionId?: string;
+  pagePathId?: string;
+  /** Omitted or `ALL` ⇒ no category filter. */
+  category?: BrowserErrorCategory;
+  page?: number;
+  pageSize?: number;
+  /** Rolling window in minutes, ending at "now". Ignored when an explicit
+   *  `startMs` / `endMs` pair is supplied. */
+  windowMinutes?: number;
+  /** Absolute range as epoch milliseconds (TZ-unambiguous). The BFF renders
+   *  these into OAP-server-local time using the OAP offset — send ms, not
+   *  pre-formatted local strings, so the browser↔OAP timezone delta is
+   *  applied correctly. */
+  startMs?: number;
+  endMs?: number;
+}
+
+export interface BrowserErrorsResponse {
+  generatedAt: number;
+  query: BrowserErrorsQueryRequest;
+  /** OAP no longer returns a cross-page total for this query; the BFF
+   *  reports the returned row count, same as the logs feed. */
+  total: number;
+  logs: BrowserErrorRow[];
+  reachable: boolean;
+  error?: string;
+}
+
+/** Where a hosted map came from. `upload` = held in memory only (lost on
+ *  restart / evicted under budget). `mount` = backed by a file in the
+ *  static `bootMountDir` (reloaded on demand, survives restarts). */
+export type SourceMapOrigin = 'upload' | 'mount';
+
+export interface SourceMapDescriptor {
+  id: string;
+  /** Display label — the uploaded filename, or the basename for mounts. */
+  label: string;
+  origin: SourceMapOrigin;
+  /** Raw `.map` byte size (the unit budgets are accounted in). */
+  bytes: number;
+  /** Unix millis the map was added (upload time / boot-scan time). */
+  addedAt: number;
+  /** Unix millis of the last resolve that used this map (drives LRU). */
+  lastUsedAt: number;
+}
+
+/** Snapshot of the in-memory cache + its budget, for the manager UI's
+ *  usage bar and the "temporary" warning. */
+export interface SourceMapUsage {
+  usedBytes: number;
+  maxTotalBytes: number;
+  maxFileBytes: number;
+  maxFileCount: number;
+}
+
+export interface SourceMapListResponse {
+  /** False when `sourceMaps.enabled` is off — the UI hides the controls. */
+  enabled: boolean;
+  maps: SourceMapDescriptor[];
+  usage: SourceMapUsage;
+}
+
+export interface SourceMapUploadResponse {
+  ok: boolean;
+  map?: SourceMapDescriptor;
+  /** `disabled` | `too_large` | `invalid_map` | `no_file`. */
+  error?: string;
+}
+
+export interface ResolveRequest {
+  /** Raw browser `error.stack` string — the primary source of frames. */
+  stack?: string;
+  /** Top-level position fallback (used when `stack` yields no frames,
+   *  e.g. a `JS`-category error with only `line`/`col`). */
+  line?: number;
+  col?: number;
+  errorUrl?: string;
+  category?: BrowserErrorCategory;
+  /** Id of the map the operator chose to resolve against. */
+  sourceMapId: string;
+}
+
+/** One resolved stack frame: the minified position as parsed from the
+ *  stack, plus the original position the map points at (null if the
+ *  frame falls outside the map). */
+export interface ResolvedFrame {
+  generated: { file: string | null; line: number | null; col: number | null; fn: string | null };
+  original:
+    | { source: string | null; line: number | null; col: number | null; name: string | null }
+    | null;
+  /** A few lines of original source around the position, when the map
+   *  carries `sourcesContent`. */
+  snippet?: { startLine: number; lines: string[] } | null;
+}
+
+export interface ResolveResponse {
+  ok: boolean;
+  /** Echo the chosen map so the UI can show "resolved against <label>"
+   *  — version-matching is the operator's call, so naming it matters. */
+  sourceMapId: string;
+  sourceMapLabel: string | null;
+  frames: ResolvedFrame[];
+  /** `disabled` | `unknown_map` | `no_input`. */
+  error?: string;
+}
diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts
index 45920e3..c6a73a4 100644
--- a/packages/api-client/src/index.ts
+++ b/packages/api-client/src/index.ts
@@ -119,6 +119,20 @@ export type {
   LogsResponse,
   LogFacetsResponse,
 } from './logs.js';
+export type {
+  BrowserErrorCategory,
+  BrowserErrorRow,
+  BrowserErrorsQueryRequest,
+  BrowserErrorsResponse,
+  SourceMapOrigin,
+  SourceMapDescriptor,
+  SourceMapUsage,
+  SourceMapListResponse,
+  SourceMapUploadResponse,
+  ResolveRequest,
+  ResolvedFrame,
+  ResolveResponse,
+} from './browser-errors.js';
 export type { OapInfo, OapCapabilities, OapBackend } from './oap-info.js';
 export type {
   RecordsTTL,
diff --git a/packages/api-client/src/menu.ts b/packages/api-client/src/menu.ts
index 22f7492..9096ee7 100644
--- a/packages/api-client/src/menu.ts
+++ b/packages/api-client/src/menu.ts
@@ -59,6 +59,9 @@ export interface LayerCaps {
   dashboards?: boolean;
   traces?: boolean;
   logs?: boolean;
+  /** Per-layer "Browser Errors" tab — BROWSER-layer JS error logs with
+   *  source-map de-obfuscation of the minified stack (#6784). */
+  browserErrors?: boolean;
   /** Trace-driven thread profiling (the original SkyWalking profile). */
   traceProfiling?: boolean;
   /** Kernel-level CPU / off-CPU profiling sourced from eBPF agents. */
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 678f285..bb4385c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -26,9 +26,15 @@ importers:
       '@fastify/cookie':
         specifier: ^11.0.1
         version: 11.0.2
+      '@fastify/multipart':
+        specifier: ^10.0.0
+        version: 10.0.0
       '@fastify/static':
         specifier: ^9.1.1
         version: 9.1.3
+      '@jridgewell/trace-mapping':
+        specifier: ^0.3.31
+        version: 0.3.31
       '@skywalking-horizon-ui/api-client':
         specifier: workspace:*
         version: link:../../packages/api-client
@@ -50,6 +56,9 @@ importers:
       pino-pretty:
         specifier: ^11.3.0
         version: 11.3.0
+      stacktrace-parser:
+        specifier: ^0.1.11
+        version: 0.1.11
       yaml:
         specifier: ^2.6.0
         version: 2.9.0
@@ -751,9 +760,15 @@ packages:
   '@fastify/ajv-compiler@4.0.5':
     resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==}
 
+  '@fastify/busboy@3.2.0':
+    resolution: {integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==}
+
   '@fastify/cookie@11.0.2':
     resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==}
 
+  '@fastify/deepmerge@3.2.1':
+    resolution: {integrity: sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==}
+
   '@fastify/error@4.2.0':
     resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==}
 
@@ -766,6 +781,9 @@ packages:
   '@fastify/merge-json-schemas@0.2.1':
     resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==}
 
+  '@fastify/multipart@10.0.0':
+    resolution: {integrity: sha512-pUx3Z1QStY7E7kwvDTIvB6P+rF5lzP+iqPgZyJyG3yBJVPvQaZxzDHYbQD89rbY0ciXrMOyGi8ezHDVexLvJDA==}
+
   '@fastify/proxy-addr@5.1.0':
     resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==}
 
@@ -3344,6 +3362,10 @@ packages:
   stackback@0.0.2:
     resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
 
+  stacktrace-parser@0.1.11:
+    resolution: {integrity: sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==}
+    engines: {node: '>=6'}
+
   stats-gl@2.4.2:
     resolution: {integrity: sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==}
     peerDependencies:
@@ -3531,6 +3553,10 @@ packages:
     resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==}
     engines: {node: '>=10'}
 
+  type-fest@0.7.1:
+    resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==}
+    engines: {node: '>=8'}
+
   typed-array-buffer@1.0.3:
     resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
     engines: {node: '>= 0.4'}
@@ -4246,11 +4272,15 @@ snapshots:
       ajv-formats: 3.0.1(ajv@8.20.0)
       fast-uri: 3.1.2
 
+  '@fastify/busboy@3.2.0': {}
+
   '@fastify/cookie@11.0.2':
     dependencies:
       cookie: 1.1.1
       fastify-plugin: 5.1.0
 
+  '@fastify/deepmerge@3.2.1': {}
+
   '@fastify/error@4.2.0': {}
 
   '@fastify/fast-json-stringify-compiler@5.0.3':
@@ -4263,6 +4293,14 @@ snapshots:
     dependencies:
       dequal: 2.0.3
 
+  '@fastify/multipart@10.0.0':
+    dependencies:
+      '@fastify/busboy': 3.2.0
+      '@fastify/deepmerge': 3.2.1
+      '@fastify/error': 4.2.0
+      fastify-plugin: 5.1.0
+      secure-json-parse: 4.1.0
+
   '@fastify/proxy-addr@5.1.0':
     dependencies:
       '@fastify/forwarded': 3.0.1
@@ -7113,6 +7151,10 @@ snapshots:
 
   stackback@0.0.2: {}
 
+  stacktrace-parser@0.1.11:
+    dependencies:
+      type-fest: 0.7.1
+
   stats-gl@2.4.2(@types/three@0.184.1)(three@0.184.0):
     dependencies:
       '@types/three': 0.184.1
@@ -7296,6 +7338,8 @@ snapshots:
 
   type-fest@0.20.2: {}
 
+  type-fest@0.7.1: {}
+
   typed-array-buffer@1.0.3:
     dependencies:
       call-bound: 1.0.4