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

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

commit 41d82f92f5a2dbddc3bc145bcd5a59c4770e5c3d
Author: Wu Sheng <[email protected]>
AuthorDate: Wed Jun 24 23:16:29 2026 +0800

    feat(inspect): add & chart foreign metrics another OAP wrote to shared 
storage
    
    Metrics Inspect can now add metrics the connected OAP doesn't define — ones
    another OAP (an older version, or a different distribution that doesn't 
carry
    the analysis rule) wrote into the shared storage — and chart their values.
    
    A new "Foreign metric" tab in the + add metric drawer takes the metric name,
    scope, and the storage value column + type (read from the catalog of the OAP
    that does define the metric). Stage several with "+ add to list" and 
bulk-add
    them, board-cap aware, through the same counting footer as the catalog tab.
    Each widget enumerates the metric's entities and plots the value series via
    OAP's new POST /inspect/values (the admin-port counterpart to 
execExpression,
    which can't evaluate a metric the OAP has no local model for). Foreign 
widgets
    carry a FOREIGN pill, behave like any other widget (entity nav, chart-cycle,
    multi-select), and persist their selection + custom entities across reloads.
    
    Absent-endpoint and upstream errors are reported honestly: a 404 on
    /inspect/values says the OAP build is too old rather than "inspect 
disabled",
    and OAP's own {error} text (a wrong value column, a metric that is actually
    defined locally, an unsupported shape) now reaches the operator instead of a
    bare HTTP 4xx.
---
 CHANGELOG.md                                       |   4 +
 apps/bff/src/http/admin/inspect.test.ts            | 171 +++++++
 apps/bff/src/http/admin/inspect.ts                 | 166 +++++++
 apps/bff/src/rbac/route-policy.ts                  |   1 +
 apps/ui/src/api/client.ts                          |  12 +
 apps/ui/src/api/scopes/inspect.ts                  |  14 +
 .../src/features/operate/inspect/InspectView.vue   | 513 +++++++++++++++++++--
 apps/ui/src/i18n/locales/de.json                   |  16 +-
 apps/ui/src/i18n/locales/en.json                   |  16 +-
 apps/ui/src/i18n/locales/es.json                   |  16 +-
 apps/ui/src/i18n/locales/fr.json                   |  16 +-
 apps/ui/src/i18n/locales/ja.json                   |  16 +-
 apps/ui/src/i18n/locales/ko.json                   |  16 +-
 apps/ui/src/i18n/locales/pt.json                   |  16 +-
 apps/ui/src/i18n/locales/zh-CN.json                |  16 +-
 docs/operate/inspect.md                            |  21 +
 packages/api-client/src/index.ts                   |   4 +
 packages/api-client/src/inspect.ts                 |  70 ++-
 18 files changed, 1063 insertions(+), 41 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index eec0300..1d547cb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -27,6 +27,10 @@ The version line is shared by every package in the monorepo 
(apps + shared packa
 - **Tag fields autocomplete on theme.** The Tags filter on Trace inspect, Log 
inspect, and the per-layer Traces / Logs tabs now suggests tag keys (before 
`=`) and per-key values (after) in a dark, dense dropdown anchored under the 
field, replacing the browser's native `<datalist>` popup. On Trace / Log 
inspect, pressing Enter commits the current tag and starts the next, mirroring 
the per-layer chip tabs.
 - **Log inspect can now read Kubernetes Pod logs across the page.** A new 
**Kubernetes Pod logs** source on Log inspect (beside Raw and Browser) tails a 
specific pod's container logs on demand from the K8s API through OAP — pick a 
Kubernetes layer, then **pick or type** a service, choose a pod and container, 
set a trailing window (30s … 30m) and optional keyword filters, and read the 
dense, read-only log lines (timestamp + content). The Layer field lists only 
Kubernetes-deployed layers ( [...]
 
+### Metrics Inspect
+
+- **Inspect can now chart a "foreign" metric — one the connected OAP doesn't 
define.** When a metric is written into shared storage by another OAP (an older 
version, or a different distribution, that the OAP behind Horizon doesn't carry 
the analysis rule for), it never shows up in the catalog drawer. The + add 
metric drawer now has a **Foreign metric** tab (beside the catalog browse): 
type the metric name, pick its scope (Service / ServiceInstance / Endpoint / 
the three relations), and g [...]
+
 ### Bundled layer dashboards
 
 - **Single-value metrics now render as cards, not flat lines, on several layer 
dashboards.** Widgets whose expression collapses the window to one number (a 
`latest(...)` total) had been mis-ported as line charts — drawn as a lone dot 
that misreads as a time series and shares one axis with an unrelated average 
trend. Each is now split into a proper single-value **card** (the total) plus a 
trend **line** (the average), matching the metric's shape, the way booster-ui 
rendered them. Affects  [...]
diff --git a/apps/bff/src/http/admin/inspect.test.ts 
b/apps/bff/src/http/admin/inspect.test.ts
new file mode 100644
index 0000000..3ec5b0e
--- /dev/null
+++ b/apps/bff/src/http/admin/inspect.test.ts
@@ -0,0 +1,171 @@
+/*
+ * 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.
+ */
+
+/**
+ * Inspect routes — the absent-endpoint discrimination that the foreign-metric
+ * feature hinges on. `POST /inspect/values` is newer than the base inspect
+ * module, so a 404 there must NOT be reported as "inspect is disabled" (the
+ * generic SW_INSPECT advice would misdirect an operator whose OAP simply
+ * predates the foreign-values endpoint). A 404 on the older catalog/entities
+ * routes still maps to `inspect_not_enabled`.
+ */
+
+import { describe, it, expect } from 'vitest';
+import Fastify, { type FastifyInstance } from 'fastify';
+import cookie from '@fastify/cookie';
+import type { FetchLike } from '@skywalking-horizon-ui/api-client';
+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 { AuditLogger } from '../../audit/logger.js';
+import { registerInspectRoutes } from './inspect.js';
+
+function fakeConfig(): ConfigSource {
+  const cfg = configSchema.parse({});
+  return { current: cfg, current_: () => cfg, path: '', onChange: () => () => 
{}, close: async () => {} };
+}
+
+/** A fetch that 404s every upstream call — i.e. OAP does not bind the path. */
+const notFoundFetch: FetchLike = async () => new Response('Not Found', { 
status: 404 });
+
+async function buildApp(fetchImpl: FetchLike): Promise<{ app: FastifyInstance; 
sessions: SessionStore }> {
+  const config = fakeConfig();
+  const sessions = new SessionStore({ ttlMinutes: 60 });
+  // audit is a typed dep the inspect routes never touch; the constructor is 
inert.
+  const audit = new AuditLogger('/tmp/horizon-inspect-test-audit.log');
+  const app = Fastify();
+  await app.register(cookie);
+  app.addHook('onRoute', makeRouteAuthHook({ config, sessions }));
+  registerInspectRoutes(app, { config, sessions, audit, fetch: fetchImpl });
+  await app.ready();
+  return { app, sessions };
+}
+
+const DAY = '2026-06-24';
+
+describe('inspect routes — absent-endpoint error mapping', () => {
+  it('maps a 404 on POST /inspect/values to inspect_values_unsupported, not 
inspect_not_enabled', async () => {
+    const { app, sessions } = await buildApp(notFoundFetch);
+    const sid = sessions.create('op', ['operator']).sid;
+    const res = await app.inject({
+      method: 'POST',
+      url: '/api/inspect/values',
+      headers: { cookie: `horizon_sid=${sid}`, 'content-type': 
'application/json' },
+      payload: {
+        expression: 'meter_x',
+        entity: { scope: 'Service', serviceName: 'svc', normal: true },
+        start: DAY,
+        end: DAY,
+        step: 'DAY',
+        foreignMetrics: [{ name: 'meter_x', valueColumn: 'value', valueType: 
'LONG' }],
+      },
+    });
+    expect(res.statusCode).toBe(404);
+    const body = res.json() as { error: string; message: string };
+    expect(body.error).toBe('inspect_values_unsupported');
+    expect(body.message).toMatch(/inspect\/values/);
+    expect(body.message).not.toMatch(/^OAP did not bind the inspect routes/);
+    await app.close();
+  });
+
+  it('still maps a 404 on GET /inspect/entities to inspect_not_enabled', async 
() => {
+    const { app, sessions } = await buildApp(notFoundFetch);
+    const sid = sessions.create('op', ['operator']).sid;
+    const res = await app.inject({
+      method: 'GET',
+      url: 
`/api/inspect/entities?metric=meter_x&start=${DAY}&end=${DAY}&step=DAY`,
+      headers: { cookie: `horizon_sid=${sid}` },
+    });
+    expect(res.statusCode).toBe(404);
+    expect((res.json() as { error: string 
}).error).toBe('inspect_not_enabled');
+    await app.close();
+  });
+
+  it('rejects a foreign values request missing foreignMetrics with a 400 
before hitting OAP', async () => {
+    const { app, sessions } = await buildApp(notFoundFetch);
+    const sid = sessions.create('op', ['operator']).sid;
+    const res = await app.inject({
+      method: 'POST',
+      url: '/api/inspect/values',
+      headers: { cookie: `horizon_sid=${sid}`, 'content-type': 
'application/json' },
+      payload: {
+        expression: 'meter_x',
+        entity: { scope: 'Service', serviceName: 'svc' },
+        start: DAY,
+        end: DAY,
+        step: 'DAY',
+        foreignMetrics: [],
+      },
+    });
+    expect(res.statusCode).toBe(400);
+    expect((res.json() as { error: string 
}).error).toBe('missing_foreign_metrics');
+    await app.close();
+  });
+
+  it('rejects a SQL-meaningful valueColumn at the BFF edge (mirrors OAP), 
before hitting OAP', async () => {
+    const { app, sessions } = await buildApp(notFoundFetch);
+    const sid = sessions.create('op', ['operator']).sid;
+    const res = await app.inject({
+      method: 'POST',
+      url: '/api/inspect/values',
+      headers: { cookie: `horizon_sid=${sid}`, 'content-type': 
'application/json' },
+      payload: {
+        expression: 'meter_x',
+        entity: { scope: 'Service', serviceName: 'svc' },
+        start: DAY,
+        end: DAY,
+        step: 'DAY',
+        foreignMetrics: [{ name: 'meter_x', valueColumn: 'value; DROP', 
valueType: 'LONG' }],
+      },
+    });
+    expect(res.statusCode).toBe(400);
+    const body = res.json() as { error: string; detail: string };
+    expect(body.error).toBe('invalid_foreign_metric');
+    expect(body.detail).toMatch(/valueColumn is invalid/);
+    await app.close();
+  });
+
+  it('rejects a SQL-meaningful valueColumn on GET /inspect/entities (foreign 
enumerate) at the edge', async () => {
+    const { app, sessions } = await buildApp(notFoundFetch);
+    const sid = sessions.create('op', ['operator']).sid;
+    const res = await app.inject({
+      method: 'GET',
+      url: 
`/api/inspect/entities?metric=meter_x&valueColumn=${encodeURIComponent('value; 
DROP')}&valueType=LONG&start=${DAY}&end=${DAY}&step=DAY`,
+      headers: { cookie: `horizon_sid=${sid}` },
+    });
+    expect(res.statusCode).toBe(400);
+    expect((res.json() as { error: string 
}).error).toBe('invalid_value_column');
+    await app.close();
+  });
+
+  it('requires inspect:read — a viewer is allowed (read verb), an 
unauthenticated caller is 401', async () => {
+    const { app, sessions } = await buildApp(notFoundFetch);
+    const anon = await app.inject({ method: 'POST', url: 
'/api/inspect/values', payload: {} });
+    expect(anon.statusCode).toBe(401);
+    // viewer holds inspect:read, so it passes the gate and reaches validation 
(400 on empty body).
+    const sid = sessions.create('v', ['viewer']).sid;
+    const viewer = await app.inject({
+      method: 'POST',
+      url: '/api/inspect/values',
+      headers: { cookie: `horizon_sid=${sid}`, 'content-type': 
'application/json' },
+      payload: {},
+    });
+    expect(viewer.statusCode).toBe(400);
+    await app.close();
+  });
+});
diff --git a/apps/bff/src/http/admin/inspect.ts 
b/apps/bff/src/http/admin/inspect.ts
index adbd587..a6d6697 100644
--- a/apps/bff/src/http/admin/inspect.ts
+++ b/apps/bff/src/http/admin/inspect.ts
@@ -42,12 +42,16 @@ import type { FastifyInstance, FastifyReply, FastifyRequest 
} from 'fastify';
 import {
   InspectApiError,
   INSPECT_ENTITY_LIMIT_MAX,
+  INSPECT_FOREIGN_VALUE_TYPES,
   INSPECT_STEPS,
   isInspectDate,
   type FetchLike,
   type InspectCatalog,
+  type InspectForeignValueType,
   type InspectMetricType,
   type InspectStep,
+  type InspectValuesRequest,
+  type ForeignMetricInput,
 } from '@skywalking-horizon-ui/api-client';
 import type { ConfigSource } from '../../config/loader.js';
 import type { AuditLogger } from '../../audit/logger.js';
@@ -74,6 +78,13 @@ const VALID_METRIC_TYPES = new Set<InspectMetricType>([
   'SAMPLED_RECORD',
 ]);
 
+const VALID_FOREIGN_VALUE_TYPES = new 
Set<InspectForeignValueType>(INSPECT_FOREIGN_VALUE_TYPES);
+
+/** Mirrors OAP's `InspectRestHandler.VALUE_COLUMN_PATTERN` — a storage column
+ *  is a plain SQL identifier. Validating it here fails a typo'd column at the
+ *  edge instead of bouncing it off OAP. */
+const VALUE_COLUMN_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
+
 const TRUTHY = new Set(['true', '1', 'yes']);
 
 export function registerInspectRoutes(app: FastifyInstance, deps: 
InspectRouteDeps): void {
@@ -268,6 +279,14 @@ export function registerInspectRoutes(app: 
FastifyInstance, deps: InspectRouteDe
         limit = parsed;
       }
 
+      // Foreign-metric path: `valueColumn` + `valueType` let OAP inspect a
+      // metric it doesn't define (persisted by another OAP into shared
+      // storage). OAP requires BOTH; we mirror that with a crisp 400 rather
+      // than forwarding a half-specified pair and surfacing OAP's generic
+      // "unknown locally" error.
+      const foreign = parseForeign(q.valueColumn, q.valueType, reply);
+      if (foreign === null) return; // 400 already sent.
+
       try {
         const got = await clients()
           .inspect()
@@ -277,6 +296,7 @@ export function registerInspectRoutes(app: FastifyInstance, 
deps: InspectRouteDe
             end: q.end,
             step,
             ...(limit !== undefined ? { limit } : {}),
+            ...foreign,
           });
         return reply.send(got);
       } catch (err) {
@@ -284,10 +304,138 @@ export function registerInspectRoutes(app: 
FastifyInstance, deps: InspectRouteDe
       }
     },
   );
+
+  // ── /api/inspect/values ──────────────────────────────────────────
+  // Reads VALUES of FOREIGN metric(s) the connected OAP doesn't define,
+  // via OAP's admin-port `POST /inspect/values` (returns the same
+  // ExpressionResult shape as execExpression). This is the value path
+  // for foreign metrics — execExpression can't evaluate a metric the
+  // OAP has no model for.
+
+  app.post(
+    '/api/inspect/values',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      if (!ensureVerb(req, reply, deps, 'inspect:read')) return;
+      const body = parseValuesBody(req.body, reply);
+      if (!body) return; // 400 already sent.
+      try {
+        const result = await clients().inspect().inspectValues(body);
+        return reply.send(result);
+      } catch (err) {
+        return passInspectError(err, reply, '/inspect/values');
+      }
+    },
+  );
 }
 
 // ── helpers ───────────────────────────────────────────────────────
 
+/** Parse the foreign-metric `valueColumn` / `valueType` pair. Returns an
+ *  empty object for an aware request (neither supplied), the validated pair
+ *  for a foreign request (both supplied), or `null` after sending a 400 when
+ *  the pair is half-specified or `valueType` is out of range. */
+function parseForeign(
+  valueColumn: string | undefined,
+  valueType: string | undefined,
+  reply: FastifyReply,
+): { valueColumn: string; valueType: InspectForeignValueType } | 
Record<string, never> | null {
+  const hasColumn = typeof valueColumn === 'string' && valueColumn.length > 0;
+  const hasType = typeof valueType === 'string' && valueType.length > 0;
+  if (!hasColumn && !hasType) return {};
+  if (!hasColumn || !hasType) {
+    reply.code(400).send({
+      error: 'foreign_requires_both',
+      message: 'valueColumn and valueType must be supplied together to inspect 
a foreign metric',
+    });
+    return null;
+  }
+  if (!VALUE_COLUMN_PATTERN.test(valueColumn as string)) {
+    reply.code(400).send({
+      error: 'invalid_value_column',
+      detail: `valueColumn is invalid: ${valueColumn}`,
+      value: valueColumn,
+    });
+    return null;
+  }
+  const upper = (valueType as string).toUpperCase();
+  if (!VALID_FOREIGN_VALUE_TYPES.has(upper as InspectForeignValueType)) {
+    reply.code(400).send({
+      error: 'invalid_value_type',
+      value: valueType,
+      allowed: INSPECT_FOREIGN_VALUE_TYPES,
+    });
+    return null;
+  }
+  return { valueColumn: valueColumn as string, valueType: upper as 
InspectForeignValueType };
+}
+
+/** Validate the `POST /api/inspect/values` body. Returns the typed request,
+ *  or `null` after sending a 400. Mirrors the essentials OAP enforces so a
+ *  malformed UI request fails crisply rather than as a generic upstream error;
+ *  OAP remains the final authority (e.g. a metric that's actually local). */
+function parseValuesBody(body: unknown, reply: FastifyReply): 
InspectValuesRequest | null {
+  if (typeof body !== 'object' || body === null) {
+    reply.code(400).send({ error: 'invalid_body' });
+    return null;
+  }
+  const b = body as Partial<InspectValuesRequest>;
+  if (typeof b.expression !== 'string' || b.expression.length === 0) {
+    reply.code(400).send({ error: 'missing_expression' });
+    return null;
+  }
+  if (typeof b.entity !== 'object' || b.entity === null || typeof 
b.entity.scope !== 'string') {
+    reply.code(400).send({ error: 'invalid_entity', detail: 'entity.scope is 
required' });
+    return null;
+  }
+  if (typeof b.step !== 'string' || 
!INSPECT_STEPS.includes(b.step.toUpperCase() as InspectStep)) {
+    reply.code(400).send({ error: 'invalid_step', allowed: INSPECT_STEPS });
+    return null;
+  }
+  const step = b.step.toUpperCase() as InspectStep;
+  if (typeof b.start !== 'string' || !isInspectDate(b.start, step)) {
+    reply.code(400).send({ error: 'invalid_start_format', step });
+    return null;
+  }
+  if (typeof b.end !== 'string' || !isInspectDate(b.end, step)) {
+    reply.code(400).send({ error: 'invalid_end_format', step });
+    return null;
+  }
+  if (!Array.isArray(b.foreignMetrics) || b.foreignMetrics.length === 0) {
+    reply.code(400).send({ error: 'missing_foreign_metrics' });
+    return null;
+  }
+  const foreignMetrics: ForeignMetricInput[] = [];
+  for (const fm of b.foreignMetrics) {
+    if (typeof fm?.name !== 'string' || fm.name.length === 0) {
+      reply.code(400).send({ error: 'invalid_foreign_metric', detail: 'name is 
required' });
+      return null;
+    }
+    if (typeof fm.valueColumn !== 'string' || fm.valueColumn.length === 0) {
+      reply.code(400).send({ error: 'invalid_foreign_metric', detail: 
'valueColumn is required' });
+      return null;
+    }
+    if (!VALUE_COLUMN_PATTERN.test(fm.valueColumn)) {
+      reply.code(400).send({ error: 'invalid_foreign_metric', detail: 
`valueColumn is invalid: ${fm.valueColumn}` });
+      return null;
+    }
+    const vt = typeof fm.valueType === 'string' ? fm.valueType.toUpperCase() : 
'';
+    if (!VALID_FOREIGN_VALUE_TYPES.has(vt as InspectForeignValueType)) {
+      reply.code(400).send({ error: 'invalid_value_type', value: fm.valueType, 
allowed: INSPECT_FOREIGN_VALUE_TYPES });
+      return null;
+    }
+    foreignMetrics.push({ name: fm.name, valueColumn: fm.valueColumn, 
valueType: vt as InspectForeignValueType });
+  }
+  return {
+    expression: b.expression,
+    entity: b.entity,
+    start: b.start,
+    end: b.end,
+    step,
+    foreignMetrics,
+  };
+}
+
 function parseStep(raw: string, reply: FastifyReply): InspectStep | null {
   const upper = raw.toUpperCase();
   if (INSPECT_STEPS.includes(upper as InspectStep)) return upper as 
InspectStep;
@@ -364,6 +512,24 @@ function ensureVerb(
 function passInspectError(err: unknown, reply: FastifyReply, path: string): 
FastifyReply {
   if (err instanceof InspectApiError) {
     if (err.status === 404) {
+      // `POST /inspect/values` (and the foreign `/inspect/entities` params) 
are
+      // newer than the base inspect module, so a 404 there means a different
+      // thing than a 404 on the catalog: the inspect module IS enabled (the
+      // catalog loaded, or the page-level banner already covers a fully-off
+      // module), but THIS OAP build predates the foreign-values endpoint. A
+      // generic "Set SW_INSPECT=default" would misdirect the operator — point
+      // at the OAP version instead. This is the exact cross-version case the
+      // foreign-metric feature exists for, so the message has to be honest.
+      if (path === '/inspect/values') {
+        return reply.code(404).send({
+          error: 'inspect_values_unsupported',
+          message:
+            'OAP did not expose POST /inspect/values. Reading a foreign 
metric’s ' +
+            'values needs a newer OAP build (or, if the whole inspect module 
is off, ' +
+            'set SW_INSPECT=default on the admin-server).',
+          path,
+        });
+      }
       return reply.code(404).send({
         error: 'inspect_not_enabled',
         message: 'OAP did not bind the inspect routes. Set SW_INSPECT=default 
on the admin-server.',
diff --git a/apps/bff/src/rbac/route-policy.ts 
b/apps/bff/src/rbac/route-policy.ts
index 632c683..6e20dce 100644
--- a/apps/bff/src/rbac/route-policy.ts
+++ b/apps/bff/src/rbac/route-policy.ts
@@ -145,6 +145,7 @@ export const ROUTE_POLICY: Record<string, RoutePolicy> = {
   'GET /api/inspect/server-time':                  'inspect:read',
   'POST /api/inspect/exec':                        'inspect:read',
   'GET /api/inspect/entities':                     'inspect:read',
+  'POST /api/inspect/values':                      'inspect:read',
   'GET /api/oap/ttl':                              'ttl:read',
   'GET /api/oap/config':                           'config:read',
 
diff --git a/apps/ui/src/api/client.ts b/apps/ui/src/api/client.ts
index 1652b65..e059500 100644
--- a/apps/ui/src/api/client.ts
+++ b/apps/ui/src/api/client.ts
@@ -375,6 +375,18 @@ export function describeApiError(err: unknown): string {
       if (typeof o.applyStatus === 'string') {
         return `${e.status} (${o.applyStatus})`;
       }
+      // OAP's inspect/admin envelope is `{ error: "<human text>" }` (no
+      // `message`), and BFF validation envelopes carry a code in `error` plus
+      // the human reason in `detail` (`{ error: 'invalid_foreign_metric',
+      // detail: 'valueColumn is invalid …' }`). Surface both rather than
+      // collapsing to a bare "HTTP <status>" or a lone code — for a foreign
+      // metric this is where OAP's / the BFF's "valueColumn is invalid …",
+      // "metric is defined locally …" reasons reach the operator.
+      if (typeof o.error === 'string' && o.error.length > 0) {
+        return typeof o.detail === 'string' && o.detail.length > 0
+          ? `${e.status} (${o.error}): ${o.detail}`
+          : `${e.status}: ${o.error}`;
+      }
     }
     if (typeof e.body === 'string' && e.body.length > 0) {
       return `${e.status}: ${e.body}`;
diff --git a/apps/ui/src/api/scopes/inspect.ts 
b/apps/ui/src/api/scopes/inspect.ts
index ea5d51d..eca2e3a 100644
--- a/apps/ui/src/api/scopes/inspect.ts
+++ b/apps/ui/src/api/scopes/inspect.ts
@@ -19,7 +19,9 @@ import type {
   EntitiesResponse,
   ExpressionResult,
   InspectExecRequest,
+  InspectForeignValueType,
   InspectStep,
+  InspectValuesRequest,
 } from '@skywalking-horizon-ui/api-client';
 import type {
   BffClient,
@@ -42,6 +44,10 @@ export class InspectApi {
     end: string;
     step: InspectStep;
     limit?: number;
+    /** FOREIGN metric (not defined on the connected OAP): supply both to
+     *  enumerate its entities from shared storage. */
+    valueColumn?: string;
+    valueType?: InspectForeignValueType;
   }): Promise<EntitiesResponse> {
     const params = new URLSearchParams({
       metric: args.metric,
@@ -50,6 +56,8 @@ export class InspectApi {
       step: args.step,
     });
     if (args.limit !== undefined) params.set('limit', String(args.limit));
+    if (args.valueColumn !== undefined) params.set('valueColumn', 
args.valueColumn);
+    if (args.valueType !== undefined) params.set('valueType', args.valueType);
     return this.bff.request<EntitiesResponse>(
       'GET',
       `/api/inspect/entities?${params.toString()}`,
@@ -60,6 +68,12 @@ export class InspectApi {
     return this.bff.request<ExpressionResult>('POST', '/api/inspect/exec', 
req);
   }
 
+  /** Read values of a FOREIGN metric (one the connected OAP doesn't define)
+   *  via `/inspect/values`. Returns the same `ExpressionResult` as `exec`. */
+  values(req: InspectValuesRequest): Promise<ExpressionResult> {
+    return this.bff.request<ExpressionResult>('POST', '/api/inspect/values', 
req);
+  }
+
   serverTime(refresh = false): Promise<InspectServerTimeResponse> {
     const path = refresh ? '/api/inspect/server-time?refresh=true' : 
'/api/inspect/server-time';
     return this.bff.request<InspectServerTimeResponse>('GET', path);
diff --git a/apps/ui/src/features/operate/inspect/InspectView.vue 
b/apps/ui/src/features/operate/inspect/InspectView.vue
index 4baa3b0..9440b16 100644
--- a/apps/ui/src/features/operate/inspect/InspectView.vue
+++ b/apps/ui/src/features/operate/inspect/InspectView.vue
@@ -38,9 +38,11 @@ import { useQuery, useQueryClient } from 
'@tanstack/vue-query';
 import InspectMetricChart from './InspectMetricChart.vue';
 import {
   INSPECT_STEPS,
+  INSPECT_FOREIGN_VALUE_TYPES,
   type EntitiesResponse,
   type EntityRow,
   type ExpressionResult,
+  type InspectForeignValueType,
   type InspectScope,
   type InspectStep,
   type MqeEntity,
@@ -64,9 +66,23 @@ interface FileNode {
   metricCount: number;
 }
 
+/** Storage metadata for a FOREIGN metric — one the connected OAP doesn't
+ *  define. Absent from the catalog, so the operator supplies it by hand.
+ *  `/inspect/entities` uses it to enumerate the metric's entities from shared
+ *  storage, and `/inspect/values` uses it to read the value series (the
+ *  admin-port counterpart to `execExpression`, which can't evaluate a metric
+ *  the OAP has no model for). */
+interface ForeignSpec {
+  valueColumn: string;
+  valueType: InspectForeignValueType;
+}
+
 interface Widget {
   id: string;
   metric: InspectCatalogEntry;
+  /** Set when the metric is foreign (see {@link ForeignSpec}); `null` for a
+   *  normal catalog metric. */
+  foreign: ForeignSpec | null;
   /** Entities resolved by `/api/inspect/entities`. May be empty
    *  before the editor has ever been opened. */
   resolvedEntities: EntityRow[];
@@ -299,6 +315,47 @@ const drawerSourceFilter = ref<Set<Source>>(new 
Set<Source>(['OAL', 'MAL·OTEL',
 const drawerActiveFile = ref<string | null>(null);
 const drawerScopeNarrow = ref<InspectScope | null>(null);
 
+// ─── Foreign-metric add form (drawer section) ──────────────────────
+// Scopes the foreign /inspect/entities path accepts (mirrors OAP's
+// isSupportedScope). Process / All are out of scope on the OAP side.
+const FOREIGN_SCOPES: InspectScope[] = [
+  'Service',
+  'ServiceInstance',
+  'Endpoint',
+  'ServiceRelation',
+  'ServiceInstanceRelation',
+  'EndpointRelation',
+];
+interface ForeignForm {
+  name: string;
+  scope: InspectScope;
+  valueColumn: string;
+  valueType: InspectForeignValueType;
+}
+function emptyForeignForm(): ForeignForm {
+  return { name: '', scope: 'Service', valueColumn: 'value', valueType: 'LONG' 
};
+}
+const foreignForm = ref<ForeignForm>(emptyForeignForm());
+const foreignErr = ref('');
+/** Foreign metrics staged for bulk add (committed via the drawer footer). */
+const foreignStaged = ref<ForeignForm[]>([]);
+/** Which drawer tab is active — catalog browse vs foreign-metric entry. */
+const drawerTab = ref<'catalog' | 'foreign'>('catalog');
+
+/** Items pending add for the active tab — catalog checkboxes or staged
+ *  foreign metrics. Drives the shared footer count + commit. */
+const drawerPending = computed(() =>
+  drawerTab.value === 'catalog' ? drawerSelection.value.size : 
foreignStaged.value.length,
+);
+/** How many of the pending items actually fit under the board cap. */
+const drawerAddCount = computed(() =>
+  Math.min(drawerPending.value, Math.max(0, boardCap.value - 
widgets.value.length)),
+);
+function commitDrawerActive(): void {
+  if (drawerTab.value === 'catalog') commitDrawer();
+  else commitForeign();
+}
+
 const drawerRegex = computed(() => {
   if (!drawerQuery.value) return null;
   try {
@@ -372,6 +429,9 @@ function openDrawer() {
   drawerSelection.value = new Set();
   drawerQuery.value = '';
   drawerScopeNarrow.value = null;
+  foreignErr.value = '';
+  foreignStaged.value = [];
+  drawerTab.value = 'catalog';
   if (!drawerActiveFile.value || !drawerFiles.value.some((f) => 
`${f.source}::${f.file}` === drawerActiveFile.value)) {
     const first = drawerFiles.value[0];
     drawerActiveFile.value = first ? `${first.source}::${first.file}` : null;
@@ -444,6 +504,54 @@ function commitDrawer() {
   }
 }
 
+/** Stage the current foreign-metric form onto the pending list. Operators
+ *  batch several foreign metrics, then commit them all at once via the shared
+ *  drawer footer (same bulk-add + board-cap model as the catalog tab). */
+function stageForeignMetric(): void {
+  foreignErr.value = '';
+  const f = foreignForm.value;
+  const name = f.name.trim();
+  if (!name) {
+    foreignErr.value = t('metric name is required');
+    return;
+  }
+  if (
+    widgets.value.some((w) => w.metric.name === name) ||
+    foreignStaged.value.some((s) => s.name === name)
+  ) {
+    foreignErr.value = t('{metric} is already on the board', { metric: name });
+    return;
+  }
+  foreignStaged.value = [
+    ...foreignStaged.value,
+    { ...f, name, valueColumn: f.valueColumn.trim() || 'value' },
+  ];
+  /* Keep scope / valueType for the next add (operators batch similar
+   * metrics from the same source); clear only the name. */
+  foreignForm.value = { ...f, name: '' };
+}
+
+function unstageForeign(name: string): void {
+  foreignStaged.value = foreignStaged.value.filter((s) => s.name !== name);
+}
+
+/** Commit the staged foreign metrics to the board, clamped to the board cap —
+ *  identical bulk-add semantics to the catalog tab's commitDrawer. */
+function commitForeign(): void {
+  const added: Widget[] = [];
+  for (const f of foreignStaged.value) {
+    if (widgets.value.length >= boardCap.value) break;
+    if (widgets.value.some((w) => w.metric.name === f.name)) continue;
+    const metric = makeForeignMetric(f.name, f.scope, f.valueColumn, 
f.valueType);
+    const w = makeWidget(metric, { foreign: { valueColumn: f.valueColumn, 
valueType: f.valueType } });
+    widgets.value.push(w);
+    added.push(w);
+  }
+  foreignStaged.value = [];
+  drawerOpen.value = false;
+  for (const w of added) void resolveEntitiesFor(w);
+}
+
 // ─── Board / widgets ───────────────────────────────────────────────
 
 interface MakeWidgetOpts {
@@ -451,11 +559,13 @@ interface MakeWidgetOpts {
   customEntities?: EntityRow[];
   selectedIds?: Set<string>;
   chart?: ChartKind;
+  foreign?: ForeignSpec | null;
 }
 function makeWidget(metric: InspectCatalogEntry, opts: MakeWidgetOpts = {}): 
Widget {
   return reactive({
     id: opts.id ?? `${metric.name}-${Math.random().toString(36).slice(2, 7)}`,
     metric,
+    foreign: opts.foreign ?? null,
     resolvedEntities: [],
     customEntities: opts.customEntities ?? [],
     selectedIds: opts.selectedIds ?? new Set<string>(),
@@ -467,6 +577,28 @@ function makeWidget(metric: InspectCatalogEntry, opts: 
MakeWidgetOpts = {}): Wid
   }) as Widget;
 }
 
+/** Synthesize a catalog-entry shell for a foreign metric the operator typed
+ *  by hand (it isn't in OAP's catalog). Only `name` + `scope` +
+ *  `valueColumnName` are load-bearing downstream; the rest is filler so the
+ *  widget code can treat foreign and catalog metrics uniformly. */
+function makeForeignMetric(
+  name: string,
+  scope: InspectScope,
+  valueColumn: string,
+  valueType: InspectForeignValueType,
+): InspectCatalogEntry {
+  return {
+    name,
+    type: valueType === 'LABELED' ? 'LABELED_VALUE' : 'REGULAR_VALUE',
+    catalog: '',
+    scopeId: 0,
+    scope,
+    valueColumnName: valueColumn,
+    downsamplings: ['MINUTE', 'HOUR', 'DAY'],
+    attribution: { source: 'unknown', file: null },
+  };
+}
+
 // ─── Local persistence ────────────────────────────────────────────
 //
 // Save the board to localStorage so the operator's selection
@@ -485,6 +617,13 @@ interface PersistedWidget {
   selectedIds: string[];
   customEntities: EntityRow[];
   chart: ChartKind;
+  /** Present only for foreign metrics — they aren't in the catalog, so a
+   *  name lookup can't reconstruct them on hydrate; carry the full spec. */
+  foreign?: {
+    scope: InspectScope;
+    valueColumn: string;
+    valueType: InspectForeignValueType;
+  };
 }
 
 interface PersistedBoard {
@@ -503,6 +642,15 @@ function serializeBoard(): PersistedBoard {
       selectedIds: [...w.selectedIds],
       customEntities: w.customEntities,
       chart: w.chart,
+      ...(w.foreign
+        ? {
+            foreign: {
+              scope: w.metric.scope,
+              valueColumn: w.foreign.valueColumn,
+              valueType: w.foreign.valueType,
+            },
+          }
+        : {}),
     })),
     density: density.value,
     boardCap: boardCap.value,
@@ -550,15 +698,19 @@ function onReset(): void {
 
 const widgets = ref<Widget[]>([]);
 
-/** Hydrate from localStorage as soon as the catalog query lands —
- *  before that we can't resolve a metric name to its catalog entry.
- *  Runs once per session: `hydrated` flips true after the first
- *  successful pass. */
+/** Hydrate from localStorage as soon as the catalog query SETTLES (success
+ *  or error), not when it first has rows. An empty catalog is a real state —
+ *  a minimal OAP, or one with no MQE-queryable metrics — and it's exactly the
+ *  OAP a foreign metric targets. Gating on `catalog.length > 0` there would
+ *  leave `hydrated` false forever, so the persistence watch never arms and
+ *  foreign widgets silently vanish on reload. Aware widgets whose catalog row
+ *  is absent are skipped gracefully below; foreign widgets reconstruct from
+ *  their saved spec without any catalog lookup. Runs once per session. */
 const hydrated = ref(false);
 
 function hydrateBoardIfReady(): void {
   if (hydrated.value) return;
-  if (catalog.value.length === 0) return;
+  if (catalogQuery.isPending.value) return;
   hydrated.value = true;
   const saved = loadBoard();
   if (!saved) return;
@@ -575,6 +727,26 @@ function hydrateBoardIfReady(): void {
   }
   const restored: Widget[] = [];
   for (const pw of saved.widgets) {
+    if (pw.foreign) {
+      /* Foreign metrics aren't in the catalog — reconstruct from the saved
+       * spec, never from a name lookup. */
+      const metric = makeForeignMetric(
+        pw.metricName,
+        pw.foreign.scope,
+        pw.foreign.valueColumn,
+        pw.foreign.valueType,
+      );
+      restored.push(
+        makeWidget(metric, {
+          id: pw.id,
+          selectedIds: new Set(pw.selectedIds),
+          customEntities: pw.customEntities ?? [],
+          chart: pw.chart ?? 'line',
+          foreign: { valueColumn: pw.foreign.valueColumn, valueType: 
pw.foreign.valueType },
+        }),
+      );
+      continue;
+    }
     const row = catalog.value.find((m) => m.name === pw.metricName);
     /* Skip widgets whose metric is no longer in the catalog — happens
      * after an OAP rule remove. The operator just sees a smaller
@@ -605,11 +777,11 @@ function hydrateBoardIfReady(): void {
 /* watchEffect runs synchronously once during setup AND re-runs
  * whenever its deps change. Crucial when vue-query returns a cached
  * catalog immediately: a plain `watch` with `immediate: false` would
- * never fire because `catalog.value` doesn't change from "cached" to
- * "cached" on mount, and `hydrateBoardIfReady` is a no-op while the
- * list is empty. */
+ * never fire because the query never transitions on mount. Gate on the
+ * query having SETTLED (not on row count) so an empty catalog still
+ * hydrates — see hydrateBoardIfReady. */
 watchEffect(() => {
-  if (catalog.value.length > 0) hydrateBoardIfReady();
+  if (!catalogQuery.isPending.value) hydrateBoardIfReady();
 });
 
 /* Persist on every meaningful change. The watch fires more than
@@ -677,17 +849,30 @@ function stepEntity(w: Widget, dir: 1 | -1) {
 
 function decodedLabel(e: EntityRow): string {
   const d = e.decoded as Record<string, unknown>;
-  const scope = e.mqeEntity.scope;
+  const scope = e.mqeEntity?.scope;
+  if (!scope) return e.entityId;
   const layer = e.layer ? ` · ${e.layer}` : '';
   if (scope === 'Service' && typeof d.serviceName === 'string') {
     return `${d.serviceName}${layer}`;
   }
   if (scope === 'ServiceInstance' && typeof d.serviceName === 'string') {
-    const inst = typeof d.serviceInstanceName === 'string' ? 
d.serviceInstanceName : '?';
+    // Foreign rows carry the 2nd-level leaf as a generic `name` (the foreign
+    // decoder can't tell instance from endpoint); aware rows use the exact 
key.
+    const inst =
+      typeof d.serviceInstanceName === 'string'
+        ? d.serviceInstanceName
+        : typeof d.name === 'string'
+          ? d.name
+          : '?';
     return `${d.serviceName} / ${inst}${layer}`;
   }
   if (scope === 'Endpoint' && typeof d.serviceName === 'string') {
-    const ep = typeof d.endpointName === 'string' ? d.endpointName : '?';
+    const ep =
+      typeof d.endpointName === 'string'
+        ? d.endpointName
+        : typeof d.name === 'string'
+          ? d.name
+          : '?';
     return `${d.serviceName} : ${ep}${layer}`;
   }
   if (scope.endsWith('Relation') && typeof d.source === 'object' && d.source 
!== null) {
@@ -702,6 +887,68 @@ function decodedLabel(e: EntityRow): string {
 
 // ─── Per-widget entity resolution ──────────────────────────────────
 
+/** Reconstruct a re-queryable MQE entity for a FOREIGN row. The foreign
+ *  `/inspect/entities` path returns `scope:null` and no `mqeEntity` (the 
metric
+ *  isn't defined here), only structurally-decoded names — so we rebuild the
+ *  entity from the operator-chosen scope plus those names. `isReal` defaults 
to
+ *  true; the 2nd-level decode emits a generic `name` leaf (instance vs 
endpoint
+ *  is byte-identical), which the scope disambiguates. */
+function buildForeignMqeEntity(decoded: Record<string, unknown>, scope: 
InspectScope): MqeEntity {
+  const str = (v: unknown): string | undefined => (typeof v === 'string' && 
v.length > 0 ? v : undefined);
+  const real = (v: unknown): boolean => v !== false;
+  const side = (v: unknown): Record<string, unknown> =>
+    typeof v === 'object' && v !== null ? (v as Record<string, unknown>) : {};
+  const e: MqeEntity = { scope };
+  switch (scope) {
+    case 'Service':
+      e.serviceName = str(decoded.serviceName);
+      e.normal = real(decoded.isReal);
+      break;
+    case 'ServiceInstance':
+      e.serviceName = str(decoded.serviceName);
+      e.normal = real(decoded.isReal);
+      e.serviceInstanceName = str(decoded.name) ?? 
str(decoded.serviceInstanceName);
+      break;
+    case 'Endpoint':
+      e.serviceName = str(decoded.serviceName);
+      e.normal = real(decoded.isReal);
+      e.endpointName = str(decoded.name) ?? str(decoded.endpointName);
+      break;
+    case 'ServiceRelation': {
+      const s = side(decoded.source);
+      const d = side(decoded.destination);
+      e.serviceName = str(s.serviceName);
+      e.normal = real(s.isReal);
+      e.destServiceName = str(d.serviceName);
+      e.destNormal = real(d.isReal);
+      break;
+    }
+    case 'ServiceInstanceRelation': {
+      const s = side(decoded.source);
+      const d = side(decoded.destination);
+      e.serviceName = str(s.serviceName);
+      e.normal = real(s.isReal);
+      e.serviceInstanceName = str(s.name);
+      e.destServiceName = str(d.serviceName);
+      e.destNormal = real(d.isReal);
+      e.destServiceInstanceName = str(d.name);
+      break;
+    }
+    case 'EndpointRelation': {
+      const s = side(decoded.source);
+      const d = side(decoded.destination);
+      e.serviceName = str(s.serviceName);
+      e.normal = real(s.isReal);
+      e.endpointName = str(s.name);
+      e.destServiceName = str(d.serviceName);
+      e.destNormal = real(d.isReal);
+      e.destEndpointName = str(d.name);
+      break;
+    }
+  }
+  return e;
+}
+
 async function resolveEntitiesFor(w: Widget): Promise<void> {
   if (!rangeValid.value) return;
   if (bucketOverflow.value) {
@@ -716,12 +963,23 @@ async function resolveEntitiesFor(w: Widget): 
Promise<void> {
       end: endForServer.value,
       step: step.value,
       limit: inspectorTopN.value,
+      ...(w.foreign
+        ? { valueColumn: w.foreign.valueColumn, valueType: w.foreign.valueType 
}
+        : {}),
     });
-    w.resolvedEntities = res.rows;
+    /* Foreign rows arrive without a re-queryable entity — rebuild one from the
+     * operator scope so they drive /inspect/values exactly like an aware row
+     * drives execExpression. */
+    w.resolvedEntities = w.foreign
+      ? res.rows.map((r) => ({
+          ...r,
+          mqeEntity: buildForeignMqeEntity(r.decoded as Record<string, 
unknown>, w.metric.scope),
+        }))
+      : res.rows;
     w.resolvedFetched = true;
     /* Single-entity default — first row is the most-recent per the
      * SWIP-14 sort order, so it's the most likely to have values. */
-    const first = res.rows[0];
+    const first = w.resolvedEntities[0];
     if (first && w.selectedIds.size === 0) {
       w.selectedIds = new Set([first.entityId]);
     }
@@ -748,15 +1006,31 @@ async function execWidget(w: Widget): Promise<void> {
   }
   w.loading = true;
   w.error = null;
-  const targets = widgetAllEntities(w).filter((e) => 
w.selectedIds.has(e.entityId));
+  const targets = widgetAllEntities(w).filter(
+    (e): e is EntityRow & { mqeEntity: MqeEntity } =>
+      w.selectedIds.has(e.entityId) && e.mqeEntity !== undefined,
+  );
+  const foreign = w.foreign;
   try {
-    const calls = targets.map((e) =>
-      bff.inspect.exec({
-        expression: w.metric.name,
-        entity: e.mqeEntity,
-        duration: { start: startForServer.value, end: endForServer.value, 
step: step.value },
-      }).then((r) => ({ entity: e, result: r })),
-    );
+    const calls = targets.map((e) => {
+      const duration = { start: startForServer.value, end: endForServer.value, 
step: step.value };
+      /* Foreign metrics route through /inspect/values (admin port) with their
+       * column/type; catalog metrics go through execExpression. Both return 
the
+       * same ExpressionResult, so the merge below is identical. */
+      const call = foreign
+        ? bff.inspect.values({
+            expression: w.metric.name,
+            entity: e.mqeEntity,
+            start: duration.start,
+            end: duration.end,
+            step: duration.step,
+            foreignMetrics: [
+              { name: w.metric.name, valueColumn: foreign.valueColumn, 
valueType: foreign.valueType },
+            ],
+          })
+        : bff.inspect.exec({ expression: w.metric.name, entity: e.mqeEntity, 
duration });
+      return call.then((r) => ({ entity: e, result: r }));
+    });
     const settled = await Promise.all(calls);
     /* Merge into one ExpressionResult by tagging each series with the
      * entity's decoded name as the metric label. Single-entity case
@@ -886,9 +1160,12 @@ function closeEditor() {
   editorForWidget.value = null;
 }
 function copyMqeEntity(w: Widget) {
-  const sel = widgetAllEntities(w).filter((e) => 
w.selectedIds.has(e.entityId));
+  const sel = widgetAllEntities(w)
+    .filter((e) => w.selectedIds.has(e.entityId))
+    .map((e) => e.mqeEntity)
+    .filter((m): m is MqeEntity => m !== undefined);
   if (sel.length === 0) return;
-  const payload = sel.length === 1 ? sel[0]!.mqeEntity : sel.map((e) => 
e.mqeEntity);
+  const payload = sel.length === 1 ? sel[0] : sel;
   navigator.clipboard?.writeText(JSON.stringify(payload, null, 2));
 }
 
@@ -1109,8 +1386,15 @@ function scopeShort(scope: InspectScope): string {
               <button class="iconbtn iconbtn--x" :title="t('remove from 
board')" @click="removeWidget(w.id)">×</button>
             </div>
             <div class="card__pills">
-              <Pill :tone="sourcePillTone(w.metric.attribution.source as 
Source)">{{ w.metric.attribution.source }}</Pill>
-              <Pill tone="ok" :title="t('entity type: {scope}', { scope: 
w.metric.scope })">{{ scopeShort(w.metric.scope) }}</Pill>
+              <template v-if="w.foreign">
+                <Pill tone="warn" :title="t('foreign metric — not defined on 
this OAP')">{{ t('foreign') }}</Pill>
+                <Pill tone="ok" :title="t('entity type: {scope}', { scope: 
w.metric.scope })">{{ scopeShort(w.metric.scope) }}</Pill>
+                <Pill tone="dim" :title="t('storage column · value type')">{{ 
w.foreign.valueType }}</Pill>
+              </template>
+              <template v-else>
+                <Pill :tone="sourcePillTone(w.metric.attribution.source as 
Source)">{{ w.metric.attribution.source }}</Pill>
+                <Pill tone="ok" :title="t('entity type: {scope}', { scope: 
w.metric.scope })">{{ scopeShort(w.metric.scope) }}</Pill>
+              </template>
             </div>
           </header>
 
@@ -1289,6 +1573,20 @@ function scopeShort(scope: InspectScope): string {
             <button class="iconbtn iconbtn--x" @click="drawerOpen = 
false">×</button>
           </header>
 
+          <nav class="drawer__tabs">
+            <button
+              class="drawer__tab"
+              :class="{ 'drawer__tab--on': drawerTab === 'catalog' }"
+              @click="drawerTab = 'catalog'"
+            >{{ t('from catalog') }}</button>
+            <button
+              class="drawer__tab"
+              :class="{ 'drawer__tab--on': drawerTab === 'foreign' }"
+              @click="drawerTab = 'foreign'"
+            >{{ t('foreign metric') }}</button>
+          </nav>
+
+          <template v-if="drawerTab === 'catalog'">
           <div class="drawer__sources">
             <span class="ins__lbl">{{ t('source') }}</span>
             <button
@@ -1389,18 +1687,74 @@ function scopeShort(scope: InspectScope): string {
             </div>
           </div>
 
+          </template>
+
+          <!-- Foreign metric: not in this OAP's catalog (persisted by another
+               OAP). Add by hand; charts values via /inspect/values. -->
+          <template v-else>
+          <section class="drawer__foreignTab">
+            <p class="drawer__foreignLede">
+              {{ t("A metric this OAP doesn't define — another OAP wrote it to 
the shared storage. Supply its value column and type (from the OAP that defines 
it) and Horizon charts the values.") }}
+            </p>
+            <label class="drawer__ff drawer__ff--full">
+              <span class="drawer__ffLabel">{{ t('metric name') }}</span>
+              <input
+                v-model="foreignForm.name"
+                class="ins__input"
+                :placeholder="t('e.g. meter_custom_pool')"
+                spellcheck="false"
+                @keyup.enter="stageForeignMetric"
+              />
+            </label>
+            <div class="drawer__foreignRow">
+              <label class="drawer__ff drawer__ff--block">
+                <span class="drawer__ffLabel">{{ t('scope') }}</span>
+                <select v-model="foreignForm.scope" class="ins__select">
+                  <option v-for="s in FOREIGN_SCOPES" :key="s" :value="s">{{ 
scopeShort(s) }}</option>
+                </select>
+              </label>
+              <label class="drawer__ff drawer__ff--block">
+                <span class="drawer__ffLabel">{{ t('value column') }}</span>
+                <input v-model="foreignForm.valueColumn" class="ins__input" 
placeholder="value" spellcheck="false" />
+              </label>
+              <label class="drawer__ff drawer__ff--block">
+                <span class="drawer__ffLabel">{{ t('value type') }}</span>
+                <select v-model="foreignForm.valueType" class="ins__select">
+                  <option v-for="vt in INSPECT_FOREIGN_VALUE_TYPES" :key="vt" 
:value="vt">{{ vt }}</option>
+                </select>
+              </label>
+            </div>
+            <div class="drawer__foreignActions">
+              <Btn :disabled="!foreignForm.name.trim()" 
@click="stageForeignMetric">{{ t('+ add to list') }}</Btn>
+              <span v-if="foreignErr" class="drawer__foreignErr">{{ foreignErr 
}}</span>
+            </div>
+
+            <div v-if="foreignStaged.length > 0" class="drawer__staged">
+              <div class="drawer__stagedHead">{{ t('staged · {n}', { n: 
foreignStaged.length }) }}</div>
+              <ul class="drawer__stagedList">
+                <li v-for="s in foreignStaged" :key="s.name" 
class="drawer__stagedRow">
+                  <span class="drawer__stagedName" :title="s.name">{{ s.name 
}}</span>
+                  <Pill tone="ok">{{ scopeShort(s.scope) }}</Pill>
+                  <Pill tone="dim">{{ s.valueColumn }} · {{ s.valueType 
}}</Pill>
+                  <button class="link drawer__stagedRemove" 
@click="unstageForeign(s.name)">{{ t('remove') }}</button>
+                </li>
+              </ul>
+            </div>
+          </section>
+          </template>
+
           <footer class="drawer__foot">
             <span class="drawer__count">
-              {{ t('{n} selected · {after} / {cap} after add', { n: 
drawerSelection.size, after: widgets.length + drawerSelection.size, cap: 
boardCap }) }}
+              {{ t('{n} selected · {after} / {cap} after add', { n: 
drawerPending, after: widgets.length + drawerPending, cap: boardCap }) }}
             </span>
             <div class="drawer__foot-btns">
               <Btn @click="drawerOpen = false">{{ t('Cancel') }}</Btn>
               <Btn
                 kind="primary"
-                :disabled="drawerSelection.size === 0 || widgets.length >= 
boardCap"
-                @click="commitDrawer"
+                :disabled="drawerPending === 0 || widgets.length >= boardCap"
+                @click="commitDrawerActive"
               >
-                {{ t('Add {n} to board', { n: Math.min(drawerSelection.size, 
Math.max(0, boardCap - widgets.length)) }) }}
+                {{ t('Add {n} to board', { n: drawerAddCount }) }}
               </Btn>
             </div>
           </footer>
@@ -1965,6 +2319,105 @@ function scopeShort(scope: InspectScope): string {
 .drawer__name { font-family: var(--rr-font-mono); font-size: var(--sw-fs-sm); 
color: var(--rr-heading); flex: 1; min-width: 200px; }
 .drawer__why { flex-basis: 100%; padding-left: 26px; font-family: 
var(--rr-font-mono); font-size: var(--sw-fs-sm); color: var(--rr-dim); }
 .drawer__listEmpty { padding: 20px; text-align: center; font-family: 
var(--rr-font-mono); font-size: var(--sw-fs-base); color: var(--rr-dim); }
+.drawer__tabs {
+  display: flex;
+  gap: 8px;
+  padding: 12px 18px;
+  border-bottom: 1px solid var(--rr-border);
+}
+.drawer__tab {
+  font-family: var(--rr-font-mono);
+  font-size: var(--sw-fs-base);
+  font-weight: var(--sw-fw-medium);
+  color: var(--rr-ink2);
+  background: var(--rr-bg2);
+  border: 1px solid var(--rr-border);
+  border-radius: var(--rr-radius-sm);
+  padding: 7px 18px;
+  cursor: pointer;
+  transition: color 120ms ease, background 120ms ease, border-color 120ms ease;
+}
+.drawer__tab:hover { color: var(--rr-heading); border-color: var(--rr-ink2); }
+.drawer__tab--on {
+  color: var(--rr-heading);
+  background: color-mix(in oklab, var(--rr-accent) 22%, transparent);
+  border-color: var(--rr-accent);
+  font-weight: var(--sw-fw-semibold);
+}
+
+.drawer__foreignTab {
+  flex: 1;
+  min-height: 0;
+  overflow-y: auto;
+  padding: 16px 18px;
+  display: flex;
+  flex-direction: column;
+  gap: 14px;
+}
+.drawer__foreignLede {
+  margin: 0;
+  max-width: 620px;
+  font-family: var(--rr-font-ui);
+  font-size: var(--sw-fs-sm);
+  line-height: 1.5;
+  color: var(--rr-dim);
+}
+.drawer__foreignRow {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 14px;
+  max-width: 620px;
+}
+.drawer__ff { display: flex; flex-direction: column; gap: 4px; }
+.drawer__ff--full { width: 100%; max-width: 620px; }
+.drawer__ff--full .ins__input { width: 100%; }
+.drawer__ff--block { min-width: 0; }
+.drawer__ff--block .ins__input, .drawer__ff--block .ins__select { width: 100%; 
}
+.drawer__ffLabel {
+  font-family: var(--rr-font-mono);
+  font-size: var(--sw-fs-xs);
+  font-weight: var(--sw-fw-bold);
+  text-transform: uppercase;
+  letter-spacing: var(--sw-ls-caps);
+  color: var(--sw-fg-3);
+}
+.drawer__foreignActions { display: flex; align-items: center; gap: 12px; }
+.drawer__foreignErr {
+  font-family: var(--rr-font-mono);
+  font-size: var(--sw-fs-sm);
+  color: var(--rr-err);
+}
+.drawer__staged { display: flex; flex-direction: column; gap: 6px; }
+.drawer__stagedHead {
+  font-family: var(--rr-font-mono);
+  font-size: var(--sw-fs-xs);
+  font-weight: var(--sw-fw-bold);
+  text-transform: uppercase;
+  letter-spacing: var(--sw-ls-caps);
+  color: var(--sw-fg-3);
+}
+.drawer__stagedList { list-style: none; margin: 0; padding: 0; display: flex; 
flex-direction: column; gap: 4px; }
+.drawer__stagedRow {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 5px 8px;
+  background: var(--rr-bg2);
+  border: 1px solid var(--rr-border);
+  border-radius: var(--rr-radius-sm);
+}
+.drawer__stagedName {
+  font-family: var(--rr-font-mono);
+  font-size: var(--sw-fs-sm);
+  color: var(--rr-heading);
+  flex: 1;
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.drawer__stagedRemove { flex: 0 0 auto; }
+
 .drawer__foot {
   padding: 12px 18px;
   border-top: 1px solid var(--rr-border);
diff --git a/apps/ui/src/i18n/locales/de.json b/apps/ui/src/i18n/locales/de.json
index 11508dc..d836a09 100644
--- a/apps/ui/src/i18n/locales/de.json
+++ b/apps/ui/src/i18n/locales/de.json
@@ -1513,5 +1513,19 @@
   "paste a trace id": "Trace-ID einfügen",
   "space- or comma-separated, AND-joined": "durch Leerzeichen oder Komma 
getrennt, mit AND verknüpft",
   "→ edit as text": "→ als Text bearbeiten",
-  "Each pod is the service instance, surfaced as its live Kubernetes pod — 
logs are tailed straight from the cluster, so the pod must be currently 
running.": "Jeder Pod ist die Service-Instanz, dargestellt als ihr aktiver 
Kubernetes-Pod – Logs werden direkt aus dem Cluster gelesen, der Pod muss also 
gerade laufen."
+  "Each pod is the service instance, surfaced as its live Kubernetes pod — 
logs are tailed straight from the cluster, so the pod must be currently 
running.": "Jeder Pod ist die Service-Instanz, dargestellt als ihr aktiver 
Kubernetes-Pod – Logs werden direkt aus dem Cluster gelesen, der Pod muss also 
gerade laufen.",
+  "foreign": "fremd",
+  "foreign metric": "fremde Metrik",
+  "metric name": "Metrikname",
+  "e.g. meter_custom_pool": "z. B. meter_custom_pool",
+  "value column": "Wertspalte",
+  "value type": "Werttyp",
+  "metric name is required": "Metrikname ist erforderlich",
+  "{metric} is already on the board": "{metric} ist bereits auf dem Board",
+  "foreign metric — not defined on this OAP": "fremde Metrik — auf diesem OAP 
nicht definiert",
+  "storage column · value type": "Speicherspalte · Werttyp",
+  "from catalog": "aus dem Katalog",
+  "+ add to list": "+ zur Liste hinzufügen",
+  "staged · {n}": "vorgemerkt · {n}",
+  "A metric this OAP doesn't define — another OAP wrote it to the shared 
storage. Supply its value column and type (from the OAP that defines it) and 
Horizon charts the values.": "Eine Metrik, die dieses OAP nicht definiert — ein 
anderes OAP hat sie in den gemeinsamen Speicher geschrieben. Gib ihre 
Wertspalte und ihren Werttyp an (aus dem OAP, das sie definiert), und Horizon 
stellt die Werte dar."
 }
diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json
index 610b195..a18f22b 100644
--- a/apps/ui/src/i18n/locales/en.json
+++ b/apps/ui/src/i18n/locales/en.json
@@ -1513,5 +1513,19 @@
   "paste a trace id": "paste a trace id",
   "space- or comma-separated, AND-joined": "space- or comma-separated, 
AND-joined",
   "→ edit as text": "→ edit as text",
-  "Each pod is the service instance, surfaced as its live Kubernetes pod — 
logs are tailed straight from the cluster, so the pod must be currently 
running.": "Each pod is the service instance, surfaced as its live Kubernetes 
pod — logs are tailed straight from the cluster, so the pod must be currently 
running."
+  "Each pod is the service instance, surfaced as its live Kubernetes pod — 
logs are tailed straight from the cluster, so the pod must be currently 
running.": "Each pod is the service instance, surfaced as its live Kubernetes 
pod — logs are tailed straight from the cluster, so the pod must be currently 
running.",
+  "foreign": "foreign",
+  "foreign metric": "foreign metric",
+  "metric name": "metric name",
+  "e.g. meter_custom_pool": "e.g. meter_custom_pool",
+  "value column": "value column",
+  "value type": "value type",
+  "metric name is required": "metric name is required",
+  "{metric} is already on the board": "{metric} is already on the board",
+  "foreign metric — not defined on this OAP": "foreign metric — not defined on 
this OAP",
+  "storage column · value type": "storage column · value type",
+  "from catalog": "from catalog",
+  "+ add to list": "+ add to list",
+  "staged · {n}": "staged · {n}",
+  "A metric this OAP doesn't define — another OAP wrote it to the shared 
storage. Supply its value column and type (from the OAP that defines it) and 
Horizon charts the values.": "A metric this OAP doesn't define — another OAP 
wrote it to the shared storage. Supply its value column and type (from the OAP 
that defines it) and Horizon charts the values."
 }
diff --git a/apps/ui/src/i18n/locales/es.json b/apps/ui/src/i18n/locales/es.json
index ec154a3..62c377f 100644
--- a/apps/ui/src/i18n/locales/es.json
+++ b/apps/ui/src/i18n/locales/es.json
@@ -1513,5 +1513,19 @@
   "paste a trace id": "pega un Trace ID",
   "space- or comma-separated, AND-joined": "separadas por espacio o coma, 
unidas con AND",
   "→ edit as text": "→ editar como texto",
-  "Each pod is the service instance, surfaced as its live Kubernetes pod — 
logs are tailed straight from the cluster, so the pod must be currently 
running.": "Cada pod es la instancia del servicio, mostrada como su pod de 
Kubernetes en vivo: los logs se leen directamente del clúster, por lo que el 
pod debe estar en ejecución."
+  "Each pod is the service instance, surfaced as its live Kubernetes pod — 
logs are tailed straight from the cluster, so the pod must be currently 
running.": "Cada pod es la instancia del servicio, mostrada como su pod de 
Kubernetes en vivo: los logs se leen directamente del clúster, por lo que el 
pod debe estar en ejecución.",
+  "foreign": "externa",
+  "foreign metric": "métrica externa",
+  "metric name": "nombre de la métrica",
+  "e.g. meter_custom_pool": "p. ej. meter_custom_pool",
+  "value column": "columna de valor",
+  "value type": "tipo de valor",
+  "metric name is required": "el nombre de la métrica es obligatorio",
+  "{metric} is already on the board": "{metric} ya está en el tablero",
+  "foreign metric — not defined on this OAP": "métrica externa — no definida 
en este OAP",
+  "storage column · value type": "columna de almacenamiento · tipo de valor",
+  "from catalog": "del catálogo",
+  "+ add to list": "+ añadir a la lista",
+  "staged · {n}": "en cola · {n}",
+  "A metric this OAP doesn't define — another OAP wrote it to the shared 
storage. Supply its value column and type (from the OAP that defines it) and 
Horizon charts the values.": "Una métrica que este OAP no define — otro OAP la 
escribió en el almacenamiento compartido. Indica su columna y tipo de valor 
(del OAP que la define) y Horizon grafica los valores."
 }
diff --git a/apps/ui/src/i18n/locales/fr.json b/apps/ui/src/i18n/locales/fr.json
index 930b562..7d797db 100644
--- a/apps/ui/src/i18n/locales/fr.json
+++ b/apps/ui/src/i18n/locales/fr.json
@@ -1513,5 +1513,19 @@
   "paste a trace id": "collez un Trace ID",
   "space- or comma-separated, AND-joined": "séparés par un espace ou une 
virgule, joints par AND",
   "→ edit as text": "→ éditer en texte",
-  "Each pod is the service instance, surfaced as its live Kubernetes pod — 
logs are tailed straight from the cluster, so the pod must be currently 
running.": "Chaque pod est l'instance du service, présentée comme son pod 
Kubernetes en direct — les logs sont lus directement depuis le cluster, le pod 
doit donc être en cours d'exécution."
+  "Each pod is the service instance, surfaced as its live Kubernetes pod — 
logs are tailed straight from the cluster, so the pod must be currently 
running.": "Chaque pod est l'instance du service, présentée comme son pod 
Kubernetes en direct — les logs sont lus directement depuis le cluster, le pod 
doit donc être en cours d'exécution.",
+  "foreign": "externe",
+  "foreign metric": "métrique externe",
+  "metric name": "nom de la métrique",
+  "e.g. meter_custom_pool": "ex. meter_custom_pool",
+  "value column": "colonne de valeur",
+  "value type": "type de valeur",
+  "metric name is required": "le nom de la métrique est obligatoire",
+  "{metric} is already on the board": "{metric} est déjà dans le tableau",
+  "foreign metric — not defined on this OAP": "métrique externe — non définie 
sur cet OAP",
+  "storage column · value type": "colonne de stockage · type de valeur",
+  "from catalog": "depuis le catalogue",
+  "+ add to list": "+ ajouter à la liste",
+  "staged · {n}": "en attente · {n}",
+  "A metric this OAP doesn't define — another OAP wrote it to the shared 
storage. Supply its value column and type (from the OAP that defines it) and 
Horizon charts the values.": "Une métrique que cet OAP ne définit pas — un 
autre OAP l'a écrite dans le stockage partagé. Indiquez sa colonne et son type 
de valeur (depuis l'OAP qui la définit) et Horizon trace les valeurs."
 }
diff --git a/apps/ui/src/i18n/locales/ja.json b/apps/ui/src/i18n/locales/ja.json
index 8befc54..6f474f6 100644
--- a/apps/ui/src/i18n/locales/ja.json
+++ b/apps/ui/src/i18n/locales/ja.json
@@ -1513,5 +1513,19 @@
   "paste a trace id": "Trace ID を貼り付け",
   "space- or comma-separated, AND-joined": "スペースまたはカンマ区切り、AND 結合",
   "→ edit as text": "→ テキストで編集",
-  "Each pod is the service instance, surfaced as its live Kubernetes pod — 
logs are tailed straight from the cluster, so the pod must be currently 
running.": "各 Pod はサービスインスタンスであり、稼働中の Kubernetes Pod 
として表示されます。ログはクラスターから直接取得されるため、Pod は現在実行中である必要があります。"
+  "Each pod is the service instance, surfaced as its live Kubernetes pod — 
logs are tailed straight from the cluster, so the pod must be currently 
running.": "各 Pod はサービスインスタンスであり、稼働中の Kubernetes Pod 
として表示されます。ログはクラスターから直接取得されるため、Pod は現在実行中である必要があります。",
+  "foreign": "外部",
+  "foreign metric": "外部メトリクス",
+  "metric name": "メトリクス名",
+  "e.g. meter_custom_pool": "例: meter_custom_pool",
+  "value column": "値カラム",
+  "value type": "値タイプ",
+  "metric name is required": "メトリクス名は必須です",
+  "{metric} is already on the board": "{metric} は既にボードにあります",
+  "foreign metric — not defined on this OAP": "外部メトリクス — この OAP では未定義",
+  "storage column · value type": "ストレージカラム · 値タイプ",
+  "from catalog": "カタログから",
+  "+ add to list": "+ リストに追加",
+  "staged · {n}": "ステージ済み · {n}",
+  "A metric this OAP doesn't define — another OAP wrote it to the shared 
storage. Supply its value column and type (from the OAP that defines it) and 
Horizon charts the values.": "この OAP が定義していないメトリクス —— 別の OAP 
が共有ストレージに書き込んだものです。値カラムと型(定義元の OAP のカタログに記載)を指定すると、Horizon が値をグラフ化します。"
 }
diff --git a/apps/ui/src/i18n/locales/ko.json b/apps/ui/src/i18n/locales/ko.json
index 2f22c50..58177f9 100644
--- a/apps/ui/src/i18n/locales/ko.json
+++ b/apps/ui/src/i18n/locales/ko.json
@@ -1513,5 +1513,19 @@
   "paste a trace id": "Trace ID 붙여넣기",
   "space- or comma-separated, AND-joined": "공백 또는 쉼표로 구분, AND 결합",
   "→ edit as text": "→ 텍스트로 편집",
-  "Each pod is the service instance, surfaced as its live Kubernetes pod — 
logs are tailed straight from the cluster, so the pod must be currently 
running.": "각 Pod는 서비스 인스턴스이며 실행 중인 Kubernetes Pod로 표시됩니다. 로그는 클러스터에서 직접 가져오므로 
Pod가 현재 실행 중이어야 합니다."
+  "Each pod is the service instance, surfaced as its live Kubernetes pod — 
logs are tailed straight from the cluster, so the pod must be currently 
running.": "각 Pod는 서비스 인스턴스이며 실행 중인 Kubernetes Pod로 표시됩니다. 로그는 클러스터에서 직접 가져오므로 
Pod가 현재 실행 중이어야 합니다.",
+  "foreign": "외부",
+  "foreign metric": "외부 메트릭",
+  "metric name": "메트릭 이름",
+  "e.g. meter_custom_pool": "예: meter_custom_pool",
+  "value column": "값 컬럼",
+  "value type": "값 유형",
+  "metric name is required": "메트릭 이름은 필수입니다",
+  "{metric} is already on the board": "{metric}은(는) 이미 보드에 있습니다",
+  "foreign metric — not defined on this OAP": "외부 메트릭 — 이 OAP에 정의되지 않음",
+  "storage column · value type": "스토리지 컬럼 · 값 유형",
+  "from catalog": "카탈로그에서",
+  "+ add to list": "+ 목록에 추가",
+  "staged · {n}": "대기 · {n}",
+  "A metric this OAP doesn't define — another OAP wrote it to the shared 
storage. Supply its value column and type (from the OAP that defines it) and 
Horizon charts the values.": "이 OAP가 정의하지 않은 메트릭 —— 다른 OAP가 공유 스토리지에 기록한 것입니다. 
값 컬럼과 유형(정의한 OAP의 카탈로그 참고)을 입력하면 Horizon이 값을 차트로 표시합니다."
 }
diff --git a/apps/ui/src/i18n/locales/pt.json b/apps/ui/src/i18n/locales/pt.json
index 8bae757..cb337c2 100644
--- a/apps/ui/src/i18n/locales/pt.json
+++ b/apps/ui/src/i18n/locales/pt.json
@@ -1513,5 +1513,19 @@
   "paste a trace id": "cole um Trace ID",
   "space- or comma-separated, AND-joined": "separadas por espaço ou vírgula, 
unidas com AND",
   "→ edit as text": "→ editar como texto",
-  "Each pod is the service instance, surfaced as its live Kubernetes pod — 
logs are tailed straight from the cluster, so the pod must be currently 
running.": "Cada pod é a instância do serviço, exibida como seu pod do 
Kubernetes ao vivo — os logs são lidos diretamente do cluster, portanto o pod 
deve estar em execução."
+  "Each pod is the service instance, surfaced as its live Kubernetes pod — 
logs are tailed straight from the cluster, so the pod must be currently 
running.": "Cada pod é a instância do serviço, exibida como seu pod do 
Kubernetes ao vivo — os logs são lidos diretamente do cluster, portanto o pod 
deve estar em execução.",
+  "foreign": "externa",
+  "foreign metric": "métrica externa",
+  "metric name": "nome da métrica",
+  "e.g. meter_custom_pool": "ex.: meter_custom_pool",
+  "value column": "coluna de valor",
+  "value type": "tipo de valor",
+  "metric name is required": "o nome da métrica é obrigatório",
+  "{metric} is already on the board": "{metric} já está no painel",
+  "foreign metric — not defined on this OAP": "métrica externa — não definida 
neste OAP",
+  "storage column · value type": "coluna de armazenamento · tipo de valor",
+  "from catalog": "do catálogo",
+  "+ add to list": "+ adicionar à lista",
+  "staged · {n}": "na fila · {n}",
+  "A metric this OAP doesn't define — another OAP wrote it to the shared 
storage. Supply its value column and type (from the OAP that defines it) and 
Horizon charts the values.": "Uma métrica que este OAP não define — outro OAP a 
gravou no armazenamento compartilhado. Informe a coluna e o tipo de valor (do 
OAP que a define) e o Horizon plota os valores."
 }
diff --git a/apps/ui/src/i18n/locales/zh-CN.json 
b/apps/ui/src/i18n/locales/zh-CN.json
index 5ca3f95..facffc4 100644
--- a/apps/ui/src/i18n/locales/zh-CN.json
+++ b/apps/ui/src/i18n/locales/zh-CN.json
@@ -1513,5 +1513,19 @@
   "paste a trace id": "粘贴 Trace ID",
   "space- or comma-separated, AND-joined": "以空格或逗号分隔,按 AND 组合",
   "→ edit as text": "→ 改为文本编辑",
-  "Each pod is the service instance, surfaced as its live Kubernetes pod — 
logs are tailed straight from the cluster, so the pod must be currently 
running.": "每个 Pod 即该服务实例,以其实时 Kubernetes Pod 的形式呈现——日志直接从集群拉取,因此该 Pod 必须正在运行。"
+  "Each pod is the service instance, surfaced as its live Kubernetes pod — 
logs are tailed straight from the cluster, so the pod must be currently 
running.": "每个 Pod 即该服务实例,以其实时 Kubernetes Pod 的形式呈现——日志直接从集群拉取,因此该 Pod 必须正在运行。",
+  "foreign": "外部",
+  "foreign metric": "外部指标",
+  "metric name": "指标名称",
+  "e.g. meter_custom_pool": "例如 meter_custom_pool",
+  "value column": "值列",
+  "value type": "值类型",
+  "metric name is required": "请填写指标名称",
+  "{metric} is already on the board": "{metric} 已在看板中",
+  "foreign metric — not defined on this OAP": "外部指标 — 当前 OAP 未定义",
+  "storage column · value type": "存储列 · 值类型",
+  "from catalog": "来自目录",
+  "+ add to list": "+ 加入列表",
+  "staged · {n}": "暂存 · {n}",
+  "A metric this OAP doesn't define — another OAP wrote it to the shared 
storage. Supply its value column and type (from the OAP that defines it) and 
Horizon charts the values.": "本 OAP 未定义的指标 —— 由另一个 OAP 
写入共享存储。填写它的值列和类型(来自定义该指标的 OAP),Horizon 即可绘制其数值。"
 }
diff --git a/docs/operate/inspect.md b/docs/operate/inspect.md
index df59e6c..1380534 100644
--- a/docs/operate/inspect.md
+++ b/docs/operate/inspect.md
@@ -62,6 +62,27 @@ The page takes a browser-local time range and converts it; 
the strings are inter
 
 Horizon converts the page's chosen time range into the correct format 
automatically — operators just pick a window.
 
+## Foreign metrics — charting another OAP's data
+
+A metric appears in the catalog only if the OAP behind Horizon **defines** it 
— i.e. it carries the OAL / MAL analysis rule that produces it. When two OAPs 
share one storage backend (for example a newer OAP writing data that an older 
OAP, or a different distribution, reads), a metric written by one OAP is 
**foreign** to the other: present in shared storage, absent from its catalog. 
The catalog browser won't list a foreign metric, but you can still enumerate 
its entities and chart its val [...]
+
+Open **+ add metric** and switch to the **Foreign metric** tab (beside **From 
catalog**):
+
+| Field | What to enter |
+|---|---|
+| Metric name | The exact metric id, e.g. `meter_custom_pool`. |
+| Scope | The metric's entity scope — Service, ServiceInstance, Endpoint, or 
one of the three relations. |
+| Value column | The metric's storage value column. Usually `value`. |
+| Value type | How the value is stored: `LONG`, `INT`, `DOUBLE`, or `LABELED`. 
|
+
+Value column and value type are storage details that can't be inferred from 
the name. Read them from the catalog of the OAP that **does** define the metric 
— its catalog row reports the value column and the type — then enter them here.
+
+Use **+ add to list** to stage a metric, repeat for as many as you need, then 
**Add N to board** to add them together. The drawer footer counts what's 
pending and respects the board cap, the same as the catalog tab.
+
+The foreign widget then behaves like any other inspect widget: it enumerates 
the entities reporting data for the metric, defaults to the top one, and plots 
the value series. Step or multi-select entities, switch chart type, and refetch 
the same way. It's marked with a `FOREIGN` pill and its value type.
+
+The connected OAP can't evaluate a foreign metric through its normal query 
path — that needs the analysis rule. Horizon reads the values through the OAP's 
admin surface instead, using the value column and type you supplied, so a wrong 
column or type surfaces as an error on the widget rather than a misleading 
chart.
+
 ## Common workflows
 
 ### "I'm authoring a widget and I don't know which MQE expression to use."
diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts
index 36898fa..8668b24 100644
--- a/packages/api-client/src/index.ts
+++ b/packages/api-client/src/index.ts
@@ -268,6 +268,7 @@ export {
   InspectApiError,
   INSPECT_STEPS,
   INSPECT_ENTITY_LIMIT_MAX,
+  INSPECT_FOREIGN_VALUE_TYPES,
   formatInspectDate,
   isInspectDate,
   type InspectClientOptions,
@@ -275,6 +276,7 @@ export {
   type InspectMetricType,
   type InspectScope,
   type InspectStep,
+  type InspectForeignValueType,
   type ListMetricsArgs,
   type ListEntitiesArgs,
   type MetricRow,
@@ -292,6 +294,8 @@ export {
   type MqeKeyValue,
   type MqeOwner,
   type InspectExecRequest,
+  type InspectValuesRequest,
+  type ForeignMetricInput,
 } from './inspect.js';
 export type {
   ExploreKind,
diff --git a/packages/api-client/src/inspect.ts 
b/packages/api-client/src/inspect.ts
index 18f08e4..38e3da3 100644
--- a/packages/api-client/src/inspect.ts
+++ b/packages/api-client/src/inspect.ts
@@ -69,6 +69,19 @@ export const INSPECT_STEPS: readonly InspectStep[] = 
['MINUTE', 'HOUR', 'DAY'] a
 /** Server-side hard cap on `/inspect/entities?limit=`. */
 export const INSPECT_ENTITY_LIMIT_MAX = 300;
 
+/** Storage value-type a caller declares when inspecting a FOREIGN metric —
+ *  one this OAP does not define (no local registry entry to recover the
+ *  column type from). Mirrors OAP's accepted set in `InspectRestHandler`:
+ *  `LONG` / `INT` / `DOUBLE` are scalar; `LABELED` is a DataTable. HEATMAP
+ *  and SAMPLED_RECORD are out of scope for `/inspect/entities`. */
+export type InspectForeignValueType = 'LONG' | 'INT' | 'DOUBLE' | 'LABELED';
+export const INSPECT_FOREIGN_VALUE_TYPES: readonly InspectForeignValueType[] = 
[
+  'LONG',
+  'INT',
+  'DOUBLE',
+  'LABELED',
+] as const;
+
 export interface MetricRow {
   name: string;
   type: InspectMetricType;
@@ -117,12 +130,17 @@ export interface EntityRow {
    *  Omitted (Java `null` → field absent thanks to `NON_NULL`) when
    *  the service is missing from the metadata cache. */
   layer?: string;
-  mqeEntity: MqeEntity;
+  /** Re-queryable MQE entity. Present on the aware path (metric defined on
+   *  this OAP). Absent on the FOREIGN path — a metric this OAP doesn't define
+   *  can't be MQE-queried here, so the structural decode emits no entity. */
+  mqeEntity?: MqeEntity;
 }
 
 export interface EntitiesResponse {
   metric: string;
-  scope: InspectScope;
+  /** `null` on the FOREIGN path: a foreign metric's exact scope can't be
+   *  recovered, only its per-row structural shape (carried in `decoded`). */
+  scope: InspectScope | null;
   step: InspectStep;
   /** Echo of the `start` query param in the step-specific format. */
   start: string;
@@ -155,6 +173,12 @@ export interface ListEntitiesArgs {
   /** 1–300, default 300 server-side. Studio defaults to a smaller
    *  number per widget. */
   limit?: number;
+  /** FOREIGN-metric storage column, e.g. `value`. Required together with
+   *  `valueType` to inspect a metric this OAP does not define; omit both
+   *  for an aware metric (OAP reads the column from its own registry). */
+  valueColumn?: string;
+  /** FOREIGN-metric value type. Required together with `valueColumn`. */
+  valueType?: InspectForeignValueType;
 }
 
 // ── MQE result shape ───────────────────────────────────────────────
@@ -209,6 +233,28 @@ export interface ExpressionResult {
   error?: string | null;
 }
 
+/** Per-metric metadata supplied to `POST /inspect/values` for a metric this
+ *  OAP doesn't define (mirrors OAP's `ForeignMetricInput`). */
+export interface ForeignMetricInput {
+  name: string;
+  valueColumn: string;
+  valueType: InspectForeignValueType;
+}
+
+/** Wire shape for `POST /inspect/values` — evaluate an MQE expression over
+ *  one or more FOREIGN metrics (values read from shared storage with the
+ *  caller-supplied column/type). Returns the native `ExpressionResult`, same
+ *  shape as GraphQL `execExpression`. This is the admin-port counterpart to
+ *  `execExpression` for metrics the connected OAP can't evaluate itself. */
+export interface InspectValuesRequest {
+  expression: string;
+  entity: MqeEntity;
+  start: string;
+  end: string;
+  step: InspectStep;
+  foreignMetrics: ForeignMetricInput[];
+}
+
 /** Wire shape for Studio's `POST /api/inspect/exec`. The BFF will
  *  translate this into a GraphQL `mutation execExpression(...)` call
  *  against the resolved MQE base. */
@@ -318,7 +364,9 @@ export class InspectClient {
     return (await res.json()) as MetricsResponse;
   }
 
-  /** `GET /inspect/entities` — `metric` + time range + step + limit. */
+  /** `GET /inspect/entities` — `metric` + time range + step + limit. Pass
+   *  `valueColumn` + `valueType` to inspect a FOREIGN metric (one this OAP
+   *  doesn't define). */
   async listEntities(args: ListEntitiesArgs): Promise<EntitiesResponse> {
     const params = new URLSearchParams({
       metric: args.metric,
@@ -327,12 +375,28 @@ export class InspectClient {
       step: args.step,
     });
     if (args.limit !== undefined) params.set('limit', String(args.limit));
+    if (args.valueColumn !== undefined) params.set('valueColumn', 
args.valueColumn);
+    if (args.valueType !== undefined) params.set('valueType', args.valueType);
     const url = `${this.base}/inspect/entities?${params.toString()}`;
     const res = await this.send(url, { method: 'GET' });
     if (!res.ok) throw await this.toError(res, url);
     return (await res.json()) as EntitiesResponse;
   }
 
+  /** `POST /inspect/values` — read the VALUES of FOREIGN metric(s) this OAP
+   *  doesn't define, supplying their storage column/type in `foreignMetrics`.
+   *  Returns the same `ExpressionResult` shape as GraphQL `execExpression`. */
+  async inspectValues(req: InspectValuesRequest): Promise<ExpressionResult> {
+    const url = `${this.base}/inspect/values`;
+    const res = await this.send(url, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify(req),
+    });
+    if (!res.ok) throw await this.toError(res, url);
+    return (await res.json()) as ExpressionResult;
+  }
+
   // ── private helpers ─────────────────────────────────────────────
 
   private async send(url: string, init: RequestInit): Promise<Response> {

Reply via email to