This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
The following commit(s) were added to refs/heads/main by this push:
new ade759f feat(inspect): add & chart foreign metrics another OAP wrote
to shared storage (#73)
ade759f is described below
commit ade759f02b34c1ae7bae1aec6a317fe72cc2c446
Author: 吴晟 Wu Sheng <[email protected]>
AuthorDate: Wed Jun 24 23:21:11 2026 +0800
feat(inspect): add & chart foreign metrics another OAP wrote to shared
storage (#73)
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> {