This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
The following commit(s) were added to refs/heads/main by this push:
new fc70244 ui/bff: TTL + OAP-config operate pages, pprof fixes,
profiling create-then-poll
fc70244 is described below
commit fc70244fcb64c88c18d07d711c64303318b56278
Author: Wu Sheng <[email protected]>
AuthorDate: Wed May 20 22:08:04 2026 +0800
ui/bff: TTL + OAP-config operate pages, pprof fixes, profiling
create-then-poll
- Add read-only Operate pages: Data retention (TTL,
getRecordsTTL/getMetricsTTL
on the query port) and OAP configuration (/debugging/config/dump, secrets
masked server-side). New ttl:read / config:read verbs granted to
maintainer+.
- Sidebar: merge cluster status + TTL + config under one "Platform
monitoring"
header above the per-layer self-observability dashboards.
- pprof: events is a single scalar enum (not a list) and duration is in
minutes (cap 15); dumpPeriod is a BLOCK/MUTEX sampling rate. Drop the
unsupported eventType field from PprofAnalyzationRequest.
- All profiling task views (trace/async/eBPF/network/pprof): after create,
poll the task list up to 4×10s until the new task id appears.
- general layer: drop networkProfiling (instance-scoped to
k8s_service/mesh_dp
per booster-ui's shipped templates).
- Refresh stale parseTopList tests to the multi-service-prefix behavior.
---
apps/bff/src/bundled_templates/layers/general.json | 1 -
apps/bff/src/client/config-dump.ts | 60 ++++
apps/bff/src/config/schema.ts | 4 +
apps/bff/src/http/admin/oap-config.ts | 75 +++++
apps/bff/src/http/query/async-profile.ts | 7 +-
apps/bff/src/http/query/dashboard.test.ts | 70 ++++-
apps/bff/src/http/query/ttl.ts | 81 ++++++
apps/bff/src/rbac/route-policy.ts | 2 +
apps/bff/src/rbac/verbs.ts | 2 +
apps/bff/src/server.ts | 4 +
apps/ui/src/api/client.ts | 7 +
apps/ui/src/api/scopes/oap-ops.ts | 33 +++
apps/ui/src/api/scopes/pprof.ts | 1 -
apps/ui/src/features/admin/roles/RolesView.vue | 2 +
apps/ui/src/features/operate/config/ConfigView.vue | 312 +++++++++++++++++++++
.../ui/src/features/operate/config/useOapConfig.ts | 44 +++
apps/ui/src/features/operate/ttl/TtlView.vue | 285 +++++++++++++++++++
apps/ui/src/features/operate/ttl/useTtl.ts | 47 ++++
.../layer/profiling/LayerAsyncProfilingView.vue | 16 ++
.../src/layer/profiling/LayerEBPFProfilingView.vue | 16 ++
.../layer/profiling/LayerNetworkProfilingView.vue | 16 ++
.../layer/profiling/LayerPprofProfilingView.vue | 120 +++++---
.../layer/profiling/LayerTraceProfilingView.vue | 16 ++
apps/ui/src/layer/profiling/useNewTaskPoll.ts | 71 +++++
apps/ui/src/shell/AppSidebar.vue | 49 +++-
apps/ui/src/shell/icons.test.ts | 2 +-
apps/ui/src/shell/icons.ts | 2 +-
apps/ui/src/shell/router/index.ts | 12 +
packages/api-client/src/async-profile.ts | 13 +-
packages/api-client/src/index.ts | 7 +
packages/api-client/src/oap-ops.ts | 85 ++++++
31 files changed, 1400 insertions(+), 62 deletions(-)
diff --git a/apps/bff/src/bundled_templates/layers/general.json
b/apps/bff/src/bundled_templates/layers/general.json
index 4319636..c27261d 100644
--- a/apps/bff/src/bundled_templates/layers/general.json
+++ b/apps/bff/src/bundled_templates/layers/general.json
@@ -20,7 +20,6 @@
"traceProfiling": true,
"ebpfProfiling": true,
"asyncProfiling": true,
- "networkProfiling": true,
"pprofProfiling": true
},
"layer-header": {
diff --git a/apps/bff/src/client/config-dump.ts
b/apps/bff/src/client/config-dump.ts
new file mode 100644
index 0000000..c840601
--- /dev/null
+++ b/apps/bff/src/client/config-dump.ts
@@ -0,0 +1,60 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { FetchLike } from '@skywalking-horizon-ui/api-client';
+import { basicAuthHeader } from './graphql.js';
+
+/**
+ * Read OAP's resolved runtime config from the admin port's
+ * `/debugging/config/dump`. With `Accept: application/json` OAP returns
+ * a flat `Record<string,string>`; secrets are masked server-side. This
+ * is the canonical fetch for the read-only OAP-config page; preflight
+ * and mqe-target keep their own narrower copies because they only probe
+ * a couple of keys and predate this helper.
+ */
+export async function fetchConfigDump(
+ adminUrl: string,
+ opts: {
+ fetch?: FetchLike;
+ timeoutMs: number;
+ auth?: { username: string; password: string };
+ },
+): Promise<Record<string, string>> {
+ const f = opts.fetch ?? globalThis.fetch.bind(globalThis);
+ const url = `${adminUrl.replace(/\/$/, '')}/debugging/config/dump`;
+ const headers: Record<string, string> = { accept: 'application/json' };
+ if (opts.auth) {
+ headers.authorization = basicAuthHeader(opts.auth.username,
opts.auth.password);
+ }
+ let init: RequestInit = { method: 'GET', headers };
+ let timer: ReturnType<typeof setTimeout> | null = null;
+ if (opts.timeoutMs > 0) {
+ const ctrl = new AbortController();
+ timer = setTimeout(() => ctrl.abort(), opts.timeoutMs);
+ init = { ...init, signal: ctrl.signal };
+ }
+ try {
+ const res = await f(url, init);
+ if (!res.ok) {
+ const text = (await res.text().catch(() => '')).slice(0, 200);
+ throw new Error(`HTTP ${res.status} at ${url}${text ? ` — ${text}` :
''}`);
+ }
+ return (await res.json()) as Record<string, string>;
+ } finally {
+ if (timer) clearTimeout(timer);
+ }
+}
diff --git a/apps/bff/src/config/schema.ts b/apps/bff/src/config/schema.ts
index 18ddadf..9ac009a 100644
--- a/apps/bff/src/config/schema.ts
+++ b/apps/bff/src/config/schema.ts
@@ -189,6 +189,8 @@ const rbacSchema = z
'profile:read',
'cluster:read',
'inspect:read',
+ 'ttl:read',
+ 'config:read',
],
// Configures observability: dashboards, alarm rules, DSL/OAL,
// diagnostics. Inherits viewer + platform reads so operators
@@ -202,6 +204,8 @@ const rbacSchema = z
'profile:read',
'cluster:read',
'inspect:read',
+ 'ttl:read',
+ 'config:read',
'overview:read',
'overview:write',
'setup:read',
diff --git a/apps/bff/src/http/admin/oap-config.ts
b/apps/bff/src/http/admin/oap-config.ts
new file mode 100644
index 0000000..7f72b98
--- /dev/null
+++ b/apps/bff/src/http/admin/oap-config.ts
@@ -0,0 +1,75 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * `GET /api/oap/config` — read-only snapshot of OAP's resolved runtime
+ * config from the admin port's `/debugging/config/dump`. OAP masks
+ * secret values server-side, so this is safe to surface. Never rejects
+ * on an unreachable admin host: returns `{ reachable: false }` so the
+ * page degrades like the cluster-status admin pane does.
+ */
+
+import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
+import type {
+ FetchLike,
+ OapConfigEntry,
+ OapConfigResponse,
+} from '@skywalking-horizon-ui/api-client';
+import type { ConfigSource } from '../../config/loader.js';
+import type { SessionStore } from '../../user/sessions.js';
+import { requireAuth } from '../../user/middleware.js';
+import { fetchConfigDump } from '../../client/config-dump.js';
+
+export interface OapConfigRouteDeps {
+ config: ConfigSource;
+ sessions: SessionStore;
+ fetch?: FetchLike;
+}
+
+export function registerOapConfigRoute(app: FastifyInstance, deps:
OapConfigRouteDeps): void {
+ const auth = requireAuth(deps);
+ app.get('/api/oap/config', { preHandler: auth }, async (_req:
FastifyRequest, reply: FastifyReply) => {
+ const cfg = deps.config.current;
+ const adminUrl = cfg.oap.adminUrl;
+ try {
+ const dump = await fetchConfigDump(adminUrl, {
+ fetch: deps.fetch,
+ timeoutMs: cfg.oap.timeoutMs,
+ auth: cfg.oap.auth,
+ });
+ const entries: OapConfigEntry[] = Object.entries(dump)
+ .map(([key, value]) => ({ key, value: String(value), module:
key.split('.', 1)[0] }))
+ .sort((a, b) => a.key.localeCompare(b.key));
+ const body: OapConfigResponse = {
+ reachable: true,
+ adminUrl,
+ entries,
+ generatedAt: Date.now(),
+ };
+ return reply.send(body);
+ } catch (err) {
+ const body: OapConfigResponse = {
+ reachable: false,
+ error: err instanceof Error ? err.message : String(err),
+ adminUrl,
+ entries: [],
+ generatedAt: Date.now(),
+ };
+ return reply.status(200).send(body);
+ }
+ });
+}
diff --git a/apps/bff/src/http/query/async-profile.ts
b/apps/bff/src/http/query/async-profile.ts
index 652ca61..6432483 100644
--- a/apps/bff/src/http/query/async-profile.ts
+++ b/apps/bff/src/http/query/async-profile.ts
@@ -374,15 +374,18 @@ export function registerAsyncProfileRoutes(
{ preHandler: auth },
async (req: FastifyRequest, reply: FastifyReply) => {
const body = req.body as
- | { taskId: string; instanceIds: string[]; eventType: string }
+ | { taskId: string; instanceIds: string[] }
| undefined;
const payload: PprofAnalyzeResponse = { tree: null, reachable: true };
if (!body?.taskId || !body.instanceIds?.length) return
reply.send(payload);
const opts = buildOapOpts(deps.config.current, deps.fetch);
try {
+ // PprofAnalyzationRequest is taskId + instanceIds only — pprof
+ // tasks are single-event, so there's no eventType selector here.
+ const request = { taskId: body.taskId, instanceIds: body.instanceIds };
const data = await graphqlPost<{
analysisResult: { tree: PprofAnalyzeResponse['tree'] } | null;
- }>(opts, GET_PPROF_ANALYZE, { request: body });
+ }>(opts, GET_PPROF_ANALYZE, { request });
payload.tree = data.analysisResult?.tree ?? null;
return reply.send(payload);
} catch (err) {
diff --git a/apps/bff/src/http/query/dashboard.test.ts
b/apps/bff/src/http/query/dashboard.test.ts
index 4aed812..ac34fa9 100644
--- a/apps/bff/src/http/query/dashboard.test.ts
+++ b/apps/bff/src/http/query/dashboard.test.ts
@@ -232,7 +232,7 @@ describe('parseLabeledSeries — relabels() multi-result
extraction', () => {
});
describe('parseTopList — owner-scope priority for display names', () => {
- it('endpoint owner → "service · endpoint"', () => {
+ it('multi-service endpoint owners → "service · endpoint" (prefix
disambiguates)', () => {
const r: MqeResultShape = {
type: 'SORTED_LIST',
results: [
@@ -242,11 +242,42 @@ describe('parseTopList — owner-scope priority for display
names', () => {
value: '100',
owner: { scope: 'Endpoint', serviceName: 'frontend',
endpointName: '/api/order' },
},
+ {
+ value: '80',
+ owner: { scope: 'Endpoint', serviceName: 'backend',
endpointName: '/api/pay' },
+ },
],
},
],
};
- expect(parseTopList(r)).toEqual([{ name: 'frontend · /api/order', value:
100 }]);
+ expect(parseTopList(r)).toEqual([
+ { name: 'frontend · /api/order', value: 100 },
+ { name: 'backend · /api/pay', value: 80 },
+ ]);
+ });
+
+ it('single-service endpoint owners → endpoint alone (redundant prefix
dropped)', () => {
+ const r: MqeResultShape = {
+ type: 'SORTED_LIST',
+ results: [
+ {
+ values: [
+ {
+ value: '100',
+ owner: { scope: 'Endpoint', serviceName: 'frontend',
endpointName: '/api/order' },
+ },
+ {
+ value: '60',
+ owner: { scope: 'Endpoint', serviceName: 'frontend',
endpointName: '/api/pay' },
+ },
+ ],
+ },
+ ],
+ };
+ expect(parseTopList(r)).toEqual([
+ { name: '/api/order', value: 100 },
+ { name: '/api/pay', value: 60 },
+ ]);
});
it('endpoint owner without serviceName → endpoint alone', () => {
@@ -261,7 +292,7 @@ describe('parseTopList — owner-scope priority for display
names', () => {
expect(parseTopList(r)).toEqual([{ name: '/loose', value: 5 }]);
});
- it('instance owner → "service · instance"', () => {
+ it('multi-service instance owners → "service · instance" (prefix
disambiguates)', () => {
const r: MqeResultShape = {
type: 'SORTED_LIST',
results: [
@@ -271,11 +302,42 @@ describe('parseTopList — owner-scope priority for display
names', () => {
value: '7',
owner: { scope: 'ServiceInstance', serviceName: 'svc-a',
serviceInstanceName: 'pod-1' },
},
+ {
+ value: '3',
+ owner: { scope: 'ServiceInstance', serviceName: 'svc-b',
serviceInstanceName: 'pod-2' },
+ },
],
},
],
};
- expect(parseTopList(r)).toEqual([{ name: 'svc-a · pod-1', value: 7 }]);
+ expect(parseTopList(r)).toEqual([
+ { name: 'svc-a · pod-1', value: 7 },
+ { name: 'svc-b · pod-2', value: 3 },
+ ]);
+ });
+
+ it('single-service instance owners → instance alone (redundant prefix
dropped)', () => {
+ const r: MqeResultShape = {
+ type: 'SORTED_LIST',
+ results: [
+ {
+ values: [
+ {
+ value: '7',
+ owner: { scope: 'ServiceInstance', serviceName: 'svc-a',
serviceInstanceName: 'pod-1' },
+ },
+ {
+ value: '4',
+ owner: { scope: 'ServiceInstance', serviceName: 'svc-a',
serviceInstanceName: 'pod-2' },
+ },
+ ],
+ },
+ ],
+ };
+ expect(parseTopList(r)).toEqual([
+ { name: 'pod-1', value: 7 },
+ { name: 'pod-2', value: 4 },
+ ]);
});
it('service-only owner → service name', () => {
diff --git a/apps/bff/src/http/query/ttl.ts b/apps/bff/src/http/query/ttl.ts
new file mode 100644
index 0000000..ce24cdc
--- /dev/null
+++ b/apps/bff/src/http/query/ttl.ts
@@ -0,0 +1,81 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * `GET /api/oap/ttl` — one round-trip combining `getRecordsTTL` and
+ * `getMetricsTTL` on the query port. Read-only data-retention view.
+ * Never rejects on an unreachable OAP: returns `{ reachable: false }`
+ * so the page degrades instead of 502-ing.
+ */
+
+import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
+import type {
+ FetchLike,
+ MetricsTTL,
+ OapTtlResponse,
+ RecordsTTL,
+} from '@skywalking-horizon-ui/api-client';
+import type { ConfigSource } from '../../config/loader.js';
+import type { SessionStore } from '../../user/sessions.js';
+import { requireAuth } from '../../user/middleware.js';
+import { buildOapOpts, graphqlPost } from '../../client/graphql.js';
+
+const TTL_QUERY = /* GraphQL */ `
+ query HorizonOapTtl {
+ records: getRecordsTTL {
+ normal trace zipkinTrace log browserErrorLog
+ coldNormal coldTrace coldZipkinTrace coldLog coldBrowserErrorLog
+ }
+ metrics: getMetricsTTL {
+ metadata minute hour day
+ coldMinute coldHour coldDay
+ }
+ }
+`;
+
+interface TtlRaw {
+ records?: RecordsTTL | null;
+ metrics?: MetricsTTL | null;
+}
+
+export interface TtlRouteDeps {
+ config: ConfigSource;
+ sessions: SessionStore;
+ fetch?: FetchLike;
+}
+
+export function registerTtlRoute(app: FastifyInstance, deps: TtlRouteDeps):
void {
+ const auth = requireAuth(deps);
+ app.get('/api/oap/ttl', { preHandler: auth }, async (_req: FastifyRequest,
reply: FastifyReply) => {
+ const cfg = deps.config.current;
+ try {
+ const raw = await graphqlPost<TtlRaw>(buildOapOpts(cfg, deps.fetch),
TTL_QUERY);
+ const body: OapTtlResponse = {
+ reachable: true,
+ records: raw.records ?? undefined,
+ metrics: raw.metrics ?? undefined,
+ };
+ return reply.send(body);
+ } catch (err) {
+ const body: OapTtlResponse = {
+ reachable: false,
+ error: err instanceof Error ? err.message : String(err),
+ };
+ return reply.status(200).send(body);
+ }
+ });
+}
diff --git a/apps/bff/src/rbac/route-policy.ts
b/apps/bff/src/rbac/route-policy.ts
index 92374d8..94500f3 100644
--- a/apps/bff/src/rbac/route-policy.ts
+++ b/apps/bff/src/rbac/route-policy.ts
@@ -170,6 +170,8 @@ export const ROUTE_POLICY: Record<string, RoutePolicy> = {
'GET /api/inspect/server-time': 'inspect:read',
'POST /api/inspect/exec': 'inspect:read',
'GET /api/inspect/entities': 'inspect:read',
+ 'GET /api/oap/ttl': 'ttl:read',
+ 'GET /api/oap/config': 'config:read',
// ── Live debugger (admin operate) ────────────────────────────────
'POST /api/debug/session': 'live-debug:write',
diff --git a/apps/bff/src/rbac/verbs.ts b/apps/bff/src/rbac/verbs.ts
index 5af2e9d..04da2c3 100644
--- a/apps/bff/src/rbac/verbs.ts
+++ b/apps/bff/src/rbac/verbs.ts
@@ -57,6 +57,8 @@ export const VERBS = {
// Platform monitoring (read-only screens that focus on OAP itself).
clusterRead: 'cluster:read',
inspectRead: 'inspect:read',
+ ttlRead: 'ttl:read',
+ configRead: 'config:read',
// Admin surface
userRead: 'user:read',
diff --git a/apps/bff/src/server.ts b/apps/bff/src/server.ts
index 86547ec..a3edb89 100644
--- a/apps/bff/src/server.ts
+++ b/apps/bff/src/server.ts
@@ -43,6 +43,7 @@ import { registerLogRoute } from './http/query/log.js';
import { registerDashboardQueryRoute } from './http/query/dashboard.js';
import { registerAlarmsQueryRoutes } from './http/query/alarms.js';
import { registerPreflightRoutes } from './http/query/preflight.js';
+import { registerTtlRoute } from './http/query/ttl.js';
import { registerProfileRoutes } from './http/query/profile.js';
import { registerEBPFRoutes } from './http/query/ebpf.js';
import { registerAsyncProfileRoutes } from './http/query/async-profile.js';
@@ -66,6 +67,7 @@ import { registerDslOalRoutes } from
'./http/admin/dsl/oal.js';
import { registerClusterRoutes } from './http/admin/cluster.js';
import { registerDebugRoutes } from './http/admin/live-debug.js';
import { registerInspectRoutes } from './http/admin/inspect.js';
+import { registerOapConfigRoute } from './http/admin/oap-config.js';
import { registerAlarmRulesRoutes } from './http/admin/alarm-rules.js';
import { registerOverviewTemplatesAdminRoutes } from
'./http/admin/overview-templates.js';
import { registerAuthStatusRoutes } from './http/admin/auth-status.js';
@@ -156,6 +158,7 @@ registerLogRoute(app, { config: source, sessions });
registerDashboardQueryRoute(app, { config: source, sessions });
registerAlarmsQueryRoutes(app, { config: source, sessions, serviceLayer });
registerPreflightRoutes(app, { config: source, sessions });
+registerTtlRoute(app, { config: source, sessions });
registerProfileRoutes(app, { config: source, sessions });
registerEBPFRoutes(app, { config: source, sessions });
registerAsyncProfileRoutes(app, { config: source, sessions });
@@ -184,6 +187,7 @@ registerDslOalRoutes(app, { config: source, sessions, audit
});
registerClusterRoutes(app, { config: source, sessions, audit });
registerDebugRoutes(app, { config: source, sessions, audit });
registerInspectRoutes(app, { config: source, sessions, audit });
+registerOapConfigRoute(app, { config: source, sessions });
registerAlarmRulesRoutes(app, { config: source, sessions });
registerOverviewTemplatesAdminRoutes(app, { config: source, sessions });
registerAuthStatusRoutes(app, { config: source, ldapHealth, sessions });
diff --git a/apps/ui/src/api/client.ts b/apps/ui/src/api/client.ts
index 249c34a..bf0bf22 100644
--- a/apps/ui/src/api/client.ts
+++ b/apps/ui/src/api/client.ts
@@ -78,6 +78,7 @@ import { PprofApi } from './scopes/pprof';
import { DslApi } from './scopes/dsl';
import { LiveDebugApi } from './scopes/live-debug';
import { InspectApi } from './scopes/inspect';
+import { OapOpsApi } from './scopes/oap-ops';
import { AlarmsApi } from './scopes/alarms';
import { LayerTemplatesApi } from './scopes/layer-template';
import { ConfigsApi } from './scopes/configs';
@@ -93,6 +94,11 @@ export type {
LayerCaps,
LayerSlots,
OapInfo,
+ RecordsTTL,
+ MetricsTTL,
+ OapTtlResponse,
+ OapConfigEntry,
+ OapConfigResponse,
SetupResponse,
SetupSavePayload,
LayerConfig,
@@ -667,6 +673,7 @@ export class BffClient {
readonly dsl = new DslApi(this);
readonly liveDebug = new LiveDebugApi(this);
readonly inspect = new InspectApi(this);
+ readonly oapOps = new OapOpsApi(this);
readonly alarms = new AlarmsApi(this);
readonly layerTemplates = new LayerTemplatesApi(this);
readonly configs = new ConfigsApi(this);
diff --git a/apps/ui/src/api/scopes/oap-ops.ts
b/apps/ui/src/api/scopes/oap-ops.ts
new file mode 100644
index 0000000..a2aabb7
--- /dev/null
+++ b/apps/ui/src/api/scopes/oap-ops.ts
@@ -0,0 +1,33 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { OapConfigResponse, OapTtlResponse } from
'@skywalking-horizon-ui/api-client';
+import type { BffClient } from '../client';
+
+/** `bff.oapOps` — read-only OAP operations diagnostics: data-retention
+ * (TTL) and the resolved runtime config dump. */
+export class OapOpsApi {
+ constructor(private readonly bff: BffClient) {}
+
+ ttl(): Promise<OapTtlResponse> {
+ return this.bff.request<OapTtlResponse>('GET', '/api/oap/ttl');
+ }
+
+ config(): Promise<OapConfigResponse> {
+ return this.bff.request<OapConfigResponse>('GET', '/api/oap/config');
+ }
+}
diff --git a/apps/ui/src/api/scopes/pprof.ts b/apps/ui/src/api/scopes/pprof.ts
index fbe1f16..552c83c 100644
--- a/apps/ui/src/api/scopes/pprof.ts
+++ b/apps/ui/src/api/scopes/pprof.ts
@@ -53,7 +53,6 @@ export class PprofApi {
analyze(body: {
taskId: string;
instanceIds: string[];
- eventType: string;
}): Promise<PprofAnalyzeResponse> {
return this.bff.request<PprofAnalyzeResponse>('POST',
'/api/pprof/analyze', body);
}
diff --git a/apps/ui/src/features/admin/roles/RolesView.vue
b/apps/ui/src/features/admin/roles/RolesView.vue
index 3b1a1f7..ced5758 100644
--- a/apps/ui/src/features/admin/roles/RolesView.vue
+++ b/apps/ui/src/features/admin/roles/RolesView.vue
@@ -73,6 +73,8 @@ const MENU_GATES: ReadonlyArray<{ label: string; verb: string
| null }> = [
{ label: 'Cluster status', verb: 'cluster:read' },
{ label: 'Platform monitoring (layers)', verb: 'cluster:read' },
{ label: 'Metrics Inspect', verb: 'inspect:read' },
+ { label: 'Data retention (TTL)', verb: 'ttl:read' },
+ { label: 'OAP configuration', verb: 'config:read' },
{ label: 'Alerting rules', verb: 'alarm-rule:read' },
{ label: 'Live debugger · Capture history', verb: 'live-debug:read' },
{ label: 'DSL Management', verb: 'rule:read' },
diff --git a/apps/ui/src/features/operate/config/ConfigView.vue
b/apps/ui/src/features/operate/config/ConfigView.vue
new file mode 100644
index 0000000..d9c46a2
--- /dev/null
+++ b/apps/ui/src/features/operate/config/ConfigView.vue
@@ -0,0 +1,312 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements. See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<script setup lang="ts">
+import { computed, ref } from 'vue';
+import { useOapConfig } from './useOapConfig';
+
+// Read-only snapshot of OAP's resolved runtime config, grouped by module
+// (the first dotted segment). OAP masks secret values to `******`
+// server-side, so nothing sensitive is exposed here.
+
+const { reachable, entries, data, isLoading, refetch } = useOapConfig();
+
+const filter = ref('');
+
+interface Group {
+ module: string;
+ rows: { key: string; value: string }[];
+}
+
+const groups = computed<Group[]>(() => {
+ const needle = filter.value.trim().toLowerCase();
+ const byModule = new Map<string, { key: string; value: string }[]>();
+ for (const e of entries.value) {
+ if (needle && !e.key.toLowerCase().includes(needle) &&
!e.value.toLowerCase().includes(needle)) {
+ continue;
+ }
+ const list = byModule.get(e.module) ?? [];
+ list.push({ key: e.key, value: e.value });
+ byModule.set(e.module, list);
+ }
+ return [...byModule.entries()]
+ .map(([module, rows]) => ({ module, rows }))
+ .sort((a, b) => a.module.localeCompare(b.module));
+});
+
+const matchCount = computed<number>(() => groups.value.reduce((n, g) => n +
g.rows.length, 0));
+
+function isMasked(v: string): boolean {
+ return v === '******';
+}
+</script>
+
+<template>
+ <div class="cfg">
+ <header class="page-head">
+ <div>
+ <div class="kicker">Operate · OAP configuration</div>
+ <h1>Runtime config</h1>
+ <p class="lede">
+ The connected OAP's resolved configuration, read from the admin
port's
+ <code>/debugging/config/dump</code> and grouped by module. Secret
values
+ (passwords, tokens, access keys) are masked to <code>******</code>
by OAP itself.
+ Read-only — change config on the OAP side and restart.
+ </p>
+ </div>
+ <button type="button" class="refresh" @click="refetch()">refresh</button>
+ </header>
+
+ <div v-if="!reachable && data?.error" class="last-error block">
+ <strong>Admin host unreachable</strong>
+ <code>{{ data.error }}</code>
+ <p class="hint">
+ Tried <code>{{ data.adminUrl }}/debugging/config/dump</code>.
+ Confirm the OAP <code>admin-server</code> module is on
+ (<code>SW_ADMIN_SERVER=default</code>) and the port is exposed.
+ </p>
+ </div>
+
+ <div v-else-if="isLoading && !data" class="empty">Reading data…</div>
+
+ <template v-else>
+ <div class="toolbar">
+ <input
+ v-model="filter"
+ type="text"
+ class="filter"
+ placeholder="Filter keys or values…"
+ spellcheck="false"
+ />
+ <span class="count">{{ matchCount }} of {{ entries.length }}
keys</span>
+ </div>
+
+ <div v-if="groups.length === 0" class="empty">No keys match “{{ filter
}}”.</div>
+
+ <section v-for="g in groups" :key="g.module" class="modblock">
+ <header class="modblock-head">
+ <code class="modname">{{ g.module }}</code>
+ <span class="modcount">{{ g.rows.length }}</span>
+ </header>
+ <table class="cfg-table">
+ <tbody>
+ <tr v-for="row in g.rows" :key="row.key">
+ <td class="ckey"><code>{{ row.key }}</code></td>
+ <td class="cval" :class="{ empty: row.value === '', masked:
isMasked(row.value) }">
+ <code>{{ row.value === '' ? '(empty)' : row.value }}</code>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </section>
+ </template>
+ </div>
+</template>
+
+<style scoped>
+.cfg {
+ padding: 20px 20px 60px;
+ max-width: 1440px;
+ margin: 0 auto;
+}
+.page-head {
+ display: flex;
+ align-items: flex-start;
+ gap: 16px;
+ margin-bottom: 22px;
+}
+.page-head > div {
+ flex: 1;
+}
+.kicker {
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ color: var(--sw-accent);
+ margin-bottom: 6px;
+}
+.page-head h1 {
+ font-size: 22px;
+ font-weight: 600;
+ letter-spacing: -0.02em;
+ color: var(--sw-fg-0);
+ margin: 0 0 8px;
+}
+.lede {
+ font-size: 12.5px;
+ color: var(--sw-fg-1);
+ line-height: 1.5;
+ margin: 0;
+ max-width: 760px;
+}
+.lede code {
+ font-family: var(--sw-mono);
+ background: var(--sw-bg-1);
+ padding: 1px 5px;
+ border-radius: 3px;
+ font-size: 11px;
+}
+.refresh {
+ background: var(--sw-bg-1);
+ border: 1px solid var(--sw-line-2);
+ color: var(--sw-fg-1);
+ font-size: 11px;
+ padding: 6px 10px;
+ border-radius: 6px;
+ cursor: pointer;
+}
+.refresh:hover {
+ background: var(--sw-bg-2);
+ color: var(--sw-fg-0);
+}
+
+.toolbar {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 16px;
+}
+.filter {
+ flex: 1;
+ max-width: 420px;
+ background: var(--sw-bg-1);
+ border: 1px solid var(--sw-line-2);
+ border-radius: 6px;
+ color: var(--sw-fg-0);
+ font-size: 12px;
+ padding: 7px 10px;
+ font-family: var(--sw-mono);
+}
+.filter:focus {
+ outline: none;
+ border-color: var(--sw-accent);
+}
+.count {
+ font-size: 11px;
+ color: var(--sw-fg-3);
+ font-variant-numeric: tabular-nums;
+}
+
+.modblock {
+ margin-bottom: 18px;
+}
+.modblock-head {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 6px;
+}
+.modblock-head .modname {
+ font-family: var(--sw-mono);
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--sw-accent);
+}
+.modblock-head .modcount {
+ font-size: 10.5px;
+ color: var(--sw-fg-3);
+ background: var(--sw-bg-1);
+ border-radius: 999px;
+ padding: 1px 7px;
+ font-variant-numeric: tabular-nums;
+}
+
+.cfg-table {
+ width: 100%;
+ border-collapse: collapse;
+ background: var(--sw-bg-1);
+ border: 1px solid var(--sw-line);
+ border-radius: 8px;
+ overflow: hidden;
+ font-size: 11.5px;
+ table-layout: fixed;
+}
+.cfg-table td {
+ padding: 6px 12px;
+ border-bottom: 1px solid var(--sw-line);
+ vertical-align: top;
+ word-break: break-all;
+}
+.cfg-table tr:last-child td {
+ border-bottom: none;
+}
+.ckey {
+ width: 44%;
+}
+.ckey code {
+ font-family: var(--sw-mono);
+ color: var(--sw-fg-1);
+}
+.cval code {
+ font-family: var(--sw-mono);
+ color: var(--sw-fg-0);
+}
+.cval.empty code {
+ color: var(--sw-fg-3);
+ font-style: italic;
+}
+.cval.masked code {
+ color: var(--sw-warn);
+ letter-spacing: 0.1em;
+}
+
+.empty {
+ padding: 14px;
+ color: var(--sw-fg-3);
+ font-size: 12px;
+ background: var(--sw-bg-1);
+ border: 1px dashed var(--sw-line-2);
+ border-radius: 6px;
+}
+
+.last-error {
+ margin-bottom: 22px;
+ padding: 10px 12px;
+ background: var(--sw-err-soft);
+ border: 1px solid rgba(239, 68, 68, 0.3);
+ border-radius: 6px;
+ font-size: 11.5px;
+ color: var(--sw-fg-1);
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 6px;
+}
+.last-error strong {
+ color: var(--sw-err);
+ font-weight: 600;
+ text-transform: uppercase;
+ font-size: 10px;
+ letter-spacing: 0.08em;
+}
+.last-error code {
+ font-family: var(--sw-mono);
+ font-size: 11.5px;
+ color: var(--sw-fg-0);
+ word-break: break-all;
+}
+.last-error .hint {
+ margin: 6px 0 0;
+ font-size: 11.5px;
+ color: var(--sw-fg-1);
+ line-height: 1.5;
+}
+.last-error .hint code {
+ background: rgba(0, 0, 0, 0.25);
+ padding: 1px 4px;
+ border-radius: 3px;
+}
+</style>
diff --git a/apps/ui/src/features/operate/config/useOapConfig.ts
b/apps/ui/src/features/operate/config/useOapConfig.ts
new file mode 100644
index 0000000..3947bad
--- /dev/null
+++ b/apps/ui/src/features/operate/config/useOapConfig.ts
@@ -0,0 +1,44 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { computed } from 'vue';
+import { useQuery } from '@tanstack/vue-query';
+import type { OapConfigResponse } from '@skywalking-horizon-ui/api-client';
+import { bffClient } from '@/api/client';
+
+/** Live OAP runtime-config dump (`/debugging/config/dump`). Only changes
+ * on OAP restart, so polled lazily; the page exposes a manual refresh. */
+export function useOapConfig() {
+ const q = useQuery({
+ queryKey: ['oap-config'],
+ queryFn: () => bffClient.oapOps.config(),
+ staleTime: 60_000,
+ refetchOnWindowFocus: false,
+ });
+
+ const data = computed<OapConfigResponse | null>(() => q.data.value ?? null);
+ const reachable = computed<boolean>(() => data.value?.reachable ?? false);
+ const entries = computed(() => data.value?.entries ?? []);
+
+ return {
+ isLoading: q.isLoading,
+ data,
+ reachable,
+ entries,
+ refetch: q.refetch,
+ };
+}
diff --git a/apps/ui/src/features/operate/ttl/TtlView.vue
b/apps/ui/src/features/operate/ttl/TtlView.vue
new file mode 100644
index 0000000..c5b20de
--- /dev/null
+++ b/apps/ui/src/features/operate/ttl/TtlView.vue
@@ -0,0 +1,285 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements. See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<script setup lang="ts">
+import { computed } from 'vue';
+import { useTtl } from './useTtl';
+
+// Read-only data-retention view. OAP reports TTL in whole DAYS, split by
+// hot/warm (the plain field) and cold (`cold*`). A cold value of -1 means
+// the backend has no cold stage configured, rendered as "no cold stage".
+
+const { reachable, records, metrics, data, isLoading, refetch } = useTtl();
+
+interface Row {
+ label: string;
+ hot: number | undefined;
+ cold?: number | undefined;
+ /** metadata has no cold counterpart. */
+ hasCold: boolean;
+}
+
+const recordRows = computed<Row[]>(() => {
+ const r = records.value;
+ if (!r) return [];
+ return [
+ { label: 'Normal', hot: r.normal, cold: r.coldNormal, hasCold: true },
+ { label: 'Trace', hot: r.trace, cold: r.coldTrace, hasCold: true },
+ { label: 'Zipkin trace', hot: r.zipkinTrace, cold: r.coldZipkinTrace,
hasCold: true },
+ { label: 'Log', hot: r.log, cold: r.coldLog, hasCold: true },
+ { label: 'Browser error log', hot: r.browserErrorLog, cold:
r.coldBrowserErrorLog, hasCold: true },
+ ];
+});
+
+const metricRows = computed<Row[]>(() => {
+ const m = metrics.value;
+ if (!m) return [];
+ return [
+ { label: 'Metadata', hot: m.metadata, hasCold: false },
+ { label: 'Minute', hot: m.minute, cold: m.coldMinute, hasCold: true },
+ { label: 'Hour', hot: m.hour, cold: m.coldHour, hasCold: true },
+ { label: 'Day', hot: m.day, cold: m.coldDay, hasCold: true },
+ ];
+});
+
+function days(n: number | undefined): string {
+ return n === undefined ? '—' : `${n} d`;
+}
+function coldLabel(r: Row): string {
+ if (!r.hasCold) return 'no cold stage';
+ if (r.cold === undefined) return '—';
+ return r.cold < 0 ? 'no cold stage' : `cold: ${r.cold} d`;
+}
+</script>
+
+<template>
+ <div class="ttl">
+ <header class="page-head">
+ <div>
+ <div class="kicker">Operate · Data retention</div>
+ <h1>TTL</h1>
+ <p class="lede">
+ How long the connected OAP keeps each class of data, reported in
whole days.
+ <strong>Records</strong> cover event-style data (traces, logs);
+ <strong>metrics</strong> cover the aggregated tiers (minute / hour /
day) plus metadata.
+ The <code>cold</code> value is the cold-stage retention (BanyanDB) —
<em>no cold stage</em>
+ means cold storage isn't configured. Read-only; change retention on
the OAP side.
+ </p>
+ </div>
+ <button type="button" class="refresh" @click="refetch()">refresh</button>
+ </header>
+
+ <div v-if="!reachable && data?.error" class="last-error block">
+ <strong>OAP unreachable</strong>
+ <code>{{ data.error }}</code>
+ <p class="hint">
+ TTL is read from the query / GraphQL port via
<code>getRecordsTTL</code> /
+ <code>getMetricsTTL</code>. Confirm <code>oap.queryUrl</code> points
at a live OAP.
+ </p>
+ </div>
+
+ <div v-else-if="isLoading && !data" class="empty">Reading data…</div>
+
+ <template v-else>
+ <section class="pane">
+ <header class="pane-head"><h2>Records</h2></header>
+ <div class="grid">
+ <div v-for="row in recordRows" :key="row.label" class="sw-card kpi">
+ <div class="sw-card-head"><h4>{{ row.label }}</h4></div>
+ <div class="kpi-body">
+ <div class="kpi-value">{{ days(row.hot) }}</div>
+ <div class="kpi-label" :class="{ none: row.hasCold && (row.cold
?? -1) < 0 }">
+ {{ coldLabel(row) }}
+ </div>
+ </div>
+ </div>
+ </div>
+ </section>
+
+ <section class="pane">
+ <header class="pane-head"><h2>Metrics</h2></header>
+ <div class="grid">
+ <div v-for="row in metricRows" :key="row.label" class="sw-card kpi">
+ <div class="sw-card-head"><h4>{{ row.label }}</h4></div>
+ <div class="kpi-body">
+ <div class="kpi-value">{{ days(row.hot) }}</div>
+ <div class="kpi-label" :class="{ none: row.hasCold && (row.cold
?? -1) < 0 }">
+ {{ coldLabel(row) }}
+ </div>
+ </div>
+ </div>
+ </div>
+ </section>
+ </template>
+ </div>
+</template>
+
+<style scoped>
+.ttl {
+ padding: 20px 20px 60px;
+ max-width: 1440px;
+ margin: 0 auto;
+}
+.page-head {
+ display: flex;
+ align-items: flex-start;
+ gap: 16px;
+ margin-bottom: 22px;
+}
+.page-head > div {
+ flex: 1;
+}
+.kicker {
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ color: var(--sw-accent);
+ margin-bottom: 6px;
+}
+.page-head h1 {
+ font-size: 22px;
+ font-weight: 600;
+ letter-spacing: -0.02em;
+ color: var(--sw-fg-0);
+ margin: 0 0 8px;
+}
+.lede {
+ font-size: 12.5px;
+ color: var(--sw-fg-1);
+ line-height: 1.5;
+ margin: 0;
+ max-width: 760px;
+}
+.lede code {
+ font-family: var(--sw-mono);
+ background: var(--sw-bg-1);
+ padding: 1px 5px;
+ border-radius: 3px;
+ font-size: 11px;
+}
+.refresh {
+ background: var(--sw-bg-1);
+ border: 1px solid var(--sw-line-2);
+ color: var(--sw-fg-1);
+ font-size: 11px;
+ padding: 6px 10px;
+ border-radius: 6px;
+ cursor: pointer;
+}
+.refresh:hover {
+ background: var(--sw-bg-2);
+ color: var(--sw-fg-0);
+}
+
+.pane {
+ margin-bottom: 26px;
+}
+.pane-head {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 10px;
+}
+.pane-head h2 {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--sw-fg-0);
+ margin: 0;
+ letter-spacing: -0.01em;
+}
+
+.grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: 12px;
+}
+.kpi .sw-card-head {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.kpi .sw-card-head h4 {
+ flex: 1;
+}
+.kpi-body {
+ padding: 14px 12px;
+}
+.kpi-value {
+ font-size: 24px;
+ font-weight: 600;
+ letter-spacing: -0.02em;
+ color: var(--sw-fg-0);
+ font-variant-numeric: tabular-nums;
+ line-height: 1.1;
+}
+.kpi-label {
+ margin-top: 4px;
+ font-size: 11px;
+ color: var(--sw-fg-2);
+ font-variant-numeric: tabular-nums;
+}
+.kpi-label.none {
+ color: var(--sw-fg-3);
+ font-style: italic;
+}
+
+.empty {
+ padding: 14px;
+ color: var(--sw-fg-3);
+ font-size: 12px;
+ background: var(--sw-bg-1);
+ border: 1px dashed var(--sw-line-2);
+ border-radius: 6px;
+}
+
+.last-error {
+ margin-bottom: 22px;
+ padding: 10px 12px;
+ background: var(--sw-err-soft);
+ border: 1px solid rgba(239, 68, 68, 0.3);
+ border-radius: 6px;
+ font-size: 11.5px;
+ color: var(--sw-fg-1);
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 6px;
+}
+.last-error strong {
+ color: var(--sw-err);
+ font-weight: 600;
+ text-transform: uppercase;
+ font-size: 10px;
+ letter-spacing: 0.08em;
+}
+.last-error code {
+ font-family: var(--sw-mono);
+ font-size: 11.5px;
+ color: var(--sw-fg-0);
+ word-break: break-all;
+}
+.last-error .hint {
+ margin: 6px 0 0;
+ font-size: 11.5px;
+ color: var(--sw-fg-1);
+ line-height: 1.5;
+}
+.last-error .hint code {
+ background: rgba(0, 0, 0, 0.25);
+ padding: 1px 4px;
+ border-radius: 3px;
+}
+</style>
diff --git a/apps/ui/src/features/operate/ttl/useTtl.ts
b/apps/ui/src/features/operate/ttl/useTtl.ts
new file mode 100644
index 0000000..7b4e43d
--- /dev/null
+++ b/apps/ui/src/features/operate/ttl/useTtl.ts
@@ -0,0 +1,47 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { computed } from 'vue';
+import { useQuery } from '@tanstack/vue-query';
+import type { OapTtlResponse } from '@skywalking-horizon-ui/api-client';
+import { bffClient } from '@/api/client';
+
+/** Live OAP data-retention (TTL) — `getRecordsTTL` + `getMetricsTTL`.
+ * TTL only changes on OAP config reload, so this is polled lazily; the
+ * page exposes a manual refresh. */
+export function useTtl() {
+ const q = useQuery({
+ queryKey: ['oap-ttl'],
+ queryFn: () => bffClient.oapOps.ttl(),
+ staleTime: 60_000,
+ refetchOnWindowFocus: false,
+ });
+
+ const data = computed<OapTtlResponse | null>(() => q.data.value ?? null);
+ const reachable = computed<boolean>(() => data.value?.reachable ?? false);
+ const records = computed(() => data.value?.records);
+ const metrics = computed(() => data.value?.metrics);
+
+ return {
+ isLoading: q.isLoading,
+ data,
+ reachable,
+ records,
+ metrics,
+ refetch: q.refetch,
+ };
+}
diff --git a/apps/ui/src/layer/profiling/LayerAsyncProfilingView.vue
b/apps/ui/src/layer/profiling/LayerAsyncProfilingView.vue
index 3901422..90fa91c 100644
--- a/apps/ui/src/layer/profiling/LayerAsyncProfilingView.vue
+++ b/apps/ui/src/layer/profiling/LayerAsyncProfilingView.vue
@@ -36,6 +36,7 @@ import type {
ProfileAnalyzationTree,
} from '@/api/client';
import ProfileFlameGraph from '@/layer/profiling/ProfileFlameGraph.vue';
+import { useNewTaskPoll } from '@/layer/profiling/useNewTaskPoll';
import Icon from '@/components/icons/Icon.vue';
const route = useRoute();
@@ -62,6 +63,7 @@ const newTask = reactive({
execArgs: '',
});
const newTaskError = ref<string | null>(null);
+const { polling, pollRound, pollForNewTask } = useNewTaskPoll();
const DURATION_OPTS = [
{ v: 30, label: '30 sec' },
@@ -170,6 +172,7 @@ async function submitNewTask(): Promise<void> {
return;
}
newTaskError.value = null;
+ const idsBefore = new Set(tasks.value.map((t) => t.id));
try {
const resp = await bffClient.asyncProfile.create(layerKey.value, {
serviceId: serviceId.value,
@@ -184,6 +187,11 @@ async function submitNewTask(): Promise<void> {
}
showNewTask.value = false;
await refreshTasks();
+ await pollForNewTask({
+ idsBefore,
+ refresh: refreshTasks,
+ currentIds: () => tasks.value.map((t) => t.id),
+ });
} catch (e) {
newTaskError.value = e instanceof Error ? e.message : String(e);
}
@@ -221,6 +229,7 @@ function instanceName(id: string): string {
>+ New Task</button>
</div>
</div>
+ <div v-if="polling" class="poll-hint">Waiting for new task… ({{
pollRound }}/4)</div>
<div v-if="tasksError" class="side-err">{{ tasksError }}</div>
<div v-else-if="tasksLoading && !tasks.length"
class="side-empty">Loading…</div>
<div v-else-if="!tasks.length" class="side-empty">
@@ -415,6 +424,13 @@ function instanceName(id: string): string {
opacity: 0.5;
cursor: not-allowed;
}
+.poll-hint {
+ padding: 6px 10px;
+ font-size: 10.5px;
+ color: var(--sw-accent);
+ background: var(--sw-bg-2);
+ border-bottom: 1px solid var(--sw-line);
+}
.side-err,
.side-empty {
padding: 12px;
diff --git a/apps/ui/src/layer/profiling/LayerEBPFProfilingView.vue
b/apps/ui/src/layer/profiling/LayerEBPFProfilingView.vue
index abd351b..63bb80c 100644
--- a/apps/ui/src/layer/profiling/LayerEBPFProfilingView.vue
+++ b/apps/ui/src/layer/profiling/LayerEBPFProfilingView.vue
@@ -46,6 +46,7 @@ import type {
} from '@/api/client';
import ProfileFlameGraph from '@/layer/profiling/ProfileFlameGraph.vue';
import ProfileStackTable from '@/layer/profiling/ProfileStackTable.vue';
+import { useNewTaskPoll } from '@/layer/profiling/useNewTaskPoll';
import Icon from '@/components/icons/Icon.vue';
const route = useRoute();
@@ -186,6 +187,7 @@ const newTask = reactive({
monitorMinutes: 10,
});
const newTaskError = ref<string | null>(null);
+const { polling, pollRound, pollForNewTask } = useNewTaskPoll();
watch(
() => layerKey.value + '|' + (selectedId.value ?? ''),
@@ -393,6 +395,7 @@ async function submitNewTask(): Promise<void> {
newTaskError.value = null;
const start =
newTask.monitorTime === 'now' ? Date.now() :
newTask.monitorTimeAt.getTime();
+ const idsBefore = new Set(tasks.value.map((t) => t.taskId));
try {
const resp = await bffClient.ebpf.create(layerKey.value, {
serviceId: selectedId.value,
@@ -412,6 +415,11 @@ async function submitNewTask(): Promise<void> {
showNewTask.value = false;
newTask.labels = [];
await refreshTasks();
+ await pollForNewTask({
+ idsBefore,
+ refresh: refreshTasks,
+ currentIds: () => tasks.value.map((t) => t.taskId),
+ });
} catch (e) {
newTaskError.value = e instanceof Error ? e.message : String(e);
}
@@ -493,6 +501,7 @@ function toggleNewTaskLabel(l: string): void {
>+ New Task</button>
</div>
</div>
+ <div v-if="polling" class="poll-hint">Waiting for new task… ({{
pollRound }}/4)</div>
<div v-if="tasksError" class="side-err">{{ tasksError }}</div>
<div v-else-if="tasksLoading && !tasks.length"
class="side-empty">Loading…</div>
<div v-else-if="!tasks.length" class="side-empty">
@@ -880,6 +889,13 @@ function toggleNewTaskLabel(l: string): void {
@keyframes ebpf-refresh-spin {
to { transform: rotate(360deg); }
}
+.poll-hint {
+ padding: 6px 10px;
+ font-size: 10.5px;
+ color: var(--sw-accent);
+ background: var(--sw-bg-2);
+ border-bottom: 1px solid var(--sw-line);
+}
.side-err,
.side-empty {
padding: 12px;
diff --git a/apps/ui/src/layer/profiling/LayerNetworkProfilingView.vue
b/apps/ui/src/layer/profiling/LayerNetworkProfilingView.vue
index 721cc0b..6245cae 100644
--- a/apps/ui/src/layer/profiling/LayerNetworkProfilingView.vue
+++ b/apps/ui/src/layer/profiling/LayerNetworkProfilingView.vue
@@ -46,6 +46,7 @@ import type {
} from '@/api/client';
import ProcessTopologyGraph from '@/layer/profiling/ProcessTopologyGraph.vue';
import TimeChart from '@/components/charts/TimeChart.vue';
+import { useNewTaskPoll } from '@/layer/profiling/useNewTaskPoll';
import Icon from '@/components/icons/Icon.vue';
const route = useRoute();
@@ -284,6 +285,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown',
onEdgeKeydown));
// ── New task modal ────────────────────────────────────────────────
const showNewTask = ref(false);
const newTaskError = ref<string | null>(null);
+const { polling, pollRound, pollForNewTask } = useNewTaskPoll();
// OAP's `EBPFNetworkDataCollectingSettings.requireCompleteRequest` and
// `requireCompleteResponse` are `Boolean!` — non-null. Every sampling
// row MUST carry the settings block, otherwise
@@ -307,6 +309,7 @@ async function submitNewTask(): Promise<void> {
return;
}
newTaskError.value = null;
+ const idsBefore = new Set(tasks.value.map((t) => t.taskId));
try {
const resp = await bffClient.networkProfile.create({
instanceId: selectedInstanceId.value,
@@ -322,6 +325,11 @@ async function submitNewTask(): Promise<void> {
}
showNewTask.value = false;
await refreshTasks();
+ await pollForNewTask({
+ idsBefore,
+ refresh: refreshTasks,
+ currentIds: () => tasks.value.map((t) => t.taskId),
+ });
} catch (e) {
newTaskError.value = e instanceof Error ? e.message : String(e);
}
@@ -369,6 +377,7 @@ function fmtTime(ms: number): string {
>+ New Task</button>
</div>
</div>
+ <div v-if="polling" class="poll-hint">Waiting for new task… ({{
pollRound }}/4)</div>
<div v-if="tasksError" class="side-err">{{ tasksError }}</div>
<div v-else-if="tasksLoading && !tasks.length"
class="side-empty">Loading…</div>
<div v-else-if="!tasks.length" class="side-empty">
@@ -596,6 +605,13 @@ function fmtTime(ms: number): string {
opacity: 0.5;
cursor: not-allowed;
}
+.poll-hint {
+ padding: 6px 10px;
+ font-size: 10.5px;
+ color: var(--sw-accent);
+ background: var(--sw-bg-2);
+ border-bottom: 1px solid var(--sw-line);
+}
.side-err,
.side-empty {
padding: 12px;
diff --git a/apps/ui/src/layer/profiling/LayerPprofProfilingView.vue
b/apps/ui/src/layer/profiling/LayerPprofProfilingView.vue
index 16082a6..2f69a56 100644
--- a/apps/ui/src/layer/profiling/LayerPprofProfilingView.vue
+++ b/apps/ui/src/layer/profiling/LayerPprofProfilingView.vue
@@ -31,6 +31,7 @@ import { useSelectedService } from
'@/layer/useSelectedService';
import { bffClient } from '@/api/client';
import type { PprofTask, PprofTree, ProfileAnalyzationTree } from
'@/api/client';
import ProfileFlameGraph from '@/layer/profiling/ProfileFlameGraph.vue';
+import { useNewTaskPoll } from '@/layer/profiling/useNewTaskPoll';
import Icon from '@/components/icons/Icon.vue';
const route = useRoute();
@@ -44,7 +45,6 @@ const currentTask = ref<PprofTask | null>(null);
const tasksLoading = ref(false);
const selectedInstances = ref<string[]>([]);
-const eventType = ref<string>('CPU');
const tree = ref<PprofTree | null>(null);
const analyzeError = ref<string | null>(null);
const analyzeLoading = ref(false);
@@ -52,18 +52,30 @@ const analyzeLoading = ref(false);
const showNewTask = ref(false);
const newTask = reactive({
instances: [] as string[],
- duration: 60,
- events: ['CPU'] as string[],
- dumpPeriod: 10,
+ // OAP measures pprof duration in MINUTES (capped at 15).
+ duration: 5,
+ // pprof takes exactly ONE event per task (OAP's PprofEventType is a
+ // scalar enum, not a list).
+ event: 'CPU',
+ // Sampling rate for BLOCK (ns/sample) / MUTEX (occurrences/sample);
+ // 1 samples every event.
+ dumpPeriod: 1,
});
const newTaskError = ref<string | null>(null);
+const { polling, pollRound, pollForNewTask } = useNewTaskPoll();
const PPROF_EVENTS = ['CPU', 'HEAP', 'BLOCK', 'GOROUTINE', 'MUTEX', 'ALLOCS',
'THREADCREATE'];
+// Per OAP: duration applies to CPU/BLOCK/MUTEX; dumpPeriod to BLOCK/MUTEX.
+const DURATION_REQUIRED = ['CPU', 'BLOCK', 'MUTEX'];
+const DUMP_PERIOD_REQUIRED = ['BLOCK', 'MUTEX'];
+const newNeedsDuration = computed(() =>
DURATION_REQUIRED.includes(newTask.event));
+const newNeedsDumpPeriod = computed(() =>
DUMP_PERIOD_REQUIRED.includes(newTask.event));
+// Values are MINUTES — OAP rejects pprof durations over 15 minutes.
const DURATION_OPTS = [
- { v: 30, label: '30 sec' },
- { v: 60, label: '1 min' },
- { v: 300, label: '5 min' },
- { v: 600, label: '10 min' },
+ { v: 1, label: '1 min' },
+ { v: 5, label: '5 min' },
+ { v: 10, label: '10 min' },
+ { v: 15, label: '15 min' },
];
watch(
@@ -83,7 +95,6 @@ async function refreshTasks(): Promise<void> {
currentTask.value = tasks.value[0] ?? null;
if (currentTask.value) {
selectedInstances.value = [...(currentTask.value.serviceInstanceIds ??
[])];
- eventType.value = currentTask.value.events?.[0] ?? 'CPU';
}
tree.value = null;
} catch (e) {
@@ -101,7 +112,6 @@ async function runAnalyze(): Promise<void> {
const resp = await bffClient.pprof.analyze({
taskId: currentTask.value.id,
instanceIds: selectedInstances.value,
- eventType: eventType.value,
});
if (!resp.reachable && resp.error) analyzeError.value = resp.error;
tree.value = resp.tree;
@@ -128,11 +138,6 @@ const profileTrees = computed<ProfileAnalyzationTree[]>(()
=> {
];
});
-function toggleEvent(e: string): void {
- const i = newTask.events.indexOf(e);
- if (i === -1) newTask.events.push(e);
- else newTask.events.splice(i, 1);
-}
function toggleInstance(id: string, dst: 'filter' | 'new'): void {
const target = dst === 'filter' ? selectedInstances.value :
newTask.instances;
const i = target.indexOf(id);
@@ -141,18 +146,19 @@ function toggleInstance(id: string, dst: 'filter' |
'new'): void {
}
async function submitNewTask(): Promise<void> {
- if (!serviceId.value || !newTask.instances.length || !newTask.events.length)
{
- newTaskError.value = 'Pick a service, instances, and events.';
+ if (!serviceId.value || !newTask.instances.length || !newTask.event) {
+ newTaskError.value = 'Pick a service, instances, and an event.';
return;
}
newTaskError.value = null;
+ const idsBefore = new Set(tasks.value.map((t) => t.id));
try {
const resp = await bffClient.pprof.create(layerKey.value, {
serviceId: serviceId.value,
serviceInstanceIds: newTask.instances,
- duration: Number(newTask.duration),
- events: newTask.events,
- dumpPeriod: Number(newTask.dumpPeriod),
+ events: newTask.event,
+ ...(newNeedsDuration.value ? { duration: Number(newTask.duration) } :
{}),
+ ...(newNeedsDumpPeriod.value ? { dumpPeriod: Number(newTask.dumpPeriod)
} : {}),
});
if (resp.errorReason) {
newTaskError.value = resp.errorReason;
@@ -160,6 +166,11 @@ async function submitNewTask(): Promise<void> {
}
showNewTask.value = false;
await refreshTasks();
+ await pollForNewTask({
+ idsBefore,
+ refresh: refreshTasks,
+ currentIds: () => tasks.value.map((t) => t.id),
+ });
} catch (e) {
newTaskError.value = e instanceof Error ? e.message : String(e);
}
@@ -193,6 +204,7 @@ function instanceName(id: string): string {
<button class="btn-new" :disabled="!serviceId" @click="showNewTask =
true">+ New Task</button>
</div>
</div>
+ <div v-if="polling" class="poll-hint">Waiting for new task… ({{
pollRound }}/4)</div>
<div v-if="tasksError" class="side-err">{{ tasksError }}</div>
<div v-else-if="tasksLoading && !tasks.length"
class="side-empty">Loading…</div>
<div v-else-if="!tasks.length" class="side-empty">
@@ -206,12 +218,15 @@ function instanceName(id: string): string {
@click="currentTask = t; selectedInstances =
[...(t.serviceInstanceIds ?? [])]; tree = null"
>
<div class="row1">
- <span class="t-tag">{{ t.events?.join(',') }}</span>
+ <span class="t-tag">{{ t.events }}</span>
<span class="ep">{{ t.serviceInstanceIds?.length ?? 0 }}
instance{{ (t.serviceInstanceIds?.length ?? 0) === 1 ? '' : 's' }}</span>
</div>
<div class="row2">
<span>{{ fmtTime(t.createTime) }}</span>
- <span class="muted">· {{ t.duration }}s · {{ t.dumpPeriod ?? '?'
}}ms</span>
+ <span class="muted">
+ <template v-if="t.duration != null">· {{ t.duration
}}min</template>
+ <template v-if="t.dumpPeriod != null"> · rate {{ t.dumpPeriod
}}</template>
+ </span>
</div>
</li>
</ul>
@@ -234,9 +249,7 @@ function instanceName(id: string): string {
</div>
<div class="tb-block">
<label class="lbl">Event</label>
- <select v-model="eventType" class="sel">
- <option v-for="e in PPROF_EVENTS" :key="e" :value="e">{{ e
}}</option>
- </select>
+ <span class="event-fixed">{{ currentTask?.events ?? '—' }}</span>
</div>
<button class="btn-primary" :disabled="analyzeLoading || !currentTask"
@click="runAnalyze">
{{ analyzeLoading ? 'Analyzing…' : 'Analyze' }}
@@ -246,7 +259,7 @@ function instanceName(id: string): string {
<div class="result">
<ProfileFlameGraph v-if="profileTrees.length" :trees="profileTrees"
metric-key="count" />
<div v-else-if="!analyzeLoading" class="result-empty">
- {{ currentTask ? 'Pick instances + event, then click Analyze.' :
'Select a task.' }}
+ {{ currentTask ? 'Pick instances, then click Analyze.' : 'Select a
task.' }}
</div>
</div>
</div>
@@ -273,31 +286,36 @@ function instanceName(id: string): string {
<span v-if="!instances.instances.value.length" class="muted">No
instances available.</span>
</div>
</div>
- <div class="field-row">
- <div class="field">
- <label>Duration</label>
- <select v-model.number="newTask.duration" class="sel">
- <option v-for="o in DURATION_OPTS" :key="o.v" :value="o.v">{{
o.label }}</option>
- </select>
- </div>
- <div class="field">
- <label>Dump period (ms)</label>
- <input type="number" min="1" v-model.number="newTask.dumpPeriod"
class="ti-input" />
- </div>
- </div>
<div class="field">
- <label>Events</label>
+ <label>Event (one per task)</label>
<div class="chip-row">
<button
v-for="e in PPROF_EVENTS"
:key="e"
type="button"
class="chip"
- :class="{ on: newTask.events.includes(e) }"
- @click="toggleEvent(e)"
+ :class="{ on: newTask.event === e }"
+ @click="newTask.event = e"
>{{ e }}</button>
</div>
</div>
+ <div class="field-row">
+ <div v-if="newNeedsDuration" class="field">
+ <label>Duration</label>
+ <select v-model.number="newTask.duration" class="sel">
+ <option v-for="o in DURATION_OPTS" :key="o.v" :value="o.v">{{
o.label }}</option>
+ </select>
+ </div>
+ <div v-if="newNeedsDumpPeriod" class="field">
+ <label>Dump period (rate)</label>
+ <input type="number" min="1" v-model.number="newTask.dumpPeriod"
class="ti-input" />
+ <span class="hint">
+ {{ newTask.event === 'BLOCK'
+ ? 'Blocked-ns sampling rate; 1 samples every event.'
+ : 'Contention-occurrences sampling rate; 1 samples every
event.' }}
+ </span>
+ </div>
+ </div>
<div v-if="newTaskError" class="dlg-err">{{ newTaskError }}</div>
</div>
<div class="dlg-foot">
@@ -375,6 +393,13 @@ function instanceName(id: string): string {
opacity: 0.5;
cursor: not-allowed;
}
+.poll-hint {
+ padding: 6px 10px;
+ font-size: 10.5px;
+ color: var(--sw-accent);
+ background: var(--sw-bg-2);
+ border-bottom: 1px solid var(--sw-line);
+}
.side-err,
.side-empty {
padding: 12px;
@@ -484,6 +509,12 @@ function instanceName(id: string): string {
border-color: var(--sw-accent);
color: #fff;
}
+.event-fixed {
+ font-size: 11.5px;
+ font-family: var(--sw-mono);
+ color: var(--sw-fg-0);
+ padding: 4px 0;
+}
.sel,
.ti-input {
background: var(--sw-bg-2);
@@ -597,6 +628,13 @@ function instanceName(id: string): string {
letter-spacing: 0.06em;
color: var(--sw-fg-3);
}
+.field .hint {
+ font-size: 10px;
+ color: var(--sw-fg-3);
+ text-transform: none;
+ letter-spacing: 0;
+ line-height: 1.4;
+}
.field-row {
display: flex;
gap: 10px;
diff --git a/apps/ui/src/layer/profiling/LayerTraceProfilingView.vue
b/apps/ui/src/layer/profiling/LayerTraceProfilingView.vue
index f21946a..72a6cea 100644
--- a/apps/ui/src/layer/profiling/LayerTraceProfilingView.vue
+++ b/apps/ui/src/layer/profiling/LayerTraceProfilingView.vue
@@ -55,6 +55,7 @@ import type {
import ProfileStackTable from '@/layer/profiling/ProfileStackTable.vue';
import ProfileFlameGraph from '@/layer/profiling/ProfileFlameGraph.vue';
import NativeTraceWaterfall from '@/layer/traces/NativeTraceWaterfall.vue';
+import { useNewTaskPoll } from '@/layer/profiling/useNewTaskPoll';
import Icon from '@/components/icons/Icon.vue';
const route = useRoute();
@@ -102,6 +103,7 @@ const tasks = ref<ProfileTask[]>([]);
const tasksError = ref<string | null>(null);
const tasksLoading = ref(false);
const currentTask = ref<ProfileTask | null>(null);
+const { polling, pollRound, pollForNewTask } = useNewTaskPoll();
const segments = ref<ProfileSegment[]>([]);
const segmentsLoading = ref(false);
@@ -292,6 +294,7 @@ async function submitNewTask(): Promise<void> {
return;
}
taskCreateError.value = null;
+ const idsBefore = new Set(tasks.value.map((t) => t.id));
try {
const startTime =
newTask.monitorTime === 'now' ? Date.now() :
newTask.monitorTimeAt.getTime();
@@ -315,6 +318,11 @@ async function submitNewTask(): Promise<void> {
showNewTask.value = false;
resetNewTask();
await refreshTasks();
+ await pollForNewTask({
+ idsBefore,
+ refresh: refreshTasks,
+ currentIds: () => tasks.value.map((t) => t.id),
+ });
} catch (e) {
taskCreateError.value = e instanceof Error ? e.message : String(e);
}
@@ -354,6 +362,7 @@ function fmtTime(ms: number): string {
>+ New Task</button>
</div>
</div>
+ <div v-if="polling" class="poll-hint">Waiting for new task… ({{
pollRound }}/4)</div>
<div v-if="tasksError" class="side-err">{{ tasksError }}</div>
<div v-else-if="tasksLoading && !tasks.length"
class="side-empty">Loading…</div>
<div v-else-if="!tasks.length" class="side-empty">
@@ -665,6 +674,13 @@ function fmtTime(ms: number): string {
font-size: 10.5px;
color: var(--sw-fg-3);
}
+.poll-hint {
+ padding: 6px 10px;
+ font-size: 10.5px;
+ color: var(--sw-accent);
+ background: var(--sw-bg-2);
+ border-bottom: 1px solid var(--sw-line);
+}
.side-err {
padding: 8px 12px;
font-size: 11px;
diff --git a/apps/ui/src/layer/profiling/useNewTaskPoll.ts
b/apps/ui/src/layer/profiling/useNewTaskPoll.ts
new file mode 100644
index 0000000..66fd680
--- /dev/null
+++ b/apps/ui/src/layer/profiling/useNewTaskPoll.ts
@@ -0,0 +1,71 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Shared "create-then-poll" helper for every profiling task family
+ * (trace / async / eBPF cpu / eBPF network / pprof). OAP acks a task
+ * creation before the task is queryable — the task has to propagate to
+ * the task-list query, which can take several seconds. So after a create
+ * we refresh the list repeatedly until the new task shows up rather than
+ * leaving the operator looking at the stale pre-create list.
+ *
+ * Detection is id-based: capture the visible task ids *before* the create,
+ * then after each refresh look for any id that wasn't there before. That
+ * covers both "empty → first task" and "a newer task appended on top of
+ * existing ones", without depending on per-family timestamp field names.
+ */
+
+import { ref } from 'vue';
+
+export const POLL_ROUNDS = 4;
+export const POLL_INTERVAL_MS = 10_000;
+
+export function useNewTaskPoll() {
+ /** True while the post-create poll is running. */
+ const polling = ref(false);
+ /** 1-based round currently in flight (0 when idle). */
+ const pollRound = ref(0);
+
+ async function pollForNewTask(opts: {
+ /** Task ids visible before the create call. */
+ idsBefore: Set<string>;
+ /** Reload the task list (the view's existing refresh). */
+ refresh: () => Promise<void>;
+ /** Task ids visible right now (read after each refresh). */
+ currentIds: () => string[];
+ }): Promise<boolean> {
+ const appeared = (): boolean => opts.currentIds().some((id) =>
!opts.idsBefore.has(id));
+ // The view typically refreshes once right after create; if the task
+ // is already visible, don't spin the poll at all.
+ if (appeared()) return true;
+ polling.value = true;
+ try {
+ for (let i = 1; i <= POLL_ROUNDS; i++) {
+ pollRound.value = i;
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
+ await opts.refresh();
+ if (appeared()) return true;
+ }
+ return false;
+ } finally {
+ polling.value = false;
+ pollRound.value = 0;
+ }
+ }
+
+ return { polling, pollRound, pollForNewTask };
+}
diff --git a/apps/ui/src/shell/AppSidebar.vue b/apps/ui/src/shell/AppSidebar.vue
index d134ff8..effe631 100644
--- a/apps/ui/src/shell/AppSidebar.vue
+++ b/apps/ui/src/shell/AppSidebar.vue
@@ -187,10 +187,20 @@ interface NavSection {
// requires (see apps/bff/src/rbac/route-policy.ts). The sidebar removes
// rows the user can't read; the BFF enforces the same verbs server-side.
const sections: NavSection[] = [
+ // OAP self-observability diagnostics (the backend itself, not the
+ // observed services). Rendered above the per-layer self-observability
+ // dashboards. All three are read-only and gated on maintainer-tier verbs.
{
- kicker: 'Operate',
+ kicker: 'Platform monitoring',
links: [
{ icon: 'svc', label: 'Cluster status', to: '/operate/cluster', verb:
'cluster:read' },
+ { icon: 'clock', label: 'Data retention (TTL)', to: '/operate/ttl',
verb: 'ttl:read' },
+ { icon: 'db', label: 'OAP configuration', to: '/operate/config', verb:
'config:read' },
+ ],
+ },
+ {
+ kicker: 'Operate',
+ links: [
{ icon: 'alert', label: 'Alerting rules', to: '/operate/alerting-rules',
verb: 'alarm-rule:read' },
{
icon: 'flame',
@@ -259,6 +269,16 @@ const visibleSections = computed<NavSection[]>(() => {
return out;
});
+// Platform monitoring (OAP self-observability) renders at the top of the
+// operate area — above the per-layer self-observability dashboards — so
+// it's pulled out of the generic section loop below.
+const platformSection = computed<NavSection | undefined>(() =>
+ visibleSections.value.find((s) => s.kicker === 'Platform monitoring'),
+);
+const menuSections = computed<NavSection[]>(() =>
+ visibleSections.value.filter((s) => s.kicker !== 'Platform monitoring'),
+);
+
const openNavL1 = ref<Set<string>>(new Set());
function isNavL1Open(to: string): boolean { return openNavL1.value.has(to); }
function toggleNavL1(row: NavRow): void {
@@ -623,15 +643,29 @@ watch(
</div>
</template>
- <!-- Platform monitoring (OAP self-observability layers) is the
- maintainer tier, not the viewer data catalog. Gate on
- `cluster:read` — the verb horizon.yaml grants maintainer /
- operator / admin but not viewer. -->
- <template v-if="operateLayers.length > 0 &&
auth.hasVerb('cluster:read')">
+ <!-- Platform monitoring — OAP self-observability under one header:
+ backend diagnostics (cluster status, data retention, runtime
+ config) on top, then the per-layer so11y_* agent dashboards.
+ Maintainer tier: diagnostics rows are verb-gated individually;
+ the layer dashboards gate on `cluster:read` (granted to
+ maintainer / operator / admin, not viewer). -->
+ <template v-if="platformSection || (operateLayers.length > 0 &&
auth.hasVerb('cluster:read'))">
<div class="sw-nav-section sw-nav-section--icon">
<Icon :name="sectionIcon('Platform monitoring')" />
<span>Platform monitoring</span>
</div>
+ <template v-if="platformSection">
+ <RouterLink
+ v-for="row in platformSection.links"
+ :key="row.to"
+ :to="row.to"
+ class="sw-nav-item"
+ :class="{ 'is-active': row.activeWhen ? row.activeWhen(route.path)
: isActiveExact(row.to) }"
+ >
+ <Icon :name="row.icon" /><span>{{ row.label }}</span>
+ </RouterLink>
+ </template>
+ <template v-if="operateLayers.length > 0 &&
auth.hasVerb('cluster:read')">
<template v-for="L in operateLayers" :key="`op:${L.key}`">
<RouterLink
v-if="isSingleFeatureLayer(L)"
@@ -689,9 +723,10 @@ watch(
</RouterLink>
</div>
</template>
+ </template>
</template>
- <template v-for="entry in visibleSections" :key="`m:${entry.kicker}`">
+ <template v-for="entry in menuSections" :key="`m:${entry.kicker}`">
<div class="sw-nav-section sw-nav-section--icon">
<Icon :name="sectionIcon(entry.kicker)" />
<span>{{ entry.kicker }}</span>
diff --git a/apps/ui/src/shell/icons.test.ts b/apps/ui/src/shell/icons.test.ts
index 736f11d..8ed993a 100644
--- a/apps/ui/src/shell/icons.test.ts
+++ b/apps/ui/src/shell/icons.test.ts
@@ -23,7 +23,7 @@ describe('sectionIcon — L0 header icon mapping', () => {
expect(sectionIcon('Overviews')).toBe('dash');
expect(sectionIcon('overview')).toBe('dash');
expect(sectionIcon('Layers')).toBe('svc');
- expect(sectionIcon('Platform monitoring')).toBe('flame');
+ expect(sectionIcon('Platform monitoring')).toBe('cluster');
expect(sectionIcon('Operate')).toBe('set');
expect(sectionIcon('Dashboard setup')).toBe('metric');
expect(sectionIcon('Admin')).toBe('user');
diff --git a/apps/ui/src/shell/icons.ts b/apps/ui/src/shell/icons.ts
index 9d6bd53..d6027b8 100644
--- a/apps/ui/src/shell/icons.ts
+++ b/apps/ui/src/shell/icons.ts
@@ -31,7 +31,7 @@ export function sectionIcon(label: string): IconName {
const k = label.toLowerCase();
if (k === 'overviews' || k === 'overview') return 'dash';
if (k === 'layers') return 'svc';
- if (k === 'platform monitoring') return 'flame';
+ if (k === 'platform monitoring') return 'cluster';
if (k === 'operate') return 'set';
if (k === 'dashboard setup') return 'metric';
if (k === 'admin') return 'user';
diff --git a/apps/ui/src/shell/router/index.ts
b/apps/ui/src/shell/router/index.ts
index a7424ba..d75f4d7 100644
--- a/apps/ui/src/shell/router/index.ts
+++ b/apps/ui/src/shell/router/index.ts
@@ -175,6 +175,18 @@ const shellRoutes: RouteRecordRaw[] = [
name: 'inspect',
component: () => import('@/features/operate/inspect/InspectView.vue'),
},
+ // Data retention (TTL) — query-port read of getRecordsTTL/getMetricsTTL.
+ {
+ path: 'operate/ttl',
+ name: 'ttl',
+ component: () => import('@/features/operate/ttl/TtlView.vue'),
+ },
+ // OAP runtime config — admin-port /debugging/config/dump, read-only.
+ {
+ path: 'operate/config',
+ name: 'oap-config',
+ component: () => import('@/features/operate/config/ConfigView.vue'),
+ },
// Live debugger — gated on `dsl-debugging`. History is local-only
// (browser localStorage) so it stays useful even when admin is down.
// Declared before the catch-all tab route so it doesn't shadow.
diff --git a/packages/api-client/src/async-profile.ts
b/packages/api-client/src/async-profile.ts
index 526068b..81de497 100644
--- a/packages/api-client/src/async-profile.ts
+++ b/packages/api-client/src/async-profile.ts
@@ -111,8 +111,11 @@ export interface PprofTask {
serviceId: string;
serviceInstanceIds: string[];
createTime: number;
- events: string[];
- duration: number;
+ // OAP's `PprofEventType` is a single scalar enum (not a list, unlike
+ // async-profiler) — one event per pprof task: CPU / HEAP / BLOCK /
+ // MUTEX / GOROUTINE / ALLOCS / THREADCREATE.
+ events: string;
+ duration?: number;
dumpPeriod?: number;
}
export interface PprofProgress {
@@ -133,8 +136,10 @@ export interface PprofTree {
export interface PprofTaskCreationRequest {
serviceId: string;
serviceInstanceIds: string[];
- duration: number;
- events: string[];
+ // duration is required for CPU / BLOCK / MUTEX; dumpPeriod for BLOCK /
+ // MUTEX. Omit them for the events that don't use them.
+ duration?: number;
+ events: string;
dumpPeriod?: number;
}
export interface PprofTaskListResponse {
diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts
index 9b7b617..9f372e1 100644
--- a/packages/api-client/src/index.ts
+++ b/packages/api-client/src/index.ts
@@ -98,6 +98,13 @@ export type {
LogFacetsResponse,
} from './logs.js';
export type { OapInfo, OapCapabilities } from './oap-info.js';
+export type {
+ RecordsTTL,
+ MetricsTTL,
+ OapTtlResponse,
+ OapConfigEntry,
+ OapConfigResponse,
+} from './oap-ops.js';
export type { PreflightModule, PreflightResult } from './preflight.js';
export type {
ProfileTask,
diff --git a/packages/api-client/src/oap-ops.ts
b/packages/api-client/src/oap-ops.ts
new file mode 100644
index 0000000..89da208
--- /dev/null
+++ b/packages/api-client/src/oap-ops.ts
@@ -0,0 +1,85 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Read-only OAP operations surfaces:
+ * - Data retention (TTL) — `getRecordsTTL` / `getMetricsTTL` on the
+ * query port (`GET /api/oap/ttl`). All values are integer DAYS; `-1`
+ * means "no cold-stage data" on the cold-* fields.
+ * - Runtime configuration dump — `/debugging/config/dump` on the admin
+ * port (`GET /api/oap/config`). A flat key→value snapshot of OAP's
+ * resolved config; OAP masks secret values server-side (rendered as
+ * `******`) per `status.default.keywords4MaskingSecretsOfConfig`.
+ * Both are surfaced under Operate as read-only diagnostics.
+ */
+
+/** OAP `RecordsTTL` — retention (days) for record-class storage.
+ * cold* fields are `-1` when the backend has no cold stage. */
+export interface RecordsTTL {
+ normal: number;
+ trace: number;
+ zipkinTrace: number;
+ log: number;
+ browserErrorLog: number;
+ coldNormal: number;
+ coldTrace: number;
+ coldZipkinTrace: number;
+ coldLog: number;
+ coldBrowserErrorLog: number;
+}
+
+/** OAP `MetricsTTL` — retention (days) for metric-class storage. */
+export interface MetricsTTL {
+ metadata: number;
+ minute: number;
+ hour: number;
+ day: number;
+ coldMinute: number;
+ coldHour: number;
+ coldDay: number;
+}
+
+/** Wire shape of `GET /api/oap/ttl`. Never throws on an unreachable OAP
+ * — `reachable: false` + `error` carries the diagnostic so the page can
+ * render a degraded state instead of a hard failure. */
+export interface OapTtlResponse {
+ reachable: boolean;
+ error?: string;
+ records?: RecordsTTL;
+ metrics?: MetricsTTL;
+}
+
+/** A single resolved config key→value from `/debugging/config/dump`. */
+export interface OapConfigEntry {
+ /** Dotted key, e.g. `core.default.restPort`. */
+ key: string;
+ /** Resolved value; secrets are masked to `******` by OAP. */
+ value: string;
+ /** First dotted segment (`core`, `storage`, …) — the owning module.
+ * Pre-split on the BFF so the UI can group without re-parsing. */
+ module: string;
+}
+
+/** Wire shape of `GET /api/oap/config`. Entries are sorted by key. */
+export interface OapConfigResponse {
+ reachable: boolean;
+ error?: string;
+ /** Admin URL the dump was read from (`oap.adminUrl`). */
+ adminUrl: string;
+ entries: OapConfigEntry[];
+ generatedAt: number;
+}