You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by wu...@apache.org on 2026/09/01 07:49:04 UTC
(skywalking-horizon-ui) branch feat/mqe-run-panel created (now 666d1ee8)
This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a change to branch feat/mqe-run-panel
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
at 666d1ee8 feat(templates): run an MQE from the layer template editor and show what OAP returns
This branch includes the following new commits:
new 666d1ee8 feat(templates): run an MQE from the layer template editor and show what OAP returns
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(skywalking-horizon-ui) 01/01: feat(templates): run an MQE from the layer template editor and show what OAP returns
Posted by wu...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a commit to branch feat/mqe-run-panel
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
commit 666d1ee8de490d56a1625b851d012ce2a1bf573d
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Tue Sep 1 15:48:43 2026 +0800
feat(templates): run an MQE from the layer template editor and show what OAP returns
The layer template editor fires no metric queries of its own, so the only way
to learn whether an expression works was to publish the template, open the
layer and look. Every MQE field now carries a run button.
The panel supplies the context an expression needs besides itself: the layer,
fixed to the template being edited; an entity — a service, narrowed to one of
its instances or endpoints, and a source/destination pair on relation metrics;
and its own time range, using the topbar's precision-tabbed picker including
custom ranges. It renders OAP's answer per result type — a graph for a time
series, one line per label on a shared axis; ranked rows with their owning
entity for a top_n; the sampled text and trace id for records; and a JSON view
of the untouched response. A blank service-list column runs the catalog default
its metric resolves to, and names it.
`POST /api/mqe/exec` (metrics:read) wraps the existing MQE fire: it takes an
epoch-ms window and formats it to OAP-local time, resolves a blank expression
from metric + layer, and returns the ExpressionResult verbatim. Its entity
scope is optional, which relation metrics require — OAP deprecated the field in
9.4.0 and senses the scope from the metric name, and forcing it empties the
result on some versions.
The topbar's time-range menu moves to components/primitives/TimeRangeMenu.vue
and is now rendered by both the topbar and this panel, so the two cannot drift.
The chip keeps everything that is global to it: the store write, the refresh
round, the route-derived disabled state and the per-user default. MonacoView
moves to components/primitives/ for the same reason — a second feature needs it.
---
apps/bff/CLAUDE.md | 1 +
apps/bff/src/http/query/mqe-exec.test.ts | 135 +++++++
apps/bff/src/http/query/mqe-exec.ts | 210 ++++++++++
apps/bff/src/logic/inspect/exec.ts | 9 +-
apps/bff/src/rbac/route-policy.ts | 4 +
apps/bff/src/server.ts | 2 +
apps/ui/CLAUDE.md | 1 +
apps/ui/src/api/client.ts | 2 +
apps/ui/src/api/scopes/mqe.ts | 32 ++
.../primitives}/MonacoView.vue | 0
.../ui/src/components/primitives/TimeRangeMenu.vue | 436 +++++++++++++++++++++
.../src/features/admin/_shared/MqeEntityPicker.vue | 294 ++++++++++++++
.../features/admin/_shared/MqeExpressionInput.vue | 76 +++-
.../src/features/admin/_shared/MqeRangePicker.vue | 174 ++++++++
.../src/features/admin/_shared/MqeResultView.vue | 346 ++++++++++++++++
apps/ui/src/features/admin/_shared/MqeRunPanel.vue | 390 ++++++++++++++++++
.../src/features/admin/_shared/mqeSeries.test.ts | 113 ++++++
apps/ui/src/features/admin/_shared/mqeSeries.ts | 102 +++++
.../layer-templates/DependencyConfigSection.vue | 5 +
.../layer-templates/DeploymentConfigEditor.vue | 12 +-
.../admin/layer-templates/LayerDashboardsAdmin.vue | 4 +
.../admin/layer-templates/MetricDefinitionRow.vue | 18 +-
.../admin/layer-templates/RoleMetricRow.vue | 7 +-
.../layer-templates/ServiceListMetricsEditor.vue | 22 +-
.../admin/layer-templates/TopologyConfigEditor.vue | 14 +-
.../admin/layer-templates/WidgetEditorCanvas.vue | 15 +
.../features/operate/live-debug/LalCellPopout.vue | 2 +-
apps/ui/src/i18n/locales/de.json | 38 +-
apps/ui/src/i18n/locales/en.json | 38 +-
apps/ui/src/i18n/locales/es.json | 38 +-
apps/ui/src/i18n/locales/fr.json | 38 +-
apps/ui/src/i18n/locales/ja.json | 38 +-
apps/ui/src/i18n/locales/ko.json | 38 +-
apps/ui/src/i18n/locales/pt.json | 38 +-
apps/ui/src/i18n/locales/zh-CN.json | 38 +-
apps/ui/src/shell/TimeRangeChip.vue | 376 +-----------------
docs/changelog/1.1.0.md | 2 +
docs/components/dashboard-widgets.md | 2 +-
docs/customization/layer-templates.md | 22 ++
docs/operate/inspect.md | 2 +
packages/api-client/src/index.ts | 5 +
packages/api-client/src/inspect.ts | 6 +-
packages/api-client/src/mqe-exec.ts | 72 ++++
43 files changed, 2831 insertions(+), 386 deletions(-)
diff --git a/apps/bff/CLAUDE.md b/apps/bff/CLAUDE.md
index 77d1db5f..99d5d98e 100644
--- a/apps/bff/CLAUDE.md
+++ b/apps/bff/CLAUDE.md
@@ -111,6 +111,7 @@ it empties it. A Cold toggle would blank the alarm list and the entity pickers.
| `trace.ts` — traces | `alarms.ts` — `/api/alarms`, `/api/alarms/count` |
| `log.ts`, `browser-errors.ts` — logs | `instance.ts` — the instance picker |
| `dashboard.ts`, `landing.ts`, `explore.ts` — metrics | `endpoint.ts` — the endpoint picker |
+| `mqe-exec.ts` — one metric expression, run from the template editor | |
| `topology.ts`, `deployment.ts`, `instance-topology.ts`, `endpoint-dependency.ts`, `infra-3d-metrics.ts` — metrics | `events.ts` — events live in `records` |
| | `ebpf.ts` — network profiling |
| | `trace-tag.ts` — tag key/value autocomplete |
diff --git a/apps/bff/src/http/query/mqe-exec.test.ts b/apps/bff/src/http/query/mqe-exec.test.ts
new file mode 100644
index 00000000..696856c6
--- /dev/null
+++ b/apps/bff/src/http/query/mqe-exec.test.ts
@@ -0,0 +1,135 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { bodySchema, entitySchema } from './mqe-exec.js';
+import { expressionForServiceMetricSeries } from '../../util/mqe-catalog.js';
+import { windowFromRange } from '../../util/window.js';
+
+const RANGE = { step: 'MINUTE' as const, startMs: 1_760_000_000_000, endMs: 1_760_003_600_000 };
+
+describe('mqe-exec entity schema', () => {
+ it('accepts an entity with NO scope — relation metrics require it omitted', () => {
+ // OAP marks `Entity.scope` nullable and deprecated (9.4.0) and senses the
+ // scope from the metric name; forcing it empties relation results on some
+ // versions, which is why every relation query the BFF builds leaves it off.
+ const parsed = entitySchema.safeParse({
+ serviceName: 'frontend',
+ normal: true,
+ destServiceName: 'orders',
+ destNormal: true,
+ });
+ expect(parsed.success).toBe(true);
+ if (parsed.success) expect('scope' in parsed.data).toBe(false);
+ });
+
+ it('accepts an explicit scope for the single-entity sites', () => {
+ for (const scope of ['Service', 'ServiceInstance', 'Endpoint']) {
+ expect(entitySchema.safeParse({ scope, serviceName: 'frontend', normal: true }).success).toBe(true);
+ }
+ });
+
+ it('carries both sides of a relation, instances included', () => {
+ const parsed = entitySchema.safeParse({
+ serviceName: 'frontend',
+ normal: true,
+ serviceInstanceName: 'frontend-1',
+ destServiceName: 'orders',
+ destNormal: false,
+ destServiceInstanceName: 'orders-2',
+ });
+ expect(parsed.success).toBe(true);
+ });
+
+ it('rejects an unknown key rather than forwarding it to OAP', () => {
+ // A typo must fail here, visibly, instead of silently widening the query.
+ expect(entitySchema.safeParse({ serviceName: 'frontend', servcieName: 'typo' }).success).toBe(false);
+ });
+
+ it('rejects an empty-string scope — absent and blank are different', () => {
+ expect(entitySchema.safeParse({ scope: '', serviceName: 'frontend' }).success).toBe(false);
+ });
+});
+
+describe('mqe-exec body schema', () => {
+ it('accepts a blank expression — the catalog default is resolved server-side', () => {
+ const parsed = bodySchema.safeParse({
+ metric: 'cpm',
+ layer: 'GENERAL',
+ entity: { scope: 'Service', serviceName: 'frontend', normal: true },
+ ...RANGE,
+ });
+ expect(parsed.success).toBe(true);
+ });
+
+ it('takes the window as epoch ms, so no caller needs the server offset', () => {
+ const parsed = bodySchema.safeParse({
+ expression: 'service_cpm',
+ entity: { scope: 'Service', serviceName: 'frontend' },
+ ...RANGE,
+ });
+ expect(parsed.success).toBe(true);
+ if (parsed.success) {
+ expect(typeof parsed.data.startMs).toBe('number');
+ expect(parsed.data.step).toBe('MINUTE');
+ }
+ });
+
+ it('rejects a step OAP does not accept on this path', () => {
+ const parsed = bodySchema.safeParse({
+ expression: 'service_cpm',
+ entity: { serviceName: 'frontend' },
+ step: 'SECOND',
+ startMs: RANGE.startMs,
+ endMs: RANGE.endMs,
+ });
+ expect(parsed.success).toBe(false);
+ });
+
+ it('requires an entity — an expression alone is not a query', () => {
+ expect(bodySchema.safeParse({ expression: 'service_cpm', ...RANGE }).success).toBe(false);
+ });
+});
+
+describe('mqe-exec window + catalog resolution', () => {
+ it('refuses a reversed range before it reaches OAP', () => {
+ expect(windowFromRange('MINUTE', RANGE.endMs, RANGE.startMs, 0)).toBeNull();
+ });
+
+ it('formats the window for its step in OAP-server local time', () => {
+ // +480 = UTC+8: the same instant must format to the server's clock, not
+ // the caller's, which is the whole reason the conversion is BFF-side.
+ const utc = windowFromRange('MINUTE', RANGE.startMs, RANGE.endMs, 0);
+ const shanghai = windowFromRange('MINUTE', RANGE.startMs, RANGE.endMs, 480);
+ expect(utc).not.toBeNull();
+ expect(shanghai).not.toBeNull();
+ expect(shanghai!.start).not.toEqual(utc!.start);
+ expect(shanghai!.start).toMatch(/^\d{4}-\d{2}-\d{2} \d{4}$/);
+ });
+
+ it('resolves a blank service-list column to its catalog default', () => {
+ // A blank `mqe` on a header column is not a blank query — the landing
+ // route runs this same default, so the panel must show the same thing.
+ // The column's `metric` is the SHORT logical id (`cpm`), not the OAP
+ // metric name, which is what the catalog is keyed by.
+ expect(expressionForServiceMetricSeries('cpm', 'GENERAL')).toBe('service_cpm');
+ });
+
+ it('returns null for a metric the catalog has no default for', () => {
+ expect(expressionForServiceMetricSeries('not_a_real_metric_xyz', 'GENERAL')).toBeNull();
+ });
+});
diff --git a/apps/bff/src/http/query/mqe-exec.ts b/apps/bff/src/http/query/mqe-exec.ts
new file mode 100644
index 00000000..36e97873
--- /dev/null
+++ b/apps/bff/src/http/query/mqe-exec.ts
@@ -0,0 +1,210 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * `POST /api/mqe/exec` — run ONE MQE expression and hand back OAP's
+ * `ExpressionResult` untouched.
+ *
+ * The read path behind the template editor's "run this expression" panel:
+ * an author needs to know whether the MQE they just typed returns anything,
+ * and every other surface in the product answers that only after a push.
+ *
+ * Deliberately NOT the dashboard route: that one collapses each result into
+ * a widget shape (scalar / series / rows) and can express only Service,
+ * ServiceInstance and Endpoint entities, so it can neither show the raw
+ * answer nor reach the relation metrics half this editor's fields carry.
+ */
+
+import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
+import { z } from 'zod';
+import type {
+ ExpressionResult,
+ FetchLike,
+ MqeEntity,
+ MqeExecResponse,
+} from '@skywalking-horizon-ui/api-client';
+import type { AuthDeps } from '../../user/middleware.js';
+import { requireAuth } from '../../user/middleware.js';
+import { graphqlPost, buildOapOpts, GraphqlError } from '../../client/graphql.js';
+import { clientGone } from '../client-gone.js';
+import { withColdStage } from '../../util/duration.js';
+import { getServerOffsetMinutes, windowFromRange } from '../../util/window.js';
+import { expressionForServiceMetricSeries } from '../../util/mqe-catalog.js';
+
+export interface MqeExecRouteDeps extends AuthDeps {
+ fetch?: FetchLike;
+}
+
+/** The full `ExpressionResult` selection set — the point of this route is
+ * that nothing is dropped, so it asks for every field OAP exposes.
+ * `debuggingTrace` is deliberately absent: it only populates under
+ * `debug: true`, which nothing here sets. */
+const EXEC_QUERY = /* GraphQL */ `
+ query HorizonMqeExec($expression: String!, $entity: Entity!, $duration: Duration!) {
+ execExpression(expression: $expression, entity: $entity, duration: $duration) {
+ type
+ error
+ results {
+ metric { labels { key value } }
+ values {
+ id
+ value
+ traceID
+ owner {
+ scope
+ serviceID
+ serviceName
+ normal
+ serviceInstanceID
+ serviceInstanceName
+ endpointID
+ endpointName
+ }
+ }
+ }
+ }
+ }
+`;
+
+/** A generous ceiling rather than a business rule — MQE expressions are
+ * single-line and the longest in the shipped bundle is well under 500
+ * characters. Bounds an unbounded string reaching OAP. */
+const MAX_EXPRESSION_LEN = 4000;
+
+/** Entity fields OAP's `Entity` input accepts. `scope` is optional and
+ * deprecated upstream (9.4.0) — a relation metric MUST omit it, because
+ * forcing it explicitly empties the result on some OAP versions. Unknown
+ * keys are stripped rather than forwarded, so a typo fails visibly here
+ * instead of silently widening the query.
+ *
+ * Exported for unit testing (see mqe-exec.test.ts). */
+export const entitySchema = z
+ .object({
+ scope: z.string().min(1).optional(),
+ serviceName: z.string().optional(),
+ normal: z.boolean().optional(),
+ serviceInstanceName: z.string().optional(),
+ endpointName: z.string().optional(),
+ processName: z.string().optional(),
+ destServiceName: z.string().optional(),
+ destNormal: z.boolean().optional(),
+ destServiceInstanceName: z.string().optional(),
+ destEndpointName: z.string().optional(),
+ destProcessName: z.string().optional(),
+ })
+ .strict();
+
+/** Exported for unit testing (see mqe-exec.test.ts). */
+export const bodySchema = z
+ .object({
+ expression: z.string().max(MAX_EXPRESSION_LEN).optional(),
+ metric: z.string().max(256).optional(),
+ layer: z.string().max(128).optional(),
+ entity: entitySchema,
+ step: z.enum(['MINUTE', 'HOUR', 'DAY']),
+ startMs: z.number().int().positive(),
+ endMs: z.number().int().positive(),
+ })
+ .strict();
+
+export function registerMqeExecRoute(app: FastifyInstance, deps: MqeExecRouteDeps): void {
+ const auth = requireAuth(deps);
+ app.post(
+ '/api/mqe/exec',
+ { preHandler: auth },
+ async (req: FastifyRequest, reply: FastifyReply) => {
+ const parsed = bodySchema.safeParse(req.body ?? {});
+ if (!parsed.success) {
+ return reply.code(400).send({ error: 'invalid_body', detail: parsed.error.flatten() });
+ }
+ const { entity, step, startMs, endMs } = parsed.data;
+
+ // A blank field is not a blank query: a service-list column with no
+ // `mqe` runs the catalog default derived from its metric id + layer,
+ // and that default is exactly what an author with an empty box needs
+ // to see. Resolution lives here because the catalog is BFF-side.
+ const typed = (parsed.data.expression ?? '').trim();
+ let expression = typed;
+ let resolvedFromCatalog = false;
+ if (!expression) {
+ const metric = (parsed.data.metric ?? '').trim();
+ const layer = (parsed.data.layer ?? '').trim();
+ if (!metric || !layer) {
+ return reply.code(400).send({
+ error: 'missing_expression',
+ detail: 'send `expression`, or `metric` + `layer` to resolve the catalog default',
+ });
+ }
+ const fromCatalog = expressionForServiceMetricSeries(metric, layer);
+ if (!fromCatalog) {
+ return reply.code(404).send({ error: 'no_catalog_default', metric, layer });
+ }
+ expression = fromCatalog;
+ resolvedFromCatalog = true;
+ }
+
+ const signal = clientGone(reply);
+ const opts = buildOapOpts(deps.config.current, deps.fetch, signal);
+ // OAP reads Duration strings in ITS OWN local time; the caller sends
+ // epoch ms so nothing client-side has to know the server's offset.
+ const offset = await getServerOffsetMinutes(deps.config, deps.fetch, signal);
+ const window = windowFromRange(step, startMs, endMs, offset);
+ if (!window) {
+ return reply.code(400).send({ error: 'invalid_range', detail: 'endMs must be after startMs' });
+ }
+
+ const coldStage = !!req.coldStage;
+ let result: ExpressionResult;
+ try {
+ const data = await graphqlPost<{ execExpression: ExpressionResult }>(opts, EXEC_QUERY, {
+ expression,
+ entity: entity as MqeEntity,
+ duration: withColdStage(
+ { coldStage },
+ { start: window.start, end: window.end, step: window.step },
+ ),
+ });
+ result = data.execExpression;
+ } catch (err) {
+ // An expression OAP REFUSED is the answer this panel exists to show —
+ // the author needs OAP's sentence, not "unreachable". OAP reports a
+ // bad expression two ways: as `type: UNKNOWN` inside a 200, and as
+ // GraphQL errors, which land here and must not be dressed up as an
+ // outage. Only a transport-level fault is a 502.
+ if (err instanceof GraphqlError && err.errors && err.errors.length > 0) {
+ return reply.code(422).send({
+ error: 'mqe_rejected',
+ detail: err.errors.map((e) => e.message).join('; '),
+ });
+ }
+ return reply.code(502).send({
+ error: 'oap_unreachable',
+ detail: err instanceof Error ? err.message : String(err),
+ });
+ }
+
+ const body: MqeExecResponse = {
+ expression,
+ resolvedFromCatalog,
+ window: { start: window.start, end: window.end, step: window.step },
+ coldStage,
+ result,
+ };
+ return reply.send(body);
+ },
+ );
+}
diff --git a/apps/bff/src/logic/inspect/exec.ts b/apps/bff/src/logic/inspect/exec.ts
index f7624957..dc2b64bf 100644
--- a/apps/bff/src/logic/inspect/exec.ts
+++ b/apps/bff/src/logic/inspect/exec.ts
@@ -89,8 +89,13 @@ export function parseExecBody(body: unknown, reply: FastifyReply): ExecBody | nu
reply.code(400).send({ error: 'missing_entity' });
return null;
}
- if (typeof (b.entity as MqeEntity).scope !== 'string') {
- reply.code(400).send({ error: 'invalid_entity', detail: 'scope is required' });
+ // `scope` is optional, and deprecated upstream since OAP 9.4.0 — the query
+ // protocol senses it from the metric name. Relation metrics must omit it
+ // entirely (forcing it empties the result on some OAP versions), so this
+ // only rejects a present-but-wrong-typed value.
+ const entityScope = (b.entity as MqeEntity).scope;
+ if (entityScope !== undefined && typeof entityScope !== 'string') {
+ reply.code(400).send({ error: 'invalid_entity', detail: 'scope must be a string when present' });
return null;
}
if (typeof b.duration !== 'object' || b.duration === null) {
diff --git a/apps/bff/src/rbac/route-policy.ts b/apps/bff/src/rbac/route-policy.ts
index 7adcdafd..80bc1587 100644
--- a/apps/bff/src/rbac/route-policy.ts
+++ b/apps/bff/src/rbac/route-policy.ts
@@ -174,6 +174,10 @@ export const ROUTE_POLICY: Record<string, RoutePolicy> = {
'GET /api/layer/:key/service-hierarchy': 'topology:read',
'POST /api/layer/:key/dashboard': 'metrics:read',
+ // Running one expression to see what it returns is a metrics read, not a
+ // platform-internals action — deliberately NOT `inspect:read`, so a
+ // dashboard author can test their own MQE.
+ 'POST /api/mqe/exec': 'metrics:read',
'GET /api/layer/:key/dashboard/config': 'metrics:read',
'POST /api/layer/:key/landing': 'metrics:read',
'GET /api/layer/:key/instances': 'metrics:read',
diff --git a/apps/bff/src/server.ts b/apps/bff/src/server.ts
index a9a60d1b..005c4d74 100644
--- a/apps/bff/src/server.ts
+++ b/apps/bff/src/server.ts
@@ -52,6 +52,7 @@ import { registerEventsRoute } from './http/query/events.js';
import { registerExploreRoutes } from './http/query/explore.js';
import { registerPodLogRoutes } from './http/query/pod-log.js';
import { registerDashboardQueryRoute } from './http/query/dashboard.js';
+import { registerMqeExecRoute } from './http/query/mqe-exec.js';
import { registerAlarmsQueryRoutes } from './http/query/alarms.js';
import { registerAiRoutes } from './ai/chat-assistant/route.js';
import { registerMcpRoutes } from './ai/mcp/route.js';
@@ -340,6 +341,7 @@ registerDashboardQueryRoute(app, {
...authDeps,
uiTemplateClient: () => buildOapClients(source.current).uiTemplate(),
});
+registerMqeExecRoute(app, { ...authDeps });
registerAlarmsQueryRoutes(app, { ...authDeps, serviceLayer });
registerAiRoutes(app, {
...authDeps,
diff --git a/apps/ui/CLAUDE.md b/apps/ui/CLAUDE.md
index 455f7962..8e720e2e 100644
--- a/apps/ui/CLAUDE.md
+++ b/apps/ui/CLAUDE.md
@@ -26,6 +26,7 @@ The scopes are not organised by screen, and several screens legitimately share o
| `dsl`, `liveDebug`, `inspect`, `explore`, `oapOps` | the Operate sub-features |
| `infra3d` | the 3D infrastructure map |
| `layerTemplates`, `templateSync`, `configs` | the admin editors, plus the layer shell and the app-level config bundle |
+| `mqe` | the layer template editor's run panel — one expression, raw result |
| `adminAudit`, `adminAuth`, `adminUsers` | the admin pages |
| `ai` | the AI chat |
diff --git a/apps/ui/src/api/client.ts b/apps/ui/src/api/client.ts
index 20f0b488..75101036 100644
--- a/apps/ui/src/api/client.ts
+++ b/apps/ui/src/api/client.ts
@@ -93,6 +93,7 @@ import { PprofApi } from './scopes/pprof';
import { DslApi } from './scopes/dsl';
import { LiveDebugApi } from './scopes/live-debug';
import { InspectApi } from './scopes/inspect';
+import { MqeApi } from './scopes/mqe';
import { ExploreApi } from './scopes/explore';
import { OapOpsApi } from './scopes/oap-ops';
import { AlarmsApi } from './scopes/alarms';
@@ -1021,6 +1022,7 @@ export class BffClient {
readonly dsl = new DslApi(this);
readonly liveDebug = new LiveDebugApi(this);
readonly inspect = new InspectApi(this);
+ readonly mqe = new MqeApi(this);
readonly explore = new ExploreApi(this);
readonly oapOps = new OapOpsApi(this);
readonly alarms = new AlarmsApi(this);
diff --git a/apps/ui/src/api/scopes/mqe.ts b/apps/ui/src/api/scopes/mqe.ts
new file mode 100644
index 00000000..64c1f501
--- /dev/null
+++ b/apps/ui/src/api/scopes/mqe.ts
@@ -0,0 +1,32 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { MqeExecRequest, MqeExecResponse } from '@skywalking-horizon-ui/api-client';
+import type { BffClient } from '../client';
+
+/** `bff.mqe` — run one MQE expression and get OAP's answer untouched.
+ *
+ * Distinct from `bff.inspect.exec`: this one takes an epoch-ms window
+ * (the BFF converts to OAP-local time), resolves a blank expression from
+ * the metric catalog, and rides `metrics:read` rather than `inspect:read`. */
+export class MqeApi {
+ constructor(private readonly bff: BffClient) {}
+
+ exec(req: MqeExecRequest, signal?: AbortSignal): Promise<MqeExecResponse> {
+ return this.bff.request<MqeExecResponse>('POST', '/api/mqe/exec', req, undefined, signal);
+ }
+}
diff --git a/apps/ui/src/features/operate/_shared/MonacoView.vue b/apps/ui/src/components/primitives/MonacoView.vue
similarity index 100%
rename from apps/ui/src/features/operate/_shared/MonacoView.vue
rename to apps/ui/src/components/primitives/MonacoView.vue
diff --git a/apps/ui/src/components/primitives/TimeRangeMenu.vue b/apps/ui/src/components/primitives/TimeRangeMenu.vue
new file mode 100644
index 00000000..0cea9113
--- /dev/null
+++ b/apps/ui/src/components/primitives/TimeRangeMenu.vue
@@ -0,0 +1,436 @@
+<!--
+ 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.
+-->
+
+<!--
+ The time-range menu: a precision tab strip (Minute / Hour / Day, each
+ captioned with the window it can address), that precision's rolling
+ presets, and a custom-range form scoped to the same precision.
+
+ PRESENTATIONAL. It reads no store and applies nothing — it reports what
+ the operator chose and lets the host decide what that means. That is what
+ lets the topbar drive the global range with it while an admin panel drives
+ a local one, without a second copy of the markup drifting from this one.
+
+ The host owns POSITIONING too: the topbar anchors it under its chip, a
+ modal floats it. Absolute positioning here would be clipped by whichever
+ scroll container the host happens to be.
+-->
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import {
+ TIME_PRESETS,
+ STEP_LIMITS,
+ isValidRange,
+ type TimeStep,
+} from '@/controls/timeRange';
+
+const props = defineProps<{
+ /** Currently applied preset id, or `'custom'`. Drives the checkmark. */
+ presetId: string;
+ /** The applied window — seeds the custom form and the opening tab. */
+ current: { startMs: number; endMs: number; step: TimeStep };
+ /** Flipped by the host when the menu becomes visible, so the tab and the
+ * custom form re-seed from `current` on each open rather than keeping
+ * whatever the operator left behind last time. */
+ open?: boolean;
+}>();
+const emit = defineEmits<{
+ pick: [presetId: string];
+ custom: [startMs: number, endMs: number, step: TimeStep];
+}>();
+
+const { t } = useI18n({ useScope: 'global' });
+
+const activeStepTab = ref<TimeStep>(props.current.step);
+const TAB_DEFS = computed<Array<{ step: TimeStep; tab: string; cap: string }>>(() => [
+ { step: 'MINUTE', tab: t('Minute'), cap: t('≤ 4 h') },
+ { step: 'HOUR', tab: t('Hour'), cap: t('≤ 14 d') },
+ { step: 'DAY', tab: t('Day'), cap: t('≤ 3 mo') },
+]);
+const presetsForActiveTab = computed(() =>
+ TIME_PRESETS.filter((p) => p.step === activeStepTab.value),
+);
+
+// Inputs are scoped to the step so the operator can't pick at finer
+// resolution than the data will support:
+// MINUTE → datetime-local (date + HH:MM)
+// HOUR → date + hour-select (00…23), no minute field at all
+// DAY → date-only
+const customOpenStep = ref<TimeStep | null>(null);
+/** Per-bound, per-step form state. For HOUR we keep date and hour in
+ * separate keys so the UI can render two distinct controls. */
+const customDraft = ref<Record<string, string>>({});
+const customError = ref<string | null>(null);
+
+function z2(n: number): string {
+ return String(n).padStart(2, '0');
+}
+const HOURS_OF_DAY: readonly number[] = Array.from({ length: 24 }, (_, i) => i);
+
+/** Seed form state for one bound. The shape depends on the step. */
+function setDraftFromMs(step: TimeStep, side: 'start' | 'end', ms: number): void {
+ const d = new Date(ms);
+ const base = `${d.getFullYear()}-${z2(d.getMonth() + 1)}-${z2(d.getDate())}`;
+ if (step === 'DAY') {
+ customDraft.value[`${step}-${side}`] = base;
+ return;
+ }
+ if (step === 'HOUR') {
+ customDraft.value[`${step}-${side}-date`] = base;
+ customDraft.value[`${step}-${side}-hour`] = z2(d.getHours());
+ return;
+ }
+ customDraft.value[`${step}-${side}`] = `${base}T${z2(d.getHours())}:${z2(d.getMinutes())}`;
+}
+
+/** Compose form state for one bound back into an ms timestamp. */
+function draftToMs(step: TimeStep, side: 'start' | 'end'): number | null {
+ if (step === 'DAY') {
+ const v = customDraft.value[`${step}-${side}`] ?? '';
+ if (!v) return null;
+ const [y, mo, da] = v.split('-').map(Number);
+ if (!y || !mo || !da) return null;
+ return new Date(y, mo - 1, da).getTime();
+ }
+ if (step === 'HOUR') {
+ const dateV = customDraft.value[`${step}-${side}-date`] ?? '';
+ const hourV = customDraft.value[`${step}-${side}-hour`] ?? '';
+ if (!dateV || hourV === '') return null;
+ const [y, mo, da] = dateV.split('-').map(Number);
+ const h = Number(hourV);
+ if (!y || !mo || !da || Number.isNaN(h)) return null;
+ return new Date(y, mo - 1, da, h, 0, 0, 0).getTime();
+ }
+ const v = customDraft.value[`${step}-${side}`] ?? '';
+ if (!v) return null;
+ const dt = new Date(v);
+ return Number.isNaN(dt.getTime()) ? null : dt.getTime();
+}
+
+/** Seed the custom form for `step` from the applied window when it fits
+ * that precision, else from "half the step's max, ending now". */
+function seedCustomDraft(step: TimeStep): void {
+ const lim = STEP_LIMITS[step];
+ const cur = props.current;
+ const fits = step === cur.step && cur.endMs > cur.startMs && cur.endMs - cur.startMs <= lim.maxMs;
+ const endMs = fits ? cur.endMs : Date.now();
+ const startMs = fits ? cur.startMs : endMs - Math.floor(lim.maxMs / 2);
+ setDraftFromMs(step, 'start', startMs);
+ setDraftFromMs(step, 'end', endMs);
+}
+
+function openCustom(step: TimeStep): void {
+ customError.value = null;
+ if (customOpenStep.value === step) {
+ customOpenStep.value = null;
+ return;
+ }
+ customOpenStep.value = step;
+ seedCustomDraft(step);
+}
+
+// Re-opening lands the operator on the precision they are actually using,
+// and straight on the inputs they last edited when that was a custom range.
+watch(
+ () => props.open,
+ (isOpen) => {
+ if (!isOpen) return;
+ activeStepTab.value = props.current.step;
+ if (props.presetId === 'custom') {
+ customOpenStep.value = props.current.step;
+ seedCustomDraft(props.current.step);
+ } else {
+ customOpenStep.value = null;
+ }
+ },
+ { immediate: true },
+);
+
+function humanDuration(ms: number): string {
+ const h = Math.floor(ms / 3_600_000);
+ if (h < 24) return t('{n} h', { n: h });
+ return t('{n} d', { n: Math.floor(h / 24) });
+}
+
+function submitCustom(step: TimeStep): void {
+ const startMs = draftToMs(step, 'start');
+ const endMs = draftToMs(step, 'end');
+ if (startMs === null || endMs === null) {
+ customError.value = t('Pick both a start and an end.');
+ return;
+ }
+ if (endMs <= startMs) {
+ customError.value = t('End must be after start.');
+ return;
+ }
+ if (!isValidRange(step, endMs - startMs)) {
+ customError.value = t('Range exceeds {step}-precision cap of {cap}.', {
+ step: step.toLowerCase(),
+ cap: humanDuration(STEP_LIMITS[step].maxMs),
+ });
+ return;
+ }
+ customError.value = null;
+ customOpenStep.value = null;
+ emit('custom', startMs, endMs, step);
+}
+</script>
+
+<template>
+ <div class="tr-menu-body">
+ <!-- Tab strip — one tab per precision; switching tabs doesn't apply
+ anything, it just swaps which preset list + custom form is shown. -->
+ <div class="tr-tabs" role="tablist">
+ <button
+ v-for="d in TAB_DEFS"
+ :key="d.step"
+ type="button"
+ class="tr-tab"
+ :class="{ 'is-on': activeStepTab === d.step }"
+ role="tab"
+ :aria-selected="activeStepTab === d.step"
+ @click="activeStepTab = d.step; customError = null"
+ >
+ <span class="tr-tab-name">{{ d.tab }}</span>
+ <span class="tr-tab-cap">{{ d.cap }}</span>
+ </button>
+ </div>
+
+ <div class="tr-tab-body">
+ <button
+ v-for="p in presetsForActiveTab"
+ :key="p.id"
+ type="button"
+ class="tr-item"
+ :class="{ 'is-on': presetId === p.id }"
+ @click="emit('pick', p.id)"
+ >
+ <span>{{ t(p.label) }}</span>
+ <span v-if="presetId === p.id" class="tr-tick">✓</span>
+ </button>
+
+ <!-- Custom expander, scoped to the active precision. -->
+ <button
+ type="button"
+ class="tr-item tr-custom-trigger"
+ :class="{ 'is-on': customOpenStep === activeStepTab }"
+ @click="openCustom(activeStepTab)"
+ >
+ <span>{{ t('Custom range…') }}</span>
+ <span class="tr-tick">{{ customOpenStep === activeStepTab ? '▾' : '▸' }}</span>
+ </button>
+ <div v-if="customOpenStep === activeStepTab" class="tr-custom">
+ <template v-for="side in (['start', 'end'] as const)" :key="side">
+ <label class="tr-custom-field">
+ <span>{{ side === 'start' ? t('Start') : t('End') }}</span>
+ <input
+ v-if="activeStepTab === 'MINUTE'"
+ v-model="customDraft[`${activeStepTab}-${side}`]"
+ type="datetime-local"
+ step="60"
+ class="tr-custom-input"
+ />
+ <div v-else-if="activeStepTab === 'HOUR'" class="tr-custom-split">
+ <input
+ v-model="customDraft[`${activeStepTab}-${side}-date`]"
+ type="date"
+ class="tr-custom-input"
+ />
+ <select
+ v-model="customDraft[`${activeStepTab}-${side}-hour`]"
+ class="tr-custom-input tr-custom-hour"
+ >
+ <option v-for="h in HOURS_OF_DAY" :key="h" :value="String(h).padStart(2, '0')">
+ {{ String(h).padStart(2, '0') }}:00
+ </option>
+ </select>
+ </div>
+ <input
+ v-else
+ v-model="customDraft[`${activeStepTab}-${side}`]"
+ type="date"
+ class="tr-custom-input"
+ />
+ </label>
+ </template>
+ <div v-if="customError" class="tr-custom-err">{{ customError }}</div>
+ <div class="tr-custom-foot">
+ <button type="button" class="tr-cust-btn ghost" @click="customOpenStep = null">
+ {{ t('Cancel') }}
+ </button>
+ <button type="button" class="tr-cust-btn primary" @click="submitCustom(activeStepTab)">
+ {{ t('Apply') }}
+ </button>
+ </div>
+ </div>
+ </div>
+
+ <!-- Host-supplied extras (the topbar's "save as my default" block). -->
+ <slot name="footer" />
+ </div>
+</template>
+
+<style scoped>
+.tr-menu-body {
+ display: flex;
+ flex-direction: column;
+}
+.tr-tabs {
+ display: flex;
+ border-bottom: 1px solid var(--sw-line);
+ background: var(--sw-bg-2);
+}
+.tr-tab {
+ flex: 1 1 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 2px;
+ padding: 8px 4px 6px;
+ background: transparent;
+ border: 0;
+ border-bottom: 2px solid transparent;
+ color: var(--sw-fg-2);
+ font: inherit;
+ cursor: pointer;
+ transition: color 0.1s ease, border-color 0.1s ease;
+}
+.tr-tab:hover {
+ color: var(--sw-fg-0);
+}
+.tr-tab.is-on {
+ color: var(--sw-accent);
+ border-bottom-color: var(--sw-accent);
+}
+.tr-tab-name {
+ font-size: 11.5px;
+ font-weight: 600;
+}
+.tr-tab-cap {
+ font-size: 9.5px;
+ color: var(--sw-fg-3);
+ letter-spacing: 0.04em;
+}
+.tr-tab.is-on .tr-tab-cap {
+ color: var(--sw-accent-2);
+}
+.tr-tab-body {
+ padding: 4px 0;
+}
+.tr-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+ padding: 5px 12px;
+ border: 0;
+ background: transparent;
+ color: var(--sw-fg-1);
+ font: inherit;
+ text-align: left;
+ cursor: pointer;
+}
+.tr-item:hover {
+ background: var(--sw-bg-2);
+ color: var(--sw-fg-0);
+}
+.tr-item.is-on {
+ background: var(--sw-accent-soft);
+ color: var(--sw-accent-2);
+ font-weight: 600;
+}
+.tr-tick {
+ font-size: 11px;
+ color: var(--sw-accent);
+}
+.tr-custom-trigger {
+ color: var(--sw-fg-2);
+}
+.tr-custom-trigger.is-on {
+ background: var(--sw-bg-2);
+ color: var(--sw-fg-0);
+ font-weight: 500;
+}
+.tr-custom {
+ padding: 6px 12px 10px;
+ background: var(--sw-bg-2);
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+.tr-custom-field {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ font-size: 10.5px;
+ color: var(--sw-fg-3);
+ letter-spacing: 0.04em;
+}
+.tr-custom-input {
+ width: 100%;
+ padding: 4px 6px;
+ background: var(--sw-bg-0);
+ border: 1px solid var(--sw-line);
+ border-radius: 4px;
+ color: var(--sw-fg-0);
+ font: inherit;
+ font-size: 11px;
+}
+.tr-custom-input:focus {
+ outline: none;
+ border-color: var(--sw-accent);
+}
+.tr-custom-split {
+ display: flex;
+ gap: 4px;
+}
+.tr-custom-split .tr-custom-input {
+ flex: 1 1 auto;
+}
+.tr-custom-hour {
+ flex: 0 0 78px;
+}
+.tr-custom-err {
+ color: var(--sw-err);
+ font-size: 10.5px;
+}
+.tr-custom-foot {
+ display: flex;
+ justify-content: flex-end;
+ gap: 6px;
+}
+.tr-cust-btn {
+ font-size: 11px;
+ padding: 3px 10px;
+ border-radius: 3px;
+ border: 1px solid var(--sw-line-2);
+ background: transparent;
+ color: var(--sw-fg-1);
+ font: inherit;
+ cursor: pointer;
+}
+.tr-cust-btn.primary {
+ background: var(--sw-accent);
+ border-color: var(--sw-accent);
+ color: #0a0d12;
+ font-weight: 600;
+}
+.tr-cust-btn:hover:not(:disabled) {
+ filter: brightness(1.08);
+}
+</style>
diff --git a/apps/ui/src/features/admin/_shared/MqeEntityPicker.vue b/apps/ui/src/features/admin/_shared/MqeEntityPicker.vue
new file mode 100644
index 00000000..e575e535
--- /dev/null
+++ b/apps/ui/src/features/admin/_shared/MqeEntityPicker.vue
@@ -0,0 +1,294 @@
+<!--
+ 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.
+-->
+
+<!--
+ One entity for an MQE run: a service, optionally narrowed to one of its
+ instances or endpoints. The layer is FIXED by the caller — it is the
+ template being edited, and exploring another layer's services from inside
+ it would only produce results that can never apply.
+
+ Emits the pair the MQE wire needs (`serviceName` + `normal`), never an OAP
+ id: `normal` cannot be rebuilt from a bare name, and a wrong flag addresses
+ a service that was never stored.
+-->
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import TypeaheadSelect from '@/components/primitives/TypeaheadSelect.vue';
+import { useLayerServices } from '@/layer/useLayerServices';
+import { useLayerInstances } from '@/layer/useLayerInstances';
+import { useLayerEndpoints } from '@/layer/useLayerEndpoints';
+import { serviceRef } from '@/utils/serviceRef';
+
+/** Which halves of the entity this site actually needs filled. */
+export type EntityShape = 'service' | 'instance' | 'endpoint';
+
+export interface PickedEntity {
+ /** Empty means LAYER-WIDE — the entity carries no service name at all,
+ * which is what a `top_n(...)` column needs: it ranks across the layer,
+ * and naming one service makes OAP fail the query outright. */
+ serviceName: string;
+ normal: boolean;
+ instanceName?: string;
+ endpointName?: string;
+}
+
+const props = defineProps<{
+ layerKey: string;
+ shape: EntityShape;
+ /** Prefix for the field labels — "Source" / "Destination" on a relation
+ * metric, absent on a single-entity one. */
+ legend?: string;
+ /** Layer-wide is meaningful only on a SINGLE-entity site: a relation is
+ * defined by naming two services, so a blank half addresses OAP's `_blank`
+ * service and comes back empty with nothing to say why. */
+ allowLayerWide?: boolean;
+ /** When set, the service is FIXED to this name and shown read-only — the
+ * deployment graph is intra-service, so its destination side may only
+ * choose an instance. Empty means the operator picks freely. */
+ lockedService?: string;
+}>();
+const emit = defineEmits<{ pick: [PickedEntity | null] }>();
+
+const { t } = useI18n({ useScope: 'global' });
+
+const layerRef = computed(() => props.layerKey);
+// The panel owns its own window and never joins the refresh round, so the
+// roster must not subscribe to the ticker either.
+const { services, isLoading: servicesLoading, isError: servicesError } = useLayerServices(layerRef, {
+ rideTicker: false,
+});
+
+/** Sentinel for the layer-wide pick. Not an OAP id — no service has one. */
+const LAYER_WIDE = '__layer_wide__';
+
+const serviceId = ref('');
+const isLocked = computed(() => !!props.lockedService);
+const serviceOptions = computed(() => {
+ const rows = services.value.map((s) => ({ value: s.id, label: s.name, hint: s.group || undefined }));
+ // Only a bare service entity can be layer-wide; instances and endpoints
+ // hang off a specific service, and a relation names two of them.
+ return props.shape === 'service' && props.allowLayerWide
+ ? [{ value: LAYER_WIDE, label: t('All services (layer-wide)'), hint: t('for top_n') }, ...rows]
+ : rows;
+});
+const picked = computed(() => services.value.find((s) => s.id === serviceId.value) ?? null);
+
+// Seeded from the roster, and re-seeded when a layer switch invalidates the
+// pick. Must stay BELOW `services` — `immediate: true` runs during setup,
+// where a later `const` is still in the TDZ and throws with no rendered error.
+watch(
+ [services, () => props.lockedService],
+ ([rows, locked]) => {
+ if (rows.length === 0) {
+ serviceId.value = '';
+ return;
+ }
+ if (locked) {
+ // The lock names a service, not an id — resolve it against this layer's
+ // roster so the emitted entity still carries the `normal` flag.
+ const hit = rows.find((r) => r.name === locked);
+ serviceId.value = hit ? hit.id : '';
+ return;
+ }
+ // The layer-wide sentinel is a deliberate pick, not a stale id — keep it.
+ if (serviceId.value === LAYER_WIDE) return;
+ if (!rows.some((r) => r.id === serviceId.value)) serviceId.value = rows[0]!.id;
+ },
+ { immediate: true },
+);
+
+const svcRef = computed(() => (picked.value ? serviceRef(picked.value.id, picked.value.name, picked.value.normal) : null));
+
+const { instances, isFetching: instancesLoading, error: instancesError } = useLayerInstances(
+ computed(() => (props.shape === 'instance' ? props.layerKey : '')),
+ computed(() => (props.shape === 'instance' ? svcRef.value : null)),
+);
+const instanceName = ref('');
+const instanceOptions = computed(() => instances.value.map((i) => ({ value: i.name, label: i.name })));
+watch(instances, (rows) => {
+ if (props.shape !== 'instance') return;
+ if (rows.length === 0) {
+ instanceName.value = '';
+ return;
+ }
+ if (!rows.some((r) => r.name === instanceName.value)) instanceName.value = rows[0]!.name;
+});
+
+// The keyword goes to OAP's own `findEndpoint`, the way the runtime picker
+// searches — an endpoint roster is unbounded, so client-side filtering over
+// a fixed page would hide most of it.
+const endpointQuery = ref('');
+const { endpoints, isFetching: endpointsLoading, error: endpointsError } = useLayerEndpoints(
+ computed(() => (props.shape === 'endpoint' ? props.layerKey : '')),
+ computed(() => (props.shape === 'endpoint' ? svcRef.value : null)),
+ endpointQuery,
+ ref(50),
+);
+const endpointName = ref('');
+const endpointOptions = computed(() => endpoints.value.map((e) => ({ value: e.name, label: e.name })));
+watch(endpoints, (rows) => {
+ if (props.shape !== 'endpoint') return;
+ if (rows.length === 0) {
+ endpointName.value = '';
+ return;
+ }
+ if (!rows.some((r) => r.name === endpointName.value)) endpointName.value = rows[0]!.name;
+});
+
+const current = computed<PickedEntity | null>(() => {
+ // Layer-wide: a real pick that names no service.
+ if (serviceId.value === LAYER_WIDE) return { serviceName: '', normal: true };
+ const s = picked.value;
+ if (!s) return null;
+ const base: PickedEntity = { serviceName: s.name, normal: s.normal !== false };
+ if (props.shape === 'instance') {
+ if (!instanceName.value) return null;
+ return { ...base, instanceName: instanceName.value };
+ }
+ if (props.shape === 'endpoint') {
+ if (!endpointName.value) return null;
+ return { ...base, endpointName: endpointName.value };
+ }
+ return base;
+});
+watch(current, (v) => emit('pick', v), { immediate: true });
+
+/** An unreadable roster and an empty layer are different facts and must not
+ * render the same: one is an outage, the other is the truth about the layer. */
+const emptyReason = computed<string | null>(() => {
+ if (servicesLoading.value) return t('Reading data…');
+ if (servicesError.value) return t('Could not read the service list for this layer.');
+ if (services.value.length === 0) return t('No services reported in this layer yet.');
+ if (props.shape === 'instance' && instancesError.value) {
+ return t('Could not read the instances of this service.');
+ }
+ if (props.shape === 'endpoint' && endpointsError.value) {
+ return t('Could not read the endpoints of this service.');
+ }
+ return null;
+});
+</script>
+
+<template>
+ <div class="mep">
+ <label class="mep-field">
+ <span class="mep-label">{{ legend ? `${legend} · ${t('service')}` : t('service') }}</span>
+ <span v-if="isLocked" class="mep-locked" :title="t('Fixed to the source service — this graph does not cross services')">
+ {{ lockedService }}
+ </span>
+ <TypeaheadSelect
+ v-else
+ v-model="serviceId"
+ :options="serviceOptions"
+ :placeholder="t('Pick a service')"
+ :disabled="services.length === 0"
+ :aria-label="t('service')"
+ block
+ />
+ </label>
+
+ <label v-if="shape === 'instance'" class="mep-field">
+ <span class="mep-label">{{ legend ? `${legend} · ${t('instance')}` : t('instance') }}</span>
+ <TypeaheadSelect
+ v-model="instanceName"
+ :options="instanceOptions"
+ :placeholder="instancesLoading ? t('Reading data…') : t('Pick an instance')"
+ :disabled="instanceOptions.length === 0"
+ :aria-label="t('instance')"
+ block
+ />
+ </label>
+
+ <template v-if="shape === 'endpoint'">
+ <label class="mep-field">
+ <span class="mep-label">{{ t('endpoint search') }}</span>
+ <input
+ v-model="endpointQuery"
+ class="mep-input"
+ type="text"
+ :placeholder="t('Filter endpoints…')"
+ spellcheck="false"
+ />
+ </label>
+ <label class="mep-field">
+ <span class="mep-label">{{ legend ? `${legend} · ${t('endpoint')}` : t('endpoint') }}</span>
+ <TypeaheadSelect
+ v-model="endpointName"
+ :options="endpointOptions"
+ :placeholder="endpointsLoading ? t('Reading data…') : t('Pick an endpoint')"
+ :disabled="endpointOptions.length === 0"
+ :aria-label="t('endpoint')"
+ block
+ />
+ </label>
+ </template>
+
+ <p v-if="emptyReason" class="mep-empty">{{ emptyReason }}</p>
+ </div>
+</template>
+
+<style scoped>
+.mep {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px 12px;
+ align-items: flex-end;
+}
+.mep-field {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ min-width: 180px;
+ flex: 1 1 180px;
+}
+.mep-label {
+ font-size: 10.5px;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--sw-fg-3);
+}
+.mep-input {
+ width: 100%;
+ padding: 4px 8px;
+ font-size: 12px;
+ color: var(--sw-fg-0);
+ background: var(--sw-bg-0);
+ border: 1px solid var(--sw-line);
+ border-radius: 4px;
+}
+.mep-input:focus {
+ outline: none;
+ border-color: var(--sw-accent);
+}
+.mep-locked {
+ display: block;
+ padding: 4px 10px;
+ font-family: var(--sw-mono);
+ font-size: 12px;
+ color: var(--sw-fg-1);
+ background: var(--sw-bg-3);
+ border: 1px solid var(--sw-line);
+ border-radius: 4px;
+}
+.mep-empty {
+ flex: 1 1 100%;
+ margin: 0;
+ font-size: 11.5px;
+ color: var(--sw-fg-3);
+}
+</style>
diff --git a/apps/ui/src/features/admin/_shared/MqeExpressionInput.vue b/apps/ui/src/features/admin/_shared/MqeExpressionInput.vue
index 18c95ce8..bc15d9bd 100644
--- a/apps/ui/src/features/admin/_shared/MqeExpressionInput.vue
+++ b/apps/ui/src/features/admin/_shared/MqeExpressionInput.vue
@@ -15,15 +15,23 @@
limitations under the License.
-->
<!--
- MQE expression field. A single-line inline input for quick edits plus an
- expand button that opens a larger pop-out so a long expression can be
- read and edited in full. Shared by the layer-dashboard and overview
- template editors via `v-model`. Self-contained (own teleported overlay)
- so it carries no cross-feature dependency.
+ MQE expression field. A single-line inline input for quick edits, an
+ expand button that opens a larger pop-out so a long expression can be read
+ and edited in full, and — when the caller says which layer and scope the
+ field belongs to — a run button that fires the expression against a real
+ entity and shows what OAP returns. Shared by the layer-dashboard and
+ overview template editors via `v-model`.
+
+ The run affordance is opt-in because it needs context this component has no
+ way to infer: the same expression means different things at Service,
+ ServiceInstance and relation scope. A caller that supplies neither
+ `layerKey` nor `siteScope` keeps exactly the old behaviour.
-->
<script setup lang="ts">
import { computed, nextTick, ref } from 'vue';
import { useI18n } from 'vue-i18n';
+import Icon from '@/components/icons/Icon.vue';
+import MqeRunPanel, { type MqeSiteScope } from './MqeRunPanel.vue';
const props = withDefaults(
defineProps<{
@@ -33,14 +41,35 @@ const props = withDefaults(
readonly?: boolean;
/** Header label for the pop-out. */
title?: string;
+ /** Layer the expression belongs to. Supplying it (with `siteScope`)
+ * turns on the run button; it is fixed, never picked in the panel. */
+ layerKey?: string;
+ /** How this field's MQE is really evaluated. See {@link MqeSiteScope}. */
+ siteScope?: MqeSiteScope;
+ /** Column id, so a BLANK expression can still be run: the BFF resolves
+ * the catalog default from the metric name plus the layer. */
+ metric?: string;
}>(),
- { modelValue: '', placeholder: '', readonly: false, title: undefined },
+ {
+ modelValue: '',
+ placeholder: '',
+ readonly: false,
+ title: undefined,
+ layerKey: undefined,
+ siteScope: undefined,
+ metric: undefined,
+ },
);
const emit = defineEmits<{ 'update:modelValue': [value: string] }>();
const { t } = useI18n({ useScope: 'global' });
const popTitle = computed(() => props.title ?? t('MQE expression'));
+// Runnable only when the caller named both halves of the context. A blank
+// expression is still runnable — that is the catalog-default case.
+const canRun = computed(() => !!props.layerKey && !!props.siteScope);
+const runOpen = ref(false);
+
const open = ref(false);
const draft = ref('');
const taRef = ref<HTMLTextAreaElement | null>(null);
@@ -83,9 +112,28 @@ function onKeydown(e: KeyboardEvent): void {
:readonly="readonly"
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
+ <button
+ v-if="canRun"
+ type="button"
+ class="mqe-run"
+ :title="t('Run this expression')"
+ :aria-label="t('Run this expression')"
+ @click="runOpen = true"
+ ><Icon name="metric" :size="12" /></button>
<button type="button" class="mqe-expand" :title="t('Expand — {title}', { title: popTitle })" @click="openPopout">⤢</button>
</div>
+ <MqeRunPanel
+ v-if="canRun"
+ :open="runOpen"
+ :expression="modelValue ?? ''"
+ :metric="metric"
+ :layer-key="layerKey!"
+ :site-scope="siteScope!"
+ :title="title"
+ @close="runOpen = false"
+ />
+
<Teleport to="body">
<div v-if="open" class="mqe-pop-backdrop" @mousedown.self="cancel">
<div class="mqe-pop" role="dialog" aria-modal="true" @keydown="onKeydown">
@@ -152,6 +200,22 @@ function onKeydown(e: KeyboardEvent): void {
color: var(--sw-fg-0);
border-color: var(--sw-accent-line);
}
+.mqe-run {
+ flex: 0 0 auto;
+ display: inline-grid;
+ place-items: center;
+ width: 24px;
+ height: 26px;
+ background: var(--sw-bg-2);
+ border: 1px solid var(--sw-line-2);
+ border-radius: 4px;
+ color: var(--sw-fg-3);
+ cursor: pointer;
+}
+.mqe-run:hover {
+ color: var(--sw-accent);
+ border-color: var(--sw-accent-line);
+}
.mqe-pop-backdrop {
position: fixed;
diff --git a/apps/ui/src/features/admin/_shared/MqeRangePicker.vue b/apps/ui/src/features/admin/_shared/MqeRangePicker.vue
new file mode 100644
index 00000000..ffa08908
--- /dev/null
+++ b/apps/ui/src/features/admin/_shared/MqeRangePicker.vue
@@ -0,0 +1,174 @@
+<!--
+ 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.
+-->
+
+<!--
+ The MQE panel's time range: the topbar's chip and menu, driven by a LOCAL
+ value.
+
+ It cannot be the topbar's own chip. That one writes the global store — so
+ picking a window here would move every dashboard behind the modal — and it
+ disables itself by route, `/admin/` included, so a copy would render grey
+ and never open. The menu inside is the same component the topbar renders.
+
+ The window is stamped when `resolve()` is called rather than held, so a
+ rolling preset always means "now" at the moment of the run, not whenever
+ the panel was opened.
+-->
+<script setup lang="ts">
+import { computed, onBeforeUnmount, ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import Icon from '@/components/icons/Icon.vue';
+import FloatingPanel from '@/components/primitives/FloatingPanel.vue';
+import TimeRangeMenu from '@/components/primitives/TimeRangeMenu.vue';
+import { TIME_PRESETS, type TimeStep } from '@/controls/timeRange';
+
+const emit = defineEmits<{ change: [] }>();
+
+interface ResolvedRange {
+ step: TimeStep;
+ startMs: number;
+ endMs: number;
+}
+
+const { t } = useI18n({ useScope: 'global' });
+
+const DEFAULT_PRESET = '1h';
+const presetId = ref<string>(DEFAULT_PRESET);
+/** Set only while `presetId === 'custom'` — a frozen window, not a rolling one. */
+const fixed = ref<ResolvedRange | null>(null);
+
+const preset = computed(() => TIME_PRESETS.find((p) => p.id === presetId.value) ?? null);
+
+function stampNow(): ResolvedRange {
+ const p = preset.value ?? TIME_PRESETS.find((x) => x.id === DEFAULT_PRESET)!;
+ const endMs = Date.now();
+ return { step: p.step, startMs: endMs - p.durationMs, endMs };
+}
+
+/** The window as of NOW for a rolling preset, or the frozen one the operator
+ * entered. Called at run time, never cached — re-running after leaving the
+ * panel open should ask about the newer data. */
+function resolve(): ResolvedRange {
+ return fixed.value ?? stampNow();
+}
+defineExpose({ resolve });
+
+/** What the menu seeds its custom form and opening tab from. */
+const current = computed<ResolvedRange>(() => fixed.value ?? stampNow());
+
+function stamp(ms: number, step: TimeStep): string {
+ const d = new Date(ms);
+ const z = (n: number) => String(n).padStart(2, '0');
+ if (step === 'DAY') return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())}`;
+ if (step === 'HOUR') return `${z(d.getMonth() + 1)}-${z(d.getDate())} ${z(d.getHours())}h`;
+ return `${z(d.getMonth() + 1)}-${z(d.getDate())} ${z(d.getHours())}:${z(d.getMinutes())}`;
+}
+const label = computed(() => {
+ if (fixed.value) {
+ const f = fixed.value;
+ return `${stamp(f.startMs, f.step)} → ${stamp(f.endMs, f.step)}`;
+ }
+ return preset.value ? t(preset.value.label) : t('Last 1 hour');
+});
+const step = computed(() => (fixed.value ?? stampNow()).step);
+
+const open = ref(false);
+const triggerEl = ref<HTMLElement | null>(null);
+
+function onPick(id: string): void {
+ presetId.value = id;
+ fixed.value = null;
+ open.value = false;
+ emit('change');
+}
+function onCustom(startMs: number, endMs: number, s: TimeStep): void {
+ presetId.value = 'custom';
+ fixed.value = { startMs, endMs, step: s };
+ open.value = false;
+ emit('change');
+}
+
+/**
+ * Escape must close the MENU, not the modal behind it.
+ *
+ * `Modal` listens on `window` and `FloatingPanel` on `document`, both in the
+ * bubble phase, so one Escape would otherwise dismiss the whole panel along
+ * with the dropdown. A capture-phase listener runs before either and stops
+ * the event there while the menu is open.
+ */
+function onEscapeCapture(e: KeyboardEvent): void {
+ if (!open.value || e.key !== 'Escape') return;
+ e.stopPropagation();
+ open.value = false;
+}
+watch(open, (isOpen) => {
+ if (isOpen) window.addEventListener('keydown', onEscapeCapture, true);
+ else window.removeEventListener('keydown', onEscapeCapture, true);
+});
+onBeforeUnmount(() => window.removeEventListener('keydown', onEscapeCapture, true));
+</script>
+
+<template>
+ <div class="mrp-range">
+ <span class="mrp-cap">{{ t('time range') }}</span>
+ <button
+ ref="triggerEl"
+ type="button"
+ class="sw-btn mrp-trigger"
+ :title="t('time range')"
+ @click="open = !open"
+ >
+ <Icon name="clock" :size="12" />
+ <span>{{ label }} · {{ step }}</span>
+ <Icon name="caret" :size="10" />
+ </button>
+
+ <!-- Teleported: the modal body scrolls, so a menu positioned inside it
+ would be clipped at the body edge instead of floating over it. -->
+ <FloatingPanel :open="open" :anchor="triggerEl" :width="280" @close="open = false">
+ <TimeRangeMenu
+ :preset-id="presetId"
+ :current="current"
+ :open="open"
+ @pick="onPick"
+ @custom="onCustom"
+ />
+ </FloatingPanel>
+ </div>
+</template>
+
+<style scoped>
+.mrp-range {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+}
+.mrp-cap {
+ font-size: 10.5px;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--sw-fg-3);
+}
+.mrp-trigger {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 10px;
+ font-size: 12px;
+ cursor: pointer;
+}
+</style>
diff --git a/apps/ui/src/features/admin/_shared/MqeResultView.vue b/apps/ui/src/features/admin/_shared/MqeResultView.vue
new file mode 100644
index 00000000..e5bca755
--- /dev/null
+++ b/apps/ui/src/features/admin/_shared/MqeResultView.vue
@@ -0,0 +1,346 @@
+<!--
+ 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.
+-->
+
+<!--
+ Renders an `ExpressionResult` per its `type`. The five MQE result types
+ carry genuinely different value shapes — `values[].id` alone is a bucket
+ timestamp, an entity name, a record's text, or nothing — so one generic
+ table would misrepresent four of them.
+
+ Everything shown comes from OAP verbatim; nothing here formats or rounds a
+ value, because the question this answers is "what did the server actually
+ return".
+-->
+<script setup lang="ts">
+import { computed, defineAsyncComponent, ref } from 'vue';
+import { useI18n } from 'vue-i18n';
+import type { ExpressionResult, MqeValues } from '@skywalking-horizon-ui/api-client';
+import TimeChart from '@/components/charts/TimeChart.vue';
+/** Loaded on demand: Monaco is a large dependency and this panel renders on
+ * every MQE row in the template editor, but the JSON view is opened rarely.
+ * A static import also drags the editor into any unit test that mounts a
+ * row, where it fails outright on jsdom's missing `queryCommandSupported`. */
+const MonacoView = defineAsyncComponent(() => import('@/components/primitives/MonacoView.vue'));
+import { alignSeries, seriesLabel } from './mqeSeries';
+import { timestampLabel } from '@/utils/formatters';
+
+const props = defineProps<{
+ result: ExpressionResult;
+ step: 'MINUTE' | 'HOUR' | 'DAY';
+}>();
+
+const { t } = useI18n({ useScope: 'global' });
+
+/** Series name from the metric's labels, or a positional fallback. A result
+ * with no labels is the unlabelled single-series case. */
+function seriesName(r: MqeValues): string {
+ return seriesLabel(r, results.value, t('value'));
+}
+
+const results = computed(() => props.result.results ?? []);
+
+/** The chart and the table are built from the SAME aligned rows, so the point
+ * under the cursor is the row beneath it. They differ in PRECISION only: the
+ * chart's tooltip rounds for legibility, while the table prints OAP's string
+ * verbatim — the table is where an exact value is read. */
+const chart = computed(() => alignSeries(props.result, props.step, t('value')));
+
+/** A series is read as a shape; everything else as rows. The JSON view is
+ * the escape hatch from both — it is OAP's reply, pretty-printed, for the
+ * exact digits and for the fields no rendering surfaces (owner ids, the
+ * raw string form of every value). */
+const hasChart = computed(
+ () => props.result.type === 'TIME_SERIES_VALUES' && chart.value.ids.length > 0,
+);
+const view = ref<'render' | 'json'>('render');
+const renderTabLabel = computed(() => (hasChart.value ? t('Graph') : t('Table')));
+const pretty = computed(() => JSON.stringify(props.result, null, 2));
+
+const copied = ref(false);
+async function copyJson(): Promise<void> {
+ try {
+ await navigator.clipboard.writeText(pretty.value);
+ copied.value = true;
+ setTimeout(() => (copied.value = false), 1200);
+ } catch {
+ /* clipboard blocked (insecure context / no permission) — no-op */
+ }
+}
+
+/** A type can be legitimate while `results` is empty: when storage returns a
+ * series whose length disagrees with the duration's bucket count, OAP logs
+ * and returns early, leaving the type set and the array empty. That is "no
+ * rows", not an error, and must not render as one. */
+const isEmpty = computed(() => {
+ if (props.result.type === 'UNKNOWN') return false;
+ if (results.value.length === 0) return true;
+ // A TIME_SERIES result can come back with result rows whose values carry no
+ // usable bucket id — there is then no axis to draw and no row to print, and
+ // an unguarded chart would render a blank 200px frame over an empty table.
+ return props.result.type === 'TIME_SERIES_VALUES' && chart.value.ids.length === 0;
+});
+
+/* ── SINGLE_VALUE: one row per result; `id` is usually absent ── */
+const singleRows = computed(() =>
+ results.value.map((r) => ({
+ name: seriesName(r),
+ value: r.values?.[0]?.value ?? null,
+ /* Present for max/min/latest — it names the bucket that WON, which is
+ the whole reason those differ from avg/count/sum. OAP sends it as
+ epoch ms; a raw 13-digit number is not a time anyone reads. */
+ at: bucketTime(r.values?.[0]?.id ?? null),
+ })),
+);
+
+/* ── SORTED_LIST / RECORD_LIST: one flat list under a single result ── */
+const listRows = computed(() => results.value[0]?.values ?? []);
+
+/** An MQE bucket id is epoch ms. Anything non-numeric is passed through —
+ * only TIME_SERIES and the select-style aggregates carry a timestamp here. */
+function bucketTime(id: string | null | undefined): string | null {
+ if (!id) return null;
+ const ms = Number(id);
+ return Number.isFinite(ms) ? timestampLabel(ms) : id;
+}
+
+function ownerOf(v: { owner?: { serviceName?: string | null; serviceInstanceName?: string | null; endpointName?: string | null; scope?: string | null } | null }): string {
+ const o = v.owner;
+ if (!o) return '';
+ return [o.endpointName, o.serviceInstanceName, o.serviceName].filter(Boolean).join(' · ');
+}
+</script>
+
+<template>
+ <div class="mrv">
+ <!-- Only offered once there is something to show: an error or an empty
+ result has no second representation worth switching to. -->
+ <div v-if="!isEmpty && result.type !== 'UNKNOWN'" class="mrv-tabs" role="tablist">
+ <button
+ type="button"
+ class="mrv-tab"
+ :class="{ 'is-on': view === 'render' }"
+ role="tab"
+ :aria-selected="view === 'render'"
+ @click="view = 'render'"
+ >{{ renderTabLabel }}</button>
+ <button
+ type="button"
+ class="mrv-tab"
+ :class="{ 'is-on': view === 'json' }"
+ role="tab"
+ :aria-selected="view === 'json'"
+ @click="view = 'json'"
+ >{{ t('JSON') }}</button>
+ </div>
+
+ <div v-if="view === 'json' && !isEmpty && result.type !== 'UNKNOWN'" class="mrv-json">
+ <div class="mrv-json-editor">
+ <MonacoView :value="pretty" language="json" />
+ </div>
+ <div class="mrv-json-bar">
+ <button type="button" class="mrv-copy" @click="copyJson">
+ {{ copied ? t('Copied') : t('Copy JSON') }}
+ </button>
+ </div>
+ </div>
+
+ <template v-else-if="result.type === 'UNKNOWN'">
+ <p class="mrv-err">{{ result.error || t('OAP could not resolve this expression.') }}</p>
+ </template>
+
+ <template v-else-if="isEmpty">
+ <p class="mrv-empty">{{ t('No rows returned for this entity and time range.') }}</p>
+ </template>
+
+ <template v-else-if="result.type === 'TIME_SERIES_VALUES'">
+ <!-- The graph IS the answer for a series — a 61-row value table beside
+ it repeats what the line already shows. Exact numbers live one
+ click away in Copy JSON, which hands over OAP's response verbatim. -->
+ <div class="mrv-chart">
+ <!-- Keyed on the axis itself: `TimeChart` watches `series` but not
+ `xLabels`, so a re-run over a different window could otherwise
+ slide new data under the old times. The key forces a remount
+ whenever the axis changes, which no future edit can undo. -->
+ <TimeChart
+ :key="chart.ids[0] + ':' + chart.ids.length"
+ :series="chart.series"
+ :x-labels="chart.xLabels"
+ :height="260"
+ />
+ </div>
+ <p class="mrv-note">{{ t('{rows} buckets · {series} series', { rows: chart.ids.length, series: chart.series.length }) }}</p>
+ </template>
+
+ <template v-else-if="result.type === 'SINGLE_VALUE'">
+ <div class="mrv-scroll">
+ <table class="mrv-table">
+ <thead>
+ <tr>
+ <th>{{ t('time') }}</th>
+ <th>{{ t('labels') }}</th>
+ <th class="num">{{ t('value') }}</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="(row, i) in singleRows" :key="i">
+ <!-- Absent for avg/count/sum: those fold every bucket, so no
+ single one is the answer. Shown as an em-dash, not 0. -->
+ <td class="mono dim">{{ row.at ?? '—' }}</td>
+ <td class="mono">{{ row.name }}</td>
+ <td class="num mono" :class="{ nil: row.value === null }">{{ row.value === null ? '—' : row.value }}</td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ </template>
+
+ <template v-else-if="result.type === 'SORTED_LIST'">
+ <div class="mrv-scroll">
+ <table class="mrv-table">
+ <thead>
+ <tr>
+ <th class="rank">#</th>
+ <th>{{ t('entity') }}</th>
+ <th class="num">{{ t('value') }}</th>
+ <th>{{ t('owner') }}</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="(v, i) in listRows" :key="i">
+ <td class="rank mono dim">{{ i + 1 }}</td>
+ <td class="mono">{{ v.id ?? '—' }}</td>
+ <td class="num mono" :class="{ nil: v.value === null }">{{ v.value === null ? '—' : v.value }}</td>
+ <td class="mono dim">{{ ownerOf(v) || '—' }}</td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ </template>
+
+ <template v-else-if="result.type === 'RECORD_LIST'">
+ <div class="mrv-scroll">
+ <table class="mrv-table">
+ <thead>
+ <tr>
+ <th>{{ t('record') }}</th>
+ <th class="num">{{ t('value') }}</th>
+ <th>{{ t('trace id') }}</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="(v, i) in listRows" :key="i">
+ <td class="mono wrap">{{ v.id ?? '—' }}</td>
+ <td class="num mono" :class="{ nil: v.value === null }">{{ v.value === null ? '—' : v.value }}</td>
+ <!-- An empty trace id is not a link: OAP sets `""` when the
+ sample has no associated trace. -->
+ <td class="mono dim">{{ v.traceID || '—' }}</td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ </template>
+ </div>
+</template>
+
+<style scoped>
+.mrv { display: flex; flex-direction: column; gap: 6px; flex: 1; min-height: 0; }
+.mrv-tabs { display: flex; gap: 2px; flex: 0 0 auto; }
+.mrv-tab {
+ padding: 3px 12px;
+ font-size: 11px;
+ letter-spacing: 0.04em;
+ color: var(--sw-fg-3);
+ background: transparent;
+ border: 1px solid transparent;
+ border-radius: 4px;
+ cursor: pointer;
+}
+.mrv-tab:hover { color: var(--sw-fg-1); }
+.mrv-tab.is-on {
+ color: var(--sw-accent);
+ background: var(--sw-accent-soft);
+ border-color: var(--sw-accent-line);
+}
+/* Monaco sizes to its host, so the host needs a definite height inside the
+ modal's flex column — `auto` collapses it to nothing. */
+.mrv-json { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 4px; }
+/* The editor takes the height; the action sits under it, left-aligned with
+ the gutter so it reads as belonging to the document above it. */
+.mrv-json-editor { flex: 1; min-height: 0; display: flex; }
+.mrv-json-editor > * { flex: 1; min-height: 0; }
+.mrv-json-bar { flex: 0 0 auto; display: flex; justify-content: flex-start; }
+.mrv-copy {
+ padding: 2px 10px;
+ font-size: 11px;
+ color: var(--sw-fg-2);
+ background: var(--sw-bg-2);
+ border: 1px solid var(--sw-line-2);
+ border-radius: 4px;
+ cursor: pointer;
+}
+.mrv-copy:hover { color: var(--sw-fg-0); border-color: var(--sw-accent-line); }
+/* The chart keeps a definite height of its own — inside the modal's flex
+ column an `auto` height would collapse to zero and render nothing. */
+.mrv-chart {
+ flex: 0 0 auto;
+ border: 1px solid var(--sw-line);
+ border-radius: 4px;
+ background: var(--sw-bg-1);
+ padding: 4px 6px 0;
+}
+.mrv-scroll { flex: 1 1 auto; min-height: 0; overflow: auto; border: 1px solid var(--sw-line); border-radius: 4px; }
+.mrv-table { width: 100%; border-collapse: collapse; font-size: 12px; }
+.mrv-table th,
+.mrv-table td {
+ text-align: left;
+ padding: 4px 8px;
+ border-bottom: 1px solid var(--sw-line);
+ vertical-align: top;
+}
+.mrv-table thead th {
+ position: sticky;
+ top: 0;
+ background: var(--sw-bg-3);
+ color: var(--sw-fg-2);
+ font-size: 10.5px;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ font-weight: 600;
+ white-space: nowrap;
+}
+.mrv-table tbody tr:last-child td { border-bottom: none; }
+.mrv-table .num { text-align: right; font-variant-numeric: tabular-nums; }
+.mrv-table .rank { width: 34px; text-align: right; }
+.mrv-table .wrap { white-space: pre-wrap; word-break: break-word; max-width: 520px; }
+.mono { font-family: var(--sw-mono); }
+.dim { color: var(--sw-fg-3); }
+.nil { color: var(--sw-fg-3); }
+.mrv-note { margin: 0; font-size: 11px; color: var(--sw-fg-3); }
+.mrv-empty { margin: 0; font-size: 12px; color: var(--sw-fg-2); }
+.mrv-err {
+ margin: 0;
+ padding: 8px 10px;
+ font-family: var(--sw-mono);
+ font-size: 12px;
+ white-space: pre-wrap;
+ word-break: break-word;
+ color: var(--sw-err);
+ background: var(--sw-err-soft);
+ border: 1px solid var(--sw-err);
+ border-radius: 4px;
+}
+</style>
diff --git a/apps/ui/src/features/admin/_shared/MqeRunPanel.vue b/apps/ui/src/features/admin/_shared/MqeRunPanel.vue
new file mode 100644
index 00000000..5b728c5d
--- /dev/null
+++ b/apps/ui/src/features/admin/_shared/MqeRunPanel.vue
@@ -0,0 +1,390 @@
+<!--
+ 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.
+-->
+
+<!--
+ Run one MQE expression from the template editor and show what OAP returns.
+ The editor fires no metric queries of its own, so without this the only way
+ to find out whether an expression works is to push the template and go look.
+
+ An MQE means nothing without an entity and a duration, so the panel supplies
+ both. The LAYER is fixed — it is the template being edited. Nothing here is
+ saved: this is a read, and the expression under test is whatever is in the
+ field right now, including one that exists only in an unsaved draft.
+-->
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import type { MqeEntity, MqeExecResponse } from '@skywalking-horizon-ui/api-client';
+import Modal from '@/components/primitives/Modal.vue';
+import Btn from '@/components/primitives/Btn.vue';
+import { bffClient } from '@/api/client';
+import MqeEntityPicker, { type EntityShape, type PickedEntity } from './MqeEntityPicker.vue';
+import MqeRangePicker from './MqeRangePicker.vue';
+import MqeResultView from './MqeResultView.vue';
+
+/**
+ * How this field's expression is really evaluated when the product runs it.
+ * `*-relation` sites are queried with the entity's `scope` OMITTED — OAP
+ * senses it from the metric name, and forcing it empties the result on some
+ * versions — so they carry a destination side as well as a source.
+ */
+export type MqeSiteScope =
+ | 'service'
+ | 'instance'
+ | 'endpoint'
+ | 'service-relation'
+ | 'instance-relation'
+ | 'endpoint-relation'
+ /** ServiceInstanceRelation confined to ONE service — the deployment graph
+ * is intra-service, so both sides share a service name and only the
+ * instances differ. Offering two free service pickers here would build an
+ * entity that graph never produces. */
+ | 'deployment-relation';
+
+const props = defineProps<{
+ open: boolean;
+ /** The expression under test. Empty is legal — see `metric`. */
+ expression: string;
+ /** Column id used to resolve a blank expression to its catalog default. */
+ metric?: string;
+ /** Fixed: the template being edited. */
+ layerKey: string;
+ /** What this field's MQE is evaluated against. */
+ siteScope: MqeSiteScope;
+ /** Names the field in the panel header (e.g. "Service list column"). */
+ title?: string;
+}>();
+const emit = defineEmits<{ close: [] }>();
+
+const { t } = useI18n({ useScope: 'global' });
+
+const RELATION: Record<MqeSiteScope, boolean> = {
+ service: false,
+ instance: false,
+ endpoint: false,
+ 'service-relation': true,
+ 'instance-relation': true,
+ 'endpoint-relation': true,
+ 'deployment-relation': true,
+};
+const isRelation = computed(() => RELATION[props.siteScope]);
+
+/** The entity halves each site needs filled. A relation asks for the same
+ * shape on both sides. */
+const shape = computed<EntityShape>(() => {
+ switch (props.siteScope) {
+ case 'instance':
+ case 'instance-relation':
+ case 'deployment-relation':
+ return 'instance';
+ case 'endpoint':
+ case 'endpoint-relation':
+ return 'endpoint';
+ default:
+ return 'service';
+ }
+});
+
+/** The OAP scope literal to send, or `undefined` for a relation — omitting
+ * it is required, not an optimisation. */
+const wireScope = computed<string | undefined>(() => {
+ if (isRelation.value) return undefined;
+ switch (props.siteScope) {
+ case 'instance':
+ return 'ServiceInstance';
+ case 'endpoint':
+ return 'Endpoint';
+ default:
+ return 'Service';
+ }
+});
+
+const source = ref<PickedEntity | null>(null);
+const dest = ref<PickedEntity | null>(null);
+
+/** The deployment graph never crosses services, so its destination picker
+ * inherits the source's service and chooses only an instance. Declared
+ * BELOW `source` — a ref read from a computed above its own declaration is
+ * a TDZ throw waiting for the first eager consumer. */
+const destLockedService = computed(() =>
+ props.siteScope === 'deployment-relation' ? (source.value?.serviceName ?? '') : '',
+);
+const rangeRef = ref<{ resolve: () => { step: 'MINUTE' | 'HOUR' | 'DAY'; startMs: number; endMs: number } } | null>(null);
+
+const running = ref(false);
+const response = ref<MqeExecResponse | null>(null);
+const failure = ref<string | null>(null);
+let inflight: AbortController | null = null;
+
+/** A blank field is runnable ONLY where a metric id can resolve the catalog
+ * default — the service-list columns. Every other site's expression is the
+ * whole query, so an empty one has nothing to send. */
+const runnableBlank = computed(() => !!props.metric);
+const hasExpression = computed(() => !!props.expression.trim() || runnableBlank.value);
+const canRun = computed(
+ () => hasExpression.value && !!source.value && (!isRelation.value || !!dest.value),
+);
+
+function buildEntity(): MqeEntity {
+ const s = source.value!;
+ // A layer-wide pick sends NO service name — `top_n(...)` ranks across the
+ // layer, and naming one service makes OAP fail the query rather than scope it.
+ const e: MqeEntity = s.serviceName ? { serviceName: s.serviceName, normal: s.normal } : { normal: true };
+ if (wireScope.value) e.scope = wireScope.value;
+ if (s.instanceName) e.serviceInstanceName = s.instanceName;
+ if (s.endpointName) e.endpointName = s.endpointName;
+ if (isRelation.value && dest.value) {
+ const d = dest.value;
+ // Intra-service graph: the destination's service IS the source's, whatever
+ // the picker last resolved, so a stale pick can never cross services.
+ const crossService = props.siteScope !== 'deployment-relation';
+ const destName = crossService ? d.serviceName : s.serviceName;
+ if (destName) {
+ e.destServiceName = destName;
+ e.destNormal = crossService ? d.normal : s.normal;
+ }
+ if (d.instanceName) e.destServiceInstanceName = d.instanceName;
+ if (d.endpointName) e.destEndpointName = d.endpointName;
+ }
+ return e;
+}
+
+/** The BFF names a reason (`missing_expression`, `invalid_range`, the OAP
+ * message); the thrown Error only carries the status line. Prefer the body,
+ * and fall back to the status when there is nothing better to say. */
+function reasonOf(err: unknown): string {
+ const body = (err as { body?: unknown } | null)?.body;
+ if (body && typeof body === 'object') {
+ const b = body as { error?: unknown; detail?: unknown };
+ const detail = typeof b.detail === 'string' ? b.detail : '';
+ const code = typeof b.error === 'string' ? b.error : '';
+ if (detail) return code ? `${code}: ${detail}` : detail;
+ if (code) return code;
+ }
+ return err instanceof Error ? err.message : String(err);
+}
+
+async function run(): Promise<void> {
+ if (!canRun.value || running.value) return;
+ // Cascade-clear: the previous answer must not sit under the spinner while a
+ // different entity or window is in flight — it reads as the new result.
+ inflight?.abort();
+ const ctrl = new AbortController();
+ inflight = ctrl;
+ response.value = null;
+ failure.value = null;
+ running.value = true;
+ const w = rangeRef.value?.resolve();
+ try {
+ const expr = props.expression.trim();
+ response.value = await bffClient.mqe.exec(
+ {
+ ...(expr ? { expression: expr } : { metric: props.metric ?? '' }),
+ layer: props.layerKey,
+ entity: buildEntity(),
+ step: w?.step ?? 'MINUTE',
+ startMs: w?.startMs ?? Date.now() - 3600_000,
+ endMs: w?.endMs ?? Date.now(),
+ },
+ ctrl.signal,
+ );
+ } catch (err) {
+ if (ctrl.signal.aborted) return;
+ failure.value = reasonOf(err);
+ } finally {
+ if (inflight === ctrl) {
+ inflight = null;
+ running.value = false;
+ }
+ }
+}
+
+/**
+ * Cascade-clear: an answer belongs to the entity and window that produced it,
+ * so changing either must drop it rather than leave it sitting under controls
+ * that now describe a different query. An operator reads a stale number as the
+ * new one — that is the failure this exists to prevent, not a tidiness rule.
+ */
+function invalidate(): void {
+ inflight?.abort();
+ inflight = null;
+ running.value = false;
+ response.value = null;
+ failure.value = null;
+}
+/** Compared by VALUE, not identity: the pickers re-emit a fresh object
+ * whenever their roster query settles, so watching the refs themselves would
+ * discard a perfectly good result on a background refetch that changed
+ * nothing the operator can see. */
+const entityKey = computed(() => {
+ const part = (e: PickedEntity | null) =>
+ e ? `${e.serviceName}|${e.normal}|${e.instanceName ?? ''}|${e.endpointName ?? ''}` : '';
+ return `${part(source.value)}»${part(dest.value)}`;
+});
+watch(entityKey, invalidate);
+
+// A reopen must not show the previous field's answer.
+watch(
+ () => props.open,
+ (isOpen) => {
+ if (isOpen) {
+ response.value = null;
+ failure.value = null;
+ } else {
+ inflight?.abort();
+ inflight = null;
+ running.value = false;
+ }
+ },
+);
+
+</script>
+
+<template>
+ <Modal
+ :open="open"
+ :title="title ? t('Run MQE · {field}', { field: title }) : t('Run MQE')"
+ width="min(980px, 94vw)"
+ fit-body
+ @close="emit('close')"
+ >
+ <div class="mrp-body">
+ <div class="mrp-expr">
+ <span class="mrp-cap">{{ t('expression') }}</span>
+ <code class="mrp-code" :class="{ 'is-empty': !expression.trim() }">{{
+ expression.trim()
+ || (runnableBlank
+ ? t('(blank — the catalog default for this metric will run)')
+ : t('(empty — type an expression to run it)'))
+ }}</code>
+ </div>
+
+ <div class="mrp-controls">
+ <div class="mrp-layer">
+ <span class="mrp-cap">{{ t('layer') }}</span>
+ <span class="mrp-fixed" :title="t('Fixed by the template being edited')">{{ layerKey || '—' }}</span>
+ </div>
+ <MqeRangePicker ref="rangeRef" @change="invalidate" />
+ </div>
+
+ <MqeEntityPicker
+ :layer-key="layerKey"
+ :shape="shape"
+ :allow-layer-wide="!isRelation"
+ :legend="isRelation ? t('source') : undefined"
+ @pick="source = $event"
+ />
+ <MqeEntityPicker
+ v-if="isRelation"
+ :layer-key="layerKey"
+ :shape="shape"
+ :legend="t('destination')"
+ :locked-service="destLockedService"
+ @pick="dest = $event"
+ />
+
+ <p v-if="isRelation" class="mrp-hint">
+ {{ t('Relation metrics are queried without an explicit scope — OAP reads it from the metric name.') }}
+ </p>
+
+ <div class="mrp-actions">
+ <Btn kind="primary" size="sm" :disabled="!canRun || running" @click="run">
+ {{ running ? t('Running…') : t('Run') }}
+ </Btn>
+ <span v-if="response" class="mrp-meta">
+ {{ response.result.type }} ·
+ {{ response.window.start }} → {{ response.window.end }} ({{ response.window.step }})
+ <template v-if="response.coldStage"> · {{ t('cold stage') }}</template>
+ </span>
+ </div>
+
+ <p v-if="response?.resolvedFromCatalog" class="mrp-hint">
+ {{ t('Blank field — ran the catalog default: {expr}', { expr: response.expression }) }}
+ </p>
+
+ <div class="mrp-result">
+ <p v-if="running" class="mrp-hint">{{ t('Reading data…') }}</p>
+ <p v-else-if="failure" class="mrp-fail">{{ failure }}</p>
+ <MqeResultView
+ v-else-if="response"
+ :result="response.result"
+ :step="response.window.step"
+ />
+ <p v-else class="mrp-hint">{{ t('Pick an entity and run to see what OAP returns.') }}</p>
+ </div>
+ </div>
+ </Modal>
+</template>
+
+<style scoped>
+.mrp-body {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ min-height: 0;
+ flex: 1;
+}
+.mrp-cap {
+ font-size: 10.5px;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--sw-fg-3);
+}
+.mrp-expr { display: flex; flex-direction: column; gap: 3px; }
+.mrp-code {
+ font-family: var(--sw-mono);
+ font-size: 12px;
+ color: var(--sw-fg-0);
+ background: var(--sw-bg-0);
+ border: 1px solid var(--sw-line);
+ border-radius: 4px;
+ padding: 6px 8px;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+.mrp-controls { display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap; }
+.mrp-layer { display: flex; flex-direction: column; gap: 3px; }
+.mrp-fixed {
+ font-family: var(--sw-mono);
+ font-size: 12px;
+ color: var(--sw-fg-1);
+ background: var(--sw-bg-3);
+ border: 1px solid var(--sw-line);
+ border-radius: 4px;
+ padding: 4px 10px;
+}
+.mrp-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
+.mrp-meta {
+ font-family: var(--sw-mono);
+ font-size: 11px;
+ color: var(--sw-fg-3);
+}
+.mrp-hint { margin: 0; font-size: 11.5px; color: var(--sw-fg-3); }
+.mrp-fail {
+ margin: 0;
+ font-size: 12px;
+ color: var(--sw-err);
+ font-family: var(--sw-mono);
+ word-break: break-word;
+}
+/* `hidden`, not `auto`: each result view scrolls INSIDE its own frame — the
+ value tables in their scroll box, Monaco in its editor. Letting this
+ container scroll instead makes every child height-unbounded, so a long
+ document grows past the modal and drags the actions below it out of
+ reach. */
+.mrp-result { flex: 1; min-height: 0; overflow: hidden; display: flex; flex-direction: column; }
+</style>
diff --git a/apps/ui/src/features/admin/_shared/mqeSeries.test.ts b/apps/ui/src/features/admin/_shared/mqeSeries.test.ts
new file mode 100644
index 00000000..1f3c4c13
--- /dev/null
+++ b/apps/ui/src/features/admin/_shared/mqeSeries.test.ts
@@ -0,0 +1,113 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { describe, it, expect } from 'vitest';
+import type { ExpressionResult } from '@skywalking-horizon-ui/api-client';
+import { alignSeries, seriesLabel } from './mqeSeries';
+
+function series(labels: Array<[string, string]>, values: Array<[string, string | null]>) {
+ return {
+ metric: { labels: labels.map(([key, value]) => ({ key, value })) },
+ values: values.map(([id, value]) => ({ id, value })),
+ };
+}
+const res = (results: ReturnType<typeof series>[]): ExpressionResult => ({
+ type: 'TIME_SERIES_VALUES',
+ results,
+});
+
+describe('alignSeries', () => {
+ it('aligns on the UNION of bucket ids, not on array index', () => {
+ // p=50 is missing the middle bucket. Aligned by index, its 1200 sample
+ // would slide under 1100 and the chart would be wrong rather than sparse.
+ const out = alignSeries(
+ res([
+ series([['p', '50']], [['1000', '1'], ['1200', '3']]),
+ series([['p', '95']], [['1000', '9'], ['1100', '8'], ['1200', '7']]),
+ ]),
+ 'MINUTE',
+ 'value',
+ );
+ expect(out.ids).toEqual(['1000', '1100', '1200']);
+ expect(out.series[0]!.data).toEqual([1, null, 3]);
+ expect(out.series[1]!.data).toEqual([9, 8, 7]);
+ });
+
+ it('gives every series the same length as xLabels — TimeChart ignores them otherwise', () => {
+ const out = alignSeries(
+ res([
+ series([['p', '50']], [['1000', '1']]),
+ series([['p', '95']], [['1000', '9'], ['1100', '8']]),
+ ]),
+ 'MINUTE',
+ 'value',
+ );
+ for (const s of out.series) expect(s.data).toHaveLength(out.xLabels.length);
+ });
+
+ it('sorts buckets numerically, not lexically', () => {
+ const out = alignSeries(
+ res([series([], [['1100', '2'], ['900', '1'], ['1000', '3']])]),
+ 'MINUTE',
+ 'value',
+ );
+ expect(out.ids).toEqual(['900', '1000', '1100']);
+ expect(out.series[0]!.data).toEqual([1, 3, 2]);
+ });
+
+ it('keeps a null value as a gap rather than a zero', () => {
+ const out = alignSeries(res([series([], [['1000', null], ['1100', '5']])]), 'MINUTE', 'value');
+ expect(out.series[0]!.data).toEqual([null, 5]);
+ });
+
+ it('falls back to the caller label when the metric carries none', () => {
+ const out = alignSeries(res([series([], [['1000', '1']])]), 'MINUTE', 'value');
+ expect(out.series[0]!.label).toBe('value');
+ });
+
+ it('is empty for a result with no rows', () => {
+ const out = alignSeries({ type: 'TIME_SERIES_VALUES', results: [] }, 'MINUTE', 'value');
+ expect(out.ids).toEqual([]);
+ expect(out.series).toEqual([]);
+ });
+});
+
+describe('seriesLabel', () => {
+ it('names a series by the label that VARIES across the set', () => {
+ // `status` is constant, so naming off it would make three legend chips
+ // that differ only in the percentile buried at the end.
+ const all = [
+ series([['p', '50'], ['status', 'all']], []),
+ series([['p', '95'], ['status', 'all']], []),
+ ];
+ expect(seriesLabel(all[0]!, all, 'value')).toBe('p=50');
+ expect(seriesLabel(all[1]!, all, 'value')).toBe('p=95');
+ });
+
+ it('keeps every label when none of them vary — a lone labelled series', () => {
+ const all = [series([['p', '95'], ['status', 'all']], [])];
+ expect(seriesLabel(all[0]!, all, 'value')).toBe('p=95 · status=all');
+ });
+
+ it('joins several varying labels', () => {
+ const all = [
+ series([['a', '1'], ['b', 'x']], []),
+ series([['a', '2'], ['b', 'y']], []),
+ ];
+ expect(seriesLabel(all[0]!, all, 'value')).toBe('a=1 · b=x');
+ });
+});
diff --git a/apps/ui/src/features/admin/_shared/mqeSeries.ts b/apps/ui/src/features/admin/_shared/mqeSeries.ts
new file mode 100644
index 00000000..09939ac7
--- /dev/null
+++ b/apps/ui/src/features/admin/_shared/mqeSeries.ts
@@ -0,0 +1,102 @@
+/*
+ * 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.
+ */
+
+/**
+ * Turn a `TIME_SERIES_VALUES` result into the `{ series, xLabels }` pair
+ * `TimeChart` draws.
+ *
+ * The alignment is on the UNION of the bucket ids, not on array index. A
+ * labelled metric returns one result per label combination and OAP may hand
+ * back a different set of buckets for each — aligning positionally would
+ * slide one percentile's 11:05 under another's 11:06 and draw a chart that
+ * is wrong rather than merely sparse. Every series is re-indexed onto the
+ * shared axis, with `null` where that series has no sample.
+ *
+ * `TimeChart` only honours `xLabels` when its length matches the first
+ * series' data length, so producing equal-length rows here is a
+ * requirement, not tidiness.
+ */
+
+import type { ExpressionResult, MqeValues } from '@skywalking-horizon-ui/api-client';
+import { bucketTimeLabel } from '@/utils/formatters';
+
+export interface AlignedSeries {
+ label: string;
+ data: Array<number | null>;
+}
+
+export interface AlignedChart {
+ xLabels: string[];
+ series: AlignedSeries[];
+ /** Bucket ids behind `xLabels`, for the value table's own rows. */
+ ids: string[];
+}
+
+/**
+ * Name a series from its own labels.
+ *
+ * Only labels whose value VARIES across the result set discriminate: a
+ * labelled metric often carries a constant dimension alongside the real one
+ * (`status='all'` beside `p='95'`), and naming off every label makes three
+ * legend chips that differ in one character. This mirrors the rule the BFF
+ * already applies when it names dashboard series.
+ */
+export function seriesLabel(r: MqeValues, all: MqeValues[], fallback: string): string {
+ const labels = r.metric?.labels ?? [];
+ if (labels.length === 0) return fallback;
+ const varying = new Set<string>();
+ for (const key of new Set(labels.map((l) => l.key))) {
+ const seen = new Set(all.map((x) => (x.metric?.labels ?? []).find((l) => l.key === key)?.value));
+ if (seen.size > 1) varying.add(key);
+ }
+ const picked = labels.filter((l) => varying.has(l.key));
+ const use = picked.length > 0 ? picked : labels;
+ return use.map((l) => `${l.key}=${l.value}`).join(' · ');
+}
+
+/** Numeric value of an MQE cell — OAP sends every value as a string, and
+ * `null` means the bucket is absent rather than zero. */
+function num(v: string | null | undefined): number | null {
+ if (v === null || v === undefined) return null;
+ const n = Number(v);
+ return Number.isFinite(n) ? n : null;
+}
+
+export function alignSeries(
+ result: ExpressionResult,
+ step: 'MINUTE' | 'HOUR' | 'DAY',
+ fallbackLabel: string,
+): AlignedChart {
+ const results = result.results ?? [];
+ const idSet = new Set<string>();
+ for (const r of results) for (const v of r.values ?? []) if (v.id) idSet.add(v.id);
+ // Bucket ids are epoch-ms; sort numerically so the axis runs in time order
+ // rather than lexically ("9…" would sort after "10…").
+ const ids = [...idSet].sort((a, b) => Number(a) - Number(b));
+ const xLabels = ids.map((id) => {
+ const ms = Number(id);
+ return Number.isFinite(ms) ? bucketTimeLabel(step, ms) : id;
+ });
+ const series = results.map((r) => {
+ const byId = new Map((r.values ?? []).map((v) => [v.id ?? '', v.value]));
+ return {
+ label: seriesLabel(r, results, fallbackLabel),
+ data: ids.map((id) => num(byId.get(id))),
+ };
+ });
+ return { xLabels, series, ids };
+}
diff --git a/apps/ui/src/features/admin/layer-templates/DependencyConfigSection.vue b/apps/ui/src/features/admin/layer-templates/DependencyConfigSection.vue
index c4b9c790..46aa0216 100644
--- a/apps/ui/src/features/admin/layer-templates/DependencyConfigSection.vue
+++ b/apps/ui/src/features/admin/layer-templates/DependencyConfigSection.vue
@@ -34,6 +34,7 @@ import { rowKey } from './row-key';
const { t } = useI18n();
const config = defineModel<EndpointDependencyConfig | undefined>('config');
+defineProps<{ layerKey?: string }>();
// Seed an empty block on open (mirrors the legacy ensureEndpointDep, which
// auto-created it on first render) so both metric sections render their
@@ -108,6 +109,8 @@ function remove(list: TopologyMetricDef[], i: number): void {
show-role
show-thresholds
mqe-placeholder="endpoint_cpm"
+ :layer-key="layerKey"
+ site-scope="endpoint"
:can-move-up="i > 0"
:can-move-down="i < nodeMetrics.length - 1"
@move-up="move(nodeMetrics, i, -1)"
@@ -129,6 +132,8 @@ function remove(list: TopologyMetricDef[], i: number): void {
v-for="(m, i) in linkMetrics"
:key="rowKey(m)"
v-model:metric="linkMetrics[i]"
+ :layer-key="layerKey"
+ site-scope="endpoint-relation"
:can-move-up="i > 0"
:can-move-down="i < linkMetrics.length - 1"
@move-up="move(linkMetrics, i, -1)"
diff --git a/apps/ui/src/features/admin/layer-templates/DeploymentConfigEditor.vue b/apps/ui/src/features/admin/layer-templates/DeploymentConfigEditor.vue
index 30000b90..c82547fe 100644
--- a/apps/ui/src/features/admin/layer-templates/DeploymentConfigEditor.vue
+++ b/apps/ui/src/features/admin/layer-templates/DeploymentConfigEditor.vue
@@ -46,7 +46,7 @@ import RoleMetricRow from './RoleMetricRow.vue';
import { rowKey } from './row-key';
const config = defineModel<DeploymentConfig | undefined>('config');
-defineProps<{ instanceNoun: string }>();
+defineProps<{ instanceNoun: string; layerKey?: string }>();
const { t } = useI18n({ useScope: 'global' });
@@ -490,6 +490,8 @@ function setPrimary(p: RolePairMetrics, v: string): void {
v-for="(m, mi) in r.nodeMetrics"
:key="rowKey(m)"
v-model:metric="r.nodeMetrics[mi]"
+ :layer-key="layerKey"
+ site-scope="instance"
:role-options="TOPOLOGY_ROLE_OPTIONS"
show-role
show-thresholds
@@ -539,6 +541,8 @@ function setPrimary(p: RolePairMetrics, v: string): void {
v-for="(m, mi) in p.metrics"
:key="rowKey(m)"
v-model:metric="p.metrics[mi]"
+ :layer-key="layerKey"
+ site-scope="deployment-relation"
:role-options="TOPOLOGY_ROLE_OPTIONS"
show-role
show-alias
@@ -569,6 +573,8 @@ function setPrimary(p: RolePairMetrics, v: string): void {
v-for="(m, i) in deploymentNodeMetrics"
:key="rowKey(m)"
v-model:metric="deploymentNodeMetrics[i]"
+ :layer-key="layerKey"
+ site-scope="instance"
:role-options="TOPOLOGY_ROLE_OPTIONS"
show-role
show-thresholds
@@ -595,6 +601,8 @@ function setPrimary(p: RolePairMetrics, v: string): void {
v-for="(m, i) in deploymentServerMetrics"
:key="rowKey(m)"
v-model:metric="deploymentServerMetrics[i]"
+ :layer-key="layerKey"
+ site-scope="deployment-relation"
:can-move-up="i > 0"
:can-move-down="i < deploymentServerMetrics.length - 1"
@move-up="moveMetric('sitLinkServer', i, -1)"
@@ -616,6 +624,8 @@ function setPrimary(p: RolePairMetrics, v: string): void {
v-for="(m, i) in deploymentClientMetrics"
:key="rowKey(m)"
v-model:metric="deploymentClientMetrics[i]"
+ :layer-key="layerKey"
+ site-scope="deployment-relation"
:can-move-up="i > 0"
:can-move-down="i < deploymentClientMetrics.length - 1"
@move-up="moveMetric('sitLinkClient', i, -1)"
diff --git a/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue b/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
index b6cd2b84..7dc5a5b0 100644
--- a/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
+++ b/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
@@ -561,6 +561,7 @@ const namingTest = computed<NamingTestResult>(() => {
<ServiceListMetricsEditor
v-model:config="selectedTpl.metrics"
:service-label="serviceNoun"
+ :layer-key="selectedKey"
/>
<ScopeTabsBar
@@ -707,17 +708,20 @@ const namingTest = computed<NamingTestResult>(() => {
v-model:config="selectedTpl.topology"
:service-noun="serviceNoun"
:instance-noun="instanceNoun"
+ :layer-key="selectedKey"
/>
<DeploymentConfigEditor
v-else-if="activeScope === 'deployment'"
v-model:config="selectedTpl.deployment"
:instance-noun="instanceNoun"
+ :layer-key="selectedKey"
/>
<DependencyConfigSection
v-else-if="activeScope === 'dependency'"
v-model:config="selectedTpl.endpointDependency"
+ :layer-key="selectedKey"
/>
<ProcessConfigEditor
diff --git a/apps/ui/src/features/admin/layer-templates/MetricDefinitionRow.vue b/apps/ui/src/features/admin/layer-templates/MetricDefinitionRow.vue
index 251198ae..56e0bafe 100644
--- a/apps/ui/src/features/admin/layer-templates/MetricDefinitionRow.vue
+++ b/apps/ui/src/features/admin/layer-templates/MetricDefinitionRow.vue
@@ -25,6 +25,8 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import type { TopologyMetricDef } from '@/api/client';
+import MqeExpressionInput from '@/features/admin/_shared/MqeExpressionInput.vue';
+import type { MqeSiteScope } from '@/features/admin/_shared/MqeRunPanel.vue';
const { t } = useI18n({ useScope: 'global' });
@@ -34,6 +36,11 @@ defineProps<{
showRole?: boolean;
showThresholds?: boolean;
mqePlaceholder?: string;
+ /** Layer being edited, and how this bucket's MQE is really evaluated.
+ * Both are needed before the row offers to run the expression — the same
+ * MQE means a different query at node vs relation scope. */
+ layerKey?: string;
+ siteScope?: MqeSiteScope;
unitPlaceholder?: string;
canMoveUp: boolean;
canMoveDown: boolean;
@@ -50,7 +57,16 @@ function toggleThresholds(m: TopologyMetricDef): void {
<div class="metric-row-head">
<label class="mf"><span>{{ t('id') }}</span><input v-model="metric.id" type="text" class="mf-input mono" /></label>
<label class="mf"><span>{{ t('label') }}</span><input v-model="metric.label" type="text" class="mf-input" /></label>
- <label class="mf mf-wide"><span>{{ t('MQE') }}</span><input v-model="metric.mqe" type="text" class="mf-input mono" :placeholder="mqePlaceholder" /></label>
+ <label class="mf mf-wide">
+ <span>{{ t('MQE') }}</span>
+ <MqeExpressionInput
+ v-model="metric.mqe"
+ :placeholder="mqePlaceholder"
+ :title="metric.label || metric.id"
+ :layer-key="layerKey"
+ :site-scope="siteScope"
+ />
+ </label>
<label class="mf mf-narrow"><span>{{ t('unit') }}</span><input v-model="metric.unit" type="text" class="mf-input" :placeholder="unitPlaceholder" /></label>
<label v-if="showRole" class="mf">
<span>{{ t('role') }}</span>
diff --git a/apps/ui/src/features/admin/layer-templates/RoleMetricRow.vue b/apps/ui/src/features/admin/layer-templates/RoleMetricRow.vue
index 237a0861..0e652f23 100644
--- a/apps/ui/src/features/admin/layer-templates/RoleMetricRow.vue
+++ b/apps/ui/src/features/admin/layer-templates/RoleMetricRow.vue
@@ -29,6 +29,7 @@
import { useI18n } from 'vue-i18n';
import type { DeploymentMetricDef } from '@skywalking-horizon-ui/api-client';
import MqeExpressionInput from '@/features/admin/_shared/MqeExpressionInput.vue';
+import type { MqeSiteScope } from '@/features/admin/_shared/MqeRunPanel.vue';
const { t } = useI18n();
@@ -40,6 +41,10 @@ defineProps<{
showThresholds?: boolean;
mqePlaceholder?: string;
mqeTitle?: string;
+ /** Layer being edited, and how this bucket's MQE is really evaluated —
+ * both are required before the row offers to run the expression. */
+ layerKey?: string;
+ siteScope?: MqeSiteScope;
unitPlaceholder?: string;
canMoveUp: boolean;
canMoveDown: boolean;
@@ -57,7 +62,7 @@ function toggleThresholds(m: DeploymentMetricDef): void {
<label class="mf"><span>{{ t('id') }}</span><input v-model="metric.id" type="text" class="mf-input mono" /></label>
<label class="mf"><span>{{ t('label') }}</span><input v-model="metric.label" type="text" class="mf-input" /></label>
<label v-if="showAlias" class="mf mf-narrow"><span>{{ t('alias') }}</span><input v-model="metric.alias" type="text" class="mf-input mono" placeholder="W" :title="t('Short prefix on the edge, e.g. W / R')" /></label>
- <label class="mf mf-wide"><span>{{ t('MQE') }}</span><MqeExpressionInput v-model="metric.mqe" :placeholder="mqePlaceholder" :title="mqeTitle" /></label>
+ <label class="mf mf-wide"><span>{{ t('MQE') }}</span><MqeExpressionInput v-model="metric.mqe" :placeholder="mqePlaceholder" :title="mqeTitle" :layer-key="layerKey" :site-scope="siteScope" /></label>
<label class="mf mf-narrow"><span>{{ t('unit') }}</span><input v-model="metric.unit" type="text" class="mf-input" :placeholder="unitPlaceholder" /></label>
<label v-if="showRole" class="mf"><span>{{ t('role') }}</span>
<select v-model="metric.role" class="mf-input">
diff --git a/apps/ui/src/features/admin/layer-templates/ServiceListMetricsEditor.vue b/apps/ui/src/features/admin/layer-templates/ServiceListMetricsEditor.vue
index f8ad3343..a755bbad 100644
--- a/apps/ui/src/features/admin/layer-templates/ServiceListMetricsEditor.vue
+++ b/apps/ui/src/features/admin/layer-templates/ServiceListMetricsEditor.vue
@@ -31,10 +31,16 @@ import type { AdminLayerTemplate } from '@/api/client';
import { fmtMetric } from '@/utils/formatters';
import { nextFreeId } from './free-id';
import { rowKey } from './row-key';
+import MqeExpressionInput from '@/features/admin/_shared/MqeExpressionInput.vue';
const { t } = useI18n();
const config = defineModel<AdminLayerTemplate['metrics'] | undefined>('config');
-defineProps<{ serviceLabel: string }>();
+defineProps<{
+ serviceLabel: string;
+ /** The layer being edited — fixed context for running a column's MQE.
+ * Absent (a layer not yet picked) hides the run affordance. */
+ layerKey?: string;
+}>();
/** The landing request accepts at most this many columns, and the push schema
* refuses a template carrying more — so the add button stops here. Mirrors the
@@ -191,7 +197,19 @@ const effectiveOrderBy = computed(
<option value="avg">avg</option>
</select>
</td>
- <td><input class="mono" v-model="c.mqe" :placeholder="t('catalog default')" /></td>
+ <td>
+ <!-- Blank is legal here: the landing route falls back to the
+ catalog default for the column's metric id, so the run panel
+ is given `metric` too and resolves the same default. -->
+ <MqeExpressionInput
+ v-model="c.mqe"
+ :placeholder="t('catalog default')"
+ :title="c.label || c.metric"
+ :layer-key="layerKey"
+ :metric="c.metric"
+ site-scope="service"
+ />
+ </td>
<td><input type="number" step="any" :value="c.scale" placeholder="1" @input="setNum(c, 'scale', $event)" /></td>
<td><input type="number" min="0" max="6" :value="c.precision" :placeholder="t('auto')" @input="setNum(c, 'precision', $event)" /></td>
<td>
diff --git a/apps/ui/src/features/admin/layer-templates/TopologyConfigEditor.vue b/apps/ui/src/features/admin/layer-templates/TopologyConfigEditor.vue
index 423df1ee..91b28d8c 100644
--- a/apps/ui/src/features/admin/layer-templates/TopologyConfigEditor.vue
+++ b/apps/ui/src/features/admin/layer-templates/TopologyConfigEditor.vue
@@ -34,7 +34,7 @@ import { rowKey } from './row-key';
const { t } = useI18n();
const config = defineModel<TopologyConfig | undefined>('config');
-defineProps<{ serviceNoun: string; instanceNoun: string }>();
+defineProps<{ serviceNoun: string; instanceNoun: string; layerKey?: string }>();
function ensure(): TopologyConfig {
if (!config.value) config.value = { nodeMetrics: [], linkServerMetrics: [], linkClientMetrics: [] };
@@ -124,6 +124,8 @@ function remove(list: TopologyMetricDef[], i: number): void {
v-for="(m, i) in nodeMetrics"
:key="rowKey(m)"
v-model:metric="nodeMetrics[i]"
+ :layer-key="layerKey"
+ site-scope="service"
:role-options="TOPOLOGY_ROLE_OPTIONS"
show-role
show-thresholds
@@ -150,6 +152,8 @@ function remove(list: TopologyMetricDef[], i: number): void {
v-for="(m, i) in serverMetrics"
:key="rowKey(m)"
v-model:metric="serverMetrics[i]"
+ :layer-key="layerKey"
+ site-scope="service-relation"
:can-move-up="i > 0"
:can-move-down="i < serverMetrics.length - 1"
@move-up="move(serverMetrics, i, -1)"
@@ -171,6 +175,8 @@ function remove(list: TopologyMetricDef[], i: number): void {
v-for="(m, i) in clientMetrics"
:key="rowKey(m)"
v-model:metric="clientMetrics[i]"
+ :layer-key="layerKey"
+ site-scope="service-relation"
:can-move-up="i > 0"
:can-move-down="i < clientMetrics.length - 1"
@move-up="move(clientMetrics, i, -1)"
@@ -205,6 +211,8 @@ function remove(list: TopologyMetricDef[], i: number): void {
v-for="(m, i) in instNodeMetrics"
:key="rowKey(m)"
v-model:metric="instNodeMetrics[i]"
+ :layer-key="layerKey"
+ site-scope="instance"
:role-options="TOPOLOGY_ROLE_OPTIONS"
show-role
show-thresholds
@@ -231,6 +239,8 @@ function remove(list: TopologyMetricDef[], i: number): void {
v-for="(m, i) in instServerMetrics"
:key="rowKey(m)"
v-model:metric="instServerMetrics[i]"
+ :layer-key="layerKey"
+ site-scope="instance-relation"
:can-move-up="i > 0"
:can-move-down="i < instServerMetrics.length - 1"
@move-up="move(instServerMetrics, i, -1)"
@@ -252,6 +262,8 @@ function remove(list: TopologyMetricDef[], i: number): void {
v-for="(m, i) in instClientMetrics"
:key="rowKey(m)"
v-model:metric="instClientMetrics[i]"
+ :layer-key="layerKey"
+ site-scope="instance-relation"
:can-move-up="i > 0"
:can-move-down="i < instClientMetrics.length - 1"
@move-up="move(instClientMetrics, i, -1)"
diff --git a/apps/ui/src/features/admin/layer-templates/WidgetEditorCanvas.vue b/apps/ui/src/features/admin/layer-templates/WidgetEditorCanvas.vue
index 9e415ed8..511c1d23 100644
--- a/apps/ui/src/features/admin/layer-templates/WidgetEditorCanvas.vue
+++ b/apps/ui/src/features/admin/layer-templates/WidgetEditorCanvas.vue
@@ -55,6 +55,7 @@ import TopList from '@/components/charts/TopList.vue';
import { fmtMetric } from '@/utils/formatters';
import { mockCardValue, mockLineSeries, mockRecordRows, mockTopGroups } from './widget-mock';
import MqeExpressionInput from '@/features/admin/_shared/MqeExpressionInput.vue';
+import type { MqeSiteScope } from '@/features/admin/_shared/MqeRunPanel.vue';
// `draft` is the parent's shared reactive wrapper around the live template;
// every edit here mutates it IN PLACE (the draft is shared by reference — never
@@ -68,6 +69,16 @@ const props = defineProps<{
}>();
const emit = defineEmits<{ (e: 'update:activePage', v: string | null): void }>();
+/** Context for running an expression from this canvas. The widget grid only
+ * renders for the three entity scopes, so anything else leaves the run
+ * affordance off rather than guessing. */
+const runLayerKey = computed(() => props.draft.template?.key ?? '');
+const runScope = computed<MqeSiteScope | undefined>(() =>
+ props.activeScope === 'service' || props.activeScope === 'instance' || props.activeScope === 'endpoint'
+ ? props.activeScope
+ : undefined,
+);
+
const { t } = useI18n();
function scopeLabel(s: AdminScope): string {
@@ -1072,6 +1083,8 @@ onBeforeUnmount(() => {
:model-value="expr"
placeholder="instance_jvm_cpu"
:title="t('Expression {n}', { n: i + 1 })"
+ :layer-key="runLayerKey"
+ :site-scope="runScope"
@update:model-value="updateExpr(i, $event)"
/>
<input
@@ -1152,6 +1165,8 @@ onBeforeUnmount(() => {
v-model="vwTarget"
placeholder="instance_jvm_cpu"
:title="t('Gate expression')"
+ :layer-key="runLayerKey"
+ :site-scope="runScope"
/>
<select class="mono" v-model="vwOp">
<option value="exists">{{ t('has value') }}</option>
diff --git a/apps/ui/src/features/operate/live-debug/LalCellPopout.vue b/apps/ui/src/features/operate/live-debug/LalCellPopout.vue
index caa26656..ebecfba6 100644
--- a/apps/ui/src/features/operate/live-debug/LalCellPopout.vue
+++ b/apps/ui/src/features/operate/live-debug/LalCellPopout.vue
@@ -28,7 +28,7 @@
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import Modal from '@/components/primitives/Modal.vue';
-import MonacoView from '@/features/operate/_shared/MonacoView.vue';
+import MonacoView from '@/components/primitives/MonacoView.vue';
import MonacoDiff from '@/components/primitives/MonacoDiff.vue';
import FloatingPanel from '@/components/primitives/FloatingPanel.vue';
import Icon from '@/components/icons/Icon.vue';
diff --git a/apps/ui/src/i18n/locales/de.json b/apps/ui/src/i18n/locales/de.json
index a6912baf..4c31de10 100644
--- a/apps/ui/src/i18n/locales/de.json
+++ b/apps/ui/src/i18n/locales/de.json
@@ -2878,5 +2878,41 @@
"metrics for {range}": "Metriken für {range}",
"metrics for {range} — a newer hour is still loading": "Metriken für {range} — eine neuere Stunde wird noch geladen",
"metrics for the hour so far": "Metriken der laufenden Stunde",
- "Reading the last completed hour…": "Letzte abgeschlossene Stunde wird gelesen…"
+ "Reading the last completed hour…": "Letzte abgeschlossene Stunde wird gelesen…",
+ "Blank field — ran the catalog default: {expr}": "Leeres Feld – Katalogvorgabe ausgeführt: {expr}",
+ "Copy JSON": "JSON kopieren",
+ "Could not read the service list for this layer.": "Die Serviceliste dieser Ebene konnte nicht gelesen werden.",
+ "Filter endpoints…": "Endpunkte filtern…",
+ "Fixed by the template being edited": "Durch das bearbeitete Template festgelegt",
+ "No rows returned for this entity and time range.": "Für diese Entität und diesen Zeitraum wurden keine Zeilen zurückgegeben.",
+ "No services reported in this layer yet.": "In dieser Ebene wurden noch keine Services gemeldet.",
+ "OAP could not resolve this expression.": "OAP konnte diesen Ausdruck nicht auflösen.",
+ "Pick an endpoint": "Endpunkt auswählen",
+ "Pick an entity and run to see what OAP returns.": "Wählen Sie eine Entität und führen Sie aus, um die Antwort von OAP zu sehen.",
+ "Pick an instance": "Instanz auswählen",
+ "Relation metrics are queried without an explicit scope — OAP reads it from the metric name.": "Beziehungsmetriken werden ohne expliziten Scope abgefragt – OAP leitet ihn aus dem Metriknamen ab.",
+ "Run": "Ausführen",
+ "Run MQE": "MQE ausführen",
+ "Run MQE · {field}": "MQE ausführen · {field}",
+ "Run this expression": "Diesen Ausdruck ausführen",
+ "(blank — the catalog default for this metric will run)": "(leer – es läuft die Katalogvorgabe für diese Metrik)",
+ "cold stage": "Cold Stage",
+ "destination": "Ziel",
+ "endpoint": "Endpunkt",
+ "endpoint search": "Endpunktsuche",
+ "instance": "Instanz",
+ "owner": "Eigentümer",
+ "record": "Datensatz",
+ "time": "Zeit",
+ "time range": "Zeitraum",
+ "trace id": "Trace-ID",
+ "{rows} buckets · {series} series": "{rows} Buckets · {series} Reihen",
+ "Fixed to the source service — this graph does not cross services": "Auf den Quell-Service festgelegt – dieser Graph überschreitet keine Service-Grenzen",
+ "All services (layer-wide)": "Alle Services (ebenenweit)",
+ "for top_n": "für top_n",
+ "Graph": "Diagramm",
+ "JSON": "JSON",
+ "(empty — type an expression to run it)": "(leer – geben Sie einen Ausdruck ein, um ihn auszuführen)",
+ "Could not read the instances of this service.": "Die Instanzen dieses Services konnten nicht gelesen werden.",
+ "Could not read the endpoints of this service.": "Die Endpunkte dieses Services konnten nicht gelesen werden."
}
diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json
index 245f1873..c0701016 100644
--- a/apps/ui/src/i18n/locales/en.json
+++ b/apps/ui/src/i18n/locales/en.json
@@ -2878,5 +2878,41 @@
"metrics for {range}": "metrics for {range}",
"metrics for {range} — a newer hour is still loading": "metrics for {range} — a newer hour is still loading",
"metrics for the hour so far": "metrics for the hour so far",
- "Reading the last completed hour…": "Reading the last completed hour…"
+ "Reading the last completed hour…": "Reading the last completed hour…",
+ "Blank field — ran the catalog default: {expr}": "Blank field — ran the catalog default: {expr}",
+ "Copy JSON": "Copy JSON",
+ "Could not read the service list for this layer.": "Could not read the service list for this layer.",
+ "Filter endpoints…": "Filter endpoints…",
+ "Fixed by the template being edited": "Fixed by the template being edited",
+ "No rows returned for this entity and time range.": "No rows returned for this entity and time range.",
+ "No services reported in this layer yet.": "No services reported in this layer yet.",
+ "OAP could not resolve this expression.": "OAP could not resolve this expression.",
+ "Pick an endpoint": "Pick an endpoint",
+ "Pick an entity and run to see what OAP returns.": "Pick an entity and run to see what OAP returns.",
+ "Pick an instance": "Pick an instance",
+ "Relation metrics are queried without an explicit scope — OAP reads it from the metric name.": "Relation metrics are queried without an explicit scope — OAP reads it from the metric name.",
+ "Run": "Run",
+ "Run MQE": "Run MQE",
+ "Run MQE · {field}": "Run MQE · {field}",
+ "Run this expression": "Run this expression",
+ "(blank — the catalog default for this metric will run)": "(blank — the catalog default for this metric will run)",
+ "cold stage": "cold stage",
+ "destination": "destination",
+ "endpoint": "endpoint",
+ "endpoint search": "endpoint search",
+ "instance": "instance",
+ "owner": "owner",
+ "record": "record",
+ "time": "time",
+ "time range": "time range",
+ "trace id": "trace id",
+ "{rows} buckets · {series} series": "{rows} buckets · {series} series",
+ "Fixed to the source service — this graph does not cross services": "Fixed to the source service — this graph does not cross services",
+ "All services (layer-wide)": "All services (layer-wide)",
+ "for top_n": "for top_n",
+ "Graph": "Graph",
+ "JSON": "JSON",
+ "(empty — type an expression to run it)": "(empty — type an expression to run it)",
+ "Could not read the instances of this service.": "Could not read the instances of this service.",
+ "Could not read the endpoints of this service.": "Could not read the endpoints of this service."
}
diff --git a/apps/ui/src/i18n/locales/es.json b/apps/ui/src/i18n/locales/es.json
index 0dc3baa4..a782c25f 100644
--- a/apps/ui/src/i18n/locales/es.json
+++ b/apps/ui/src/i18n/locales/es.json
@@ -2878,5 +2878,41 @@
"metrics for {range}": "métricas de {range}",
"metrics for {range} — a newer hour is still loading": "métricas de {range} — se está cargando una hora más reciente",
"metrics for the hour so far": "métricas de la hora en curso",
- "Reading the last completed hour…": "Leyendo la última hora completa…"
+ "Reading the last completed hour…": "Leyendo la última hora completa…",
+ "Blank field — ran the catalog default: {expr}": "Campo vacío: se ejecutó el valor por defecto del catálogo: {expr}",
+ "Copy JSON": "Copiar JSON",
+ "Could not read the service list for this layer.": "No se pudo leer la lista de servicios de esta capa.",
+ "Filter endpoints…": "Filtrar endpoints…",
+ "Fixed by the template being edited": "Fijada por la plantilla que se está editando",
+ "No rows returned for this entity and time range.": "No se devolvieron filas para esta entidad y este rango de tiempo.",
+ "No services reported in this layer yet.": "Todavía no se han reportado servicios en esta capa.",
+ "OAP could not resolve this expression.": "OAP no pudo resolver esta expresión.",
+ "Pick an endpoint": "Elegir un endpoint",
+ "Pick an entity and run to see what OAP returns.": "Elige una entidad y ejecuta para ver qué devuelve OAP.",
+ "Pick an instance": "Elegir una instancia",
+ "Relation metrics are queried without an explicit scope — OAP reads it from the metric name.": "Las métricas de relación se consultan sin scope explícito: OAP lo deduce del nombre de la métrica.",
+ "Run": "Ejecutar",
+ "Run MQE": "Ejecutar MQE",
+ "Run MQE · {field}": "Ejecutar MQE · {field}",
+ "Run this expression": "Ejecutar esta expresión",
+ "(blank — the catalog default for this metric will run)": "(vacío: se ejecutará el valor por defecto del catálogo para esta métrica)",
+ "cold stage": "cold stage",
+ "destination": "destino",
+ "endpoint": "endpoint",
+ "endpoint search": "búsqueda de endpoints",
+ "instance": "instancia",
+ "owner": "propietario",
+ "record": "registro",
+ "time": "tiempo",
+ "time range": "rango de tiempo",
+ "trace id": "trace id",
+ "{rows} buckets · {series} series": "{rows} buckets · {series} series",
+ "Fixed to the source service — this graph does not cross services": "Fijado al servicio de origen: este grafo no cruza servicios",
+ "All services (layer-wide)": "Todos los servicios (toda la capa)",
+ "for top_n": "para top_n",
+ "Graph": "Gráfico",
+ "JSON": "JSON",
+ "(empty — type an expression to run it)": "(vacío: escribe una expresión para ejecutarla)",
+ "Could not read the instances of this service.": "No se pudieron leer las instancias de este servicio.",
+ "Could not read the endpoints of this service.": "No se pudieron leer los endpoints de este servicio."
}
diff --git a/apps/ui/src/i18n/locales/fr.json b/apps/ui/src/i18n/locales/fr.json
index e69a4257..39e7e74d 100644
--- a/apps/ui/src/i18n/locales/fr.json
+++ b/apps/ui/src/i18n/locales/fr.json
@@ -2878,5 +2878,41 @@
"metrics for {range}": "métriques pour {range}",
"metrics for {range} — a newer hour is still loading": "métriques pour {range} — une heure plus récente est en cours de chargement",
"metrics for the hour so far": "métriques de l'heure en cours",
- "Reading the last completed hour…": "Lecture de la dernière heure complète…"
+ "Reading the last completed hour…": "Lecture de la dernière heure complète…",
+ "Blank field — ran the catalog default: {expr}": "Champ vide — valeur par défaut du catalogue exécutée : {expr}",
+ "Copy JSON": "Copier le JSON",
+ "Could not read the service list for this layer.": "Impossible de lire la liste des services de cette couche.",
+ "Filter endpoints…": "Filtrer les endpoints…",
+ "Fixed by the template being edited": "Fixée par le template en cours d'édition",
+ "No rows returned for this entity and time range.": "Aucune ligne renvoyée pour cette entité et cette plage de temps.",
+ "No services reported in this layer yet.": "Aucun service signalé dans cette couche pour l'instant.",
+ "OAP could not resolve this expression.": "OAP n'a pas pu résoudre cette expression.",
+ "Pick an endpoint": "Choisir un endpoint",
+ "Pick an entity and run to see what OAP returns.": "Choisissez une entité et exécutez pour voir ce que renvoie OAP.",
+ "Pick an instance": "Choisir une instance",
+ "Relation metrics are queried without an explicit scope — OAP reads it from the metric name.": "Les métriques de relation sont interrogées sans scope explicite — OAP le déduit du nom de la métrique.",
+ "Run": "Exécuter",
+ "Run MQE": "Exécuter la MQE",
+ "Run MQE · {field}": "Exécuter la MQE · {field}",
+ "Run this expression": "Exécuter cette expression",
+ "(blank — the catalog default for this metric will run)": "(vide — la valeur par défaut du catalogue pour cette métrique sera exécutée)",
+ "cold stage": "cold stage",
+ "destination": "destination",
+ "endpoint": "endpoint",
+ "endpoint search": "recherche d'endpoints",
+ "instance": "instance",
+ "owner": "propriétaire",
+ "record": "enregistrement",
+ "time": "temps",
+ "time range": "plage de temps",
+ "trace id": "trace id",
+ "{rows} buckets · {series} series": "{rows} buckets · {series} séries",
+ "Fixed to the source service — this graph does not cross services": "Fixé au service source — ce graphe ne traverse pas les services",
+ "All services (layer-wide)": "Tous les services (toute la couche)",
+ "for top_n": "pour top_n",
+ "Graph": "Graphique",
+ "JSON": "JSON",
+ "(empty — type an expression to run it)": "(vide — saisissez une expression pour l'exécuter)",
+ "Could not read the instances of this service.": "Impossible de lire les instances de ce service.",
+ "Could not read the endpoints of this service.": "Impossible de lire les endpoints de ce service."
}
diff --git a/apps/ui/src/i18n/locales/ja.json b/apps/ui/src/i18n/locales/ja.json
index 3d27903c..adb788c3 100644
--- a/apps/ui/src/i18n/locales/ja.json
+++ b/apps/ui/src/i18n/locales/ja.json
@@ -2878,5 +2878,41 @@
"metrics for {range}": "{range} のメトリクス",
"metrics for {range} — a newer hour is still loading": "{range} のメトリクス — 新しい 1 時間を読み込み中",
"metrics for the hour so far": "現在の 1 時間のこれまでのメトリクス",
- "Reading the last completed hour…": "直近の完了した 1 時間を読み込み中…"
+ "Reading the last completed hour…": "直近の完了した 1 時間を読み込み中…",
+ "Blank field — ran the catalog default: {expr}": "空欄のため、カタログ既定値を実行しました: {expr}",
+ "Copy JSON": "JSON をコピー",
+ "Could not read the service list for this layer.": "このレイヤーのサービス一覧を取得できませんでした。",
+ "Filter endpoints…": "エンドポイントを絞り込む…",
+ "Fixed by the template being edited": "編集中のテンプレートで固定されています",
+ "No rows returned for this entity and time range.": "このエンティティと期間では行が返されませんでした。",
+ "No services reported in this layer yet.": "このレイヤーではまだサービスが報告されていません。",
+ "OAP could not resolve this expression.": "OAP はこの式を解決できませんでした。",
+ "Pick an endpoint": "エンドポイントを選択",
+ "Pick an entity and run to see what OAP returns.": "エンティティを選んで実行すると、OAP の応答が表示されます。",
+ "Pick an instance": "インスタンスを選択",
+ "Relation metrics are queried without an explicit scope — OAP reads it from the metric name.": "リレーションメトリクスはスコープを指定せずに問い合わせます — OAP がメトリクス名から判断します。",
+ "Run": "実行",
+ "Run MQE": "MQE を実行",
+ "Run MQE · {field}": "MQE を実行 · {field}",
+ "Run this expression": "この式を実行",
+ "(blank — the catalog default for this metric will run)": "(空欄 — このメトリクスのカタログ既定値が実行されます)",
+ "cold stage": "コールドステージ",
+ "destination": "宛先",
+ "endpoint": "エンドポイント",
+ "endpoint search": "エンドポイント検索",
+ "instance": "インスタンス",
+ "owner": "所有者",
+ "record": "レコード",
+ "time": "時刻",
+ "time range": "期間",
+ "trace id": "トレース ID",
+ "{rows} buckets · {series} series": "{rows} バケット · {series} 系列",
+ "Fixed to the source service — this graph does not cross services": "ソースのサービスに固定 — このグラフはサービスをまたぎません",
+ "All services (layer-wide)": "すべてのサービス(レイヤー全体)",
+ "for top_n": "top_n 用",
+ "Graph": "グラフ",
+ "JSON": "JSON",
+ "(empty — type an expression to run it)": "(空欄 — 実行する式を入力してください)",
+ "Could not read the instances of this service.": "このサービスのインスタンスを取得できませんでした。",
+ "Could not read the endpoints of this service.": "このサービスのエンドポイントを取得できませんでした。"
}
diff --git a/apps/ui/src/i18n/locales/ko.json b/apps/ui/src/i18n/locales/ko.json
index 32a44b48..dcc97870 100644
--- a/apps/ui/src/i18n/locales/ko.json
+++ b/apps/ui/src/i18n/locales/ko.json
@@ -2878,5 +2878,41 @@
"metrics for {range}": "{range} 메트릭",
"metrics for {range} — a newer hour is still loading": "{range} 메트릭 — 더 최근 시간대를 불러오는 중",
"metrics for the hour so far": "현재 시간대까지의 메트릭",
- "Reading the last completed hour…": "마지막으로 완료된 1시간을 읽는 중…"
+ "Reading the last completed hour…": "마지막으로 완료된 1시간을 읽는 중…",
+ "Blank field — ran the catalog default: {expr}": "빈 필드 — 카탈로그 기본값을 실행했습니다: {expr}",
+ "Copy JSON": "JSON 복사",
+ "Could not read the service list for this layer.": "이 레이어의 서비스 목록을 읽을 수 없습니다.",
+ "Filter endpoints…": "엔드포인트 필터…",
+ "Fixed by the template being edited": "편집 중인 템플릿에서 고정됨",
+ "No rows returned for this entity and time range.": "이 엔티티와 시간 범위에서 반환된 행이 없습니다.",
+ "No services reported in this layer yet.": "이 레이어에서 아직 보고된 서비스가 없습니다.",
+ "OAP could not resolve this expression.": "OAP가 이 표현식을 해석하지 못했습니다.",
+ "Pick an endpoint": "엔드포인트 선택",
+ "Pick an entity and run to see what OAP returns.": "엔티티를 선택하고 실행하면 OAP의 응답을 볼 수 있습니다.",
+ "Pick an instance": "인스턴스 선택",
+ "Relation metrics are queried without an explicit scope — OAP reads it from the metric name.": "관계 메트릭은 스코프를 지정하지 않고 조회합니다 — OAP가 메트릭 이름에서 판단합니다.",
+ "Run": "실행",
+ "Run MQE": "MQE 실행",
+ "Run MQE · {field}": "MQE 실행 · {field}",
+ "Run this expression": "이 표현식 실행",
+ "(blank — the catalog default for this metric will run)": "(비어 있음 — 이 메트릭의 카탈로그 기본값이 실행됩니다)",
+ "cold stage": "콜드 스테이지",
+ "destination": "대상",
+ "endpoint": "엔드포인트",
+ "endpoint search": "엔드포인트 검색",
+ "instance": "인스턴스",
+ "owner": "소유자",
+ "record": "레코드",
+ "time": "시간",
+ "time range": "시간 범위",
+ "trace id": "트레이스 ID",
+ "{rows} buckets · {series} series": "{rows}개 버킷 · {series}개 시리즈",
+ "Fixed to the source service — this graph does not cross services": "소스 서비스로 고정 — 이 그래프는 서비스를 넘지 않습니다",
+ "All services (layer-wide)": "모든 서비스 (레이어 전체)",
+ "for top_n": "top_n 용",
+ "Graph": "그래프",
+ "JSON": "JSON",
+ "(empty — type an expression to run it)": "(비어 있음 — 실행할 표현식을 입력하세요)",
+ "Could not read the instances of this service.": "이 서비스의 인스턴스를 읽을 수 없습니다.",
+ "Could not read the endpoints of this service.": "이 서비스의 엔드포인트를 읽을 수 없습니다."
}
diff --git a/apps/ui/src/i18n/locales/pt.json b/apps/ui/src/i18n/locales/pt.json
index dd2b58b3..6eae4317 100644
--- a/apps/ui/src/i18n/locales/pt.json
+++ b/apps/ui/src/i18n/locales/pt.json
@@ -2878,5 +2878,41 @@
"metrics for {range}": "métricas de {range}",
"metrics for {range} — a newer hour is still loading": "métricas de {range} — uma hora mais recente ainda está a carregar",
"metrics for the hour so far": "métricas da hora em curso",
- "Reading the last completed hour…": "Lendo a última hora completa…"
+ "Reading the last completed hour…": "Lendo a última hora completa…",
+ "Blank field — ran the catalog default: {expr}": "Campo em branco — executado o padrão do catálogo: {expr}",
+ "Copy JSON": "Copiar JSON",
+ "Could not read the service list for this layer.": "Não foi possível ler a lista de serviços desta camada.",
+ "Filter endpoints…": "Filtrar endpoints…",
+ "Fixed by the template being edited": "Fixada pelo template em edição",
+ "No rows returned for this entity and time range.": "Nenhuma linha retornada para esta entidade e intervalo de tempo.",
+ "No services reported in this layer yet.": "Nenhum serviço reportado nesta camada ainda.",
+ "OAP could not resolve this expression.": "O OAP não conseguiu resolver esta expressão.",
+ "Pick an endpoint": "Escolher um endpoint",
+ "Pick an entity and run to see what OAP returns.": "Escolha uma entidade e execute para ver o que o OAP retorna.",
+ "Pick an instance": "Escolher uma instância",
+ "Relation metrics are queried without an explicit scope — OAP reads it from the metric name.": "Métricas de relação são consultadas sem scope explícito — o OAP o deduz do nome da métrica.",
+ "Run": "Executar",
+ "Run MQE": "Executar MQE",
+ "Run MQE · {field}": "Executar MQE · {field}",
+ "Run this expression": "Executar esta expressão",
+ "(blank — the catalog default for this metric will run)": "(em branco — será executado o padrão do catálogo para esta métrica)",
+ "cold stage": "cold stage",
+ "destination": "destino",
+ "endpoint": "endpoint",
+ "endpoint search": "busca de endpoints",
+ "instance": "instância",
+ "owner": "proprietário",
+ "record": "registro",
+ "time": "tempo",
+ "time range": "intervalo de tempo",
+ "trace id": "trace id",
+ "{rows} buckets · {series} series": "{rows} buckets · {series} séries",
+ "Fixed to the source service — this graph does not cross services": "Fixado no serviço de origem — este grafo não cruza serviços",
+ "All services (layer-wide)": "Todos os serviços (toda a camada)",
+ "for top_n": "para top_n",
+ "Graph": "Gráfico",
+ "JSON": "JSON",
+ "(empty — type an expression to run it)": "(vazio — digite uma expressão para executá-la)",
+ "Could not read the instances of this service.": "Não foi possível ler as instâncias deste serviço.",
+ "Could not read the endpoints of this service.": "Não foi possível ler os endpoints deste serviço."
}
diff --git a/apps/ui/src/i18n/locales/zh-CN.json b/apps/ui/src/i18n/locales/zh-CN.json
index 786d15d3..340e2542 100644
--- a/apps/ui/src/i18n/locales/zh-CN.json
+++ b/apps/ui/src/i18n/locales/zh-CN.json
@@ -2878,5 +2878,41 @@
"metrics for {range}": "{range} 的指标",
"metrics for {range} — a newer hour is still loading": "{range} 的指标 —— 更新的一小时仍在加载",
"metrics for the hour so far": "本小时至今的指标",
- "Reading the last completed hour…": "正在读取上一个完整小时…"
+ "Reading the last completed hour…": "正在读取上一个完整小时…",
+ "Blank field — ran the catalog default: {expr}": "字段为空 —— 已运行目录默认表达式:{expr}",
+ "Copy JSON": "复制 JSON",
+ "Could not read the service list for this layer.": "无法读取该 layer 的服务列表。",
+ "Filter endpoints…": "筛选端点…",
+ "Fixed by the template being edited": "由正在编辑的模板固定",
+ "No rows returned for this entity and time range.": "该实体与时间范围内没有返回数据行。",
+ "No services reported in this layer yet.": "该 layer 尚未上报任何服务。",
+ "OAP could not resolve this expression.": "OAP 无法解析该表达式。",
+ "Pick an endpoint": "选择端点",
+ "Pick an entity and run to see what OAP returns.": "选择一个实体并运行,即可看到 OAP 的返回结果。",
+ "Pick an instance": "选择实例",
+ "Relation metrics are queried without an explicit scope — OAP reads it from the metric name.": "关系类指标查询时不指定 scope —— OAP 会根据指标名自行判断。",
+ "Run": "运行",
+ "Run MQE": "运行 MQE",
+ "Run MQE · {field}": "运行 MQE · {field}",
+ "Run this expression": "运行该表达式",
+ "(blank — the catalog default for this metric will run)": "(留空 —— 将运行该指标的目录默认表达式)",
+ "cold stage": "冷存储",
+ "destination": "目标端",
+ "endpoint": "端点",
+ "endpoint search": "端点搜索",
+ "instance": "实例",
+ "owner": "归属",
+ "record": "记录",
+ "time": "时间",
+ "time range": "时间范围",
+ "trace id": "Trace ID",
+ "{rows} buckets · {series} series": "{rows} 个时间桶 · {series} 条序列",
+ "Fixed to the source service — this graph does not cross services": "固定为来源服务 —— 该拓扑图不跨服务",
+ "All services (layer-wide)": "全部服务(整个 layer)",
+ "for top_n": "用于 top_n",
+ "Graph": "图表",
+ "JSON": "JSON",
+ "(empty — type an expression to run it)": "(为空 —— 输入一个表达式即可运行)",
+ "Could not read the instances of this service.": "无法读取该服务的实例列表。",
+ "Could not read the endpoints of this service.": "无法读取该服务的端点列表。"
}
diff --git a/apps/ui/src/shell/TimeRangeChip.vue b/apps/ui/src/shell/TimeRangeChip.vue
index 1c9d8f31..b8c402f9 100644
--- a/apps/ui/src/shell/TimeRangeChip.vue
+++ b/apps/ui/src/shell/TimeRangeChip.vue
@@ -15,11 +15,12 @@
limitations under the License.
-->
<script setup lang="ts">
-import { computed, onBeforeUnmount, ref, watch } from 'vue';
+import { computed, onBeforeUnmount, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Icon from '@/components/icons/Icon.vue';
+import TimeRangeMenu from '@/components/primitives/TimeRangeMenu.vue';
import { useAutoRefreshStore } from '@/controls/autoRefresh';
-import { useTimeRangeStore, TIME_PRESETS, STEP_LIMITS, isValidRange, type TimeStep } from '@/controls/timeRange';
+import { useTimeRangeStore, type TimeStep } from '@/controls/timeRange';
import { useTimeDefaultsStore } from '@/state/timeDefaults';
import { useTopbarTimeContext } from '@/shell/useTopbarTimeContext';
@@ -63,21 +64,8 @@ const globalTimeTooltip = computed<string>(() => {
const timeRange = useTimeRangeStore();
const timeMenuOpen = ref(false);
const timeClusterEl = ref<HTMLElement | null>(null);
-/** Active tab in the dropdown — one tab per precision. Seeded from
- * the currently-selected preset's step so reopening lands on the
- * same precision the operator last used. */
-const activeStepTab = ref<TimeStep>(timeRange.step);
-watch(() => timeRange.step, (s) => (activeStepTab.value = s));
// Computed (not a module const) so the tab / cap labels re-resolve
// against the current locale on switch.
-const TAB_DEFS = computed<Array<{ step: TimeStep; tab: string; cap: string }>>(() => [
- { step: 'MINUTE', tab: t('Minute'), cap: t('≤ 4 h') },
- { step: 'HOUR', tab: t('Hour'), cap: t('≤ 14 d') },
- { step: 'DAY', tab: t('Day'), cap: t('≤ 3 mo') },
-]);
-const presetsForActiveTab = computed(() =>
- TIME_PRESETS.filter((p) => p.step === activeStepTab.value),
-);
function pickTimePreset(id: string): void {
timeRange.selectPreset(id);
timeMenuOpen.value = false;
@@ -90,7 +78,6 @@ function onTimeMenuClickClose(ev: MouseEvent): void {
const el = timeClusterEl.value;
if (el && !el.contains(ev.target as Node)) {
timeMenuOpen.value = false;
- customOpenStep.value = null;
}
}
if (typeof window !== 'undefined') {
@@ -111,138 +98,14 @@ const timeChipLabel = computed<string>(() => {
return t(timeRange.label);
});
-// Inputs are scoped to the step so the operator can't pick at finer
-// resolution than the data will support:
-// MINUTE → datetime-local (date + HH:MM)
-// HOUR → date + hour-select (00…23), no minute field at all
-// DAY → date-only
-// Submit clamps against STEP_LIMITS so an overlong window is rejected at
-// the boundary instead of silently truncated.
-const customOpenStep = ref<TimeStep | null>(null);
-/** Per-bound, per-step form state. For HOUR we keep date and hour
- * in separate keys so the UI can render two distinct controls. */
-const customDraft = ref<Record<string, string>>({});
-const customError = ref<string | null>(null);
-
-function z2(n: number): string {
- return String(n).padStart(2, '0');
-}
-const HOURS_OF_DAY: readonly number[] = Array.from({ length: 24 }, (_, i) => i);
-
-/** Seed form state for one bound. The shape depends on the step. */
-function setDraftFromMs(step: TimeStep, side: 'start' | 'end', ms: number): void {
- const d = new Date(ms);
- const base = `${d.getFullYear()}-${z2(d.getMonth() + 1)}-${z2(d.getDate())}`;
- if (step === 'DAY') {
- customDraft.value[`${step}-${side}`] = base;
- return;
- }
- if (step === 'HOUR') {
- customDraft.value[`${step}-${side}-date`] = base;
- customDraft.value[`${step}-${side}-hour`] = z2(d.getHours());
- return;
- }
- // MINUTE
- customDraft.value[`${step}-${side}`] = `${base}T${z2(d.getHours())}:${z2(d.getMinutes())}`;
-}
-/** Compose form state for one bound back into an ms timestamp. */
-function draftToMs(step: TimeStep, side: 'start' | 'end'): number | null {
- if (step === 'DAY') {
- const v = customDraft.value[`${step}-${side}`] ?? '';
- if (!v) return null;
- const [y, mo, da] = v.split('-').map(Number);
- if (!y || !mo || !da) return null;
- return new Date(y, mo - 1, da).getTime();
- }
- if (step === 'HOUR') {
- const dateV = customDraft.value[`${step}-${side}-date`] ?? '';
- const hourV = customDraft.value[`${step}-${side}-hour`] ?? '';
- if (!dateV || hourV === '') return null;
- const [y, mo, da] = dateV.split('-').map(Number);
- const h = Number(hourV);
- if (!y || !mo || !da || Number.isNaN(h)) return null;
- return new Date(y, mo - 1, da, h, 0, 0, 0).getTime();
- }
- // MINUTE
- const v = customDraft.value[`${step}-${side}`] ?? '';
- if (!v) return null;
- const dt = new Date(v);
- return Number.isNaN(dt.getTime()) ? null : dt.getTime();
-}
-/** Seed the custom-range form for `step`. Pulls from the currently
- * applied range when the step matches and the window fits; otherwise
- * falls back to "half the step's max, ending now". Called both from
- * the manual "Custom range…" click AND from the auto-expand on
- * picker-open path below — keeps the seed logic in one place. */
-function seedCustomDraft(step: TimeStep): void {
- const lim = STEP_LIMITS[step];
- let endMs: number;
- let startMs: number;
- const cur = timeRange.range;
- const fits = step === timeRange.step && cur.endMs > cur.startMs && cur.endMs - cur.startMs <= lim.maxMs;
- if (fits) {
- startMs = cur.startMs;
- endMs = cur.endMs;
- } else {
- endMs = Date.now();
- startMs = endMs - Math.floor(lim.maxMs / 2);
- }
- setDraftFromMs(step, 'start', startMs);
- setDraftFromMs(step, 'end', endMs);
-}
-function openCustom(step: TimeStep): void {
- customError.value = null;
- if (customOpenStep.value === step) {
- customOpenStep.value = null;
- return;
- }
- customOpenStep.value = step;
- seedCustomDraft(step);
-}
-
-// Auto-expand the custom-range form whenever the picker opens AND the
-// currently-applied range is a custom one — so re-opening lands the
-// operator straight on the inputs they last edited, on the matching
-// precision tab. For preset ranges (Last 15m, etc.) we leave the
-// custom form collapsed so the preset list is the focal point.
-watch(timeMenuOpen, (open) => {
- if (!open) return;
- activeStepTab.value = timeRange.step;
- if (timeRange.presetId === 'custom') {
- customOpenStep.value = timeRange.step;
- seedCustomDraft(timeRange.step);
- } else {
- customOpenStep.value = null;
- }
-});
-function submitCustom(step: TimeStep): void {
- const startMs = draftToMs(step, 'start');
- const endMs = draftToMs(step, 'end');
- if (startMs === null || endMs === null) {
- customError.value = t('Pick both a start and an end.');
- return;
- }
- if (endMs <= startMs) {
- customError.value = t('End must be after start.');
- return;
- }
- if (!isValidRange(step, endMs - startMs)) {
- const lim = STEP_LIMITS[step];
- customError.value = t('Range exceeds {step}-precision cap of {cap}.', { step: step.toLowerCase(), cap: humanDuration(lim.maxMs) });
- return;
- }
- customError.value = null;
+/** The menu validated the range and reports it; the store and the refresh
+ * round are this component's business, not the menu's. */
+function applyCustom(startMs: number, endMs: number, step: TimeStep): void {
timeRange.selectCustom(startMs, endMs, step);
timeMenuOpen.value = false;
- customOpenStep.value = null;
if (!ownsTimeRange.value) auto.refreshNow('time-change');
}
-function humanDuration(ms: number): string {
- const h = Math.floor(ms / 3_600_000);
- if (h < 24) return t('{n} h', { n: h });
- const d = Math.floor(h / 24);
- return t('{n} d', { n: d });
-}
+
function formatRangeStamp(ms: number, step: TimeStep): string {
const d = new Date(ms);
const z = (n: number) => String(n).padStart(2, '0');
@@ -268,88 +131,13 @@ function formatRangeStamp(ms: number, step: TimeStep): string {
</button>
<transition name="rf-menu">
<div v-if="timeMenuOpen && !ownsTimeRange" class="tr-menu">
- <!-- Tab strip — one tab per precision; switching tabs
- doesn't apply anything, it just swaps which preset
- list + custom form is shown. -->
- <div class="tr-tabs" role="tablist">
- <button
- v-for="t in TAB_DEFS"
- :key="t.step"
- type="button"
- class="tr-tab"
- :class="{ 'is-on': activeStepTab === t.step }"
- role="tab"
- :aria-selected="activeStepTab === t.step"
- @click="activeStepTab = t.step; customError = null"
- >
- <span class="tr-tab-name">{{ t.tab }}</span>
- <span class="tr-tab-cap">{{ t.cap }}</span>
- </button>
- </div>
-
- <div class="tr-tab-body">
- <button
- v-for="p in presetsForActiveTab"
- :key="p.id"
- type="button"
- class="tr-item"
- :class="{ 'is-on': timeRange.presetId === p.id }"
- @click="pickTimePreset(p.id)"
- >
- <span>{{ t(p.label) }}</span>
- <span v-if="timeRange.presetId === p.id" class="tr-tick">✓</span>
- </button>
- <!-- Custom expander, scoped to the active precision. -->
- <button
- type="button"
- class="tr-item tr-custom-trigger"
- :class="{ 'is-on': customOpenStep === activeStepTab }"
- @click="openCustom(activeStepTab)"
- >
- <span>{{ t('Custom range…') }}</span>
- <span class="tr-tick">{{ customOpenStep === activeStepTab ? '▾' : '▸' }}</span>
- </button>
- <div v-if="customOpenStep === activeStepTab" class="tr-custom">
- <template v-for="side in (['start', 'end'] as const)" :key="side">
- <label class="tr-custom-field">
- <span>{{ side === 'start' ? t('Start') : t('End') }}</span>
- <input
- v-if="activeStepTab === 'MINUTE'"
- type="datetime-local"
- step="60"
- class="tr-custom-input"
- v-model="customDraft[`${activeStepTab}-${side}`]"
- />
- <div v-else-if="activeStepTab === 'HOUR'" class="tr-custom-split">
- <input
- type="date"
- class="tr-custom-input"
- v-model="customDraft[`${activeStepTab}-${side}-date`]"
- />
- <select
- class="tr-custom-input tr-custom-hour"
- v-model="customDraft[`${activeStepTab}-${side}-hour`]"
- >
- <option v-for="h in HOURS_OF_DAY" :key="h" :value="String(h).padStart(2, '0')">
- {{ String(h).padStart(2, '0') }}:00
- </option>
- </select>
- </div>
- <input
- v-else
- type="date"
- class="tr-custom-input"
- v-model="customDraft[`${activeStepTab}-${side}`]"
- />
- </label>
- </template>
- <div v-if="customError" class="tr-custom-err">{{ customError }}</div>
- <div class="tr-custom-foot">
- <button type="button" class="tr-cust-btn ghost" @click="customOpenStep = null">{{ t('Cancel') }}</button>
- <button type="button" class="tr-cust-btn primary" @click="submitCustom(activeStepTab)">{{ t('Apply') }}</button>
- </div>
- </div>
- </div>
+ <TimeRangeMenu
+ :preset-id="timeRange.presetId"
+ :current="{ startMs: timeRange.range.startMs, endMs: timeRange.range.endMs, step: timeRange.step }"
+ :open="timeMenuOpen"
+ @pick="pickTimePreset"
+ @custom="applyCustom"
+ />
<!-- Per-user "Save as my default" / "Reset to org default".
Persists the current rolling window's minute count into
localStorage, or clears it so the org default wins. Hidden
@@ -402,7 +190,7 @@ function formatRangeStamp(ms: number, step: TimeStep): string {
top: calc(100% + 4px);
right: 0;
z-index: 60;
- width: 240px;
+ width: 280px;
background: var(--sw-bg-1);
border: 1px solid var(--sw-line);
border-radius: 6px;
@@ -412,140 +200,6 @@ function formatRangeStamp(ms: number, step: TimeStep): string {
max-height: 70vh;
overflow-y: auto;
}
-/* Tab strip + body for the 3-precision dropdown. The tab list sits
- * flush with the menu top and slides the body content under it.
- * Active tab gets an underline + accent text so the precision is
- * obvious at a glance. */
-.tr-tabs {
- display: flex;
- border-bottom: 1px solid var(--sw-line);
- background: var(--sw-bg-2);
-}
-.tr-tab {
- flex: 1 1 0;
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 2px;
- padding: 8px 4px 6px;
- background: transparent;
- border: 0;
- border-bottom: 2px solid transparent;
- color: var(--sw-fg-2);
- font: inherit;
- cursor: pointer;
- transition: color 0.1s ease, border-color 0.1s ease;
-}
-.tr-tab:hover {
- color: var(--sw-fg-0);
-}
-.tr-tab.is-on {
- color: var(--sw-accent);
- border-bottom-color: var(--sw-accent);
-}
-.tr-tab-name {
- font-size: 11.5px;
- font-weight: 600;
-}
-.tr-tab-cap {
- font-size: 9.5px;
- color: var(--sw-fg-3);
- letter-spacing: 0.04em;
-}
-.tr-tab.is-on .tr-tab-cap {
- color: var(--sw-accent-2);
-}
-.tr-tab-body {
- padding: 4px 0;
-}
-.tr-item {
- display: flex;
- align-items: center;
- justify-content: space-between;
- width: 100%;
- padding: 5px 12px;
- border: 0;
- background: transparent;
- color: var(--sw-fg-1);
- font: inherit;
- text-align: left;
- cursor: pointer;
-}
-.tr-item:hover {
- background: var(--sw-bg-2);
- color: var(--sw-fg-0);
-}
-.tr-item.is-on {
- background: var(--sw-accent-soft);
- color: var(--sw-accent-2);
- font-weight: 600;
-}
-.tr-tick {
- font-size: 11px;
- color: var(--sw-accent);
-}
-.tr-menu {
- width: 280px;
-}
-.tr-custom-trigger {
- color: var(--sw-fg-2);
-}
-.tr-custom-trigger.is-on {
- background: var(--sw-bg-2);
- color: var(--sw-fg-0);
- font-weight: 500;
-}
-.tr-custom {
- padding: 6px 12px 10px;
- background: var(--sw-bg-2);
- border-top: 1px dotted var(--sw-line);
- display: flex;
- flex-direction: column;
- gap: 6px;
-}
-.tr-custom-field {
- display: flex;
- flex-direction: column;
- gap: 2px;
- font-size: 10px;
- text-transform: uppercase;
- letter-spacing: 0.06em;
- color: var(--sw-fg-3);
-}
-.tr-custom-input {
- background: var(--sw-bg-1);
- color: var(--sw-fg-0);
- border: 1px solid var(--sw-line);
- border-radius: 3px;
- padding: 4px 6px;
- font-family: var(--sw-mono);
- font-size: 11.5px;
- outline: none;
-}
-.tr-custom-input:focus {
- border-color: var(--sw-accent);
-}
-.tr-custom-split {
- display: flex;
- gap: 6px;
-}
-.tr-custom-split .tr-custom-input {
- flex: 1 1 0;
- min-width: 0;
-}
-.tr-custom-hour {
- flex: 0 0 78px;
-}
-.tr-custom-err {
- font-size: 10.5px;
- color: var(--sw-err);
-}
-.tr-custom-foot {
- display: flex;
- justify-content: flex-end;
- gap: 6px;
- margin-top: 2px;
-}
.tr-cust-btn {
font-size: 11px;
padding: 3px 10px;
diff --git a/docs/changelog/1.1.0.md b/docs/changelog/1.1.0.md
index 646b4544..53ea628e 100644
--- a/docs/changelog/1.1.0.md
+++ b/docs/changelog/1.1.0.md
@@ -86,6 +86,8 @@
**A template published elsewhere reaches an open browser on its own.** Horizon re-reads the template store on a slow cycle of its own, so a dashboard pushed from another Horizon, from `swctl`, or from anything else writing the same OAP store appears within about a minute — without anyone reloading the page.
+**Every MQE field in the layer template editor can now be run where you type it.** A run button beside each expression opens a panel that picks a service (and, where the metric needs one, an instance or an endpoint), picks a time range with the same precision-tabbed picker the top of the page uses (custom ranges included), fires the expression against your OAP and shows exactly what came back — a graph of the series it returned, aligned on one time axis with a legend per label, the entit [...]
+
### Fixes
**A template store that could not be read emptied every dashboard, overview and map.** They are rendered from the last successful read now, so a brief outage of OAP's admin port leaves the console up, with the banner saying how stale it is. Horizon still never substitutes the templates bundled in the release — showing shipped defaults in place of your own configuration would misrepresent what is on screen — so a Horizon that has never read the store still blocks those pages rather than i [...]
diff --git a/docs/components/dashboard-widgets.md b/docs/components/dashboard-widgets.md
index c1803a10..5097eb95 100644
--- a/docs/components/dashboard-widgets.md
+++ b/docs/components/dashboard-widgets.md
@@ -20,7 +20,7 @@ Six widget types render on per-layer dashboards. Each `widget.type` you set in a
| `title` | Widget title shown in the card header. |
| `tip` | Optional hover hint. |
| `type` | One of `card`, `line`, `top`, `record`, `table`, or `tab`. |
-| `expressions[]` | MQE expressions. `card` typically uses one; `line` one-per-series; `top` one-per-tab; `table` one labeled `latest(…)` metric. |
+| `expressions[]` | MQE expressions. `card` typically uses one; `line` one-per-series; `top` one-per-tab; `table` one labeled `latest(…)` metric. Each one can be run against your OAP from the editor before you publish — see [Trying an expression](../customization/layer-templates.md#trying-an-expression-before-you-publish). |
| `expressionLabels[]` | Used by `top` for tab labels and by `line` for legend names. |
| `expressionUnits[]` | Per-expression unit override (mixed-unit charts). |
| `expressionAxes[]` | `0` = left axis (default), `1` = right axis. |
diff --git a/docs/customization/layer-templates.md b/docs/customization/layer-templates.md
index 8e61cb09..fbc0cf1d 100644
--- a/docs/customization/layer-templates.md
+++ b/docs/customization/layer-templates.md
@@ -562,6 +562,28 @@ A top banner summarizes page state — *Synced from OAP — N diverged, Y local*
If OAP already holds a record that isn't readable as the template it is stored as — pushed by an older Horizon, by another tool, or by hand — the banner turns to **UNREADABLE** and lists each one with its OAP record id and the reason. Two shapes end up there: a record stored under a name Horizon never asks for (a lower-case layer key, an OAP legacy alias), and a record stored under the right name whose content declares a *different* layer. Neither renders anywhere: a layer whose only rec [...]
+### Trying an expression before you publish
+
+Every MQE field in the editor has a **run** button beside it. It opens a panel that evaluates that one expression against your OAP and shows what came back, so you can tell a working expression from a broken one without publishing anything.
+
+The panel supplies the context an expression needs besides itself:
+
+- **Layer** — fixed to the template you are editing, and shown rather than chosen. An expression from this template only ever runs against this layer.
+- **Entity** — a service, narrowed to one of its instances or endpoints when the metric is scoped that way. Relation metrics (topology links, deployment links, API dependency links) ask for a **source** and a **destination**, because that is the pair they measure.
+- **Time range** — the same picker the top of the page uses: a precision tab (Minute / Hour / Day, each capped at the window it can address), that precision's rolling presets, and a custom start/end range. It is the panel's own window, not the page's. Widen it when a metric is reported hourly and a short window shows nothing.
+
+What comes back is OAP's own answer, shaped to what it returned. A time series is drawn as a graph — one line per series, all on a shared time axis, with a legend naming each from the metric's own labels. **Copy JSON** hands you OAP's response verbatim when you need the exact numbers. A `top_n` is ranked rows with the entity each value belongs to; record metrics show the sampled text and its trace id; an aggregate is a single row. **Copy JSON** takes the whole response away for a bug report.
+
+Three results are worth recognising:
+
+- **An error message** is OAP telling you the expression is wrong — an unknown metric name, or a syntax error — and it is quoted exactly as OAP phrased it.
+- **No rows** means the expression is valid but this entity and window hold nothing. Try a wider range or a busier service before assuming the expression is at fault.
+- **A blank field still runs.** A service-list column with no expression falls back to a default derived from its metric id, and the panel names the expression it used, so you can see what your operators will actually get.
+
+Nothing here writes anything: the panel is a read, and it runs the expression currently in the field — including one you have typed but not yet saved.
+
+To find out which metrics this OAP exposes and what scope each one lives at, use [Metrics Inspect](../operate/inspect.md) — the catalog answers *which* metric, this panel answers *does mine work*.
+
### Bundled defaults vs. your OAP-published templates
Each layer template has two copies: the **bundled** default shipped with Horizon, and the **remote** copy stored on OAP (what end users actually render — OAP wins at render time). On boot, Horizon seeds OAP **only with templates that are absent there** — a brand-new layer with no remote copy yet is pushed automatically so it works out of the box.
diff --git a/docs/operate/inspect.md b/docs/operate/inspect.md
index cd742b1f..26930708 100644
--- a/docs/operate/inspect.md
+++ b/docs/operate/inspect.md
@@ -13,6 +13,8 @@ Common scenarios:
- **"What's the right scope for this metric?"** — the catalog row shows the metric's scope (Service / ServiceInstance / Endpoint / Process / All); this is load-bearing when authoring widgets.
- **"Which analysis rule defines this metric?"** — rule-source attribution, when available, surfaces the DSL rule file (OAL, MAL·OTEL, MAL·Telegraf, MAL·Meter, LAL→MAL, …) that defines the metric.
+Once you know the metric and its scope, the expression you build from it can be run where you author it: every MQE field in the layer template editor has a run button — see [Trying an expression](../customization/layer-templates.md#trying-an-expression-before-you-publish).
+
## Prerequisites
- OAP 11.x.
diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts
index 3b76fdb1..8d12b95c 100644
--- a/packages/api-client/src/index.ts
+++ b/packages/api-client/src/index.ts
@@ -348,6 +348,11 @@ export {
type InspectValuesRequest,
type ForeignMetricInput,
} from './inspect.js';
+export type {
+ MqeExecStep,
+ MqeExecRequest,
+ MqeExecResponse,
+} from './mqe-exec.js';
export type {
ExploreKind,
ExploreTraceSource,
diff --git a/packages/api-client/src/inspect.ts b/packages/api-client/src/inspect.ts
index 3312d23d..9d9686c7 100644
--- a/packages/api-client/src/inspect.ts
+++ b/packages/api-client/src/inspect.ts
@@ -100,7 +100,11 @@ export interface MetricsResponse {
* side serialises with `@JsonInclude(NON_NULL)` so any field that
* doesn't apply to the scope is omitted from the JSON. */
export interface MqeEntity {
- scope: InspectScope;
+ /** Optional, and deprecated upstream since OAP 9.4.0 — the query protocol
+ * senses the scope from the metric name. It MUST be omitted for relation
+ * metrics: forcing it explicitly empties the result on some OAP versions,
+ * which is why every relation query the BFF builds leaves it off. */
+ scope?: InspectScope;
serviceName?: string;
normal?: boolean;
serviceInstanceName?: string;
diff --git a/packages/api-client/src/mqe-exec.ts b/packages/api-client/src/mqe-exec.ts
new file mode 100644
index 00000000..569f2081
--- /dev/null
+++ b/packages/api-client/src/mqe-exec.ts
@@ -0,0 +1,72 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * `POST /api/mqe/exec` — run ONE MQE expression and return OAP's
+ * `ExpressionResult` untouched.
+ *
+ * This is the read path behind the template editor's "run this expression"
+ * panel. It differs from `POST /api/inspect/exec` in three ways that matter
+ * to a dashboard author rather than to a platform operator:
+ *
+ * - the window arrives as epoch ms and the BFF formats it to OAP-local
+ * time, so no caller has to know the server's timezone;
+ * - a blank expression is resolved from `metric` + `layer` through the
+ * same catalog the service-list columns use, so an unfilled `mqe` field
+ * can be explored;
+ * - it is a `metrics:read` route, not `inspect:read` — testing your own
+ * expression is not a platform-internals action.
+ *
+ * The entity is passed through verbatim. Its `scope` is optional on purpose:
+ * relation metrics must be queried WITHOUT one (see {@link MqeEntity}).
+ */
+
+import type { ExpressionResult, MqeEntity } from './inspect.js';
+
+export type MqeExecStep = 'MINUTE' | 'HOUR' | 'DAY';
+
+export interface MqeExecRequest {
+ /** The expression to run. Omit (or send empty) together with `metric` +
+ * `layer` to have the BFF resolve the catalog default instead. */
+ expression?: string;
+ /** Service-list column id, used only to resolve a blank `expression`. */
+ metric?: string;
+ /** Layer key the expression belongs to — required to resolve `metric`,
+ * and echoed back so the caller can show what was queried. */
+ layer?: string;
+ /** Entity to evaluate against. `scope` omitted ⇒ OAP senses it from the
+ * metric name, which is REQUIRED for relation metrics. */
+ entity: MqeEntity;
+ step: MqeExecStep;
+ startMs: number;
+ endMs: number;
+}
+
+export interface MqeExecResponse {
+ /** The expression actually sent to OAP — the caller's, or the catalog
+ * default when it asked for one. Never blank on a 200. */
+ expression: string;
+ /** True when `expression` came from the metric catalog rather than the
+ * request, so the panel can say so. */
+ resolvedFromCatalog: boolean;
+ /** The OAP-local window the query used, formatted for its step. */
+ window: { start: string; end: string; step: MqeExecStep };
+ /** Whether cold-stage data was requested (from the global header). */
+ coldStage: boolean;
+ /** OAP's answer, verbatim. `type: 'UNKNOWN'` carries `error`. */
+ result: ExpressionResult;
+}