diff --git a/apps/bff/src/config/schema.ts b/apps/bff/src/config/schema.ts index 434ae61..54fe1d4 100644 --- a/apps/bff/src/config/schema.ts +++ b/apps/bff/src/config/schema.ts @@ -55,6 +55,16 @@ const ServerConfig = z.object({ trustProxy: z.boolean().default(false), }); +/** MQE (GraphQL `execExpression`) target override. Both fields are + * optional; when omitted, the BFF discovers them from admin's + * `/debugging/config/dump`. Set one or both to override the + * discovered values — useful in k8s setups where admin and REST + * surfaces are reachable through different ingresses. */ +const OapMqeConfig = z.object({ + host: z.string().min(1).optional(), + port: z.number().int().positive().max(65535).optional(), +}); + const OapConfig = z.object({ /** One or more OAP admin URLs. The BFF fan-outs reads (e.g. `/list`) * to every URL when building the cluster matrix; writes go to the @@ -66,6 +76,8 @@ const OapConfig = z.object({ * Default 10s — long enough for the dump streams; short enough * that a hung OAP doesn't stall the UI indefinitely. */ timeoutMs: z.number().int().nonnegative().default(10_000), + /** Optional MQE-fire override (SWIP-14 inspect). */ + mqe: OapMqeConfig.optional(), }); const LocalUser = z.object({ diff --git a/apps/bff/src/inspect/attribution.ts b/apps/bff/src/inspect/attribution.ts new file mode 100644 index 0000000..68f91b0 --- /dev/null +++ b/apps/bff/src/inspect/attribution.ts @@ -0,0 +1,278 @@ +/* + * Copyright 2026 The Vantage Studio Authors + * + * Licensed 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. + */ + +/** + * Build an in-process index of `metric name → { source, file }`. + * + * SWIP-14's `/inspect/metrics` does not return rule provenance — the + * catalog only knows type / scope / downsamplings. Studio cross- + * references that catalog against the rule files it already manages + * (via the existing `/runtime/oal/*` and `/runtime/rule/*` admin + * APIs) to add a `source` + `file` dimension that the operator can + * use to filter the Inspect board's catalog drawer. + * + * Fingerprint: `oalFiles[]` (list of file names) + `runtime/rule/list` + * rows (each row carries `contentHash`). The cache rebuilds whenever + * any input changes; an explicit `refresh()` busts the cache so the + * SPA's manual refresh button picks up newly added rules. + */ + +import type { OalClient, RuntimeRuleClient } from '@vantage-studio/api-client'; +import { parseOalMetricNames } from './parser-oal.js'; +import { parseMalMetricNames } from './parser-mal.js'; + +export type AttributionSource = 'OAL' | 'MAL·OTEL' | 'MAL·Telegraf' | 'LAL→MAL' | 'unknown'; + +export interface MetricAttribution { + source: AttributionSource; + /** Full file path, e.g. `core.oal`, `otel-rules/jvm-memory`, + * `telegraf-rules/cpu`, `log-mal-rules/log-status`. Includes the + * catalog prefix for MAL/LAL→MAL so different catalogs don't + * collide on duplicate file names. `null` only when a metric has + * multiple ambiguous owners (currently rare). */ + file: string | null; + /** Populated when more than one rule file claimed this metric; + * empty otherwise. Lets the SPA surface "ambiguous" with the + * candidate list. */ + candidates?: string[]; +} + +export interface AttributionIndex { + /** Stringly fingerprint of the inputs that produced this index. + * Two indexes are interchangeable iff their fingerprints match. */ + fingerprint: string; + /** Metric name → attribution. Unknown metrics are not in the map; + * callers should default to `source: 'unknown', file: null`. */ + byMetric: Map; +} + +export interface AttributionDeps { + oal(): OalClient; + rules(): RuntimeRuleClient; +} + +interface CacheState { + index: AttributionIndex | null; + /** A second-tier guard against thundering-herd rebuilds: while a + * rebuild is in flight, every caller awaits the same promise. */ + inflight: Promise | null; +} + +export class AttributionCache { + private state: CacheState = { index: null, inflight: null }; + + /** Force the next call to `get()` to fully rebuild the index. */ + invalidate(): void { + this.state = { index: null, inflight: null }; + } + + /** Returns the cached index when its fingerprint still matches the + * current inputs; rebuilds otherwise. Concurrent callers share one + * in-flight rebuild. */ + async get(deps: AttributionDeps): Promise { + const cached = this.state.index; + if (cached) { + try { + const fp = await computeFingerprint(deps); + if (fp === cached.fingerprint) return cached; + } catch { + // If fingerprint computation fails (admin unreachable), serve + // the stale index — the catalog endpoints would have failed + // by now anyway, so this only matters when admin recovers. + return cached; + } + } + if (this.state.inflight) return this.state.inflight; + const p = buildIndex(deps).then((idx) => { + this.state = { index: idx, inflight: null }; + return idx; + }); + this.state.inflight = p; + return p; + } +} + +const MAL_CATALOGS = ['otel-rules', 'telegraf-rules', 'log-mal-rules'] as const; + +/** Compute a fingerprint that uniquely identifies the current rule + * set. Either side is best-effort — if `/runtime/oal/*` or + * `/runtime/rule/*` is disabled on this OAP, that side is treated + * as empty rather than failing the whole attribution. + * + * We fingerprint over: + * - the list of OAL file names (their content only changes on OAP + * restart, so the file list is a sufficient proxy); + * - runtime-rule rows from `/runtime/rule/list` (operator-pushed + * + already-touched bundled rules; each carries a contentHash); + * - bundled rule names + content hashes from + * `/runtime/rule/bundled?catalog=...` for each MAL catalog (the + * baked-in defaults, almost always the bulk of MAL attribution). + */ +async function computeFingerprint(deps: AttributionDeps): Promise { + const rules = deps.rules(); + const [oalFiles, malRuntimeRows, ...bundledLists] = await Promise.all([ + deps + .oal() + .listFiles() + .then((r) => r.files) + .catch(() => [] as string[]), + rules + .list() + .then((r) => r.rules.map((row) => `${row.catalog}/${row.name}@${row.contentHash}`)) + .catch(() => [] as string[]), + ...MAL_CATALOGS.map((c) => + rules + .listBundled(c, false) + .then((rows) => rows.map((b) => `${c}/${b.name}@${b.contentHash}`)) + .catch(() => [] as string[]), + ), + ]); + const oalPart = [...oalFiles].sort().join('|'); + const runtimePart = [...malRuntimeRows].sort().join('|'); + const bundledPart = bundledLists.flat().sort().join('|'); + return `oal:${oalPart}||rt:${runtimePart}||bn:${bundledPart}`; +} + +async function buildIndex(deps: AttributionDeps): Promise { + const oal = deps.oal(); + const rules = deps.rules(); + + // Pull every rule source in parallel. Each side is best-effort — + // when an OAP module is disabled (e.g. `SW_RECEIVER_RUNTIME_RULE` + // unset), the call fails and we drop that bucket without failing + // the whole attribution. + const [filesEnvSafe, listEnvSafe, ...bundledLists] = await Promise.all([ + oal.listFiles().catch(() => ({ files: [] as string[], count: 0 })), + rules.list().catch(() => ({ + generatedAt: 0, + loaderStats: { active: 0, pending: 0 }, + rules: [] as Awaited>['rules'], + })), + ...MAL_CATALOGS.map((c) => + rules + .listBundled(c, true) + .then((rows) => ({ catalog: c, rows })) + .catch(() => ({ catalog: c, rows: [] })), + ), + ]); + + // OAL: fetch every file's content and extract LHS metric names. + // Per-file getFileContent errors are tolerated. + const oalContents = await Promise.all( + filesEnvSafe.files.map(async (name) => { + try { + return { name, content: await oal.getFileContent(name) }; + } catch { + return { name, content: null }; + } + }), + ); + + /* MAL: combine runtime + bundled sources. + * + * 1. `/runtime/rule/list` rows are operator-pushed runtime rules + * plus bundled rules the dslManager has already touched. We + * fetch their content via `/runtime/rule?catalog=…&name=…`. + * 2. `/runtime/rule/bundled?catalog=…&withContent=true` gives us + * every baked-in rule for each MAL catalog, with content inline + * — no per-rule fetch needed. This is the bulk of attribution + * in a vanilla OAP install. + * + * `lal` is excluded — LAL files emit logs (not metrics); the + * `log-mal-rules` catalog covers the LAL→MAL bridge metrics. */ + const malRuntimeRows = listEnvSafe.rules.filter((r) => r.catalog !== 'lal'); + const malRuntimeContents = await Promise.all( + malRuntimeRows.map(async (row) => { + try { + const got = await rules.get({ catalog: row.catalog, name: row.name }); + if ('notModified' in got) return { catalog: row.catalog, name: row.name, content: null }; + return { catalog: row.catalog, name: row.name, content: got.content }; + } catch { + return { catalog: row.catalog, name: row.name, content: null }; + } + }), + ); + + const malBundledContents = bundledLists.flatMap((b) => + b.rows.map((row) => ({ catalog: b.catalog, name: row.name, content: row.content ?? null })), + ); + + const malContents = [...malRuntimeContents, ...malBundledContents]; + + // Build the index. Detect conflicts (one metric claimed by multiple + // files) so the SPA can surface them. + const byMetric = new Map(); + const claims = new Map(); + + const claim = (metric: string, source: AttributionSource, file: string) => { + const arr = claims.get(metric) ?? []; + arr.push({ source, file }); + claims.set(metric, arr); + }; + + for (const { name, content } of oalContents) { + if (content === null) continue; + for (const m of parseOalMetricNames(content)) claim(m, 'OAL', name); + } + + /* Dedup runtime + bundled (an operator-pushed override and the + * underlying bundled twin are the same logical file; we just want + * the metric → file mapping, not version tracking). */ + const seenMal = new Set(); + for (const { catalog, name, content } of malContents) { + if (content === null) continue; + const file = `${catalog}/${name}`; + if (seenMal.has(file)) continue; + seenMal.add(file); + const source: AttributionSource = + catalog === 'otel-rules' + ? 'MAL·OTEL' + : catalog === 'telegraf-rules' + ? 'MAL·Telegraf' + : catalog === 'log-mal-rules' + ? 'LAL→MAL' + : 'unknown'; + for (const m of parseMalMetricNames(content)) claim(m, source, file); + } + + for (const [metric, arr] of claims) { + if (arr.length === 1) { + const only = arr[0]!; + byMetric.set(metric, { source: only.source, file: only.file }); + } else { + // Ambiguous: keep the first claim's source for display, expose + // the full candidate list. (In practice this should be zero — + // OAL and MAL use disjoint name spaces — but the UI is honest.) + const first = arr[0]!; + byMetric.set(metric, { + source: first.source, + file: first.file, + candidates: arr.map((c) => `${c.source}:${c.file}`), + }); + } + } + + const fingerprint = await computeFingerprint(deps); + return { fingerprint, byMetric }; +} + +/** Look up a metric and fall back to `unknown` when the metric is + * not in the index (typical for OAP-bundled core metrics that Studio + * doesn't manage — though /runtime/oal/files does return those too, + * so the fallthrough is rare). */ +export function attributeOrUnknown(index: AttributionIndex, metric: string): MetricAttribution { + return index.byMetric.get(metric) ?? { source: 'unknown', file: null }; +} diff --git a/apps/bff/src/inspect/parser-mal.ts b/apps/bff/src/inspect/parser-mal.ts new file mode 100644 index 0000000..b409786 --- /dev/null +++ b/apps/bff/src/inspect/parser-mal.ts @@ -0,0 +1,91 @@ +/* + * Copyright 2026 The Vantage Studio Authors + * + * Licensed 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. + */ + +/** + * Extract metric names from a MAL rule file body. The wire shape: + * + * filter: ... + * expSuffix: ... + * metricPrefix: ... + * metricsRules: + * - name: + * exp: ... + * + * # OR (legacy) + * rules: + * - metricsName: + * ... + * + * The two shapes coexist in OAP's MAL configs (`metricsRules` is the + * 10.x rewrite; `rules` is the original). Both yield the same metric + * name per rule, optionally prefixed by `metricPrefix`. We walk + * either array and emit `prefix + name` when both are present. + * + * On parse failure (malformed YAML) we return an empty list rather + * than throwing — the attribution layer should keep going for the + * other files instead of failing the whole catalog merge. + */ + +import { parse as parseYaml } from 'yaml'; + +interface MalRuleNode { + name?: unknown; + metricsName?: unknown; +} + +interface MalRoot { + metricPrefix?: unknown; + metricsRules?: unknown; + rules?: unknown; +} + +export function parseMalMetricNames(content: string): string[] { + let doc: unknown; + try { + doc = parseYaml(content); + } catch { + return []; + } + if (typeof doc !== 'object' || doc === null) return []; + const root = doc as MalRoot; + const prefix = + typeof root.metricPrefix === 'string' && root.metricPrefix.length > 0 + ? `${root.metricPrefix}_` + : ''; + const out: string[] = []; + const seen = new Set(); + const collect = (rules: unknown) => { + if (!Array.isArray(rules)) return; + for (const r of rules) { + if (typeof r !== 'object' || r === null) continue; + const node = r as MalRuleNode; + const raw = + typeof node.name === 'string' + ? node.name + : typeof node.metricsName === 'string' + ? node.metricsName + : null; + if (!raw) continue; + const full = prefix + raw; + if (seen.has(full)) continue; + seen.add(full); + out.push(full); + } + }; + collect(root.metricsRules); + collect(root.rules); + return out; +} diff --git a/apps/bff/src/inspect/parser-oal.ts b/apps/bff/src/inspect/parser-oal.ts new file mode 100644 index 0000000..a67bbe2 --- /dev/null +++ b/apps/bff/src/inspect/parser-oal.ts @@ -0,0 +1,55 @@ +/* + * Copyright 2026 The Vantage Studio Authors + * + * Licensed 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. + */ + +/** + * Extract metric names from raw OAL text — every statement is of the + * form ` = from(...).(...);` so the LHS of + * the first `=` on each (non-comment) line is the metric name. + * + * Used by the inspect attribution layer to map metric names returned + * by `/inspect/metrics` back to the .oal file that declared them. + * + * The parser handles line comments (`//`) and block comments (`/* … *\/`). + * It does not need to fully tokenise OAL — only LHS-before-equals + * matters; the RHS can be arbitrarily complex. + */ + +const METRIC_NAME_RE = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/; + +/** Strip C-style line and block comments from a source string. */ +function stripComments(src: string): string { + // Block comments first — non-greedy, multiline. + const noBlock = src.replace(/\/\*[\s\S]*?\*\//g, ''); + // Line comments next. + return noBlock.replace(/\/\/[^\n]*/g, ''); +} + +/** Extract every metric name (LHS of `=`) from a `.oal` file body. + * Returns deduplicated metric names in declaration order. */ +export function parseOalMetricNames(content: string): string[] { + const cleaned = stripComments(content); + const out: string[] = []; + const seen = new Set(); + for (const line of cleaned.split('\n')) { + const m = METRIC_NAME_RE.exec(line); + if (!m) continue; + const name = m[1]!; + if (seen.has(name)) continue; + seen.add(name); + out.push(name); + } + return out; +} diff --git a/apps/bff/src/oap/clients.ts b/apps/bff/src/oap/clients.ts index 344743e..030c433 100644 --- a/apps/bff/src/oap/clients.ts +++ b/apps/bff/src/oap/clients.ts @@ -23,6 +23,7 @@ import { DslDebuggingClient, + InspectClient, OalClient, RuntimeRuleClient, StatusClient, @@ -52,6 +53,10 @@ export interface OapClients { /** Build a DSL-debugging client for one specific admin URL — used * by the per-node fan-out for `/dsl-debugging/status`. */ debugForUrl(adminUrl: string): DslDebuggingClient; + /** Inspect API client (SWIP-14) — metadata-only catalog + entity + * enumeration. Identical across nodes (storage is shared), so we + * bind to the first admin URL. */ + inspect(): InspectClient; /** All admin URLs, in config order. */ adminUrls(): readonly string[]; } @@ -89,6 +94,9 @@ export function buildOapClients( debugForUrl(adminUrl: string): DslDebuggingClient { return new DslDebuggingClient({ adminUrl, fetch, timeoutMs }); }, + inspect(): InspectClient { + return new InspectClient({ adminUrl: primaryUrl, fetch, timeoutMs }); + }, adminUrls(): readonly string[] { return config.oap.adminUrls; }, diff --git a/apps/bff/src/oap/inspect-exec.ts b/apps/bff/src/oap/inspect-exec.ts new file mode 100644 index 0000000..aab9cc7 --- /dev/null +++ b/apps/bff/src/oap/inspect-exec.ts @@ -0,0 +1,199 @@ +/* + * Copyright 2026 The Vantage Studio Authors + * + * Licensed 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. + */ + +/** + * MQE fire — `POST /api/inspect/exec` forwards to OAP's GraphQL + * `mutation execExpression(expression, entity, duration)` and returns + * the `ExpressionResult` payload to the SPA. + * + * SWIP-14 deliberately punts value queries off the admin port so that + * MQE auth / quotas / observability stay on a single surface. The BFF + * resolves that surface via `MqeTargetCache` (Phase 3) and is the + * single egress point for MQE traffic from Studio. + * + * The handler validates inbound shape, builds the GraphQL request, + * folds GraphQL errors into the BFF's envelope (`mqe_error`), and + * returns `data.execExpression` verbatim on success. The SPA does not + * need GraphQL knowledge. + */ + +import type { FastifyReply } from 'fastify'; +import type { FetchLike, ExpressionResult, MqeEntity } from '@vantage-studio/api-client'; +import { INSPECT_STEPS, isInspectDate, type InspectStep } from '@vantage-studio/api-client'; +import type { MqeTarget } from './mqe-target.js'; + +interface DurationInput { + start: string; + end: string; + step: InspectStep; + coldStage?: boolean; +} + +interface ExecBody { + expression: string; + entity: MqeEntity; + duration: DurationInput; + debug?: boolean; +} + +export interface ExecDeps { + fetch: FetchLike; + /** Per-call timeout (ms). 0 disables. */ + timeoutMs: number; +} + +/* SkyWalking's MQE entry point is on `Query`, not `Mutation` + * (see oap-server/.../metrics-v3.graphqls — `extend type Query`). */ +const GRAPHQL_QUERY = + 'query Exec($expression: String!, $entity: Entity!, $duration: Duration!, $debug: Boolean) {\n' + + ' execExpression(expression: $expression, entity: $entity, duration: $duration, debug: $debug) {\n' + + ' type\n' + + ' error\n' + + ' results {\n' + + ' metric { labels { key value } }\n' + + ' values { id value traceID owner { scope serviceID serviceName normal serviceInstanceID serviceInstanceName endpointID endpointName } }\n' + + ' }\n' + + ' }\n' + + '}\n'; + +/** Validate the request body and either send a 400 / return null, or + * return the parsed body for execution. */ +export function parseExecBody(body: unknown, reply: FastifyReply): ExecBody | null { + if (typeof body !== 'object' || body === null) { + reply.code(400).send({ error: 'invalid_body' }); + return null; + } + const b = body as Partial; + if (typeof b.expression !== 'string' || b.expression.length === 0) { + reply.code(400).send({ error: 'missing_expression' }); + return null; + } + if (typeof b.entity !== 'object' || b.entity === null) { + 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' }); + return null; + } + if (typeof b.duration !== 'object' || b.duration === null) { + reply.code(400).send({ error: 'missing_duration' }); + return null; + } + const d = b.duration as DurationInput; + if (typeof d.start !== 'string' || typeof d.end !== 'string') { + reply.code(400).send({ error: 'invalid_duration', detail: 'start and end must be strings' }); + return null; + } + if (typeof d.step !== 'string' || !INSPECT_STEPS.includes(d.step.toUpperCase() as InspectStep)) { + reply.code(400).send({ + error: 'invalid_duration', + detail: `step must be one of ${INSPECT_STEPS.join(', ')}`, + }); + return null; + } + const step = d.step.toUpperCase() as InspectStep; + if (!isInspectDate(d.start, step)) { + reply + .code(400) + .send({ error: 'invalid_duration', detail: `start does not match ${step} format` }); + return null; + } + if (!isInspectDate(d.end, step)) { + reply + .code(400) + .send({ error: 'invalid_duration', detail: `end does not match ${step} format` }); + return null; + } + return { + expression: b.expression, + entity: b.entity as MqeEntity, + duration: { + start: d.start, + end: d.end, + step, + ...(d.coldStage !== undefined ? { coldStage: !!d.coldStage } : {}), + }, + ...(b.debug !== undefined ? { debug: !!b.debug } : {}), + }; +} + +interface GraphQlEnvelope { + data?: { execExpression?: ExpressionResult }; + errors?: { message: string; path?: string[] }[]; +} + +/** Fire the MQE mutation against the resolved base. Returns the + * `ExpressionResult` on success. Throws a {@link MqeFireError} with + * the GraphQL error array attached on failure. */ +export async function fireMqe( + target: MqeTarget, + req: ExecBody, + deps: ExecDeps, +): Promise { + const url = `${target.baseUrl.replace(/\/$/, '')}/graphql`; + const payload = { + query: GRAPHQL_QUERY, + variables: { + expression: req.expression, + entity: req.entity, + duration: req.duration, + debug: req.debug ?? false, + }, + }; + let init: RequestInit = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify(payload), + }; + let timer: ReturnType | null = null; + if (deps.timeoutMs > 0) { + const ctrl = new AbortController(); + timer = setTimeout(() => ctrl.abort(), deps.timeoutMs); + init = { ...init, signal: ctrl.signal }; + } + try { + const res = await deps.fetch(url, init); + if (!res.ok) { + const text = await res.text(); + throw new MqeFireError(`MQE HTTP ${res.status}: ${text.slice(0, 200)}`, []); + } + const env = (await res.json()) as GraphQlEnvelope; + if (env.errors && env.errors.length > 0) { + const msg = env.errors.map((e) => e.message).join('; '); + throw new MqeFireError(`MQE error: ${msg}`, env.errors); + } + if (!env.data || !env.data.execExpression) { + throw new MqeFireError('MQE response missing data.execExpression', []); + } + return env.data.execExpression; + } finally { + if (timer) clearTimeout(timer); + } +} + +export class MqeFireError extends Error { + constructor( + message: string, + public readonly graphqlErrors: { message: string; path?: string[] }[], + ) { + super(message); + this.name = 'MqeFireError'; + } +} diff --git a/apps/bff/src/oap/inspect-routes.ts b/apps/bff/src/oap/inspect-routes.ts new file mode 100644 index 0000000..9133682 --- /dev/null +++ b/apps/bff/src/oap/inspect-routes.ts @@ -0,0 +1,406 @@ +/* + * Copyright 2026 The Vantage Studio Authors + * + * Licensed 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. + */ + +/** + * SWIP-14 Inspect API surface — BFF proxy for the admin-only routes + * exposed by OAP's `inspect` feature module (port 17128): + * + * GET /api/inspect/metrics + * GET /api/inspect/entities + * + * The MQE values themselves are NOT served here — those go via the + * regular GraphQL `execExpression` mutation, which the BFF proxies in + * `inspect-exec.ts` (Phase 4). The merged catalog endpoint + * `/api/inspect/catalog` (Phase 2) layers Studio-side rule attribution + * on top of `/inspect/metrics`. + * + * Inspect is read-only and metadata-ish — every route is gated on a + * single `inspect:read` verb. There is no write surface. + * + * INSPECT_NOT_ENABLED disambiguation: OAP returns a plain 404 for the + * inspect routes when `SW_INSPECT=default` was not set. The plain + * `oap_unreachable` envelope from the generic error handler would be + * misleading there, so we sniff for 404s on these specific paths and + * promote them to a structured `inspect_not_enabled` code that the SPA + * surfaces as an actionable banner. + */ + +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { + InspectApiError, + INSPECT_ENTITY_LIMIT_MAX, + INSPECT_STEPS, + isInspectDate, + type FetchLike, + type InspectCatalog, + type InspectMetricType, + type InspectStep, +} from '@vantage-studio/api-client'; +import type { ConfigHandle } from '../config/loader.js'; +import type { AuditLogger } from '../audit/logger.js'; +import { requireAuth } from '../auth/middleware.js'; +import { sessionHasVerb } from '../rbac/policy.js'; +import type { Session, InMemorySessionStore } from '../auth/sessions.js'; +import { buildOapClients, type OapClients } from './clients.js'; +import { AttributionCache, attributeOrUnknown } from '../inspect/attribution.js'; +import { MqeTargetCache } from './mqe-target.js'; +import { parseExecBody, fireMqe, MqeFireError } from './inspect-exec.js'; +import { ServerTimeCache } from './server-time.js'; + +export interface InspectRouteDeps { + config: ConfigHandle; + sessions: InMemorySessionStore; + audit: AuditLogger; + fetch?: FetchLike; +} + +const VALID_METRIC_TYPES = new Set([ + 'REGULAR_VALUE', + 'LABELED_VALUE', + 'HEATMAP', + 'SAMPLED_RECORD', +]); + +const TRUTHY = new Set(['true', '1', 'yes']); + +export function registerInspectRoutes(app: FastifyInstance, deps: InspectRouteDeps): void { + const auth = requireAuth(deps); + /* One cache per server. The cache's `get()` is fingerprint-aware, + * so it auto-invalidates whenever rules change; `refresh=true` on + * /api/inspect/catalog busts it explicitly for the SPA's manual + * refresh button. */ + const attribution = new AttributionCache(); + const mqeTarget = new MqeTargetCache(); + const serverTime = new ServerTimeCache(); + + function clients(): OapClients { + return buildOapClients(deps.config.current(), { fetch: deps.fetch }); + } + + // ── /api/inspect/metrics ───────────────────────────────────────── + + app.get( + '/api/inspect/metrics', + { preHandler: auth }, + async (req: FastifyRequest, reply: FastifyReply) => { + if (!ensureVerb(req, reply, deps, 'inspect:read')) return; + const q = req.query as Record; + + const types = parseTypes(q.type, reply); + if (types === null) return; + const catalogs = parseCatalogs(q.catalog); + + const args: Parameters['listMetrics']>[0] = {}; + if (typeof q.regex === 'string' && q.regex.length > 0) args.regex = q.regex; + if (types.length > 0) args.type = types; + if (catalogs.length > 0) args.catalog = catalogs; + if (typeof q.mqeQueryable === 'string' && TRUTHY.has(q.mqeQueryable.toLowerCase())) { + args.mqeQueryable = true; + } + + try { + const got = await clients().inspect().listMetrics(args); + return reply.send(got); + } catch (err) { + return passInspectError(err, reply, '/inspect/metrics'); + } + }, + ); + + // ── /api/inspect/catalog ───────────────────────────────────────── + // Merges `/inspect/metrics` with Studio's rule-file attribution + // (source + file per metric). This is the endpoint the Inspect + // page's catalog drawer hits — the raw `/api/inspect/metrics` + // proxy above stays for scripting / debugging. + + app.get( + '/api/inspect/catalog', + { preHandler: auth }, + async (req: FastifyRequest, reply: FastifyReply) => { + if (!ensureVerb(req, reply, deps, 'inspect:read')) return; + const q = req.query as Record; + if (q.refresh === 'true' || q.refresh === '1') attribution.invalidate(); + + try { + const c = clients(); + const [metricsRes, idx] = await Promise.all([ + c.inspect().listMetrics(), + attribution.get({ oal: () => c.oal(), rules: () => c.primary() }), + ]); + + const entries = metricsRes.metrics.map((m) => { + const attr = attributeOrUnknown(idx, m.name); + return { + ...m, + attribution: attr, + }; + }); + + const summary: Record = {}; + for (const e of entries) { + summary[e.attribution.source] = (summary[e.attribution.source] ?? 0) + 1; + } + + return reply.send({ + metrics: entries, + summary, + attributionFingerprint: idx.fingerprint, + }); + } catch (err) { + return passInspectError(err, reply, '/inspect/metrics'); + } + }, + ); + + // ── /api/inspect/mqe-target ────────────────────────────────────── + // Resolves the GraphQL base URL for MQE `execExpression` calls. + // The result is cached for ~60s; `?refresh=true` busts the cache + // so the operator can re-pull after reconfiguring OAP. + + app.get( + '/api/inspect/mqe-target', + { preHandler: auth }, + async (req: FastifyRequest, reply: FastifyReply) => { + if (!ensureVerb(req, reply, deps, 'inspect:read')) return; + const q = req.query as Record; + if (q.refresh === 'true' || q.refresh === '1') mqeTarget.invalidate(); + try { + const target = await mqeTarget.resolve({ + config: () => deps.config.current(), + fetch: deps.fetch ?? globalThis.fetch.bind(globalThis), + }); + return reply.send(target); + } catch (err) { + return reply.code(502).send({ + error: 'mqe_target_unresolved', + message: err instanceof Error ? err.message : String(err), + }); + } + }, + ); + + // ── /api/inspect/server-time ───────────────────────────────────── + // Caches OAP's `getTimeInfo` so the SPA can display dates in browser + // local TZ while sending server-TZ strings to OAP. + + app.get( + '/api/inspect/server-time', + { preHandler: auth }, + async (req: FastifyRequest, reply: FastifyReply) => { + if (!ensureVerb(req, reply, deps, 'inspect:read')) return; + const q = req.query as Record; + if (q.refresh === 'true' || q.refresh === '1') serverTime.invalidate(); + const fetchImpl = deps.fetch ?? globalThis.fetch.bind(globalThis); + const value = await serverTime.get({ + config: () => deps.config.current(), + fetch: fetchImpl, + mqeTarget, + }); + return reply.send(value); + }, + ); + + // ── /api/inspect/exec ──────────────────────────────────────────── + // Fires `mutation execExpression` against the resolved MQE base + // and returns the `ExpressionResult` payload verbatim. + + app.post( + '/api/inspect/exec', + { preHandler: auth }, + async (req: FastifyRequest, reply: FastifyReply) => { + if (!ensureVerb(req, reply, deps, 'inspect:read')) return; + const body = parseExecBody(req.body, reply); + if (!body) return; // 400 already sent. + + const cfg = deps.config.current(); + const fetchImpl = deps.fetch ?? globalThis.fetch.bind(globalThis); + try { + const target = await mqeTarget.resolve({ + config: () => cfg, + fetch: fetchImpl, + }); + const result = await fireMqe(target, body, { + fetch: fetchImpl, + timeoutMs: cfg.oap.timeoutMs, + }); + return reply.send(result); + } catch (err) { + if (err instanceof MqeFireError) { + return reply.code(502).send({ + error: 'mqe_error', + message: err.message, + graphqlErrors: err.graphqlErrors, + }); + } + return reply.code(502).send({ + error: 'mqe_target_unresolved', + message: err instanceof Error ? err.message : String(err), + }); + } + }, + ); + + // ── /api/inspect/entities ──────────────────────────────────────── + + app.get( + '/api/inspect/entities', + { preHandler: auth }, + async (req: FastifyRequest, reply: FastifyReply) => { + if (!ensureVerb(req, reply, deps, 'inspect:read')) return; + const q = req.query as Record; + + if (!q.metric) return reply.code(400).send({ error: 'missing_metric' }); + if (!q.start) return reply.code(400).send({ error: 'missing_start' }); + if (!q.end) return reply.code(400).send({ error: 'missing_end' }); + if (!q.step) return reply.code(400).send({ error: 'missing_step' }); + + const step = parseStep(q.step, reply); + if (step === null) return; + + // We validate dates client-side too. OAP also validates and + // returns a 400 with a helpful message, but pre-validating keeps + // a misformatted UI input from showing as a generic error. + if (!isInspectDate(q.start, step)) { + return reply.code(400).send({ error: 'invalid_start_format', step, value: q.start }); + } + if (!isInspectDate(q.end, step)) { + return reply.code(400).send({ error: 'invalid_end_format', step, value: q.end }); + } + + let limit: number | undefined; + if (q.limit !== undefined) { + const parsed = Number.parseInt(q.limit, 10); + if (!Number.isInteger(parsed) || parsed < 1 || parsed > INSPECT_ENTITY_LIMIT_MAX) { + return reply.code(400).send({ + error: 'invalid_limit', + min: 1, + max: INSPECT_ENTITY_LIMIT_MAX, + value: q.limit, + }); + } + limit = parsed; + } + + try { + const got = await clients() + .inspect() + .listEntities({ + metric: q.metric, + start: q.start, + end: q.end, + step, + ...(limit !== undefined ? { limit } : {}), + }); + return reply.send(got); + } catch (err) { + return passInspectError(err, reply, '/inspect/entities'); + } + }, + ); +} + +// ── helpers ─────────────────────────────────────────────────────── + +function parseStep(raw: string, reply: FastifyReply): InspectStep | null { + const upper = raw.toUpperCase(); + if (INSPECT_STEPS.includes(upper as InspectStep)) return upper as InspectStep; + reply.code(400).send({ + error: 'invalid_step', + value: raw, + allowed: INSPECT_STEPS, + }); + return null; +} + +/** Returns the (deduped) `InspectMetricType[]` parsed from the query; + * returns `null` after sending a 400 if any value is unrecognised. */ +function parseTypes( + raw: string | string[] | undefined, + reply: FastifyReply, +): InspectMetricType[] | null { + if (raw === undefined) return []; + const arr = Array.isArray(raw) ? raw : [raw]; + const out: InspectMetricType[] = []; + for (const v of arr) { + const upper = v.toUpperCase(); + if (!VALID_METRIC_TYPES.has(upper as InspectMetricType)) { + reply.code(400).send({ error: 'invalid_type', value: v }); + return null; + } + if (!out.includes(upper as InspectMetricType)) out.push(upper as InspectMetricType); + } + return out; +} + +/** Catalog values are open-ended on the OAP side (`DefaultScopeDefine` + * can add new catalogs without an OAP-side enum change), so we don't + * validate the value itself — just upper-case for consistency and + * pass through. */ +function parseCatalogs(raw: string | string[] | undefined): InspectCatalog[] { + if (raw === undefined) return []; + const arr = Array.isArray(raw) ? raw : [raw]; + const out: InspectCatalog[] = []; + for (const v of arr) { + const upper = v.toUpperCase(); + if (!out.includes(upper)) out.push(upper); + } + return out; +} + +function ensureVerb( + req: FastifyRequest, + reply: FastifyReply, + deps: InspectRouteDeps, + verb: string, +): boolean { + const session: Session | undefined = req.session; + if (!session) { + reply.code(401).send({ error: 'unauthenticated' }); + return false; + } + if (!sessionHasVerb(deps.config.current(), session.roles, verb)) { + reply.code(403).send({ error: 'permission_denied', verb }); + return false; + } + return true; +} + +/** Translate every Inspect error into the BFF's envelope. The unique + * case is OAP returning 404 for an inspect path — that means the + * inspect module isn't enabled on OAP (no handler bound). Promote + * to a structured `inspect_not_enabled` code so the SPA can surface + * an actionable banner instead of a generic 404. + * + * All other InspectApiError shapes (400 from validation, 500 from + * storage) are forwarded with their original status + body, so + * OAP's error.message reaches the operator unchanged. */ +function passInspectError(err: unknown, reply: FastifyReply, path: string): FastifyReply { + if (err instanceof InspectApiError) { + if (err.status === 404) { + return reply.code(404).send({ + error: 'inspect_not_enabled', + message: 'OAP did not bind the inspect routes. Set SW_INSPECT=default on the admin-server.', + path, + }); + } + return reply.code(err.status).send(err.body); + } + return reply.code(502).send({ + error: 'oap_unreachable', + message: err instanceof Error ? err.message : String(err), + path, + }); +} diff --git a/apps/bff/src/oap/mqe-target.ts b/apps/bff/src/oap/mqe-target.ts new file mode 100644 index 0000000..3987700 --- /dev/null +++ b/apps/bff/src/oap/mqe-target.ts @@ -0,0 +1,195 @@ +/* + * Copyright 2026 The Vantage Studio Authors + * + * Licensed 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. + */ + +/** + * Resolve the GraphQL base URL for MQE (`execExpression`) calls. + * + * Resolution order: + * + * 1. Operator override in studio.yaml under `oap.mqe.{host,port}`. + * Both fields are independently optional — if only host is set, + * port is discovered, and vice versa. + * + * 2. Admin-server `GET /debugging/config/dump` — a flat + * `Map` with keys like `core.default.restPort` + * and (when the sharing-server module is enabled) + * `sharing-server.default.restPort`. Prefer the sharing-server + * values because 10.5+ defaults the query GraphQL there; fall + * back to core's REST otherwise. + * + * 3. Host fallback: if the discovered bind host is empty / wildcard + * (`0.0.0.0`, `::`), reuse the admin URL's hostname. Port-forward + * and ingress setups frequently bind on a wildcard but expose a + * reachable address as the admin URL. + * + * The result is cached in-process for `CACHE_TTL_MS`. Cache is busted + * by either elapsed time or an explicit `refresh()` call (driven by + * the SPA's manual refresh button). + */ + +import type { FetchLike } from '@vantage-studio/api-client'; +import type { StudioConfig } from '../config/schema.js'; + +export interface MqeTarget { + /** e.g. `http://oap-rest.cluster.local:12800` — no trailing slash. */ + baseUrl: string; + /** Human-readable rationale for the operator: `sharing-server`, + * `core.restPort`, `studio.yaml override`, `admin host fallback`, + * combinations thereof. */ + via: string; + /** What the operator's studio.yaml had set (echo, not discovery). */ + configured: { host?: string; port?: number }; +} + +export interface ResolveDeps { + config(): StudioConfig; + fetch: FetchLike; +} + +const CACHE_TTL_MS = 60_000; + +interface CacheEntry { + target: MqeTarget; + expiresAt: number; +} + +export class MqeTargetCache { + private entry: CacheEntry | null = null; + + invalidate(): void { + this.entry = null; + } + + async resolve(deps: ResolveDeps): Promise { + const now = Date.now(); + if (this.entry && this.entry.expiresAt > now) return this.entry.target; + const target = await resolveMqeTarget(deps); + this.entry = { target, expiresAt: now + CACHE_TTL_MS }; + return target; + } +} + +async function resolveMqeTarget(deps: ResolveDeps): Promise { + const cfg = deps.config().oap; + const configured: { host?: string; port?: number } = {}; + if (cfg.mqe?.host !== undefined) configured.host = cfg.mqe.host; + if (cfg.mqe?.port !== undefined) configured.port = cfg.mqe.port; + + // Fast path: full override — no admin call needed. + if (configured.host !== undefined && configured.port !== undefined) { + return { + baseUrl: `http://${configured.host}:${configured.port}`, + via: 'studio.yaml override (host + port)', + configured, + }; + } + + // Otherwise we need the config dump. Fetch from the first admin URL. + const adminUrl = cfg.adminUrls[0]!; + const dump = await fetchConfigDump(adminUrl, deps.fetch, cfg.timeoutMs); + const adminHost = new URL(adminUrl).hostname; + + const picked = pickFromDump(dump, adminHost); + + const finalHost = configured.host ?? picked.host; + const finalPort = configured.port ?? picked.port; + + if (finalPort === undefined) { + throw new Error( + 'mqe target: could not discover REST port from /debugging/config/dump (neither sharing-server nor core REST appears in the dump)', + ); + } + + const viaParts: string[] = []; + viaParts.push( + configured.host !== undefined ? 'host from studio.yaml' : `host from ${picked.hostFrom}`, + ); + viaParts.push( + configured.port !== undefined ? 'port from studio.yaml' : `port from ${picked.portFrom}`, + ); + + return { + baseUrl: `http://${finalHost}:${finalPort}`, + via: viaParts.join(', '), + configured, + }; +} + +interface PickResult { + host: string; + port: number | undefined; + hostFrom: string; + portFrom: string; +} + +function pickFromDump(dump: Record, adminHost: string): PickResult { + // Prefer sharing-server over core (10.5+ defaults the query + // GraphQL on the sharing-server REST). + const sharingHost = dump['sharing-server.default.restHost']; + const sharingPortStr = dump['sharing-server.default.restPort']; + const coreHost = dump['core.default.restHost']; + const corePortStr = dump['core.default.restPort']; + + const sharingPort = parsePort(sharingPortStr); + const corePort = parsePort(corePortStr); + + const preferSharing = sharingPort !== undefined; + const host = preferSharing ? sharingHost : coreHost; + const port = preferSharing ? sharingPort : corePort; + const moduleLabel = preferSharing ? 'sharing-server.restHost' : 'core.restHost'; + const portLabel = preferSharing ? 'sharing-server.restPort' : 'core.restPort'; + + // Wildcard host fallback. OAP commonly binds on 0.0.0.0; the operator + // reaches it through the admin URL's host. + const isWildcard = !host || host === '0.0.0.0' || host === '::' || host === ''; + const resolvedHost = isWildcard ? adminHost : host; + const hostFrom = isWildcard ? `admin URL host (${moduleLabel} was wildcard)` : moduleLabel; + + return { host: resolvedHost, port, hostFrom, portFrom: portLabel }; +} + +function parsePort(raw: string | undefined): number | undefined { + if (raw === undefined || raw === '') return undefined; + const n = Number.parseInt(raw, 10); + if (!Number.isFinite(n) || n <= 0 || n > 65535) return undefined; + return n; +} + +async function fetchConfigDump( + adminUrl: string, + fetch: FetchLike, + timeoutMs: number, +): Promise> { + const base = adminUrl.replace(/\/$/, ''); + const url = `${base}/debugging/config/dump`; + let init: RequestInit = { method: 'GET', headers: { Accept: 'application/json' } }; + let timer: ReturnType | null = null; + if (timeoutMs > 0) { + const ctrl = new AbortController(); + timer = setTimeout(() => ctrl.abort(), timeoutMs); + init = { ...init, signal: ctrl.signal }; + } + try { + const res = await fetch(url, init); + if (!res.ok) { + const body = await res.text(); + throw new Error(`config dump failed: HTTP ${res.status} from ${url} — ${body.slice(0, 200)}`); + } + return (await res.json()) as Record; + } finally { + if (timer) clearTimeout(timer); + } +} diff --git a/apps/bff/src/oap/preflight-routes.ts b/apps/bff/src/oap/preflight-routes.ts new file mode 100644 index 0000000..737b98a --- /dev/null +++ b/apps/bff/src/oap/preflight-routes.ts @@ -0,0 +1,44 @@ +/* + * Copyright 2026 The Vantage Studio Authors + * + * Licensed 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 { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import type { FetchLike } from '@vantage-studio/api-client'; +import type { ConfigHandle } from '../config/loader.js'; +import { requireAuth } from '../auth/middleware.js'; +import type { InMemorySessionStore } from '../auth/sessions.js'; +import { runPreflight } from './preflight.js'; + +export interface PreflightRouteDeps { + config: ConfigHandle; + sessions: InMemorySessionStore; + fetch?: FetchLike; +} + +/** `GET /api/preflight` — interrogates OAP's config-dump and returns + * per-module enablement. Authenticated but ungated by verb — every + * logged-in user can see whether OAP is correctly set up. */ +export function registerPreflightRoutes(app: FastifyInstance, deps: PreflightRouteDeps): void { + const auth = requireAuth(deps); + app.get( + '/api/preflight', + { preHandler: auth }, + async (_req: FastifyRequest, reply: FastifyReply) => { + const fetchImpl = deps.fetch ?? globalThis.fetch.bind(globalThis); + const result = await runPreflight(deps.config.current(), fetchImpl); + return reply.send(result); + }, + ); +} diff --git a/apps/bff/src/oap/preflight.ts b/apps/bff/src/oap/preflight.ts new file mode 100644 index 0000000..0d4b341 --- /dev/null +++ b/apps/bff/src/oap/preflight.ts @@ -0,0 +1,183 @@ +/* + * Copyright 2026 The Vantage Studio Authors + * + * Licensed 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. + */ + +/** + * Preflight check — interrogates `/debugging/config/dump` on OAP and + * reports which of Studio's required OAP modules are enabled. The + * SPA shows a one-time modal at login when any required selector is + * missing, listing the env var and what UI breaks without it. + * + * The dump returns a `Map` of dotted keys + * `..`. A module is enabled iff at least + * one key with its prefix appears in the dump. + * + * If admin-server itself is unreachable we return early with + * `adminReachable: false` and every module marked `enabled: false`; + * the operator's first move is "check OAP / admin port" rather than + * "set selectors". + */ + +import type { FetchLike } from '@vantage-studio/api-client'; +import type { StudioConfig } from '../config/schema.js'; + +export interface PreflightModule { + /** OAP module name as it appears in the config-dump key prefix. */ + name: string; + /** The env var that controls this module's selector. */ + envVar: string; + /** True when Studio depends on this module being on. */ + required: boolean; + /** True iff the dump carries at least one key with this module's prefix. */ + enabled: boolean; + /** What part of Studio's UI breaks when this module is off. */ + affects: string; +} + +export interface PreflightResult { + adminUrl: string; + /** True iff `/debugging/config/dump` responded 2xx. */ + adminReachable: boolean; + /** Short reason when `adminReachable` is false. */ + adminError?: string; + modules: PreflightModule[]; + /** Total keys in the dump. Diagnostic only. */ + dumpKeyCount: number; + generatedAt: number; +} + +interface ModuleDef { + name: string; + envVar: string; + required: boolean; + affects: string; +} + +const REQUIRED_MODULES: readonly ModuleDef[] = [ + { + name: 'admin-server', + envVar: 'SW_ADMIN_SERVER', + required: true, + affects: + 'Everything Studio does against the admin port. Without admin-server, the other three modules fail at boot with ModuleNotFoundException.', + }, + { + name: 'receiver-runtime-rule', + envVar: 'SW_RECEIVER_RUNTIME_RULE', + required: true, + affects: + "DSL Management (Catalog, OAL catalog), Editor save/load, Cluster status rule matrix, Live debugger rule picker, and the Inspect drawer's source attribution.", + }, + { + name: 'dsl-debugging', + envVar: 'SW_DSL_DEBUGGING', + required: true, + affects: + 'Live debugger across MAL / LAL / OAL (start / poll / stop) and the DSL-debugging health pane on Cluster status.', + }, + { + name: 'inspect', + envVar: 'SW_INSPECT', + required: true, + affects: + 'The Inspect page — every /api/inspect/* call returns 404 inspect_not_enabled and the page shows a banner instead of the board.', + }, +]; + +export async function runPreflight( + config: StudioConfig, + fetch: FetchLike, +): Promise { + const adminUrl = config.oap.adminUrls[0]!; + const generatedAt = Date.now(); + const dump = await fetchConfigDump(adminUrl, fetch, config.oap.timeoutMs); + + if (!dump.ok) { + return { + adminUrl, + adminReachable: false, + adminError: dump.error, + modules: REQUIRED_MODULES.map((m) => ({ + name: m.name, + envVar: m.envVar, + required: m.required, + affects: m.affects, + enabled: false, + })), + dumpKeyCount: 0, + generatedAt, + }; + } + + const keys = Object.keys(dump.body); + const enabledPrefixes = new Set(); + for (const k of keys) { + const top = k.split('.', 1)[0]; + if (top) enabledPrefixes.add(top); + } + + const modules: PreflightModule[] = REQUIRED_MODULES.map((m) => ({ + name: m.name, + envVar: m.envVar, + required: m.required, + affects: m.affects, + enabled: enabledPrefixes.has(m.name), + })); + + return { + adminUrl, + adminReachable: true, + modules, + dumpKeyCount: keys.length, + generatedAt, + }; +} + +interface DumpOk { + ok: true; + body: Record; +} +interface DumpErr { + ok: false; + error: string; +} + +async function fetchConfigDump( + adminUrl: string, + fetch: FetchLike, + timeoutMs: number, +): Promise { + const url = `${adminUrl.replace(/\/$/, '')}/debugging/config/dump`; + let init: RequestInit = { method: 'GET', headers: { Accept: 'application/json' } }; + let timer: ReturnType | null = null; + if (timeoutMs > 0) { + const ctrl = new AbortController(); + timer = setTimeout(() => ctrl.abort(), timeoutMs); + init = { ...init, signal: ctrl.signal }; + } + try { + const res = await fetch(url, init); + if (!res.ok) { + const text = (await res.text()).slice(0, 200); + return { ok: false, error: `HTTP ${res.status}${text ? ` — ${text}` : ''}` }; + } + const body = (await res.json()) as Record; + return { ok: true, body }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } finally { + if (timer) clearTimeout(timer); + } +} diff --git a/apps/bff/src/oap/routes.ts b/apps/bff/src/oap/routes.ts index b890bb1..4d03973 100644 --- a/apps/bff/src/oap/routes.ts +++ b/apps/bff/src/oap/routes.ts @@ -213,15 +213,21 @@ export function registerOapRoutes(app: FastifyInstance, deps: OapRouteDeps): voi if (!q.name) return reply.code(400).send({ error: 'missing_name' }); const mode = parseDeleteMode(q.mode, reply); if (mode === null) return; - if (!ensureVerb(req, reply, deps, 'rule:delete')) return; + /* `mode=revertToBundled` is a structural change — it swaps the + * active row's identity back to the bundled twin, the same + * write-class that `rule:write:structural` already gates on + * the addOrUpdate path. A caller with only `rule:delete` + * must not be able to revert. */ + const verb = mode === 'revertToBundled' ? 'rule:write:structural' : 'rule:delete'; + if (!ensureVerb(req, reply, deps, verb)) return; try { const result = await clients().primary().delete(catalog, q.name, mode); - auditMutation(deps, req, 'delete', 'rule:delete', catalog, q.name, result.applyStatus, { + auditMutation(deps, req, 'delete', verb, catalog, q.name, result.applyStatus, { mode, }); return reply.send(result); } catch (err) { - return passOapErrorAudit(err, reply, deps, req, 'delete', 'rule:delete', catalog, q.name, { + return passOapErrorAudit(err, reply, deps, req, 'delete', verb, catalog, q.name, { mode, }); } diff --git a/apps/bff/src/oap/server-time.ts b/apps/bff/src/oap/server-time.ts new file mode 100644 index 0000000..1af5cf3 --- /dev/null +++ b/apps/bff/src/oap/server-time.ts @@ -0,0 +1,186 @@ +/* + * Copyright 2026 The Vantage Studio Authors + * + * Licensed 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. + */ + +/** + * Server-time discovery — proxy for OAP's GraphQL `getTimeInfo` + * query. Returns the OAP server's UTC offset (in minutes) and current + * timestamp. Studio's SPA caches this once and uses it to: + * + * - Display dates in the browser's local timezone (operator-facing). + * - Convert browser-local dates to server-timezone strings when + * firing MQE (`yyyy-MM-dd HHmm` etc. — OAP parses these in its + * own TZ, not UTC). + * + * The booster UI uses the same path (`graphql/fragments/app.ts:17`). + * The wire shape returns `timezone` as an integer in the +/- HHMM + * shape — e.g. `800` for UTC+8, `-500` for UTC-5, `530` for UTC+5:30. + * We translate to plain minutes so the SPA doesn't have to repeat the + * parsing. + */ + +import type { FetchLike } from '@vantage-studio/api-client'; +import type { StudioConfig } from '../config/schema.js'; +import type { MqeTargetCache } from './mqe-target.js'; + +export interface ServerTime { + /** OAP server's UTC offset in minutes. `+480` for UTC+8, `-300` + * for UTC-5, `+330` for India (UTC+5:30). */ + offsetMinutes: number; + /** OAP-side current epoch millis (snapshot at fetch time). */ + currentTimestampMillis: number; + /** Where the offset came from — `oap` is the real graphql call; + * `fallback` is the local BFF clock returned when OAP's + * `getTimeInfo` is unreachable. */ + source: 'oap' | 'fallback'; + /** Resolved MQE base URL the BFF queried. Diagnostic. */ + mqeBaseUrl?: string; + /** Short error message when source === 'fallback'. */ + error?: string; +} + +const GRAPHQL_QUERY = + 'query ServerTime {\n getTimeInfo {\n timezone\n currentTimestamp\n }\n}\n'; + +interface CacheEntry { + value: ServerTime; + expiresAt: number; +} + +const CACHE_TTL_MS = 5 * 60_000; + +export class ServerTimeCache { + private entry: CacheEntry | null = null; + + invalidate(): void { + this.entry = null; + } + + async get(deps: ServerTimeDeps): Promise { + const now = Date.now(); + if (this.entry && this.entry.expiresAt > now) return this.entry.value; + const value = await resolveServerTime(deps); + /* Don't cache fallbacks for the full TTL — recover faster when + * OAP comes back. */ + const ttl = value.source === 'oap' ? CACHE_TTL_MS : 15_000; + this.entry = { value, expiresAt: now + ttl }; + return value; + } +} + +export interface ServerTimeDeps { + config(): StudioConfig; + fetch: FetchLike; + mqeTarget: MqeTargetCache; +} + +async function resolveServerTime(deps: ServerTimeDeps): Promise { + let mqeBaseUrl: string | undefined; + /* AbortController gives every server-time call the same upper bound + * the other OAP-bound BFF calls already respect (`oap.timeoutMs`). + * Without it a hung /graphql leaks the request indefinitely. */ + const timeoutMs = deps.config().oap.timeoutMs; + const ctrl = timeoutMs > 0 ? new AbortController() : null; + const timer = ctrl ? setTimeout(() => ctrl.abort(), timeoutMs) : null; + try { + const target = await deps.mqeTarget.resolve({ + config: () => deps.config(), + fetch: deps.fetch, + }); + mqeBaseUrl = target.baseUrl; + const init: RequestInit = { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ query: GRAPHQL_QUERY }), + ...(ctrl ? { signal: ctrl.signal } : {}), + }; + const res = await deps.fetch(`${target.baseUrl.replace(/\/$/, '')}/graphql`, init); + if (!res.ok) { + const txt = (await res.text()).slice(0, 200); + return fallback(`HTTP ${res.status}: ${txt}`, mqeBaseUrl); + } + const env = (await res.json()) as { + data?: { getTimeInfo?: { timezone?: number | string; currentTimestamp?: number } }; + errors?: { message: string }[]; + }; + if (env.errors && env.errors.length > 0) { + return fallback(env.errors.map((e) => e.message).join('; '), mqeBaseUrl); + } + const info = env.data?.getTimeInfo; + if ( + !info || + info.timezone === undefined || + info.timezone === null || + typeof info.currentTimestamp !== 'number' + ) { + return fallback('getTimeInfo missing timezone / currentTimestamp', mqeBaseUrl); + } + const offsetMinutes = parseTimezone(info.timezone); + if (offsetMinutes === null) { + return fallback( + `getTimeInfo timezone is not parseable: ${JSON.stringify(info.timezone)}`, + mqeBaseUrl, + ); + } + return { + offsetMinutes, + currentTimestampMillis: info.currentTimestamp, + source: 'oap', + mqeBaseUrl, + }; + } catch (err) { + return fallback(err instanceof Error ? err.message : String(err), mqeBaseUrl); + } finally { + if (timer) clearTimeout(timer); + } +} + +function fallback(error: string, mqeBaseUrl?: string): ServerTime { + return { + offsetMinutes: -new Date().getTimezoneOffset(), + currentTimestampMillis: Date.now(), + source: 'fallback', + error, + ...(mqeBaseUrl !== undefined ? { mqeBaseUrl } : {}), + }; +} + +/** OAP's `timezone` field is typed as String on the GraphQL schema + * and arrives in `+HHMM` / `-HHMM` form (e.g. `"+0000"`, `"-0500"`, + * `"+0530"`). Some older OAP builds emit a bare integer; both + * shapes are accepted here. Returns `null` when unparseable. */ +export function parseTimezone(tz: string | number): number | null { + if (typeof tz === 'number' && Number.isFinite(tz)) return hhmmIntegerToMinutes(tz); + if (typeof tz !== 'string') return null; + const trimmed = tz.trim(); + /* Accept "+HHMM", "-HHMM", "HHMM", and the colon variants + * "+HH:MM" / "HH:MM" for forward compatibility. */ + const m = /^([+-]?)(\d{1,2}):?(\d{2})$/.exec(trimmed); + if (!m) return null; + const sign = m[1] === '-' ? -1 : 1; + const hours = Number(m[2]); + const mins = Number(m[3]); + if (mins >= 60) return null; + return sign * (hours * 60 + mins); +} + +/** Legacy path: integer `+/- HHMM`. */ +export function hhmmIntegerToMinutes(tz: number): number { + const sign = tz < 0 ? -1 : 1; + const abs = Math.abs(tz); + const hours = Math.trunc(abs / 100); + const mins = abs % 100; + return sign * (hours * 60 + mins); +} diff --git a/apps/bff/src/server.ts b/apps/bff/src/server.ts index 6ab052e..b119a7f 100644 --- a/apps/bff/src/server.ts +++ b/apps/bff/src/server.ts @@ -32,6 +32,8 @@ import { registerAuthRoutes } from './auth/routes.js'; import type { VerifyDeps } from './auth/local.js'; import { registerOapRoutes } from './oap/routes.js'; import { registerDebugRoutes } from './oap/debug-routes.js'; +import { registerInspectRoutes } from './oap/inspect-routes.js'; +import { registerPreflightRoutes } from './oap/preflight-routes.js'; import { createNoopWireLogger, type WireLogger } from './wire/logger.js'; import { makeWireFetch } from './wire/fetch.js'; import { registerWireHook } from './wire/hook.js'; @@ -117,6 +119,19 @@ export async function buildServer(opts: BuildServerOptions): Promise reply.code(200).send({ ok: true })); app.get('/readyz', async (_req, reply) => reply.code(200).send({ ok: true })); diff --git a/apps/bff/src/wire/fetch.ts b/apps/bff/src/wire/fetch.ts index 9f509a1..e41ce62 100644 --- a/apps/bff/src/wire/fetch.ts +++ b/apps/bff/src/wire/fetch.ts @@ -75,16 +75,17 @@ export function makeWireFetch( if (!wire.enabled()) return res; - // Tee the response body for logging without consuming the - // caller's stream. - let resBody: string | undefined; - try { - const cloned = res.clone(); - const text = await cloned.text(); - resBody = truncate(text, max); - } catch { - resBody = ''; - } + /* Tee the response body for logging without consuming the + * caller's stream. Two safeguards: + * - Skip the read entirely for streaming / large content + * types (dumps are streamed YAML; binary downloads should + * never be buffered through wire log). + * - Cap the cloned body read at `max * 4` bytes so a + * misclassified large response can't balloon BFF memory. + * Without the cap, `cloned.text()` reads the entire body + * before `truncate` runs, so the M-byte dump is fully + * buffered every time. */ + const resBody = await captureResponseBody(res, max); wire.log({ traceId, @@ -104,6 +105,69 @@ export function makeWireFetch( }; } +/** Read up to `max * 4` bytes from a cloned response and truncate to + * `max` chars for logging. Returns a marker string for streaming / + * binary content types or when reading fails. */ +async function captureResponseBody(res: Response, max: number): Promise { + const ct = res.headers.get('content-type') ?? ''; + if (looksStreamy(ct)) return `<${ct} response — not captured>`; + const cl = Number.parseInt(res.headers.get('content-length') ?? '', 10); + /* The `cloned.text()` call below buffers everything before any + * truncation, so cap at the read level when the server announces + * a big payload. */ + const readCap = max * 4; + if (Number.isFinite(cl) && cl > readCap) { + return `<${cl}-byte response — capped, not captured>`; + } + let cloned: Response; + try { + cloned = res.clone(); + } catch { + return ''; + } + try { + const reader = cloned.body?.getReader(); + if (!reader) { + const text = await cloned.text(); + return truncate(text, max); + } + const decoder = new TextDecoder('utf-8', { fatal: false }); + let out = ''; + let bytes = 0; + while (out.length < readCap) { + const { value, done } = await reader.read(); + if (done) break; + if (value) { + bytes += value.byteLength; + out += decoder.decode(value, { stream: true }); + if (bytes > readCap) break; + } + } + out += decoder.decode(); + try { + await reader.cancel(); + } catch { + /* ignore */ + } + return truncate(out, max); + } catch { + return ''; + } +} + +function looksStreamy(contentType: string): boolean { + const ct = contentType.toLowerCase(); + return ( + ct.startsWith('application/x-yaml') || + ct.startsWith('text/yaml') || + ct.startsWith('application/octet-stream') || + ct.startsWith('application/zip') || + ct.startsWith('application/gzip') || + ct.startsWith('application/x-tar') || + ct.startsWith('multipart/') + ); +} + function describeBody(body: unknown, max: number): string | undefined { if (body === null || body === undefined) return undefined; if (typeof body === 'string') return truncate(body, max); diff --git a/apps/bff/test/inspect-parsers.test.ts b/apps/bff/test/inspect-parsers.test.ts new file mode 100644 index 0000000..e3ac3e3 --- /dev/null +++ b/apps/bff/test/inspect-parsers.test.ts @@ -0,0 +1,91 @@ +/* + * Copyright 2026 The Vantage Studio Authors + * + * Licensed 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, expect, it } from 'vitest'; +import { parseOalMetricNames } from '../src/inspect/parser-oal.js'; +import { parseMalMetricNames } from '../src/inspect/parser-mal.js'; + +describe('parseOalMetricNames', () => { + it('extracts LHS metric names from realistic OAL', () => { + const src = ` +// core.oal — realistic snippet +service_cpm = from(Service.*).cpm(); +service_resp_time = from(Service.latency).longAvg(); +/* block comment + noise = from(Anything).whatever(); */ +service_apdex = from(Service.latency).apdex(name, status); +`; + expect(parseOalMetricNames(src)).toEqual(['service_cpm', 'service_resp_time', 'service_apdex']); + }); + + it('deduplicates same metric appearing twice', () => { + const src = 'a = from(X.y).foo();\na = from(X.z).bar();'; + expect(parseOalMetricNames(src)).toEqual(['a']); + }); + + it('ignores `==` and trailing whitespace edge cases', () => { + // Lines that aren't an assignment must not be picked up. + const src = ` +if (x == 1) {} +// some_metric = from(... in comment +real_metric = from(Service.foo).cpm(); +`; + expect(parseOalMetricNames(src)).toEqual(['real_metric']); + }); + + it('returns empty for empty input', () => { + expect(parseOalMetricNames('')).toEqual([]); + }); +}); + +describe('parseMalMetricNames', () => { + it('walks `metricsRules[*].name` with metricPrefix applied', () => { + const yaml = ` +metricPrefix: instance_jvm_memory +metricsRules: + - name: heap_used + exp: jvm_memory_used_bytes.tagEqual('area','heap').sum(['service','instance']) + - name: heap_max + exp: jvm_memory_max_bytes.tagEqual('area','heap').sum(['service','instance']) +`; + expect(parseMalMetricNames(yaml)).toEqual([ + 'instance_jvm_memory_heap_used', + 'instance_jvm_memory_heap_max', + ]); + }); + + it('walks the legacy `rules[*].metricsName` shape', () => { + const yaml = ` +rules: + - metricsName: meter_node_cpu_total_percentage + exp: ... + - metricsName: meter_node_cpu_system_percentage + exp: ... +`; + expect(parseMalMetricNames(yaml)).toEqual([ + 'meter_node_cpu_total_percentage', + 'meter_node_cpu_system_percentage', + ]); + }); + + it('returns [] on malformed YAML', () => { + expect(parseMalMetricNames(': not\nvalid: -:')).toEqual([]); + }); + + it('returns [] when neither rules[] nor metricsRules[] is present', () => { + expect(parseMalMetricNames('filter: foo\nbar: baz')).toEqual([]); + }); +}); diff --git a/apps/bff/test/inspect-routes.test.ts b/apps/bff/test/inspect-routes.test.ts new file mode 100644 index 0000000..8bace5b --- /dev/null +++ b/apps/bff/test/inspect-routes.test.ts @@ -0,0 +1,923 @@ +/* + * Copyright 2026 The Vantage Studio Authors + * + * Licensed 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 { afterEach, describe, expect, it } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import type { FetchLike } from '@vantage-studio/api-client'; +import { staticConfig } from '../src/config/loader.js'; +import { createMemoryAuditLogger } from '../src/audit/logger.js'; +import { buildServer } from '../src/server.js'; +import { makeConfig } from './helpers.js'; + +interface Ctx { + app: FastifyInstance; + oapCalls: { url: string; init: RequestInit }[]; + sid: string; +} + +function jsonResponse(body: unknown, init: ResponseInit = {}): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + ...init, + }); +} + +interface InspectStub { + metrics?: (url: string) => Response; + entities?: (url: string) => Response; + /** /runtime/oal/files — bare name listing. */ + oalFiles?: () => Response; + /** /runtime/oal/files/{name} — raw .oal text. */ + oalFile?: (name: string) => Response; + /** /runtime/rule/list — MAL rule rows with contentHash. */ + ruleList?: () => Response; + /** /runtime/rule?catalog=…&name=… — single rule YAML. */ + ruleGet?: (catalog: string, name: string) => Response; + /** /runtime/rule/bundled?catalog=…&withContent=… */ + ruleBundled?: (catalog: string) => Response; + /** /debugging/config/dump — flat HashMap. */ + configDump?: () => Response; + /** Overrides studio config (e.g. mqe override). */ + mqe?: { host?: string; port?: number }; + /** GraphQL `execExpression` endpoint — receives the POSTed body. */ + graphql?: (body: unknown) => Response; + /** GraphQL `getTimeInfo` endpoint — separate from MQE exec. */ + serverTimeGraphql?: (body: unknown) => Response; +} + +function makeFetch(stub: InspectStub): { + fetch: FetchLike; + calls: { url: string; init: RequestInit }[]; +} { + const calls: { url: string; init: RequestInit }[] = []; + const impl: FetchLike = async (input, init) => { + const url = input.toString(); + calls.push({ url, init: init ?? {} }); + if (url.includes('/inspect/metrics')) + return stub.metrics?.(url) ?? new Response('no metrics stub', { status: 500 }); + if (url.includes('/inspect/entities')) + return stub.entities?.(url) ?? new Response('no entities stub', { status: 500 }); + // Match `/runtime/oal/files/{name}` BEFORE the bare `/runtime/oal/files`. + const oalFileMatch = url.match(/\/runtime\/oal\/files\/([^?]+)/); + if (oalFileMatch) { + const name = decodeURIComponent(oalFileMatch[1]!); + return stub.oalFile?.(name) ?? new Response(`no oalFile stub for ${name}`, { status: 500 }); + } + if (url.includes('/runtime/oal/files')) + return stub.oalFiles?.() ?? new Response('no oalFiles stub', { status: 500 }); + if (url.includes('/runtime/rule/list')) + return stub.ruleList?.() ?? new Response('no ruleList stub', { status: 500 }); + if (url.includes('/runtime/rule/bundled')) { + const u = new URL(url); + const cat = u.searchParams.get('catalog') ?? ''; + return stub.ruleBundled?.(cat) ?? jsonResponse([]); + } + if (url.match(/\/runtime\/rule\?/)) { + const u = new URL(url); + const catalog = u.searchParams.get('catalog') ?? ''; + const name = u.searchParams.get('name') ?? ''; + return stub.ruleGet?.(catalog, name) ?? new Response('no ruleGet stub', { status: 500 }); + } + if (url.includes('/debugging/config/dump')) + return stub.configDump?.() ?? new Response('no configDump stub', { status: 500 }); + if (url.endsWith('/graphql')) { + let parsed: unknown = null; + if (typeof init?.body === 'string') { + try { + parsed = JSON.parse(init.body); + } catch { + /* leave null */ + } + } + /* getTimeInfo uses its own stub if provided, so MQE exec and + * server-time tests can coexist. */ + const body = parsed as { query?: string } | null; + if (body?.query && body.query.includes('getTimeInfo')) { + return ( + stub.serverTimeGraphql?.(body) ?? new Response('no serverTime stub', { status: 500 }) + ); + } + return stub.graphql?.(parsed) ?? new Response('no graphql stub', { status: 500 }); + } + return new Response(`unmocked: ${url}`, { status: 500 }); + }; + return { fetch: impl, calls }; +} + +async function makeApp(stub: InspectStub, opts: { rbac?: boolean } = {}): Promise { + const cfg = makeConfig({ + users: [{ username: 'alice', passwordHash: '$argon2id$pretend', roles: ['admin'] }], + rbac: opts.rbac + ? { + enabled: true, + roles: { + admin: { verbs: ['*'] }, + reader: { verbs: ['inspect:read'] }, + noinspect: { verbs: ['rule:read'] }, + }, + } + : undefined, + }); + if (stub.mqe) cfg.oap.mqe = stub.mqe; + const config = staticConfig(cfg); + const audit = createMemoryAuditLogger(); + const { fetch, calls } = makeFetch(stub); + const built = await buildServer({ + config, + audit, + loggerOptions: false, + verifyDeps: { verify: async () => true }, + oapFetch: fetch, + }); + + const login = await built.app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { username: 'alice', password: 'pw' }, + }); + const setCookie = login.headers['set-cookie']; + const cookieStr = Array.isArray(setCookie) ? setCookie.join('; ') : (setCookie as string); + const sid = /sid=([^;]+)/.exec(cookieStr)![1]!; + + return { app: built.app, oapCalls: calls, sid }; +} + +describe('GET /api/inspect/metrics', () => { + let ctx: Ctx; + afterEach(async () => { + await ctx.app.close(); + }); + + it('proxies the catalog with no filters', async () => { + ctx = await makeApp({ + metrics: () => + jsonResponse({ + metrics: [ + { + name: 'service_cpm', + type: 'REGULAR_VALUE', + catalog: 'SERVICE', + scopeId: 1, + scope: 'Service', + valueColumnName: 'value', + downsamplings: ['MINUTE', 'HOUR', 'DAY'], + }, + ], + }), + }); + const r = await ctx.app.inject({ + method: 'GET', + url: '/api/inspect/metrics', + headers: { cookie: `sid=${ctx.sid}` }, + }); + expect(r.statusCode).toBe(200); + const body = r.json() as { metrics: { name: string }[] }; + expect(body.metrics[0]!.name).toBe('service_cpm'); + expect(ctx.oapCalls[0]!.url).toBe('http://oap-1:17128/inspect/metrics'); + }); + + it('passes regex, repeatable type, repeatable catalog, mqeQueryable', async () => { + ctx = await makeApp({ metrics: () => jsonResponse({ metrics: [] }) }); + const r = await ctx.app.inject({ + method: 'GET', + url: + '/api/inspect/metrics?regex=service_.*' + + '&type=REGULAR_VALUE&type=LABELED_VALUE' + + '&catalog=SERVICE&catalog=ENDPOINT' + + '&mqeQueryable=true', + headers: { cookie: `sid=${ctx.sid}` }, + }); + expect(r.statusCode).toBe(200); + const oap = new URL(ctx.oapCalls[0]!.url); + expect(oap.pathname).toBe('/inspect/metrics'); + expect(oap.searchParams.get('regex')).toBe('service_.*'); + expect(oap.searchParams.getAll('type')).toEqual(['REGULAR_VALUE', 'LABELED_VALUE']); + expect(oap.searchParams.getAll('catalog')).toEqual(['SERVICE', 'ENDPOINT']); + expect(oap.searchParams.get('mqeQueryable')).toBe('true'); + }); + + it('rejects an unknown `type` value with 400 invalid_type', async () => { + ctx = await makeApp({ metrics: () => jsonResponse({ metrics: [] }) }); + const r = await ctx.app.inject({ + method: 'GET', + url: '/api/inspect/metrics?type=BANANA', + headers: { cookie: `sid=${ctx.sid}` }, + }); + expect(r.statusCode).toBe(400); + expect(r.json()).toMatchObject({ error: 'invalid_type', value: 'BANANA' }); + expect(ctx.oapCalls).toHaveLength(0); + }); + + it('promotes OAP 404 to inspect_not_enabled', async () => { + ctx = await makeApp({ + metrics: () => new Response('no handler', { status: 404 }), + }); + const r = await ctx.app.inject({ + method: 'GET', + url: '/api/inspect/metrics', + headers: { cookie: `sid=${ctx.sid}` }, + }); + expect(r.statusCode).toBe(404); + expect(r.json()).toMatchObject({ error: 'inspect_not_enabled' }); + }); + + it('passes through OAP 500 with original body', async () => { + ctx = await makeApp({ + metrics: () => + new Response(JSON.stringify({ error: 'storage exploded' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }), + }); + const r = await ctx.app.inject({ + method: 'GET', + url: '/api/inspect/metrics', + headers: { cookie: `sid=${ctx.sid}` }, + }); + expect(r.statusCode).toBe(500); + expect(r.json()).toEqual({ error: 'storage exploded' }); + }); + + it('returns 401 without a session cookie', async () => { + ctx = await makeApp({ metrics: () => jsonResponse({ metrics: [] }) }); + const r = await ctx.app.inject({ method: 'GET', url: '/api/inspect/metrics' }); + expect(r.statusCode).toBe(401); + }); + + it('with RBAC on, a session without inspect:read gets 403', async () => { + // Login with a user whose role has rule:read but not inspect:read. + const cfg = makeConfig({ + users: [{ username: 'bob', passwordHash: '$argon2id$pretend', roles: ['noinspect'] }], + rbac: { + enabled: true, + roles: { + admin: { verbs: ['*'] }, + noinspect: { verbs: ['rule:read'] }, + }, + }, + }); + const config = staticConfig(cfg); + const audit = createMemoryAuditLogger(); + const { fetch } = makeFetch({ metrics: () => jsonResponse({ metrics: [] }) }); + const built = await buildServer({ + config, + audit, + loggerOptions: false, + verifyDeps: { verify: async () => true }, + oapFetch: fetch, + }); + const login = await built.app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { username: 'bob', password: 'pw' }, + }); + const setCookie = login.headers['set-cookie']; + const cookieStr = Array.isArray(setCookie) ? setCookie.join('; ') : (setCookie as string); + const sid = /sid=([^;]+)/.exec(cookieStr)![1]!; + const r = await built.app.inject({ + method: 'GET', + url: '/api/inspect/metrics', + headers: { cookie: `sid=${sid}` }, + }); + expect(r.statusCode).toBe(403); + expect(r.json()).toMatchObject({ error: 'permission_denied', verb: 'inspect:read' }); + await built.app.close(); + }); +}); + +describe('GET /api/inspect/entities', () => { + let ctx: Ctx; + afterEach(async () => { + await ctx.app.close(); + }); + + it('proxies metric / start / end / step / limit and returns the rows', async () => { + ctx = await makeApp({ + entities: () => + jsonResponse({ + metric: 'service_cpm', + scope: 'Service', + step: 'MINUTE', + start: '2026-05-10 1220', + end: '2026-05-10 1240', + rows: [ + { + entityId: 'cGF5bWVudA==.1', + decoded: { serviceName: 'payment', isReal: true }, + layer: 'GENERAL', + mqeEntity: { scope: 'Service', serviceName: 'payment', normal: true }, + }, + ], + }), + }); + const r = await ctx.app.inject({ + method: 'GET', + url: + '/api/inspect/entities?metric=service_cpm' + + '&start=2026-05-10%201220&end=2026-05-10%201240&step=MINUTE&limit=10', + headers: { cookie: `sid=${ctx.sid}` }, + }); + expect(r.statusCode).toBe(200); + const body = r.json() as { rows: { entityId: string }[] }; + expect(body.rows[0]!.entityId).toBe('cGF5bWVudA==.1'); + const oap = new URL(ctx.oapCalls[0]!.url); + expect(oap.searchParams.get('step')).toBe('MINUTE'); + expect(oap.searchParams.get('start')).toBe('2026-05-10 1220'); + expect(oap.searchParams.get('limit')).toBe('10'); + }); + + it('400 missing_metric when metric is absent', async () => { + ctx = await makeApp({ entities: () => jsonResponse({ rows: [] }) }); + const r = await ctx.app.inject({ + method: 'GET', + url: '/api/inspect/entities?start=2026-05-10&end=2026-05-10&step=DAY', + headers: { cookie: `sid=${ctx.sid}` }, + }); + expect(r.statusCode).toBe(400); + expect(r.json()).toMatchObject({ error: 'missing_metric' }); + }); + + it('400 invalid_step for a bad step value', async () => { + ctx = await makeApp({ entities: () => jsonResponse({ rows: [] }) }); + const r = await ctx.app.inject({ + method: 'GET', + url: '/api/inspect/entities?metric=x&start=2026-05-10&end=2026-05-10&step=SECOND', + headers: { cookie: `sid=${ctx.sid}` }, + }); + expect(r.statusCode).toBe(400); + expect(r.json()).toMatchObject({ error: 'invalid_step' }); + }); + + it('400 invalid_start_format for a date that does not match the step', async () => { + ctx = await makeApp({ entities: () => jsonResponse({ rows: [] }) }); + const r = await ctx.app.inject({ + method: 'GET', + // MINUTE step expects `yyyy-MM-dd HHmm` but we pass DAY-shape `yyyy-MM-dd`. + url: '/api/inspect/entities?metric=x&start=2026-05-10&end=2026-05-10&step=MINUTE', + headers: { cookie: `sid=${ctx.sid}` }, + }); + expect(r.statusCode).toBe(400); + expect(r.json()).toMatchObject({ error: 'invalid_start_format', step: 'MINUTE' }); + // Pre-validation short-circuits before we hit OAP. + expect(ctx.oapCalls).toHaveLength(0); + }); + + it('400 invalid_limit when outside [1, 300]', async () => { + ctx = await makeApp({ entities: () => jsonResponse({ rows: [] }) }); + const r = await ctx.app.inject({ + method: 'GET', + url: '/api/inspect/entities?metric=x&start=2026-05-10&end=2026-05-10' + '&step=DAY&limit=500', + headers: { cookie: `sid=${ctx.sid}` }, + }); + expect(r.statusCode).toBe(400); + expect(r.json()).toMatchObject({ error: 'invalid_limit', max: 300 }); + }); + + it('passes through OAP 400 (e.g. unknown metric) verbatim', async () => { + ctx = await makeApp({ + entities: () => + new Response(JSON.stringify({ error: 'unknown metric: foo' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }), + }); + const r = await ctx.app.inject({ + method: 'GET', + url: '/api/inspect/entities?metric=foo&start=2026-05-10&end=2026-05-10&step=DAY', + headers: { cookie: `sid=${ctx.sid}` }, + }); + expect(r.statusCode).toBe(400); + expect(r.json()).toEqual({ error: 'unknown metric: foo' }); + }); +}); + +describe('GET /api/inspect/catalog', () => { + let ctx: Ctx; + afterEach(async () => { + await ctx.app.close(); + }); + + it('merges /inspect/metrics with OAL + MAL attribution', async () => { + ctx = await makeApp({ + metrics: () => + jsonResponse({ + metrics: [ + { + name: 'service_cpm', + type: 'REGULAR_VALUE', + catalog: 'SERVICE', + scopeId: 1, + scope: 'Service', + valueColumnName: 'value', + downsamplings: ['MINUTE'], + }, + { + name: 'instance_jvm_memory_heap_used', + type: 'REGULAR_VALUE', + catalog: 'SERVICE_INSTANCE', + scopeId: 2, + scope: 'ServiceInstance', + valueColumnName: 'value', + downsamplings: ['MINUTE'], + }, + { + name: 'mystery_metric_no_source', + type: 'REGULAR_VALUE', + catalog: 'SERVICE', + scopeId: 1, + scope: 'Service', + valueColumnName: 'value', + downsamplings: ['MINUTE'], + }, + ], + }), + oalFiles: () => jsonResponse({ files: ['core.oal'], count: 1 }), + oalFile: (name) => { + if (name === 'core.oal') + return new Response('service_cpm = from(Service.*).cpm();\n', { + status: 200, + headers: { 'Content-Type': 'text/plain' }, + }); + return new Response('not found', { status: 404 }); + }, + ruleList: () => + jsonResponse({ + generatedAt: 1730000000000, + loaderStats: { active: 1, pending: 0 }, + rules: [ + { + catalog: 'otel-rules', + name: 'jvm-memory', + status: 'ACTIVE', + localState: 'RUNNING', + loaderGc: 'LIVE', + loaderKind: 'RUNTIME', + loaderName: 'runtime:otel-rules/jvm-memory@0510-1200', + contentHash: 'abc', + bundled: false, + suspendOrigin: 'NONE', + updateTime: 0, + lastApplyError: '', + pendingUnregister: false, + }, + ], + }), + ruleGet: (catalog, name) => { + if (catalog === 'otel-rules' && name === 'jvm-memory') { + return new Response( + 'metricPrefix: instance_jvm_memory\nmetricsRules:\n - name: heap_used\n exp: jvm_memory_used_bytes\n', + { + status: 200, + headers: { + 'Content-Type': 'application/x-yaml; charset=utf-8', + etag: '"abc"', + 'x-sw-content-hash': 'abc', + 'x-sw-status': 'ACTIVE', + 'x-sw-source': 'runtime', + 'x-sw-update-time': '0', + }, + }, + ); + } + return new Response('not found', { status: 404 }); + }, + }); + + const r = await ctx.app.inject({ + method: 'GET', + url: '/api/inspect/catalog', + headers: { cookie: `sid=${ctx.sid}` }, + }); + expect(r.statusCode).toBe(200); + const body = r.json() as { + metrics: { name: string; attribution: { source: string; file: string | null } }[]; + summary: Record; + attributionFingerprint: string; + }; + const byName = Object.fromEntries(body.metrics.map((m) => [m.name, m.attribution])); + expect(byName['service_cpm']).toEqual({ source: 'OAL', file: 'core.oal' }); + expect(byName['instance_jvm_memory_heap_used']).toEqual({ + source: 'MAL·OTEL', + file: 'otel-rules/jvm-memory', + }); + expect(byName['mystery_metric_no_source']).toEqual({ source: 'unknown', file: null }); + expect(body.summary['OAL']).toBe(1); + expect(body.summary['MAL·OTEL']).toBe(1); + expect(body.summary['unknown']).toBe(1); + expect(body.attributionFingerprint).toContain('oal:core.oal'); + expect(body.attributionFingerprint).toContain('otel-rules/jvm-memory@abc'); + }); + + it('refresh=true re-pulls the rules (cache busts)', async () => { + let oalCalls = 0; + const stub: InspectStub = { + metrics: () => jsonResponse({ metrics: [] }), + oalFiles: () => { + oalCalls += 1; + return jsonResponse({ files: ['core.oal'], count: 1 }); + }, + oalFile: () => + new Response('a = from(X.y).foo();', { + status: 200, + headers: { 'Content-Type': 'text/plain' }, + }), + ruleList: () => + jsonResponse({ + generatedAt: 0, + loaderStats: { active: 0, pending: 0 }, + rules: [], + }), + }; + ctx = await makeApp(stub); + // First call — cold cache. + await ctx.app.inject({ + method: 'GET', + url: '/api/inspect/catalog', + headers: { cookie: `sid=${ctx.sid}` }, + }); + // Second call without refresh — fingerprint matches, only the + // fingerprint endpoints get re-hit, the file content read stays cached. + await ctx.app.inject({ + method: 'GET', + url: '/api/inspect/catalog', + headers: { cookie: `sid=${ctx.sid}` }, + }); + const before = oalCalls; + // Third call with refresh=true — full rebuild, listFiles is hit + // again as part of the rebuild path on top of the fingerprint call. + await ctx.app.inject({ + method: 'GET', + url: '/api/inspect/catalog?refresh=true', + headers: { cookie: `sid=${ctx.sid}` }, + }); + expect(oalCalls).toBeGreaterThan(before); + }); +}); + +describe('GET /api/inspect/mqe-target', () => { + let ctx: Ctx; + afterEach(async () => { + await ctx.app.close(); + }); + + it('prefers sharing-server REST when present', async () => { + ctx = await makeApp({ + configDump: () => + jsonResponse({ + 'core.default.restHost': '0.0.0.0', + 'core.default.restPort': '12800', + 'sharing-server.default.restHost': '0.0.0.0', + 'sharing-server.default.restPort': '11800', + }), + }); + const r = await ctx.app.inject({ + method: 'GET', + url: '/api/inspect/mqe-target', + headers: { cookie: `sid=${ctx.sid}` }, + }); + expect(r.statusCode).toBe(200); + const body = r.json() as { baseUrl: string; via: string; configured: object }; + // sharing host was 0.0.0.0 → falls back to admin host `oap-1`. + expect(body.baseUrl).toBe('http://oap-1:11800'); + expect(body.via).toContain('sharing-server.restPort'); + expect(body.via).toContain('admin URL host'); + }); + + it('falls back to core REST when sharing-server is absent', async () => { + ctx = await makeApp({ + configDump: () => + jsonResponse({ + 'core.default.restHost': 'rest.cluster.local', + 'core.default.restPort': '12800', + }), + }); + const r = await ctx.app.inject({ + method: 'GET', + url: '/api/inspect/mqe-target', + headers: { cookie: `sid=${ctx.sid}` }, + }); + expect(r.statusCode).toBe(200); + const body = r.json() as { baseUrl: string; via: string }; + expect(body.baseUrl).toBe('http://rest.cluster.local:12800'); + expect(body.via).toContain('core.restPort'); + }); + + it('respects a full studio.yaml override without calling admin', async () => { + ctx = await makeApp({ + mqe: { host: 'mqe.gateway.local', port: 9443 }, + // No configDump stub — must not be called when both fields are set. + }); + const r = await ctx.app.inject({ + method: 'GET', + url: '/api/inspect/mqe-target', + headers: { cookie: `sid=${ctx.sid}` }, + }); + expect(r.statusCode).toBe(200); + const body = r.json() as { + baseUrl: string; + via: string; + configured: { host: string; port: number }; + }; + expect(body.baseUrl).toBe('http://mqe.gateway.local:9443'); + expect(body.via).toBe('studio.yaml override (host + port)'); + expect(body.configured).toEqual({ host: 'mqe.gateway.local', port: 9443 }); + // No outbound admin fetch for the full-override path. + expect(ctx.oapCalls.filter((c) => c.url.includes('config/dump'))).toHaveLength(0); + }); + + it('stitches a host override on top of a discovered port', async () => { + ctx = await makeApp({ + mqe: { host: 'rest.gateway.local' }, + configDump: () => + jsonResponse({ + 'core.default.restHost': '0.0.0.0', + 'core.default.restPort': '12800', + }), + }); + const r = await ctx.app.inject({ + method: 'GET', + url: '/api/inspect/mqe-target', + headers: { cookie: `sid=${ctx.sid}` }, + }); + expect(r.statusCode).toBe(200); + const body = r.json() as { baseUrl: string; via: string }; + expect(body.baseUrl).toBe('http://rest.gateway.local:12800'); + expect(body.via).toBe('host from studio.yaml, port from core.restPort'); + }); + + it('502 when neither port appears in the dump', async () => { + ctx = await makeApp({ + configDump: () => + jsonResponse({ + 'storage.default.url': 'jdbc:h2', + }), + }); + const r = await ctx.app.inject({ + method: 'GET', + url: '/api/inspect/mqe-target', + headers: { cookie: `sid=${ctx.sid}` }, + }); + expect(r.statusCode).toBe(502); + expect(r.json()).toMatchObject({ error: 'mqe_target_unresolved' }); + }); +}); + +describe('GET /api/preflight', () => { + let ctx: Ctx; + afterEach(async () => { + await ctx.app.close(); + }); + + it('marks every required module enabled when its prefix is in the dump', async () => { + ctx = await makeApp({ + configDump: () => + jsonResponse({ + 'admin-server.default.host': '0.0.0.0', + 'admin-server.default.port': '17128', + 'receiver-runtime-rule.default.foo': 'bar', + 'dsl-debugging.default.sampleCap': '100', + 'inspect.default.x': 'y', + 'core.default.restPort': '12800', + }), + }); + const r = await ctx.app.inject({ + method: 'GET', + url: '/api/preflight', + headers: { cookie: `sid=${ctx.sid}` }, + }); + expect(r.statusCode).toBe(200); + const body = r.json() as { + adminReachable: boolean; + modules: { name: string; enabled: boolean }[]; + }; + expect(body.adminReachable).toBe(true); + const m = Object.fromEntries(body.modules.map((x) => [x.name, x.enabled])); + expect(m['admin-server']).toBe(true); + expect(m['receiver-runtime-rule']).toBe(true); + expect(m['dsl-debugging']).toBe(true); + expect(m['inspect']).toBe(true); + }); + + it('marks the missing modules false (the OAP-was-not-fully-configured case)', async () => { + ctx = await makeApp({ + configDump: () => + jsonResponse({ + 'admin-server.default.host': '0.0.0.0', + 'inspect.default.x': 'y', + // receiver-runtime-rule and dsl-debugging absent. + }), + }); + const r = await ctx.app.inject({ + method: 'GET', + url: '/api/preflight', + headers: { cookie: `sid=${ctx.sid}` }, + }); + expect(r.statusCode).toBe(200); + const body = r.json() as { modules: { name: string; enabled: boolean; envVar: string }[] }; + const m = Object.fromEntries(body.modules.map((x) => [x.name, x])); + expect(m['admin-server']!.enabled).toBe(true); + expect(m['inspect']!.enabled).toBe(true); + expect(m['receiver-runtime-rule']!.enabled).toBe(false); + expect(m['receiver-runtime-rule']!.envVar).toBe('SW_RECEIVER_RUNTIME_RULE'); + expect(m['dsl-debugging']!.enabled).toBe(false); + }); + + it('reports adminReachable=false when the dump endpoint fails', async () => { + ctx = await makeApp({ + configDump: () => new Response('boom', { status: 500 }), + }); + const r = await ctx.app.inject({ + method: 'GET', + url: '/api/preflight', + headers: { cookie: `sid=${ctx.sid}` }, + }); + expect(r.statusCode).toBe(200); + const body = r.json() as { + adminReachable: boolean; + adminError?: string; + modules: { enabled: boolean }[]; + }; + expect(body.adminReachable).toBe(false); + expect(body.adminError).toContain('500'); + /* All modules collapse to disabled when admin itself is down — + * the operator's first move is "check OAP", not "set selectors". */ + for (const m of body.modules) expect(m.enabled).toBe(false); + }); +}); + +describe('POST /api/inspect/exec', () => { + let ctx: Ctx; + afterEach(async () => { + await ctx.app.close(); + }); + + function execBody(overrides: Record = {}): Record { + return { + expression: 'service_cpm', + entity: { scope: 'Service', serviceName: 'payment', normal: true }, + duration: { start: '2026-05-10 1220', end: '2026-05-10 1240', step: 'MINUTE' }, + ...overrides, + }; + } + + it('fires the GraphQL mutation and returns the ExpressionResult', async () => { + let graphqlCalls = 0; + let receivedBody: unknown = null; + ctx = await makeApp({ + mqe: { host: 'mqe.local', port: 12800 }, + graphql: (body) => { + graphqlCalls += 1; + receivedBody = body; + return jsonResponse({ + data: { + execExpression: { + type: 'TIME_SERIES_VALUES', + error: null, + results: [ + { + metric: { labels: [] }, + values: [{ id: '2026051012200000', value: '42', traceID: null, owner: null }], + }, + ], + }, + }, + }); + }, + }); + const r = await ctx.app.inject({ + method: 'POST', + url: '/api/inspect/exec', + headers: { cookie: `sid=${ctx.sid}`, 'content-type': 'application/json' }, + payload: JSON.stringify(execBody()), + }); + expect(r.statusCode).toBe(200); + const body = r.json() as { type: string; results: { values: { value: string }[] }[] }; + expect(body.type).toBe('TIME_SERIES_VALUES'); + expect(body.results[0]!.values[0]!.value).toBe('42'); + expect(graphqlCalls).toBe(1); + const sent = receivedBody as { + query: string; + variables: { + expression: string; + entity: { scope: string; serviceName: string }; + duration: { step: string }; + }; + }; + expect(sent.query).toContain('execExpression'); + expect(sent.query).toContain('query Exec'); + expect(sent.variables.expression).toBe('service_cpm'); + expect(sent.variables.entity.serviceName).toBe('payment'); + expect(sent.variables.duration.step).toBe('MINUTE'); + }); + + it('400 on missing expression', async () => { + ctx = await makeApp({ mqe: { host: 'x', port: 1 } }); + const r = await ctx.app.inject({ + method: 'POST', + url: '/api/inspect/exec', + headers: { cookie: `sid=${ctx.sid}`, 'content-type': 'application/json' }, + payload: JSON.stringify({ + entity: { scope: 'Service', serviceName: 'a' }, + duration: { start: '2026-05-10', end: '2026-05-10', step: 'DAY' }, + }), + }); + expect(r.statusCode).toBe(400); + expect(r.json()).toMatchObject({ error: 'missing_expression' }); + }); + + it('400 on bad start format for the step', async () => { + ctx = await makeApp({ mqe: { host: 'x', port: 1 } }); + const r = await ctx.app.inject({ + method: 'POST', + url: '/api/inspect/exec', + headers: { cookie: `sid=${ctx.sid}`, 'content-type': 'application/json' }, + payload: JSON.stringify( + execBody({ + duration: { start: '2026-05-10', end: '2026-05-10 1240', step: 'MINUTE' }, + }), + ), + }); + expect(r.statusCode).toBe(400); + expect(r.json()).toMatchObject({ error: 'invalid_duration' }); + }); + + it('502 mqe_error when GraphQL returns errors', async () => { + ctx = await makeApp({ + mqe: { host: 'mqe.local', port: 12800 }, + graphql: () => + jsonResponse({ + data: null, + errors: [{ message: 'metric not found: foo' }], + }), + }); + const r = await ctx.app.inject({ + method: 'POST', + url: '/api/inspect/exec', + headers: { cookie: `sid=${ctx.sid}`, 'content-type': 'application/json' }, + payload: JSON.stringify(execBody()), + }); + expect(r.statusCode).toBe(502); + const body = r.json() as { error: string; graphqlErrors: { message: string }[] }; + expect(body.error).toBe('mqe_error'); + expect(body.graphqlErrors[0]!.message).toBe('metric not found: foo'); + }); +}); + +describe('GET /api/inspect/server-time', () => { + let ctx: Ctx; + afterEach(async () => { + await ctx.app.close(); + }); + + it('returns offset in minutes converted from the getTimeInfo HHMM integer', async () => { + ctx = await makeApp({ + mqe: { host: 'mqe.local', port: 12800 }, + serverTimeGraphql: () => + jsonResponse({ + data: { getTimeInfo: { timezone: 800, currentTimestamp: 1730000000000 } }, + }), + }); + const r = await ctx.app.inject({ + method: 'GET', + url: '/api/inspect/server-time', + headers: { cookie: `sid=${ctx.sid}` }, + }); + expect(r.statusCode).toBe(200); + expect(r.json()).toMatchObject({ + offsetMinutes: 480, + source: 'oap', + currentTimestampMillis: 1730000000000, + }); + }); + + it('falls back to BFF local offset when getTimeInfo returns errors', async () => { + ctx = await makeApp({ + mqe: { host: 'mqe.local', port: 12800 }, + serverTimeGraphql: () => jsonResponse({ data: null, errors: [{ message: 'unknown field' }] }), + }); + const r = await ctx.app.inject({ + method: 'GET', + url: '/api/inspect/server-time', + headers: { cookie: `sid=${ctx.sid}` }, + }); + expect(r.statusCode).toBe(200); + const body = r.json() as { source: string; error?: string; offsetMinutes: number }; + expect(body.source).toBe('fallback'); + expect(body.error).toContain('unknown field'); + expect(typeof body.offsetMinutes).toBe('number'); + }); +}); diff --git a/apps/bff/test/inspect-server-time.test.ts b/apps/bff/test/inspect-server-time.test.ts new file mode 100644 index 0000000..c164fd9 --- /dev/null +++ b/apps/bff/test/inspect-server-time.test.ts @@ -0,0 +1,52 @@ +/* + * Copyright 2026 The Vantage Studio Authors + * + * Licensed 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, expect, it } from 'vitest'; +import { hhmmIntegerToMinutes, parseTimezone } from '../src/oap/server-time.js'; + +describe('hhmmIntegerToMinutes', () => { + it.each([ + [0, 0], + [800, 480], + [-500, -300], + [530, 330], // India / Sri Lanka + [-330, -210], // Suriname + [1300, 780], // some Pacific + [-1200, -720], // Baker Island + ])('%i → %i', (tz, expected) => { + expect(hhmmIntegerToMinutes(tz)).toBe(expected); + }); +}); + +describe('parseTimezone', () => { + it.each<[string | number, number | null]>([ + ['+0000', 0], + ['+0800', 480], + ['-0500', -300], + ['+0530', 330], + ['-0330', -210], + ['0800', 480], // no-sign treated as positive + ['+08:00', 480], // colon form + ['-05:00', -300], + ['UTC', null], // unparseable + ['', null], + [800, 480], // legacy integer + [-500, -300], + [NaN, null], + ])('%j → %j', (tz, expected) => { + expect(parseTimezone(tz)).toBe(expected); + }); +}); diff --git a/apps/bff/test/oap-routes.test.ts b/apps/bff/test/oap-routes.test.ts index b5c89f9..ece2a9e 100644 --- a/apps/bff/test/oap-routes.test.ts +++ b/apps/bff/test/oap-routes.test.ts @@ -385,7 +385,7 @@ describe('OAP route — /api/rule/inactivate + /delete', () => { expect(ctx.audit.events.find((e) => e.action === 'inactivate')).toBeTruthy(); }); - it('delete passes mode=revertToBundled through', async () => { + it('delete passes mode=revertToBundled through, gates on rule:write:structural', async () => { ctx = await makeApp({ delete: () => jsonResponse({ @@ -404,6 +404,66 @@ describe('OAP route — /api/rule/inactivate + /delete', () => { expect(ctx.oapCalls[0]!.url).toContain('mode=revertToBundled'); const audit = ctx.audit.events.find((e) => e.action === 'delete')!; expect(audit.details).toMatchObject({ mode: 'revertToBundled' }); + expect(audit.verb).toBe('rule:write:structural'); + }); + + it('delete mode=revertToBundled is denied for a role with only rule:delete', async () => { + /* `revertToBundled` is the structural equivalent of an + * addOrUpdate-with-allowStorageChange; the verb table reserves + * `rule:write:structural` for those. A reader-style role with + * just `rule:delete` must not be able to revert. */ + ctx = await makeApp( + { + delete: () => + jsonResponse({ + applyStatus: 'no_change', + catalog: 'lal', + name: 'envoy-als', + message: '', + }), + }, + { rbac: true }, + ); + // Re-login as the `reader` role (rule:read + cluster:read only — no delete, no structural). + // The default `makeApp` user is `admin` with `*`; we need a separate session. + const cfg = makeConfig({ + users: [{ username: 'reader', passwordHash: '$argon2id$pretend', roles: ['reader'] }], + rbac: { + enabled: true, + roles: { + reader: { verbs: ['rule:read', 'rule:delete', 'cluster:read'] }, + }, + }, + }); + const config = staticConfig(cfg); + const audit = createMemoryAuditLogger(); + const { fetch } = makeFetch({ + delete: () => + jsonResponse({ applyStatus: 'no_change', catalog: 'lal', name: 'envoy-als', message: '' }), + }); + const built = await buildServer({ + config, + audit, + loggerOptions: false, + verifyDeps: { verify: async () => true }, + oapFetch: fetch, + }); + const login = await built.app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { username: 'reader', password: 'pw' }, + }); + const setCookie = login.headers['set-cookie']; + const cookieStr = Array.isArray(setCookie) ? setCookie.join('; ') : (setCookie as string); + const sid = /sid=([^;]+)/.exec(cookieStr)![1]!; + const r = await built.app.inject({ + method: 'POST', + url: '/api/rule/delete?catalog=lal&name=envoy-als&mode=revertToBundled', + headers: { cookie: `sid=${sid}` }, + }); + expect(r.statusCode).toBe(403); + expect(r.json()).toMatchObject({ error: 'permission_denied', verb: 'rule:write:structural' }); + await built.app.close(); }); it('delete with invalid mode rejects 400 without calling OAP', async () => { diff --git a/apps/ui/package.json b/apps/ui/package.json index 72943db..dd55205 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -14,6 +14,7 @@ "@tanstack/vue-query": "^5.59.20", "@vantage-studio/api-client": "workspace:*", "@vantage-studio/design-tokens": "workspace:*", + "echarts": "^6.0.0", "monaco-editor": "^0.52.0", "pinia": "^2.2.6", "vue": "^3.5.13", diff --git a/apps/ui/src/api/client.ts b/apps/ui/src/api/client.ts index 3b4d014..974eb01 100644 --- a/apps/ui/src/api/client.ts +++ b/apps/ui/src/api/client.ts @@ -30,8 +30,13 @@ import type { Catalog, DeleteMode, DslDebuggingStatus, + EntitiesResponse, + ExpressionResult, + InspectExecRequest, + InspectStep, ListEnvelope, LocalState, + MetricRow, OalFilesResponse, OalRulesResponse, OalSourceDetail, @@ -80,6 +85,72 @@ export interface ClusterStateResponse { rules: ClusterRule[]; } +/** + * BFF-only response shape for `/api/inspect/server-time`. Kept in + * sync by hand with `apps/bff/src/oap/server-time.ts`. + */ +export interface InspectServerTimeResponse { + offsetMinutes: number; + currentTimestampMillis: number; + source: 'oap' | 'fallback'; + mqeBaseUrl?: string; + error?: string; +} + +/** + * BFF-only response shape for `/api/preflight`. Kept in sync by hand + * with `apps/bff/src/oap/preflight.ts`. + */ +export interface PreflightModule { + name: string; + envVar: string; + required: boolean; + enabled: boolean; + affects: string; +} + +export interface PreflightResponse { + adminUrl: string; + adminReachable: boolean; + adminError?: string; + modules: PreflightModule[]; + dumpKeyCount: number; + generatedAt: number; +} + +/** + * BFF-only response shape for `/api/inspect/catalog` — `/inspect/metrics` + * with the Studio-side rule attribution joined in. Kept in sync by + * hand with `apps/bff/src/oap/inspect-routes.ts`. + */ +export interface InspectCatalogEntry extends MetricRow { + attribution: { + source: 'OAL' | 'MAL·OTEL' | 'MAL·Telegraf' | 'LAL→MAL' | 'unknown'; + file: string | null; + candidates?: string[]; + }; +} + +export interface InspectCatalogResponse { + metrics: InspectCatalogEntry[]; + /** Per-source counts for the toolbar summary. */ + summary: Record; + /** Fingerprint that bumps whenever the underlying rule set changes. */ + attributionFingerprint: string; +} + +/** + * BFF-only response shape for `/api/inspect/mqe-target` — discovered + * (or operator-overridden) base URL the BFF uses to fire MQE + * `execExpression` mutations. Echoed back to the UI for the MQE-target + * panel. + */ +export interface InspectMqeTargetResponse { + baseUrl: string; + via: string; + configured: { host?: string; port?: number }; +} + export interface BffMe { username: string; roles: readonly string[]; @@ -360,6 +431,62 @@ export class BffClient { return this.request('GET', '/api/debug/status'); } + // ── SWIP-14 Inspect ───────────────────────────────────────────── + + /** `GET /api/inspect/catalog[?refresh=true]` — `/inspect/metrics` + * with Studio's rule-file attribution joined in. The catalog + * drawer hits this. Pass `refresh=true` to bust the BFF-side + * attribution cache (e.g. after a rule edit). */ + async inspectCatalog(refresh = false): Promise { + const path = refresh ? '/api/inspect/catalog?refresh=true' : '/api/inspect/catalog'; + return this.request('GET', path); + } + + /** `GET /api/inspect/entities?metric=…&start=…&end=…&step=…[&limit=]`. */ + async inspectEntities(args: { + metric: string; + start: string; + end: string; + step: InspectStep; + limit?: number; + }): Promise { + const params = new URLSearchParams({ + metric: args.metric, + start: args.start, + end: args.end, + step: args.step, + }); + if (args.limit !== undefined) params.set('limit', String(args.limit)); + return this.request('GET', `/api/inspect/entities?${params.toString()}`); + } + + /** `GET /api/inspect/mqe-target[?refresh=true]` — discovered MQE + * base URL, with operator overrides stitched in. */ + async inspectMqeTarget(refresh = false): Promise { + const path = refresh ? '/api/inspect/mqe-target?refresh=true' : '/api/inspect/mqe-target'; + return this.request('GET', path); + } + + /** `POST /api/inspect/exec` — fires `mutation execExpression` on + * the resolved MQE base and returns the `ExpressionResult` shape. */ + async inspectExec(req: InspectExecRequest): Promise { + return this.request('POST', '/api/inspect/exec', req); + } + + /** `GET /api/preflight` — per-module enablement check against + * OAP's `/debugging/config/dump`. Authenticated, no verb gate. */ + async preflight(): Promise { + return this.request('GET', '/api/preflight'); + } + + /** `GET /api/inspect/server-time[?refresh=true]` — OAP's UTC + * offset in minutes. SPA uses this to convert browser-local + * dates to server-TZ strings before sending to MQE. */ + async inspectServerTime(refresh = false): Promise { + const path = refresh ? '/api/inspect/server-time?refresh=true' : '/api/inspect/server-time'; + return this.request('GET', path); + } + /** Trigger a `/api/dump[/{catalog}]` download. Uses an invisible * anchor click — the BFF's session cookie is HttpOnly and gets * sent with the same-origin request automatically. */ diff --git a/apps/ui/src/design/primitives/CatalogNav.vue b/apps/ui/src/design/primitives/CatalogNav.vue index 9105fab..35ca3d4 100644 --- a/apps/ui/src/design/primitives/CatalogNav.vue +++ b/apps/ui/src/design/primitives/CatalogNav.vue @@ -77,6 +77,16 @@ const sections: NavSection[] = [ }, ], }, + { + kicker: 'inspect', + links: [ + { + label: 'Inspect', + to: '/inspect', + active: (p) => p.startsWith('/inspect'), + }, + ], + }, { kicker: 'live debugger', links: [ diff --git a/apps/ui/src/router.ts b/apps/ui/src/router.ts index c0d8ed9..990eab3 100644 --- a/apps/ui/src/router.ts +++ b/apps/ui/src/router.ts @@ -67,6 +67,13 @@ const routes: RouteRecordRaw[] = [ component: () => import('./views/OalCatalog.vue'), meta: { layout: 'main', requiresAuth: true }, }, + { + /* SWIP-14 Inspect — proposal page is currently static / mocked. */ + path: '/inspect', + name: 'inspect', + component: () => import('./views/Inspect.vue'), + meta: { layout: 'main', requiresAuth: true }, + }, { /* /debug → defaults to MAL tab. * /debug/{mal|lal|oal} → preselects the named tab; query params diff --git a/apps/ui/src/views/Catalog.vue b/apps/ui/src/views/Catalog.vue index 90205a2..4f46ae9 100644 --- a/apps/ui/src/views/Catalog.vue +++ b/apps/ui/src/views/Catalog.vue @@ -255,6 +255,12 @@ function submitNewRule(): void { {{ ruleCount }} rules {{ bundledCount }} bundled + {{ isPending ? 'refreshing…' : 'refresh' }} o.value === raw)) return raw as PollChoice; + } catch { + /* private-browsing / quota — ignore */ + } + return '5'; +} +const pollChoice = ref(loadPoll()); +watch(pollChoice, (next) => { + try { + localStorage.setItem(POLL_KEY, next); + } catch { + /* ignore */ + } +}); +/** Milliseconds for vue-query's `refetchInterval`. `false` disables + * the auto-poll (manual mode); any number triggers the next poll + * after that many ms. Returned via a function so vue-query re-reads + * the ref each tick — flipping the dropdown takes effect immediately + * without remounting the query. */ +const pollMs = computed(() => + pollChoice.value === 'off' ? false : Number(pollChoice.value) * 1000, +); +const pollLabel = computed( + () => POLL_OPTIONS.find((o) => o.value === pollChoice.value)?.label ?? '5s', +); + /** Per-node reachability slice. The rule-matrix payload is unused * here — clusterState() also surfaces `nodes[]` so we keep using * the same fan-out endpoint for consistency. */ const query = useQuery({ queryKey: ['cluster/state'], queryFn: () => bff.clusterState(), - refetchInterval: 5_000, + refetchInterval: () => pollMs.value, refetchOnWindowFocus: true, }); @@ -49,12 +94,27 @@ const query = useQuery({ const debugStatusQuery = useQuery({ queryKey: ['debug/status'], queryFn: (): Promise => bff.debugStatus(), - refetchInterval: 5_000, + refetchInterval: () => pollMs.value, refetchOnWindowFocus: true, }); const debugNodes = computed(() => debugStatusQuery.data.value?.nodes ?? []); +/** Preflight — which of Studio's required OAP modules are loaded on + * the admin server. Polled at 30s; refresh-now in the header pokes + * it on demand. */ +const preflightQuery = useQuery({ + queryKey: ['preflight'], + queryFn: () => bff.preflight(), + refetchInterval: 30_000, + refetchOnWindowFocus: true, +}); + +const preflight = computed(() => preflightQuery.data.value ?? null); +const modules = computed(() => preflight.value?.modules ?? []); +const missing = computed(() => modules.value.filter((m) => m.required && !m.enabled)); +const adminUnreachable = computed(() => preflight.value !== null && !preflight.value.adminReachable); + function debugStatusBadgeTone( ok: boolean, injectionEnabled: boolean | undefined, @@ -82,12 +142,76 @@ function nodeLabel(url: string): string {

Cluster status

- - {{ query.isFetching.value ? 'refreshing…' : 'live · 5s' }} + + {{ + query.isFetching.value + ? 'refreshing…' + : pollChoice === 'off' + ? 'manual' + : `live · ${pollLabel}` + }} - refresh now + + refresh now +
+
+ required modules + + OAP-side selectors Studio needs · sourced from + /debugging/config/dump + +
+ +
loading preflight…
+
+ + OAP admin unreachable at {{ preflight.adminUrl }}. + {{ preflight.adminError }} +
+ + + + + + + + + + + + + + + + + + +
modulestateenv varwhat it gates
{{ m.name }} + + {{ m.enabled ? 'enabled' : 'missing' }} + + + {{ m.envVar }}=default + {{ m.envVar }}=default + {{ m.affects }}
+ +

+ Set the env var{{ missing.length === 1 ? '' : 's' }} above on the OAP container and + restart. Studio recovers on the next poll without a re-login. +

+
+
nodes
    @@ -123,7 +247,6 @@ function nodeLabel(url: string): string { health injection active sessions - module / phase @@ -148,10 +271,6 @@ function nodeLabel(url: string): string { - - {{ n.status.module }} · {{ n.status.phase }} - - @@ -215,17 +334,65 @@ function nodeLabel(url: string): string { margin-left: 2px; } -.cs__dbgsource code { +.cs__dbgerr { + margin-left: 8px; + color: var(--rr-dim); + font-style: italic; + font-size: 14.5px; +} + +.cs__modules { + display: flex; + flex-direction: column; + gap: 6px; +} +.cs__modtable { + width: 100%; + border-collapse: collapse; + font-size: 15.5px; + background: var(--rr-bg2); + border: 1px solid var(--rr-border); +} +.cs__modtable th, +.cs__modtable td { + padding: 6px 10px; + text-align: left; + border-bottom: 1px solid var(--rr-border); +} +.cs__modtable th { + font-family: var(--rr-font-mono); + font-size: 13px; + letter-spacing: 1.1px; + text-transform: uppercase; + color: var(--rr-dim); +} +.cs__modname code { font-family: var(--rr-font-mono); font-size: 14.5px; + color: var(--rr-heading); +} +.cs__modenv code { + font-family: var(--rr-font-mono); + font-size: 13px; color: var(--rr-ink2); } - -.cs__dbgerr { - margin-left: 8px; +.cs__modenv--missing { color: var(--rr-err) !important; } +.cs__modaffects { + color: var(--rr-ink2); + font-size: 13.5px; + line-height: 1.55; +} +.cs__modrow--off { background: color-mix(in oklab, var(--rr-err) 8%, transparent); } +.cs__modulesHint { + margin: 6px 0 0; + font-family: var(--rr-font-mono); + font-size: 12.5px; color: var(--rr-dim); +} +.cs__moduleErr { + margin-left: 8px; font-style: italic; - font-size: 14.5px; + color: var(--rr-dim); } .cs { @@ -254,6 +421,21 @@ function nodeLabel(url: string): string { flex: 1 1 auto; } +.cs__pollSelect select { + font-family: var(--rr-font-mono); + font-size: 13px; + padding: 3px 8px; + background: var(--rr-bg2); + color: var(--rr-ink); + border: 1px solid var(--rr-border); + border-radius: var(--rr-radius-sm); + cursor: pointer; +} +.cs__pollSelect select:hover { + border-color: var(--rr-border2); + color: var(--rr-heading); +} + .cs__refreshing { display: inline-flex; align-items: center; diff --git a/apps/ui/src/views/Inspect.vue b/apps/ui/src/views/Inspect.vue new file mode 100644 index 0000000..79e4a9e --- /dev/null +++ b/apps/ui/src/views/Inspect.vue @@ -0,0 +1,2137 @@ + + + + + + diff --git a/apps/ui/src/views/OalCatalog.vue b/apps/ui/src/views/OalCatalog.vue index 5503d1b..50d9ff7 100644 --- a/apps/ui/src/views/OalCatalog.vue +++ b/apps/ui/src/views/OalCatalog.vue @@ -25,6 +25,7 @@ import { useRouter } from 'vue-router'; import { useQuery } from '@tanstack/vue-query'; import { bff } from '../api/client.js'; import Pill from '../design/primitives/Pill.vue'; +import Btn from '../design/primitives/Btn.vue'; import { tokenizeLine, type Token } from './syntaxHighlight.js'; const router = useRouter(); @@ -127,6 +128,12 @@ const fileLines = computed(() => {

    OAL catalog

    read-only + {{ filesQuery.isFetching.value ? 'refreshing…' : 'refresh' }} OAL hot-update is upstream-deferred. Each .oal file defines source classes (the input row the analyzer emits, e.g. diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile index 36a13a4..3907fb2 100644 --- a/deploy/docker/Dockerfile +++ b/deploy/docker/Dockerfile @@ -55,6 +55,15 @@ RUN pnpm -F @vantage-studio/ui build && \ # is required on pnpm 10+ for non-injected workspaces. RUN pnpm deploy --filter @vantage-studio/bff --prod --legacy /deploy +# Seed an empty /data with nonroot (65532:65532) ownership. The runtime +# stage copies this in. Docker auto-seeds named volumes from the +# image's directory contents (including ownership) on first mount, so +# `studio-data:/data` ends up writable by the BFF without operator-side +# chown. The `.keep` file is required because Docker's named-volume +# seeding ignores entirely empty directories. +RUN install -d -o 65532 -g 65532 -m 0750 /seed-data && \ + install -m 0640 -o 65532 -g 65532 /dev/null /seed-data/.keep + # ── runtime ──────────────────────────────────────────────────────── FROM gcr.io/distroless/nodejs24-debian12:nonroot AS runtime WORKDIR /app @@ -70,6 +79,10 @@ COPY --from=builder /deploy/node_modules ./node_modules COPY --from=builder /workspace/apps/ui/dist ./ui COPY deploy/docker/studio.yaml.example ./studio.yaml.example +# /data pre-owned by nonroot (65532:65532). On first `docker volume` +# mount this ownership is propagated to the named volume. +COPY --from=builder /seed-data /data + ENV NODE_ENV=production ENV STUDIO_CONFIG=/data/studio.yaml ENV STUDIO_CONFIG_EXAMPLE=/app/studio.yaml.example diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml index a8bd6ab..3bafe15 100644 --- a/deploy/docker/docker-compose.yml +++ b/deploy/docker/docker-compose.yml @@ -21,12 +21,12 @@ # Replace the password by editing ./.studio-data/studio.yaml after the # first run (or before, if you mount a hand-written one in). # -# OAP image: the runtime-rule receiver feature lives on -# feature/runtime-rule-hot-update upstream and isn't in a tagged -# release yet. For the demo to reach the relevant /runtime/rule/* -# endpoints you need an OAP build of that branch — pull a CI image or -# build locally from ~/github/skywalking and tag it as -# `apache/skywalking-oap-server:runtime-rule`. +# OAP image: SWIP-13 (admin-server + runtime-rule + dsl-debugging) and +# SWIP-14 (inspect) aren't in a tagged release yet. For the demo to +# reach every /runtime/* + /dsl-debugging/* + /inspect/* surface +# Studio uses, you need an OAP build of the consolidated branch — pull +# a CI image or build locally from ~/github/skywalking and tag it as +# `apache/skywalking-oap-server:admin-server` (matches install.md). name: vantage-studio @@ -44,20 +44,28 @@ services: networks: [vs] oap: - image: apache/skywalking-oap-server:runtime-rule + image: apache/skywalking-oap-server:admin-server depends_on: banyandb: condition: service_healthy environment: SW_STORAGE: banyandb SW_STORAGE_BANYANDB_TARGETS: 'banyandb:17912' + # All four admin-bound selectors must be set — see install.md. + # Without `admin-server`, the other three fail at boot with + # `ModuleNotFoundException: admin-server`. Studio's preflight + # surface (Cluster status · Required modules) shows which are + # on at runtime. + SW_ADMIN_SERVER: default SW_RECEIVER_RUNTIME_RULE: default - # Bind the runtime-rule admin port to the cluster network only. + SW_DSL_DEBUGGING: default + SW_INSPECT: default + # Bind the admin-server port to the cluster network only. # Studio reaches it as `oap:17128`; nothing on the host can. SW_CORE_REST_HOST: 0.0.0.0 expose: - - '12800' # status / GraphQL - - '17128' # runtime-rule admin (BFF-only) + - '12800' # status / GraphQL (MQE fires here) + - '17128' # admin-server (BFF-only) healthcheck: test: ['CMD-SHELL', 'wget -qO- http://localhost:12800/status/cluster/nodes >/dev/null'] interval: 5s diff --git a/docs/auth.md b/docs/auth.md index 457431f..b977fd0 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -118,10 +118,12 @@ rbac: - rule:delete - rule:debug - cluster:read + - inspect:read viewer: verbs: - rule:read - cluster:read + - inspect:read ``` A user's effective verbs are the union of their assigned roles' @@ -152,6 +154,7 @@ page (left nav, bottom). | `rule:delete` | `delete` (default mode) | | `rule:debug` | live debugger — start / poll / stop debug sessions across MAL / LAL / OAL | | `cluster:read` | cluster matrix, dsl-debugging status pane | +| `inspect:read` | Inspect (SWIP-14) — `/api/inspect/{catalog,metrics,entities,mqe-target,exec}` | | `admin` | (reserved — audit-read in a later release) | | `*` | all of the above (wildcard) | diff --git a/docs/configure.md b/docs/configure.md index e735173..84a4c41 100644 --- a/docs/configure.md +++ b/docs/configure.md @@ -41,6 +41,9 @@ oap: - http://oap-1:17128 # writes go to the first; reads fan out to all - http://oap-2:17128 statusUrl: http://oap:12800 # OAP query/status plugin (cluster discovery) + mqe: # OPTIONAL — overrides discovered MQE base for Inspect (SWIP-14) + host: rest.example.com # both fields are independently optional + port: 12800 # when omitted, BFF discovers via /debugging/config/dump auth: backend: local # only "local" in v1; OIDC + LDAP later @@ -92,12 +95,15 @@ debugLog: # OPTIONAL — wire-level capture for integration testing | `adminUrls` | yes | Array of base URLs to OAP's `admin-server` (default port `17128` — same port runtime-rule used standalone before SWIP-13). The BFF fans `/runtime/rule/list` out across every URL for the cluster matrix and fans `/dsl-debugging/status` for the debugger health pane. **Writes** (`addOrUpdate`, `inactivate`, `delete`) hit only the first URL — OAP's forward-RPC handles peer convergence. **Live debugger** session installs hit the first URL too; OAP itself broadcasts `InstallDebugSession` cluster-wide. | | `statusUrl` | yes | OAP query/status plugin URL (default `12800`). Used for `/status/cluster/nodes` lookups. | | `timeoutMs` | no | Per-call timeout (ms) for every BFF→OAP request. Default `10000`. Set to `0` to disable. The cluster fan-out shares this timeout per node — a slow node times out individually without stalling the whole call. | +| `mqe.host` | no | MQE-fire override for the Inspect feature (SWIP-14). When set, the BFF uses this host instead of the one discovered through admin's `/debugging/config/dump`. Useful in k8s setups where the admin port and the public REST port are reachable through different ingress hostnames. Independent of `mqe.port`. | +| `mqe.port` | no | MQE-fire port override. Same semantics as `mqe.host`. Set either, both, or neither — the BFF stitches in whichever is missing from the discovery path (sharing-server REST → core REST, host falling back to the admin URL when the bound host is `0.0.0.0`). | -> **OAP-side opt-in.** The admin-server, runtime-rule, and dsl-debugging -> selectors all default to empty on OAP. Set -> `SW_ADMIN_SERVER=default`, `SW_RECEIVER_RUNTIME_RULE=default`, and -> `SW_DSL_DEBUGGING=default` on the OAP container so the URLs Studio -> calls actually exist. See [`install.md`](install.md) for details. +> **OAP-side opt-in.** The admin-server, runtime-rule, dsl-debugging, +> and inspect selectors all default to empty on OAP. Set +> `SW_ADMIN_SERVER=default`, `SW_RECEIVER_RUNTIME_RULE=default`, +> `SW_DSL_DEBUGGING=default`, and `SW_INSPECT=default` on the OAP +> container so the URLs Studio calls actually exist. See +> [`install.md`](install.md) for details. ### `auth` @@ -135,6 +141,7 @@ Verb table: | `rule:delete` | `delete` (default mode) | | `rule:debug` | live debugger — start / poll / stop debug sessions across MAL / LAL / OAL | | `cluster:read` | cluster matrix, dsl-debugging status pane | +| `inspect:read` | Inspect (SWIP-14) — `/api/inspect/{catalog,metrics,entities,mqe-target,exec}` | | `admin` | (reserved — audit-read in a later release) | | `*` | all of the above | diff --git a/docs/inspect.md b/docs/inspect.md new file mode 100644 index 0000000..f516189 --- /dev/null +++ b/docs/inspect.md @@ -0,0 +1,190 @@ + + +# Inspect + +The **Inspect** page lets you browse OAP's metric catalog, pick which +entity (service / instance / endpoint / relation) holds values for a +given metric, and chart the MQE series — all in one place, five +widgets per row by default. + +It binds to two upstream surfaces: + +| Studio route | Calls | +| ------------------------------ | -------------------------------------------------------------------- | +| `GET /api/inspect/catalog` | admin `GET /inspect/metrics` + Studio's MAL/OAL rule attribution | +| `GET /api/inspect/entities` | admin `GET /inspect/entities?metric=…&start=…&end=…&step=…&limit=…` | +| `GET /api/inspect/mqe-target` | admin `GET /debugging/config/dump` (resolves the MQE base URL) | +| `POST /api/inspect/exec` | resolved MQE base — `mutation execExpression(expression, entity, …)` | + +Inspect is admin-only on the OAP side; the catalog and entity routes +live on the admin-server port `17128`. The MQE-fire route goes to the +public REST / sharing-server surface (default `12800`), discovered at +runtime. + +## Prerequisites + +- **OAP 10.5.0+** with these selectors set: + ```env + SW_ADMIN_SERVER=default + SW_INSPECT=default + ``` + When `SW_INSPECT` is unset, the inspect page renders a banner with + the exact command to run. +- Studio configured to reach OAP — see [`configure.md`](configure.md). +- Operator role with the `inspect:read` verb (or `*`). + +## The page + +### Toolbar + +- **range** — `start` / `end` / `step` (MINUTE / HOUR / DAY). The + date format adapts to the step: `yyyy-MM-dd` for DAY, `yyyy-MM-dd HH` + for HOUR, `yyyy-MM-dd HHmm` for MINUTE. Switching the step resets + the range to a sensible default. +- **board cap** — soft cap on widget count. Default 10. Each widget + fires its own MQE call; the cap exists to keep an over-eager + operator from drowning the query surface. +- **inspector top-n** — per-widget entity cap passed as + `limit=` to `/inspect/entities`. Default 10. The server-side hard + cap is 300. +- **per row** — 1 / 3 / 5 widgets per row. Chart height grows when + the density is lower. + +### MQE target + +Shows the resolved MQE base URL and a short rationale, e.g. +`sharing-server.restPort, admin URL host (sharing-server.restHost was wildcard)`. + +To override (typical for k8s ingress setups where admin and REST +hostnames differ), set `oap.mqe.host` and/or `oap.mqe.port` in +`studio.yaml`: + +```yaml +oap: + adminUrls: + - http://oap-admin.cluster.local:17128 + statusUrl: http://oap.cluster.local:12800 + mqe: + host: rest-gateway.cluster.local # both fields independent + port: 12800 +``` + +Each `mqe.*` field is independently optional. The BFF discovers any +missing piece from `/debugging/config/dump`: + +1. If `oap.mqe.host` is unset, use `sharing-server.restHost` from the + dump, preferring it over `core.restHost`. If the bound host is + `0.0.0.0` / wildcard, fall back to the admin URL's host. +2. If `oap.mqe.port` is unset, use `sharing-server.restPort` if the + sharing-server module is enabled; otherwise `core.restPort`. + +The resolved value is cached BFF-side for 60s. Click **refresh** in +the page header to bust the cache, re-read `/debugging/config/dump`, +and re-pull the catalog. + +### Catalog drawer + +Click **+ add metric** to open the catalog. Layout is two-pane: + +- **Left**: rule files grouped by source — `OAL`, `MAL · OTEL`, + `MAL · Telegraf`, `LAL → MAL`, plus an `unknown` bucket for + metrics Studio couldn't attribute (rare; happens when a metric + appears in `/inspect/metrics` but no `.oal` / MAL rule Studio + reads declared it). Per-file badge: scope when single, otherwise + scope count. Click a file to load its metrics; click `+ all` next + to a file to select every MQE-queryable metric in it. +- **Right**: metric rows for the active file with a regex search, + per-row checkbox, and the metric type pill. HEATMAP and + SAMPLED_RECORD rows are visible but disabled — `/inspect/entities` + only handles `REGULAR_VALUE` and `LABELED_VALUE` per SWIP-14. + +The breadcrumb has `select all N` / `clear` shortcuts when bulk +selection is what you actually want. + +### Widget + +Each metric on the board renders one card: + +- **Header** — metric name, source pill, scope (entity-type) pill, + chart toggle (`line` ⇄ `bar` ⇄ `area`), remove. +- **Entity bar** — `◀` / `▶` cycle through the entities + `/inspect/entities` returned, top-1 selected by default (the most + recent per SWIP-14's sort order, so the most likely to have data). + Click the entity button to open the editor. +- **Entity editor** — three sections: + 1. **Resolved** — multi-select over the entities the inspect API + returned. + 2. **Custom** — entities you added by hand (form-built, not JSON). + 3. **Form** — scope-aware fields. For `Service` it's just + `serviceName` + `normal`. For `ServiceRelation` it's + `serviceName` / `normal` + `destServiceName` / `destNormal`. + Endpoint / Instance / \*Relation scopes get the right field + set automatically — the metric's scope is fixed, you only + fill in the names. + 4. The chart re-fires for every selection change. +- **Chart** — ECharts, single series when one entity is selected, + multi-series when more. For `LABELED_VALUE` metrics with multiple + entities, Studio falls back to one representative label per entity + to keep the chart readable; pick one entity to see all labels. + +### Refresh + +The header **refresh** button does three things in order: + +1. Hits the BFF with `?refresh=true` on `/api/inspect/catalog` and + `/api/inspect/mqe-target` so the BFF re-pulls the underlying + admin endpoints and rebuilds its attribution index. +2. Invalidates the vue-query cache for every `['inspect', …]` key. +3. Re-resolves entities and re-fires MQE for every widget on the + board. + +Use it after you've edited a MAL/OAL rule in another tab — the +catalog drawer will pick up the new metric attribution without a +page reload. + +## RBAC + +Add `inspect:read` to whichever role the operator has. With RBAC +disabled (the default), every authenticated user can use Inspect. + +```yaml +rbac: + enabled: true + roles: + admin: + verbs: ['*'] + operator: + verbs: + - rule:read + - rule:write + - rule:debug + - cluster:read + - inspect:read # ← required for the Inspect page + viewer: + verbs: + - rule:read + - cluster:read + - inspect:read # read-only inspectors get it too +``` + +## Troubleshooting + +| Symptom | Likely cause | +| -------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| Banner: "Inspect API not enabled on OAP" | `SW_INSPECT` is unset / empty on OAP. Set `SW_INSPECT=default` and restart OAP. | +| MQE target shows "unresolved" | `/debugging/config/dump` failed (admin-server not reachable, status module disabled, etc.). | +| Widget says "no values in range" | The picked entity has no MQE values in `[start, end]`. Widen the range or pick another. | +| Widget error "unknown metric: …" | Metric was removed between catalog fetch and exec fire. Refresh the page. | +| Catalog drawer shows metric under `unknown` source | Metric is in `/inspect/metrics` but not in any OAL file or MAL rule Studio reads. | + +For deeper debugging, enable Studio's `debugLog` (see +[`configure.md`](configure.md)) to capture both inbound `/api/*` calls +and every BFF→OAP egress with shared trace IDs. diff --git a/docs/install.md b/docs/install.md index ced5e77..cab5c4d 100644 --- a/docs/install.md +++ b/docs/install.md @@ -58,21 +58,32 @@ branch. Build it from a checkout of `apache/skywalking` The minimum SkyWalking version is **10.5.0** — see [`compatibility.md`](compatibility.md). -### OAP enablement — three opt-in selectors +### OAP enablement — four required selectors -All three SWIP-13 selectors default to **empty (disabled)**. Set the -following env vars on the OAP container so the surfaces Studio uses -come up: +Studio's BFF only talks to admin-server-bound OAP modules. All four +selectors default to **empty (disabled)** in stock OAP; Studio +**requires all four**. Set them on the OAP container: ```env -SW_ADMIN_SERVER=default # shared HTTP server on :17128 +SW_ADMIN_SERVER=default # shared HTTP server on :17128 (host module for the others) SW_RECEIVER_RUNTIME_RULE=default # /runtime/rule/* + /runtime/oal/* SW_DSL_DEBUGGING=default # /dsl-debugging/* live debugger +SW_INSPECT=default # /inspect/* — catalog + entity browser (SWIP-14) ``` -Without `admin-server`, the runtime-rule and dsl-debugging modules -fail at boot with `ModuleNotFoundException: admin-server`. Enable -all three together. +What breaks if you omit each one: + +| Selector | What stops working in Studio | +| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SW_ADMIN_SERVER` | Everything. Without the host module, the other three fail at boot with `ModuleNotFoundException: admin-server`. | +| `SW_RECEIVER_RUNTIME_RULE` | DSL Management pages (Catalog, OAL catalog), the Editor's rule fetch/save path, the Cluster status rule-convergence matrix, the Live debugger's rule picker, and the Inspect drawer's source attribution (every metric falls back to the `unknown` bucket). | +| `SW_DSL_DEBUGGING` | The Live debugger across all three DSLs (start / poll / stop), and the DSL-debugging health pane in Cluster status. | +| `SW_INSPECT` | The Inspect page — every `/api/inspect/*` call returns `404 inspect_not_enabled` and the page renders an actionable banner instead of the board. | + +The "Backend unreachable" / `oap_unreachable` banners you see in +Studio when a selector is missing are honest reports — Studio is +running fine, the upstream just isn't exposing the path. Set the +selector + restart OAP and the page recovers on the next poll. > The admin-server has **no authentication** in this release. Reach > it only over the cluster's private network — never expose port diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index 5fe745b..82eed10 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -32,6 +32,36 @@ export { type OalSourceDetail, type OalSourceMetricDetail, } from './oal.js'; +export { + InspectClient, + InspectApiError, + INSPECT_STEPS, + INSPECT_ENTITY_LIMIT_MAX, + formatInspectDate, + isInspectDate, + type InspectClientOptions, + type InspectCatalog, + type InspectMetricType, + type InspectScope, + type InspectStep, + type ListMetricsArgs, + type ListEntitiesArgs, + type MetricRow, + type MetricsResponse, + type EntityRow, + type EntitiesResponse, + type MqeEntity, + type DecodedEntity, + type InspectErrorBody, + type ExpressionResultType, + type ExpressionResult, + type MqeValues, + type MqeValue, + type MqeMetadata, + type MqeKeyValue, + type MqeOwner, + type InspectExecRequest, +} from './inspect.js'; export { DslDebuggingClient, DEBUG_CATALOGS, diff --git a/packages/api-client/src/inspect.ts b/packages/api-client/src/inspect.ts new file mode 100644 index 0000000..8b3873e --- /dev/null +++ b/packages/api-client/src/inspect.ts @@ -0,0 +1,369 @@ +/* + * Copyright 2026 The Vantage Studio Authors + * + * Licensed 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. + */ + +/** + * SWIP-14 Inspect API — read-only catalog + entity enumeration on + * admin-server's port 17128. The endpoints are: + * + * GET /inspect/metrics + * GET /inspect/entities?metric=…&start=…&end=…&step=…&limit=… + * + * Both are admin-only and are off by default — the operator must set + * `SW_INSPECT=default` on OAP. Wire shapes match the implementation on + * `swip-14-inspect-api` (final, post-review-pass-2). See + * `oap-server/server-admin/inspect/src/main/java/.../response/`. + * + * The MQE values themselves live behind OAP's regular GraphQL + * `execExpression` surface; the Inspect API only returns the catalog + * and MQE-ready Entity shapes you can paste into that mutation. + */ + +import type { FetchLike } from './runtime-rule.js'; + +// ── Catalog / metrics ────────────────────────────────────────────── + +/** MetricsType emitted by OAP's `MetricsType.java`; values match + * `Column.ValueDataType` after the metadata mapping. */ +export type InspectMetricType = 'REGULAR_VALUE' | 'LABELED_VALUE' | 'HEATMAP' | 'SAMPLED_RECORD'; + +/** Catalog string from `DefaultScopeDefine` — uppercase, underscore- + * separated. Used for the `/inspect/metrics?catalog=` filter. */ +export type InspectCatalog = + | 'SERVICE' + | 'SERVICE_INSTANCE' + | 'ENDPOINT' + | 'SERVICE_RELATION' + | 'SERVICE_INSTANCE_RELATION' + | 'ENDPOINT_RELATION' + | (string & {}); + +/** Scope name (e.g. `Service`, `ServiceInstance`, `Endpoint`, + * `ServiceRelation`, `ServiceInstanceRelation`, `EndpointRelation`). + * Comes straight from `Scope.Finder.valueOf(scopeId).name()`. */ +export type InspectScope = + | 'Service' + | 'ServiceInstance' + | 'Endpoint' + | 'ServiceRelation' + | 'ServiceInstanceRelation' + | 'EndpointRelation' + | (string & {}); + +export type InspectStep = 'MINUTE' | 'HOUR' | 'DAY'; +export const INSPECT_STEPS: readonly InspectStep[] = ['MINUTE', 'HOUR', 'DAY'] as const; + +/** Server-side hard cap on `/inspect/entities?limit=`. */ +export const INSPECT_ENTITY_LIMIT_MAX = 300; + +export interface MetricRow { + name: string; + type: InspectMetricType; + catalog: InspectCatalog; + scopeId: number; + scope: InspectScope; + valueColumnName: string; + downsamplings: InspectStep[]; +} + +export interface MetricsResponse { + metrics: MetricRow[]; +} + +// ── Entities ─────────────────────────────────────────────────────── + +/** MQE Entity input shape — field names match SkyWalking's GraphQL + * `Entity` input verbatim so the operator can paste this block + * straight into a `mutation execExpression(…, entity: …)`. The OAP + * 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; + serviceName?: string; + normal?: boolean; + serviceInstanceName?: string; + endpointName?: string; + destServiceName?: string; + destNormal?: boolean; + destServiceInstanceName?: string; + destEndpointName?: string; +} + +/** Decoded entity-id payload — scope-dependent shape. For single + * scopes (`Service`, `ServiceInstance`, `Endpoint`) it carries + * service/instance/endpoint fields at the top level; for *Relation + * scopes it nests under `source` / `destination`. Modelled here as + * an open record because the JSON shape is fixed per scope but the + * union is wide. */ +export type DecodedEntity = Record; + +export interface EntityRow { + entityId: string; + decoded: DecodedEntity; + /** Set for service-bearing rows; one row per registered Layer. + * Omitted (Java `null` → field absent thanks to `NON_NULL`) when + * the service is missing from the metadata cache. */ + layer?: string; + mqeEntity: MqeEntity; +} + +export interface EntitiesResponse { + metric: string; + scope: InspectScope; + step: InspectStep; + /** Echo of the `start` query param in the step-specific format. */ + start: string; + /** Echo of the `end` query param in the step-specific format. */ + end: string; + rows: EntityRow[]; +} + +// ── Request args ─────────────────────────────────────────────────── + +export interface ListMetricsArgs { + /** Java regex.Pattern over metric name. No filter when omitted. */ + regex?: string; + /** Repeatable; matches `MetricsType` enum names. */ + type?: InspectMetricType[]; + /** Repeatable; matches `DefaultScopeDefine` catalog names. */ + catalog?: InspectCatalog[]; + /** If true, narrows to MQE-queryable types (`REGULAR_VALUE` + + * `LABELED_VALUE`). */ + mqeQueryable?: boolean; +} + +export interface ListEntitiesArgs { + metric: string; + /** Date string per step format. Use `formatInspectDate(date, step)` + * to build it. */ + start: string; + end: string; + step: InspectStep; + /** 1–300, default 300 server-side. Studio defaults to a smaller + * number per widget. */ + limit?: number; +} + +// ── MQE result shape ─────────────────────────────────────────────── +// +// What `mutation execExpression(...)` returns on the public GraphQL +// surface — `data.execExpression`. Studio's BFF unwraps the GraphQL +// envelope and forwards this shape verbatim to the SPA. + +export type ExpressionResultType = + | 'UNKNOWN' + | 'SINGLE_VALUE' + | 'TIME_SERIES_VALUES' + | 'SORTED_LIST' + | 'RECORD_LIST'; + +export interface MqeOwner { + scope?: string | null; + serviceID?: string | null; + serviceName?: string | null; + normal?: boolean | null; + serviceInstanceID?: string | null; + serviceInstanceName?: string | null; + endpointID?: string | null; + endpointName?: string | null; +} + +export interface MqeKeyValue { + key: string; + value: string; +} + +export interface MqeMetadata { + labels: MqeKeyValue[]; +} + +export interface MqeValue { + id?: string | null; + owner?: MqeOwner | null; + /** Stringified number or `null` when absent. */ + value: string | null; + traceID?: string | null; +} + +export interface MqeValues { + metric: MqeMetadata; + values: MqeValue[]; +} + +export interface ExpressionResult { + type: ExpressionResultType; + results: MqeValues[]; + error?: string | null; +} + +/** Wire shape for Studio's `POST /api/inspect/exec`. The BFF will + * translate this into a GraphQL `mutation execExpression(...)` call + * against the resolved MQE base. */ +export interface InspectExecRequest { + expression: string; + entity: MqeEntity; + duration: { + start: string; + end: string; + step: InspectStep; + /** Cold-stage flag, BanyanDB-only. Default false. */ + coldStage?: boolean; + }; + /** Forwarded to GraphQL as `debug: Boolean`. Off by default. */ + debug?: boolean; +} + +// ── Date format ──────────────────────────────────────────────────── + +/** Format a `Date` into the date string OAP expects for the given + * step. Mirrors `Duration.getStartTimeBucket` / `getEndTimeBucket`'s + * accepted shapes: + * + * DAY → `yyyy-MM-dd` + * HOUR → `yyyy-MM-dd HH` + * MINUTE → `yyyy-MM-dd HHmm` + * + * All values are zero-padded; the date is interpreted in the OAP + * server's local timezone, so prefer feeding through `UTC` if your + * OAP is configured for UTC (the default for containerised deploys). + */ +export function formatInspectDate(d: Date, step: InspectStep): string { + const y = d.getUTCFullYear(); + const m = String(d.getUTCMonth() + 1).padStart(2, '0'); + const day = String(d.getUTCDate()).padStart(2, '0'); + const date = `${y}-${m}-${day}`; + if (step === 'DAY') return date; + const h = String(d.getUTCHours()).padStart(2, '0'); + if (step === 'HOUR') return `${date} ${h}`; + const min = String(d.getUTCMinutes()).padStart(2, '0'); + return `${date} ${h}${min}`; +} + +/** True iff `s` parses as a valid date string for the given step. */ +export function isInspectDate(s: string, step: InspectStep): boolean { + if (step === 'DAY') return /^\d{4}-\d{2}-\d{2}$/.test(s); + if (step === 'HOUR') return /^\d{4}-\d{2}-\d{2} \d{2}$/.test(s); + return /^\d{4}-\d{2}-\d{2} \d{4}$/.test(s); +} + +// ── Errors ───────────────────────────────────────────────────────── + +/** OAP's inspect error envelope: `{ "error": "string" }`. */ +export interface InspectErrorBody { + error: string; +} + +export class InspectApiError extends Error { + constructor( + public readonly status: number, + public readonly body: InspectErrorBody | string, + public readonly url: string, + ) { + const detail = typeof body === 'string' ? body : body.error; + super(`${status} on ${url} — ${detail}`); + this.name = 'InspectApiError'; + } +} + +// ── Client ───────────────────────────────────────────────────────── + +export interface InspectClientOptions { + /** OAP admin port URL, e.g. `http://oap:17128`. No trailing slash. */ + adminUrl: string; + fetch?: FetchLike; + headers?: Record; + /** Default per-call timeout in ms. `0` disables. */ + timeoutMs?: number; +} + +export class InspectClient { + private readonly fetchImpl: FetchLike; + private readonly base: string; + private readonly defaultHeaders: Record; + private readonly timeoutMs: number; + + constructor(options: InspectClientOptions) { + this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis); + this.base = options.adminUrl.replace(/\/$/, ''); + this.defaultHeaders = options.headers ?? {}; + this.timeoutMs = options.timeoutMs ?? 0; + } + + /** `GET /inspect/metrics` with optional `regex` / `type` / `catalog` + * / `mqeQueryable` filters. Returns the full catalog when no + * filters are passed. */ + async listMetrics(args: ListMetricsArgs = {}): Promise { + const params = new URLSearchParams(); + if (args.regex !== undefined) params.set('regex', args.regex); + if (args.mqeQueryable === true) params.set('mqeQueryable', 'true'); + for (const t of args.type ?? []) params.append('type', t); + for (const c of args.catalog ?? []) params.append('catalog', c); + const qs = params.toString(); + const url = `${this.base}/inspect/metrics${qs ? `?${qs}` : ''}`; + const res = await this.send(url, { method: 'GET' }); + if (!res.ok) throw await this.toError(res, url); + return (await res.json()) as MetricsResponse; + } + + /** `GET /inspect/entities` — `metric` + time range + step + limit. */ + async listEntities(args: ListEntitiesArgs): Promise { + const params = new URLSearchParams({ + metric: args.metric, + start: args.start, + end: args.end, + step: args.step, + }); + if (args.limit !== undefined) params.set('limit', String(args.limit)); + const url = `${this.base}/inspect/entities?${params.toString()}`; + const res = await this.send(url, { method: 'GET' }); + if (!res.ok) throw await this.toError(res, url); + return (await res.json()) as EntitiesResponse; + } + + // ── private helpers ───────────────────────────────────────────── + + private async send(url: string, init: RequestInit): Promise { + const headers: Record = { + Accept: 'application/json', + ...this.defaultHeaders, + ...((init.headers as Record) ?? {}), + }; + const finalInit: RequestInit = { ...init, headers }; + if (this.timeoutMs > 0) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + try { + return await this.fetchImpl(url, { ...finalInit, signal: controller.signal }); + } finally { + clearTimeout(timer); + } + } + return this.fetchImpl(url, finalInit); + } + + private async toError(res: Response, url: string): Promise { + const text = await res.text(); + let parsed: InspectErrorBody | string = text; + try { + const json = JSON.parse(text) as Record; + if (typeof json.error === 'string') { + parsed = json as unknown as InspectErrorBody; + } + } catch { + // not JSON; keep the raw text. + } + return new InspectApiError(res.status, parsed, url); + } +} diff --git a/packages/api-client/test/inspect.test.ts b/packages/api-client/test/inspect.test.ts new file mode 100644 index 0000000..48d1f63 --- /dev/null +++ b/packages/api-client/test/inspect.test.ts @@ -0,0 +1,221 @@ +/* + * Copyright 2026 The Vantage Studio Authors + * + * Licensed 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, expect, it, vi } from 'vitest'; +import { + InspectApiError, + InspectClient, + INSPECT_ENTITY_LIMIT_MAX, + formatInspectDate, + isInspectDate, + type EntitiesResponse, + type MetricsResponse, +} from '../src/index.js'; + +function jsonResponse(body: unknown, init?: ResponseInit): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + ...init, + }); +} + +interface MockCall { + url: string; + init: RequestInit; +} + +function makeFakeFetch(...responses: Response[]) { + const calls: MockCall[] = []; + const queue = [...responses]; + const fn = vi.fn(async (input: string | URL, init?: RequestInit) => { + calls.push({ url: input.toString(), init: init ?? {} }); + return queue.shift() ?? new Response('exhausted', { status: 500 }); + }); + return { fn, calls }; +} + +// Real /inspect/metrics fixture shape from the e2e suite. Three rows +// cover REGULAR_VALUE, LABELED_VALUE, and HEATMAP so the wire types +// accept the full mix. +const sampleMetrics: MetricsResponse = { + metrics: [ + { + name: 'service_cpm', + type: 'REGULAR_VALUE', + catalog: 'SERVICE', + scopeId: 1, + scope: 'Service', + valueColumnName: 'value', + downsamplings: ['MINUTE', 'HOUR', 'DAY'], + }, + { + name: 'service_percentile', + type: 'LABELED_VALUE', + catalog: 'SERVICE', + scopeId: 1, + scope: 'Service', + valueColumnName: 'value', + downsamplings: ['MINUTE', 'HOUR', 'DAY'], + }, + { + name: 'endpoint_response_time', + type: 'HEATMAP', + catalog: 'ENDPOINT', + scopeId: 3, + scope: 'Endpoint', + valueColumnName: 'dataset', + downsamplings: ['MINUTE', 'HOUR', 'DAY'], + }, + ], +}; + +// Real /inspect/entities fixture: a Service-scope row at GENERAL layer. +const sampleEntities: EntitiesResponse = { + metric: 'service_cpm', + scope: 'Service', + step: 'MINUTE', + start: '2026-05-10 1220', + end: '2026-05-10 1240', + rows: [ + { + entityId: 'cGF5bWVudA==.1', + decoded: { serviceName: 'payment', isReal: true }, + layer: 'GENERAL', + mqeEntity: { scope: 'Service', serviceName: 'payment', normal: true }, + }, + ], +}; + +describe('InspectClient.listMetrics', () => { + it('returns the catalog with no params', async () => { + const { fn, calls } = makeFakeFetch(jsonResponse(sampleMetrics)); + const client = new InspectClient({ adminUrl: 'http://oap:17128', fetch: fn }); + + const got = await client.listMetrics(); + + expect(got.metrics).toHaveLength(3); + expect(got.metrics[0]!.name).toBe('service_cpm'); + expect(calls[0]!.url).toBe('http://oap:17128/inspect/metrics'); + }); + + it('appends type / catalog as repeatable params and mqeQueryable as a flag', async () => { + const { fn, calls } = makeFakeFetch(jsonResponse({ metrics: [] })); + const client = new InspectClient({ adminUrl: 'http://oap:17128', fetch: fn }); + + await client.listMetrics({ + regex: 'service_.*', + type: ['REGULAR_VALUE', 'LABELED_VALUE'], + catalog: ['SERVICE'], + mqeQueryable: true, + }); + + const url = new URL(calls[0]!.url); + expect(url.pathname).toBe('/inspect/metrics'); + expect(url.searchParams.get('regex')).toBe('service_.*'); + expect(url.searchParams.getAll('type')).toEqual(['REGULAR_VALUE', 'LABELED_VALUE']); + expect(url.searchParams.getAll('catalog')).toEqual(['SERVICE']); + expect(url.searchParams.get('mqeQueryable')).toBe('true'); + }); + + it('throws InspectApiError carrying the OAP error body', async () => { + const { fn } = makeFakeFetch( + new Response(JSON.stringify({ error: 'something blew up' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }), + ); + const client = new InspectClient({ adminUrl: 'http://oap:17128', fetch: fn }); + + await expect(client.listMetrics()).rejects.toMatchObject({ + name: 'InspectApiError', + status: 500, + body: { error: 'something blew up' }, + }); + }); +}); + +describe('InspectClient.listEntities', () => { + it('builds the right query string and parses the response', async () => { + const { fn, calls } = makeFakeFetch(jsonResponse(sampleEntities)); + const client = new InspectClient({ adminUrl: 'http://oap:17128', fetch: fn }); + + const got = await client.listEntities({ + metric: 'service_cpm', + start: '2026-05-10 1220', + end: '2026-05-10 1240', + step: 'MINUTE', + limit: 10, + }); + + expect(got.rows[0]!.entityId).toBe('cGF5bWVudA==.1'); + expect(got.rows[0]!.mqeEntity.serviceName).toBe('payment'); + const url = new URL(calls[0]!.url); + expect(url.pathname).toBe('/inspect/entities'); + expect(url.searchParams.get('metric')).toBe('service_cpm'); + expect(url.searchParams.get('start')).toBe('2026-05-10 1220'); + expect(url.searchParams.get('end')).toBe('2026-05-10 1240'); + expect(url.searchParams.get('step')).toBe('MINUTE'); + expect(url.searchParams.get('limit')).toBe('10'); + }); + + it('translates OAP 400 errors to InspectApiError', async () => { + const { fn } = makeFakeFetch( + new Response(JSON.stringify({ error: 'unknown metric: foo' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }), + ); + const client = new InspectClient({ adminUrl: 'http://oap:17128', fetch: fn }); + + await expect( + client.listEntities({ metric: 'foo', start: '2026-05-10', end: '2026-05-10', step: 'DAY' }), + ).rejects.toBeInstanceOf(InspectApiError); + }); +}); + +describe('formatInspectDate / isInspectDate', () => { + // Fix a known instant so the test isn't TZ-sensitive: 2026-05-10 + // 12:34 UTC. The formatter is UTC-anchored, mirroring how OAP runs + // in container deployments. + const d = new Date(Date.UTC(2026, 4, 10, 12, 34, 56)); + + it('formats DAY as yyyy-MM-dd', () => { + expect(formatInspectDate(d, 'DAY')).toBe('2026-05-10'); + }); + + it('formats HOUR as yyyy-MM-dd HH', () => { + expect(formatInspectDate(d, 'HOUR')).toBe('2026-05-10 12'); + }); + + it('formats MINUTE as yyyy-MM-dd HHmm', () => { + expect(formatInspectDate(d, 'MINUTE')).toBe('2026-05-10 1234'); + }); + + it('isInspectDate validates each step format', () => { + expect(isInspectDate('2026-05-10', 'DAY')).toBe(true); + expect(isInspectDate('2026-05-10 12', 'HOUR')).toBe(true); + expect(isInspectDate('2026-05-10 1234', 'MINUTE')).toBe(true); + expect(isInspectDate('2026-05-10', 'MINUTE')).toBe(false); + expect(isInspectDate('2026/05/10', 'DAY')).toBe(false); + }); +}); + +describe('constants', () => { + it('exposes the server-side cap', () => { + expect(INSPECT_ENTITY_LIMIT_MAX).toBe(300); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1c21ac7..5f87555 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -81,6 +81,9 @@ importers: '@vantage-studio/design-tokens': specifier: workspace:* version: link:../../packages/design-tokens + echarts: + specifier: ^6.0.0 + version: 6.0.0 monaco-editor: specifier: ^0.52.0 version: 0.52.2 @@ -1120,6 +1123,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + echarts@6.0.0: + resolution: {integrity: sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==} + editorconfig@1.0.7: resolution: {integrity: sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==} engines: {node: '>=14'} @@ -1884,6 +1890,9 @@ packages: peerDependencies: typescript: '>=4.8.4' + tslib@2.3.0: + resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -2103,6 +2112,9 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zrender@6.0.0: + resolution: {integrity: sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==} + snapshots: '@asamuzakjp/css-color@3.2.0': @@ -2939,6 +2951,11 @@ snapshots: eastasianwidth@0.2.0: {} + echarts@6.0.0: + dependencies: + tslib: 2.3.0 + zrender: 6.0.0 + editorconfig@1.0.7: dependencies: '@one-ini/wasm': 0.1.1 @@ -3764,6 +3781,8 @@ snapshots: dependencies: typescript: 5.9.3 + tslib@2.3.0: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -3959,3 +3978,7 @@ snapshots: yocto-queue@0.1.0: {} zod@3.25.76: {} + + zrender@6.0.0: + dependencies: + tslib: 2.3.0