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> {
