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;
+}

Reply via email to