This is an automated email from the ASF dual-hosted git repository. wu-sheng pushed a commit to branch refactor/ai-profiling-oap-owned-rules in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
commit 33885452ca56f1e9a974bb90e456117abd586185 Author: Wu Sheng <[email protected]> AuthorDate: Wed Jul 29 10:22:13 2026 +0800 feat(operate): edit continuous-profiling policies Horizon could already SHOW the results of auto-triggered profiling tasks (continuousProfilingCauses, and the network view surfaces CONTINUOUS_PROFILING tasks) but had no way to see or change the rules that trigger them. OAP has exposed both halves all along -- queryContinuousProfilingServiceTargets and setContinuousProfilingPolicy -- and booster-ui shipped an editor for them; this brings the capability across, shaped for Horizon. Placement is Operate rather than a sixth profiling tab, because a policy is configuration: it is armed once and then fires unattended, which is a different activity from the per-layer tabs where an operator starts a task and watches it. Operate -> Continuous profiling: pick a layer + service, edit its targets (ON_CPU / OFF_CPU / NETWORK), each with conditions of {monitor type, threshold, period, count} and, for the HTTP monitors, a URI list or regex. Three things the UI has to get right because OAP's model demands it: - setContinuousProfilingPolicy REPLACES a service's whole policy, so the page edits a full draft and the button says "Replace policy" -- an append-style save would silently delete the targets it omitted. - The mutation input names the field `targetType` while the query output names it `type` (ContinuousProfilingPolicyTargetCreation vs ContinuousProfilingPolicyTarget). Reading a policy and posting it straight back fails with "field name 'type' is not defined for input object type", so the BFF renames on the way out. Caught by validating against a live OAP. - A stored policy that matches no process is indistinguishable from a working one, so the Monitored instances panel reads queryContinuousProfilingMonitoringInstances per target and shows what OAP is actually evaluating, with per-process trigger counts. Verbs reuse the profiling pair rather than inventing one: reading policies is profile:read, saving is profile:enable -- arming a policy is starting a task, just later and unattended. Validated against a local e2e OAP: policy write returns status:true and reads back byte-identical, including the uriRegex on the NETWORK target; an unknown target is rejected by the route. The public demo has NO continuous-profiling policy on any of its 59 services, so it exercises the empty state only. --- CHANGELOG.md | 4 + apps/bff/src/http/query/continuous-profiling.ts | 263 ++++++++++++++++++++ apps/bff/src/rbac/route-policy.ts | 6 + apps/bff/src/server.ts | 2 + apps/ui/src/api/client.ts | 2 + apps/ui/src/api/scopes/continuous-profiling.ts | 60 +++++ .../ContinuousProfilingView.vue | 275 +++++++++++++++++++++ .../components/CheckItemRow.vue | 216 ++++++++++++++++ .../components/MonitoredInstances.vue | 154 ++++++++++++ .../components/PolicyTargetCard.vue | 153 ++++++++++++ .../features/operate/continuous-profiling/data.ts | 58 +++++ .../continuous-profiling/useContinuousProfiling.ts | 141 +++++++++++ apps/ui/src/i18n/locales/de.json | 34 ++- apps/ui/src/i18n/locales/en.json | 34 ++- apps/ui/src/i18n/locales/es.json | 34 ++- apps/ui/src/i18n/locales/fr.json | 34 ++- apps/ui/src/i18n/locales/ja.json | 34 ++- apps/ui/src/i18n/locales/ko.json | 34 ++- apps/ui/src/i18n/locales/pt.json | 34 ++- apps/ui/src/i18n/locales/zh-CN.json | 34 ++- apps/ui/src/shell/router/index.ts | 9 + apps/ui/src/shell/useSidebarMenu.ts | 6 + docs/operate/profiling.md | 24 ++ packages/api-client/src/continuous-profiling.ts | 109 ++++++++ packages/api-client/src/index.ts | 12 + 25 files changed, 1758 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a3e439..24ed8d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,10 @@ The version line is shared by every package in the monorepo (apps + shared packa - **Conversation history now persists per user in your browser, with controls.** Past chats move to the browser's IndexedDB — far larger than before, so long conversations with embedded charts survive — and are scoped to your username, so a shared browser keeps each person's history separate. A **Save history** toggle (on by default) turns persistence off entirely; a usage meter shows how much of the client budget (default 500 MB, `HORIZON_AI_HISTORY_MAX_MB`) is in use; **Clear all** (wi [...] - **Two tabs can't quietly overwrite each other's chats.** History is read once when the assistant opens, and each conversation is stored under its own id, so saving one never touches another. Before every save the assistant checks whether that same conversation was continued somewhere else — in a second tab — and if the two have diverged **nothing is overwritten**: the conversation is marked **Not saved** in the History sidebar and the chat asks which version you want to keep, this one [...] +### Profiling + +- **Continuous profiling now has a home: Operate → Continuous profiling.** Every profiling surface until now started a task *on demand* — you pick a target and start it. Continuous profiling is the opposite: arm a policy once and OAP starts the profiling task **itself** when a process crosses a threshold, with nobody present, which is how you catch the problem that only shows up at 3 a.m. Horizon could already show you the *results* of those auto-triggered tasks, but there was no way to [...] + ### Traces & logs - **Custom time ranges on the Traces and Logs tabs now return results when your browser and the OAP server sit in different timezones.** A custom range (and the metric→trace drill's centered window) was sent as a browser-local wall-clock string that the server re-read in its own timezone — so on a UTC-container deployment the window shifted by your UTC offset and came back empty, while the rolling presets kept working. Both paths now send absolute timestamps and the server applies the OA [...] diff --git a/apps/bff/src/http/query/continuous-profiling.ts b/apps/bff/src/http/query/continuous-profiling.ts new file mode 100644 index 0000000..7f54194 --- /dev/null +++ b/apps/bff/src/http/query/continuous-profiling.ts @@ -0,0 +1,263 @@ +/* + * 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. + */ + +/** + * Continuous (auto-triggered) profiling policy routes. + * + * These configure the rules that make OAP start a profiling task BY ITSELF when + * a process crosses a threshold — distinct from the profiling tabs, which create + * tasks on demand. Thin routes: one backend call each, so they reach `client/` + * directly rather than through a 1:1 `logic/` passthrough. + */ + +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import type { + ContinuousProfilingInstancesResponse, + ContinuousProfilingPoliciesResponse, + ContinuousProfilingPolicyItem, + ContinuousProfilingPolicyRequest, + ContinuousProfilingPolicyTarget, + ContinuousProfilingSetResponse, + ContinuousProfilingTargetType, + FetchLike, +} 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 { graphqlPost, buildOapOpts } from '../../client/graphql.js'; + +export interface ContinuousProfilingRouteDeps { + config: ConfigSource; + sessions: SessionStore; + fetch?: FetchLike; +} + +const VALID_TARGETS = new Set<string>(['ON_CPU', 'OFF_CPU', 'NETWORK']); +// Mirrors OAP's ContinuousProfilingMonitorType, minus the non-selectable +// UNKNOWN(0). Validated here only so a malformed body fails with a readable +// message instead of a GraphQL enum-coercion error the UI cannot present — +// OAP remains the authority on what a policy may contain. +const VALID_MONITORS = new Set<string>([ + 'PROCESS_CPU', + 'PROCESS_THREAD_COUNT', + 'SYSTEM_LOAD', + 'HTTP_ERROR_RATE', + 'HTTP_AVG_RESPONSE_TIME', +]); + +const QUERY_POLICIES = /* GraphQL */ ` + query queryContinuousProfilingPolicies($serviceId: ID!) { + targets: queryContinuousProfilingServiceTargets(serviceId: $serviceId) { + type + triggeredCount + lastTriggerTimestamp + checkItems { + type + threshold + period + count + uriList + uriRegex + } + } + } +`; + +const SET_POLICY = /* GraphQL */ ` + mutation setContinuousProfilingPolicy($request: ContinuousProfilingPolicyCreation!) { + result: setContinuousProfilingPolicy(request: $request) { + status + errorReason + } + } +`; + +const QUERY_INSTANCES = /* GraphQL */ ` + query queryContinuousProfilingMonitoringInstances($serviceId: ID!, $target: ContinuousProfilingTargetType!) { + instances: queryContinuousProfilingMonitoringInstances(serviceId: $serviceId, target: $target) { + id + name + triggeredCount + lastTriggerTimestamp + processes { + id + name + detectType + labels + triggeredCount + lastTriggerTimestamp + } + } + } +`; + +function softErr<T extends { reachable: boolean; error?: string }>(payload: T, err: unknown): T { + payload.reachable = false; + payload.error = err instanceof Error ? err.message : String(err); + return payload; +} + +/** The mutation input's shape for one target. NOT the read shape: OAP names the + * field `targetType` on `ContinuousProfilingPolicyTargetCreation` but `type` on + * the `ContinuousProfilingPolicyTarget` it returns. Sending the read shape back + * fails with "field name 'type' that is not defined for input object type". */ +interface PolicyTargetInput { + targetType: ContinuousProfilingTargetType; + checkItems: ContinuousProfilingPolicyItem[]; +} + +/** + * Strip a policy down to what the mutation input accepts, and rename `type` → + * `targetType`. The read type also carries `triggeredCount` / + * `lastTriggerTimestamp`, which are OUTPUT-only — forwarding them makes OAP + * reject the whole write on an unknown-field error. + */ +function sanitiseTargets(raw: unknown): { targets: PolicyTargetInput[] } | { error: string } { + if (!Array.isArray(raw)) return { error: 'targets must be an array' }; + const targets: PolicyTargetInput[] = []; + for (const t of raw as ContinuousProfilingPolicyTarget[]) { + if (!t || !VALID_TARGETS.has(t.type)) { + return { error: `target type must be one of ${[...VALID_TARGETS].join(', ')}` }; + } + if (!Array.isArray(t.checkItems) || !t.checkItems.length) { + return { error: `target ${t.type} needs at least one check item` }; + } + const checkItems: ContinuousProfilingPolicyItem[] = []; + for (const it of t.checkItems) { + if (!it || !VALID_MONITORS.has(it.type)) { + return { error: `monitor type must be one of ${[...VALID_MONITORS].join(', ')}` }; + } + if (typeof it.threshold !== 'string' || !it.threshold.trim()) { + return { error: `${it.type}: threshold is required` }; + } + if (!Number.isFinite(it.period) || it.period <= 0) { + return { error: `${it.type}: period must be a positive number of seconds` }; + } + if (!Number.isFinite(it.count) || it.count <= 0) { + return { error: `${it.type}: count must be a positive number` }; + } + const uriList = Array.isArray(it.uriList) ? it.uriList.filter((u) => typeof u === 'string' && u) : []; + const uriRegex = typeof it.uriRegex === 'string' && it.uriRegex ? it.uriRegex : ''; + // OAP's own rule, mirrored here only to fail with a message the form can + // show against the offending item rather than a bare backend string. + if (uriList.length && uriRegex) { + return { error: `${it.type}: set a URI list OR a URI regex, not both` }; + } + checkItems.push({ + type: it.type, + threshold: it.threshold.trim(), + period: Math.round(it.period), + count: Math.round(it.count), + ...(uriList.length ? { uriList } : {}), + ...(uriRegex ? { uriRegex } : {}), + }); + } + targets.push({ targetType: t.type, checkItems }); + } + return { targets }; +} + +export function registerContinuousProfilingRoutes( + app: FastifyInstance, + deps: ContinuousProfilingRouteDeps, +): void { + const auth = requireAuth(deps); + + app.get( + '/api/continuous-profiling/policies', + { preHandler: auth }, + async (req: FastifyRequest, reply: FastifyReply) => { + const q = req.query as { service?: string }; + const payload: ContinuousProfilingPoliciesResponse = { targets: [], reachable: true }; + if (!q.service) { + payload.error = 'missing service'; + return reply.send(payload); + } + const opts = buildOapOpts(deps.config.current, deps.fetch); + try { + const data = await graphqlPost<{ targets: ContinuousProfilingPolicyTarget[] }>(opts, QUERY_POLICIES, { + serviceId: q.service, + }); + payload.targets = data.targets ?? []; + return reply.send(payload); + } catch (err) { + return reply.send(softErr(payload, err)); + } + }, + ); + + app.post( + '/api/continuous-profiling/policies', + { preHandler: auth }, + async (req: FastifyRequest, reply: FastifyReply) => { + const raw = (req.body ?? {}) as Partial<ContinuousProfilingPolicyRequest>; + const payload: ContinuousProfilingSetResponse = { status: false, reachable: true }; + if (typeof raw.serviceId !== 'string' || !raw.serviceId) { + payload.errorReason = 'missing serviceId'; + return reply.send(payload); + } + const sanitised = sanitiseTargets(raw.targets); + if ('error' in sanitised) { + payload.errorReason = sanitised.error; + return reply.send(payload); + } + const opts = buildOapOpts(deps.config.current, deps.fetch); + try { + const data = await graphqlPost<{ result: { status: boolean; errorReason?: string | null } }>( + opts, + SET_POLICY, + { request: { serviceId: raw.serviceId, targets: sanitised.targets } }, + ); + payload.status = data.result?.status ?? false; + payload.errorReason = data.result?.errorReason ?? null; + return reply.send(payload); + } catch (err) { + return reply.send(softErr(payload, err)); + } + }, + ); + + app.get( + '/api/continuous-profiling/instances', + { preHandler: auth }, + async (req: FastifyRequest, reply: FastifyReply) => { + const q = req.query as { service?: string; target?: string }; + const payload: ContinuousProfilingInstancesResponse = { instances: [], reachable: true }; + if (!q.service) { + payload.error = 'missing service'; + return reply.send(payload); + } + if (!q.target || !VALID_TARGETS.has(q.target)) { + payload.error = `target must be one of ${[...VALID_TARGETS].join(', ')}`; + return reply.send(payload); + } + const opts = buildOapOpts(deps.config.current, deps.fetch); + try { + const data = await graphqlPost<{ + instances: ContinuousProfilingInstancesResponse['instances']; + }>(opts, QUERY_INSTANCES, { + serviceId: q.service, + target: q.target as ContinuousProfilingTargetType, + }); + payload.instances = data.instances ?? []; + return reply.send(payload); + } catch (err) { + return reply.send(softErr(payload, err)); + } + }, + ); +} diff --git a/apps/bff/src/rbac/route-policy.ts b/apps/bff/src/rbac/route-policy.ts index 3c348e2..1bf7690 100644 --- a/apps/bff/src/rbac/route-policy.ts +++ b/apps/bff/src/rbac/route-policy.ts @@ -158,6 +158,11 @@ export const ROUTE_POLICY: Record<string, RoutePolicy> = { 'GET /api/ebpf/network/topology': 'profile:read', 'GET /api/ebpf/network/processes': 'profile:read', 'POST /api/layer/:key/ebpf/network/process-relation-metrics': 'profile:read', + // Continuous-profiling policies are profiling CONFIG: reading which rules are + // armed is a profiling read; arming them creates tasks (automatically, later), + // so the write sits with task creation on `profile:enable` below. + 'GET /api/continuous-profiling/policies': 'profile:read', + 'GET /api/continuous-profiling/instances': 'profile:read', 'GET /api/overview/dashboards': 'overview:read', 'GET /api/overview/dashboards/:id': 'overview:read', @@ -237,6 +242,7 @@ export const ROUTE_POLICY: Record<string, RoutePolicy> = { 'POST /api/layer/:key/ebpf/network/tasks': 'profile:enable', 'POST /api/ebpf/network/tasks': 'profile:enable', 'POST /api/ebpf/network/tasks/:taskId/keep-alive': 'profile:enable', + 'POST /api/continuous-profiling/policies': 'profile:enable', 'POST /api/browser-errors/source-maps': 'source-map:write', 'DELETE /api/browser-errors/source-maps/:id': 'source-map:write', diff --git a/apps/bff/src/server.ts b/apps/bff/src/server.ts index fda8bf5..579911d 100644 --- a/apps/bff/src/server.ts +++ b/apps/bff/src/server.ts @@ -56,6 +56,7 @@ 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 { registerContinuousProfilingRoutes } from './http/query/continuous-profiling.js'; import { registerAsyncProfileRoutes } from './http/query/async-profile.js'; // Config (CRUD for templates / settings) import { registerDashboardConfigRoute } from './http/config/dashboard.js'; @@ -286,6 +287,7 @@ registerEBPFRoutes(app, { uiTemplateClient: () => buildOapClients(source.current).uiTemplate(), }); registerAsyncProfileRoutes(app, { config: source, sessions }); +registerContinuousProfilingRoutes(app, { config: source, sessions }); // ── Config ───────────────────────────────────────────────────────── registerDashboardConfigRoute(app, { diff --git a/apps/ui/src/api/client.ts b/apps/ui/src/api/client.ts index 399d27c..7347351 100644 --- a/apps/ui/src/api/client.ts +++ b/apps/ui/src/api/client.ts @@ -82,6 +82,7 @@ import { EventsApi } from './scopes/events'; import { ProfileApi } from './scopes/profile'; import { EbpfApi } from './scopes/ebpf'; import { NetworkProfileApi } from './scopes/network-profile'; +import { ContinuousProfilingApi } from './scopes/continuous-profiling'; import { AsyncProfileApi } from './scopes/async-profile'; import { PprofApi } from './scopes/pprof'; import { DslApi } from './scopes/dsl'; @@ -900,6 +901,7 @@ export class BffClient { readonly browserErrors = new BrowserErrorsApi(this); readonly events = new EventsApi(this); readonly profile = new ProfileApi(this); + readonly continuousProfiling = new ContinuousProfilingApi(this); readonly ebpf = new EbpfApi(this); readonly networkProfile = new NetworkProfileApi(this); readonly asyncProfile = new AsyncProfileApi(this); diff --git a/apps/ui/src/api/scopes/continuous-profiling.ts b/apps/ui/src/api/scopes/continuous-profiling.ts new file mode 100644 index 0000000..a42d321 --- /dev/null +++ b/apps/ui/src/api/scopes/continuous-profiling.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 { + ContinuousProfilingInstancesResponse, + ContinuousProfilingPoliciesResponse, + ContinuousProfilingPolicyTarget, + ContinuousProfilingSetResponse, + ContinuousProfilingTargetType, +} from '@skywalking-horizon-ui/api-client'; +import type { BffClient } from '../client'; + +/** `bff.continuousProfiling` — the auto-trigger policies behind + * continuous profiling (as opposed to the on-demand profiling tasks). */ +export class ContinuousProfilingApi { + constructor(private readonly bff: BffClient) {} + + policies(serviceId: string): Promise<ContinuousProfilingPoliciesResponse> { + return this.bff.request<ContinuousProfilingPoliciesResponse>( + 'GET', + `/api/continuous-profiling/policies?service=${encodeURIComponent(serviceId)}`, + ); + } + + /** Replaces the service's WHOLE policy — send every target you want kept, + * because OAP treats the omitted ones as deleted. */ + savePolicies( + serviceId: string, + targets: ContinuousProfilingPolicyTarget[], + ): Promise<ContinuousProfilingSetResponse> { + return this.bff.request<ContinuousProfilingSetResponse>('POST', '/api/continuous-profiling/policies', { + serviceId, + targets, + }); + } + + instances( + serviceId: string, + target: ContinuousProfilingTargetType, + ): Promise<ContinuousProfilingInstancesResponse> { + return this.bff.request<ContinuousProfilingInstancesResponse>( + 'GET', + `/api/continuous-profiling/instances?service=${encodeURIComponent(serviceId)}&target=${encodeURIComponent(target)}`, + ); + } +} diff --git a/apps/ui/src/features/operate/continuous-profiling/ContinuousProfilingView.vue b/apps/ui/src/features/operate/continuous-profiling/ContinuousProfilingView.vue new file mode 100644 index 0000000..3aef29d --- /dev/null +++ b/apps/ui/src/features/operate/continuous-profiling/ContinuousProfilingView.vue @@ -0,0 +1,275 @@ +<!-- + 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. +--> + +<!-- + Continuous-profiling policies — the rules that make OAP start a profiling + task BY ITSELF, as opposed to the per-layer Profiling tabs where an operator + starts one on demand. + + Policies are stored per SERVICE, and `setContinuousProfilingPolicy` replaces + the service's whole policy: the targets sent become the targets it has. So the + page always sends the full draft, and Save is presented as "replace", not + "append". Picking a different service RESETS the draft — carrying one + service's rules into another's editor would silently arm the wrong thing. +--> +<script setup lang="ts"> +import { computed, ref, watch } from 'vue'; +import { useI18n } from 'vue-i18n'; +import type { + ContinuousProfilingPolicyTarget, + ContinuousProfilingTargetType, +} from '@skywalking-horizon-ui/api-client'; +import { bffClient } from '@/api/client'; +import Btn from '@/components/primitives/Btn.vue'; +import Icon from '@/components/icons/Icon.vue'; +import TypeaheadSelect from '@/components/primitives/TypeaheadSelect.vue'; +import { useLayers } from '@/shell/useLayers'; +import PolicyTargetCard from './components/PolicyTargetCard.vue'; +import MonitoredInstances from './components/MonitoredInstances.vue'; +import { TARGET_TYPES, newCheckItem } from './data'; +import { useContinuousProfiling } from './useContinuousProfiling'; + +const { t } = useI18n(); +const { layers } = useLayers(); + +const layerKey = ref<string | null>(null); +const serviceId = ref<string | null>(null); +const services = ref<Array<{ id: string; name: string }>>([]); +const loadingServices = ref(false); + +const { draft, seed, serverTargets, reachable, isFetching, save, saving, saveError, saved } = + useContinuousProfiling(serviceId); + +const layerOptions = computed(() => layers.value.map((l) => ({ value: l.key, label: l.name || l.key }))); +const serviceOptions = computed(() => services.value.map((s) => ({ value: s.id, label: s.name }))); + +// Cascade-clear: a layer change invalidates the service list AND anything the +// old service's policy populated, so reset both BEFORE the new roster lands. +watch(layerKey, async (key) => { + serviceId.value = null; + services.value = []; + if (!key) return; + loadingServices.value = true; + try { + const res = await bffClient.layer.services(key); + services.value = res.reachable ? res.services.map((s) => ({ id: s.id, name: s.name })) : []; + } finally { + loadingServices.value = false; + } +}); + +watch([serviceId, isFetching], () => seed()); + +const usedTargets = computed(() => new Set(draft.value.map((d) => d.type))); +const addableTargets = computed(() => TARGET_TYPES.filter((ty) => !usedTargets.value.has(ty))); + +/** Status comes from the SERVER read, not the draft — an unsaved target has no + * trigger history, and showing the old one against edited rules would mislead. */ +function statusFor(type: ContinuousProfilingTargetType) { + const found = serverTargets.value.find((s) => s.type === type); + return found ? { triggeredCount: found.triggeredCount, lastTriggerTimestamp: found.lastTriggerTimestamp } : null; +} + +function addTarget(type: ContinuousProfilingTargetType): void { + draft.value = [...draft.value, { type, checkItems: [newCheckItem()] }]; +} + +function updateTarget(index: number, target: ContinuousProfilingPolicyTarget): void { + draft.value = draft.value.map((d, i) => (i === index ? target : d)); +} + +function removeTarget(index: number): void { + draft.value = draft.value.filter((_, i) => i !== index); +} +</script> + +<template> + <div class="cp"> + <header class="page-head"> + <div> + <div class="kicker">{{ t('Operate · Continuous profiling') }}</div> + <h1>{{ t('Continuous profiling policies') }}</h1> + <p class="lede"> + {{ + t( + 'Rules that let OAP start a profiling task on its own when a process crosses a threshold — no approval, no operator present. Policies are stored per service; saving replaces that service’s whole policy, so keep every rule you want to survive.', + ) + }} + </p> + </div> + </header> + + <div class="pickers"> + <label class="picker"> + <span class="label">{{ t('Layer') }}</span> + <TypeaheadSelect + v-model="layerKey" + :options="layerOptions" + :placeholder="t('Select a layer')" + :aria-label="t('Layer')" + /> + </label> + <label class="picker"> + <span class="label">{{ t('Service') }}</span> + <TypeaheadSelect + v-model="serviceId" + :options="serviceOptions" + :disabled="!layerKey || loadingServices" + :placeholder="loadingServices ? t('Reading data…') : t('Select a service')" + :aria-label="t('Service')" + /> + </label> + </div> + + <p v-if="!serviceId" class="empty"> + {{ t('Pick a layer and a service to see and edit its continuous-profiling policy.') }} + </p> + + <template v-else> + <p v-if="isFetching" class="empty">{{ t('Reading data…') }}</p> + <p v-else-if="!reachable" class="empty err"> + {{ t('Could not read the policy for this service.') }} + </p> + + <template v-else> + <div class="panes"> + <div class="policies"> + <PolicyTargetCard + v-for="(target, i) in draft" + :key="target.type" + :target="target" + :status="statusFor(target.type)" + @update="updateTarget(i, $event)" + @remove="removeTarget(i)" + /> + + <p v-if="!draft.length" class="empty"> + {{ t('No policy is armed for this service. Add a target to start one.') }} + </p> + + <div v-if="addableTargets.length" class="add-row"> + <span class="label">{{ t('Add target') }}</span> + <Btn v-for="ty in addableTargets" :key="ty" kind="ghost" size="sm" @click="addTarget(ty)"> + <Icon name="plus" /> + {{ ty }} + </Btn> + </div> + </div> + + <aside class="side"> + <MonitoredInstances + v-for="target in draft" + :key="target.type" + :service-id="serviceId" + :target="target.type" + /> + </aside> + </div> + + <footer class="actions"> + <Btn kind="primary" :disabled="saving" @click="save"> + {{ saving ? t('Saving…') : t('Replace policy') }} + </Btn> + <span v-if="saved" class="ok">{{ t('Policy saved.') }}</span> + <span v-if="saveError" class="err">{{ saveError }}</span> + </footer> + </template> + </template> + </div> +</template> + +<style scoped> +.cp { + padding: var(--sw-density-pad); + display: flex; + flex-direction: column; + gap: 14px; +} +.kicker { + font-size: var(--sw-fs-xs); + letter-spacing: var(--sw-ls-caps); + text-transform: uppercase; + color: var(--sw-fg-3); +} +h1 { + margin: 2px 0 6px; + font-size: var(--sw-fs-xl); + font-weight: var(--sw-fw-semibold); + color: var(--sw-fg-0); +} +.lede { + margin: 0; + max-width: 76ch; + font-size: var(--sw-fs-sm); + color: var(--sw-fg-2); + line-height: var(--sw-lh-relaxed); +} +.pickers { + display: flex; + gap: 12px; +} +.picker, +.label { + display: flex; + flex-direction: column; + gap: 4px; +} +.label { + font-size: var(--sw-fs-xs); + color: var(--sw-fg-2); +} +.panes { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(260px, 1fr); + gap: 14px; + align-items: start; +} +.policies, +.side { + display: flex; + flex-direction: column; + gap: 10px; +} +.add-row { + display: flex; + align-items: center; + gap: 8px; +} +.empty { + margin: 0; + font-size: var(--sw-fs-sm); + color: var(--sw-fg-3); +} +.actions { + display: flex; + align-items: center; + gap: 12px; +} +.ok { + font-size: var(--sw-fs-xs); + color: var(--sw-ok); +} +.err { + font-size: var(--sw-fs-xs); + color: var(--sw-err); +} +@media (max-width: 1100px) { + .panes { + grid-template-columns: minmax(0, 1fr); + } +} +</style> diff --git a/apps/ui/src/features/operate/continuous-profiling/components/CheckItemRow.vue b/apps/ui/src/features/operate/continuous-profiling/components/CheckItemRow.vue new file mode 100644 index 0000000..aa9055c --- /dev/null +++ b/apps/ui/src/features/operate/continuous-profiling/components/CheckItemRow.vue @@ -0,0 +1,216 @@ +<!-- + Licensed to the Apache Software Foundation (ASF) under one or more + contributor license agreements. See the NOTICE file distributed with + this work for additional information regarding copyright ownership. + The ASF licenses this file to You under the Apache License, Version 2.0 + (the "License"); you may not use this file except in compliance with + the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<!-- + One armed condition inside a policy target. + + The URI fields appear only for the HTTP monitors, and only ONE of them may + carry a value — OAP rejects a check item holding both a list and a regex, so + filling either disables the other rather than letting the save fail. +--> +<script setup lang="ts"> +import { computed } from 'vue'; +import { useI18n } from 'vue-i18n'; +import type { + ContinuousProfilingMonitorType, + ContinuousProfilingPolicyItem, +} from '@skywalking-horizon-ui/api-client'; +import Btn from '@/components/primitives/Btn.vue'; +import TypeaheadSelect from '@/components/primitives/TypeaheadSelect.vue'; +import Icon from '@/components/icons/Icon.vue'; +import { MONITOR_TYPES, THRESHOLD_HINT, supportsUriFilter } from '../data'; + +const props = defineProps<{ item: ContinuousProfilingPolicyItem; removable: boolean }>(); +const emit = defineEmits<{ update: [ContinuousProfilingPolicyItem]; remove: [] }>(); + +const { t } = useI18n(); + +const monitorOptions = computed(() => MONITOR_TYPES.map((m) => ({ value: m, label: m }))); +const showUri = computed(() => supportsUriFilter(props.item.type)); +const uriListText = computed(() => (props.item.uriList ?? []).join('\n')); +const hasList = computed(() => (props.item.uriList ?? []).length > 0); +const hasRegex = computed(() => !!props.item.uriRegex); + +function patch(part: Partial<ContinuousProfilingPolicyItem>): void { + emit('update', { ...props.item, ...part }); +} + +function changeMonitor(type: ContinuousProfilingMonitorType): void { + // Dropping the URI filter when moving to a non-HTTP monitor keeps the draft + // consistent with what the form shows — a hidden field that still ships would + // be rejected by OAP with no visible cause. + patch(supportsUriFilter(type) ? { type } : { type, uriList: undefined, uriRegex: undefined }); +} + +function changeUriList(raw: string): void { + const list = raw + .split('\n') + .map((s) => s.trim()) + .filter(Boolean); + patch({ uriList: list.length ? list : undefined }); +} +</script> + +<template> + <div class="check-item"> + <div class="grid"> + <label class="field"> + <span class="label">{{ t('Monitor type') }}</span> + <TypeaheadSelect + :model-value="item.type" + :options="monitorOptions" + :aria-label="t('Monitor type')" + @update:model-value="changeMonitor($event as ContinuousProfilingMonitorType)" + /> + </label> + + <label class="field"> + <span class="label"> + {{ t('Threshold') }} + <em class="hint">{{ t(THRESHOLD_HINT[item.type]) }}</em> + </span> + <input + class="input" + type="text" + :value="item.threshold" + :placeholder="t(THRESHOLD_HINT[item.type])" + @input="patch({ threshold: ($event.target as HTMLInputElement).value })" + /> + </label> + + <label class="field narrow"> + <span class="label">{{ t('Period (seconds)') }}</span> + <input + class="input" + type="number" + min="1" + :value="item.period" + @input="patch({ period: Number(($event.target as HTMLInputElement).value) })" + /> + </label> + + <label class="field narrow"> + <span class="label">{{ t('Times before triggering') }}</span> + <input + class="input" + type="number" + min="1" + :value="item.count" + @input="patch({ count: Number(($event.target as HTMLInputElement).value) })" + /> + </label> + + <Btn + v-if="removable" + kind="ghost" + size="sm" + class="remove" + :aria-label="t('Remove condition')" + @click="emit('remove')" + > + <Icon name="close" /> + </Btn> + </div> + + <div v-if="showUri" class="uri"> + <label class="field"> + <span class="label">{{ t('URI list — one per line') }}</span> + <textarea + class="input area" + rows="2" + :disabled="hasRegex" + :value="uriListText" + :placeholder="hasRegex ? t('Clear the URI regex to use a list') : '/api/v1/orders'" + @input="changeUriList(($event.target as HTMLTextAreaElement).value)" + ></textarea> + </label> + <label class="field"> + <span class="label">{{ t('URI regex') }}</span> + <input + class="input" + type="text" + :disabled="hasList" + :value="item.uriRegex ?? ''" + :placeholder="hasList ? t('Clear the URI list to use a regex') : '/api/.*'" + @input="patch({ uriRegex: ($event.target as HTMLInputElement).value || undefined })" + /> + </label> + </div> + </div> +</template> + +<style scoped> +.check-item { + padding: 10px 12px; + border: 1px solid var(--sw-line); + border-radius: var(--sw-radius); + background: var(--sw-bg-2); +} +.grid { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: flex-end; +} +.field { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 190px; + flex: 1; +} +.field.narrow { + min-width: 120px; + flex: 0 0 130px; +} +.label { + font-size: var(--sw-fs-xs); + color: var(--sw-fg-2); + display: flex; + gap: 6px; + align-items: baseline; +} +.hint { + font-style: normal; + color: var(--sw-fg-3); + font-size: var(--sw-fs-xs); +} +.input { + background: var(--sw-bg-1); + border: 1px solid var(--sw-line); + border-radius: var(--sw-radius); + color: var(--sw-fg-0); + font-size: var(--sw-fs-sm); + padding: 5px 8px; + width: 100%; +} +.input:disabled { + opacity: 0.5; +} +.area { + resize: vertical; + font-family: var(--sw-mono); +} +.uri { + display: flex; + gap: 10px; + margin-top: 10px; +} +.remove { + flex: 0 0 auto; +} +</style> diff --git a/apps/ui/src/features/operate/continuous-profiling/components/MonitoredInstances.vue b/apps/ui/src/features/operate/continuous-profiling/components/MonitoredInstances.vue new file mode 100644 index 0000000..bad55e0 --- /dev/null +++ b/apps/ui/src/features/operate/continuous-profiling/components/MonitoredInstances.vue @@ -0,0 +1,154 @@ +<!-- + 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 evidence that a policy is matching something. + + An armed policy that matches NO process looks identical to one that is simply + quiet, so this panel is the difference between "configured" and "working": + it lists the instances and processes OAP is actually evaluating for a target, + with how often each has fired. +--> +<script setup lang="ts"> +import { computed, toRef } from 'vue'; +import { useI18n } from 'vue-i18n'; +import type { ContinuousProfilingTargetType } from '@skywalking-horizon-ui/api-client'; +import { useContinuousProfilingInstances } from '../useContinuousProfiling'; + +const props = defineProps<{ + serviceId: string | null; + target: ContinuousProfilingTargetType | null; +}>(); + +const { t } = useI18n(); +const { instances, reachable, error, isFetching } = useContinuousProfilingInstances( + toRef(props, 'serviceId'), + toRef(props, 'target'), +); + +const empty = computed(() => !isFetching.value && reachable.value && instances.value.length === 0); + +function when(ms: number | null | undefined): string { + return ms ? new Date(ms).toLocaleString() : '—'; +} +</script> + +<template> + <div class="monitored"> + <h4>{{ t('Monitored instances') }}</h4> + + <p v-if="isFetching" class="note">{{ t('Reading data…') }}</p> + <p v-else-if="error || !reachable" class="note err"> + {{ error ?? t('Could not read monitored instances.') }} + </p> + <p v-else-if="empty" class="note"> + {{ t('No instance matches this policy yet. A policy only evaluates processes an eBPF agent reports.') }} + </p> + + <ul v-else class="list"> + <li v-for="inst in instances" :key="inst.id" class="inst"> + <div class="row"> + <span class="name">{{ inst.name }}</span> + <span class="stat">{{ t('Triggered') }} {{ inst.triggeredCount }}×</span> + <span class="stat">{{ when(inst.lastTriggerTimestamp) }}</span> + </div> + <ul v-if="inst.processes.length" class="procs"> + <li v-for="p in inst.processes" :key="p.id" class="proc"> + <span class="pname">{{ p.name }}</span> + <span class="tag">{{ p.detectType }}</span> + <span v-for="l in p.labels" :key="l" class="tag soft">{{ l }}</span> + <span class="stat">{{ p.triggeredCount }}×</span> + </li> + </ul> + </li> + </ul> + </div> +</template> + +<style scoped> +.monitored { + border: 1px solid var(--sw-line); + border-radius: var(--sw-radius); + background: var(--sw-bg-1); + padding: 12px; +} +h4 { + margin: 0 0 8px; + font-size: var(--sw-fs-sm); + font-weight: var(--sw-fw-semibold); + color: var(--sw-fg-1); +} +.note { + margin: 0; + font-size: var(--sw-fs-xs); + color: var(--sw-fg-3); + line-height: var(--sw-lh-relaxed); +} +.note.err { + color: var(--sw-err); +} +.list, +.procs { + list-style: none; + margin: 0; + padding: 0; +} +.inst + .inst { + margin-top: 10px; + border-top: 1px solid var(--sw-line); + padding-top: 10px; +} +.row { + display: flex; + gap: 10px; + align-items: baseline; +} +.name { + font-size: var(--sw-fs-sm); + color: var(--sw-fg-0); + font-weight: var(--sw-fw-medium); +} +.stat { + font-size: var(--sw-fs-xs); + color: var(--sw-fg-3); +} +.procs { + margin-top: 4px; + padding-left: 10px; +} +.proc { + display: flex; + gap: 6px; + align-items: baseline; + font-size: var(--sw-fs-xs); + color: var(--sw-fg-2); + padding: 2px 0; +} +.pname { + font-family: var(--sw-mono); + color: var(--sw-fg-1); +} +.tag { + border: 1px solid var(--sw-line-2); + border-radius: var(--sw-radius); + padding: 0 5px; + color: var(--sw-fg-3); +} +.tag.soft { + border-style: dashed; +} +</style> diff --git a/apps/ui/src/features/operate/continuous-profiling/components/PolicyTargetCard.vue b/apps/ui/src/features/operate/continuous-profiling/components/PolicyTargetCard.vue new file mode 100644 index 0000000..600e4fd --- /dev/null +++ b/apps/ui/src/features/operate/continuous-profiling/components/PolicyTargetCard.vue @@ -0,0 +1,153 @@ +<!-- + Licensed to the Apache Software Foundation (ASF) under one or more + contributor license agreements. See the NOTICE file distributed with + this work for additional information regarding copyright ownership. + The ASF licenses this file to You under the Apache License, Version 2.0 + (the "License"); you may not use this file except in compliance with + the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<!-- + One eBPF target's rules (ON_CPU / OFF_CPU / NETWORK). + + A target with no check items cannot be saved — OAP requires at least one — + so removing the last condition removes the whole target instead. +--> +<script setup lang="ts"> +import { useI18n } from 'vue-i18n'; +import type { + ContinuousProfilingPolicyItem, + ContinuousProfilingPolicyTarget, +} from '@skywalking-horizon-ui/api-client'; +import Btn from '@/components/primitives/Btn.vue'; +import Icon from '@/components/icons/Icon.vue'; +import CheckItemRow from './CheckItemRow.vue'; +import { newCheckItem } from '../data'; + +const props = defineProps<{ + target: ContinuousProfilingPolicyTarget; + /** Live status for this target from the last read — absent until saved once. */ + status?: { triggeredCount?: number; lastTriggerTimestamp?: number | null } | null; +}>(); +const emit = defineEmits<{ update: [ContinuousProfilingPolicyTarget]; remove: [] }>(); + +const { t } = useI18n(); + +function updateItem(index: number, item: ContinuousProfilingPolicyItem): void { + const checkItems = props.target.checkItems.map((c, i) => (i === index ? item : c)); + emit('update', { ...props.target, checkItems }); +} + +function addItem(): void { + emit('update', { ...props.target, checkItems: [...props.target.checkItems, newCheckItem()] }); +} + +function removeItem(index: number): void { + if (props.target.checkItems.length <= 1) { + emit('remove'); + return; + } + emit('update', { + ...props.target, + checkItems: props.target.checkItems.filter((_, i) => i !== index), + }); +} + +function lastTrigger(ms: number | null | undefined): string { + return ms ? new Date(ms).toLocaleString() : t('never'); +} +</script> + +<template> + <section class="target"> + <header class="head"> + <div class="ident"> + <Icon name="flame" /> + <h3>{{ target.type }}</h3> + </div> + <div class="meta"> + <span v-if="status" class="stat"> + {{ t('Triggered') }} <strong>{{ status.triggeredCount ?? 0 }}×</strong> + </span> + <span v-if="status" class="stat"> + {{ t('Last') }} <strong>{{ lastTrigger(status.lastTriggerTimestamp) }}</strong> + </span> + <Btn kind="ghost" size="sm" :aria-label="t('Remove target')" @click="emit('remove')"> + <Icon name="trash" /> + </Btn> + </div> + </header> + + <div class="items"> + <CheckItemRow + v-for="(item, i) in target.checkItems" + :key="i" + :item="item" + :removable="true" + @update="updateItem(i, $event)" + @remove="removeItem(i)" + /> + </div> + + <Btn kind="ghost" size="sm" @click="addItem"> + <Icon name="plus" /> + {{ t('Add condition') }} + </Btn> + </section> +</template> + +<style scoped> +.target { + border: 1px solid var(--sw-line); + border-radius: var(--sw-radius); + background: var(--sw-bg-1); + padding: 12px; + display: flex; + flex-direction: column; + gap: 10px; +} +.head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.ident { + display: flex; + align-items: center; + gap: 8px; +} +h3 { + margin: 0; + font-size: var(--sw-fs-md); + font-weight: var(--sw-fw-semibold); + color: var(--sw-fg-0); + letter-spacing: var(--sw-ls-tight); +} +.meta { + display: flex; + align-items: center; + gap: 12px; +} +.stat { + font-size: var(--sw-fs-xs); + color: var(--sw-fg-2); +} +.stat strong { + color: var(--sw-fg-1); + font-weight: var(--sw-fw-medium); +} +.items { + display: flex; + flex-direction: column; + gap: 8px; +} +</style> diff --git a/apps/ui/src/features/operate/continuous-profiling/data.ts b/apps/ui/src/features/operate/continuous-profiling/data.ts new file mode 100644 index 0000000..1a1ff95 --- /dev/null +++ b/apps/ui/src/features/operate/continuous-profiling/data.ts @@ -0,0 +1,58 @@ +/* + * 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 { + ContinuousProfilingMonitorType, + ContinuousProfilingPolicyItem, + ContinuousProfilingTargetType, +} from '@skywalking-horizon-ui/api-client'; + +/** The three eBPF profiling flavours a policy can arm. OAP enum values — + * never translated (see the i18n policy in CLAUDE.md). */ +export const TARGET_TYPES: ContinuousProfilingTargetType[] = ['ON_CPU', 'OFF_CPU', 'NETWORK']; + +export const MONITOR_TYPES: ContinuousProfilingMonitorType[] = [ + 'PROCESS_CPU', + 'PROCESS_THREAD_COUNT', + 'SYSTEM_LOAD', + 'HTTP_ERROR_RATE', + 'HTTP_AVG_RESPONSE_TIME', +]; + +/** + * `threshold` is a string on the wire because its UNIT follows the monitor + * type — OAP parses it per type, so "75" means 75% for PROCESS_CPU and 75ms for + * HTTP_AVG_RESPONSE_TIME. The hint is the only thing telling an operator which + * they are typing; keep it next to the field. + */ +export const THRESHOLD_HINT: Record<ContinuousProfilingMonitorType, string> = { + PROCESS_CPU: 'percentage, e.g. 75', + PROCESS_THREAD_COUNT: 'a positive integer, e.g. 200', + SYSTEM_LOAD: 'a decimal load average, e.g. 3.5', + HTTP_ERROR_RATE: 'percentage, e.g. 10', + HTTP_AVG_RESPONSE_TIME: 'milliseconds, e.g. 500', +}; + +/** Only the HTTP monitors sample by URI; the process/system ones have no URI + * dimension, so the filter fields stay hidden for them. */ +export function supportsUriFilter(type: ContinuousProfilingMonitorType): boolean { + return type === 'HTTP_ERROR_RATE' || type === 'HTTP_AVG_RESPONSE_TIME'; +} + +export function newCheckItem(): ContinuousProfilingPolicyItem { + return { type: 'PROCESS_CPU', threshold: '', period: 60, count: 3 }; +} diff --git a/apps/ui/src/features/operate/continuous-profiling/useContinuousProfiling.ts b/apps/ui/src/features/operate/continuous-profiling/useContinuousProfiling.ts new file mode 100644 index 0000000..78bb431 --- /dev/null +++ b/apps/ui/src/features/operate/continuous-profiling/useContinuousProfiling.ts @@ -0,0 +1,141 @@ +/* + * 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, ref, type Ref } from 'vue'; +import { useQuery } from '@tanstack/vue-query'; +import type { + ContinuousProfilingPolicyTarget, + ContinuousProfilingTargetType, +} from '@skywalking-horizon-ui/api-client'; +import { bffClient } from '@/api/client'; + +/** + * Continuous-profiling policies for ONE service. + * + * The policy is read once per service and then edited as a local draft: OAP's + * `setContinuousProfilingPolicy` REPLACES the service's whole policy, so the + * draft must always carry every target the operator wants kept — saving a + * subset silently deletes the rest. `draft` is therefore seeded from the server + * state and sent back whole. + */ +export function useContinuousProfiling(serviceId: Ref<string | null>) { + const draft = ref<ContinuousProfilingPolicyTarget[]>([]); + /** Set once the draft has been seeded for the CURRENT service, so switching + * services re-seeds instead of carrying another service's rules over. */ + const seededFor = ref<string | null>(null); + const saving = ref(false); + const saveError = ref<string | null>(null); + const saved = ref(false); + + const q = useQuery({ + queryKey: ['continuous-profiling-policies', serviceId], + queryFn: () => bffClient.continuousProfiling.policies(serviceId.value as string), + enabled: computed(() => !!serviceId.value), + staleTime: 30_000, + refetchOnWindowFocus: false, + }); + + const reachable = computed<boolean>(() => q.data.value?.reachable ?? false); + const serverTargets = computed<ContinuousProfilingPolicyTarget[]>(() => q.data.value?.targets ?? []); + + // Seed the draft from the server response the first time it lands for this + // service. Deep-cloned so editing a field never mutates the query cache. + function seed(): void { + const id = serviceId.value; + if (!id || q.isFetching.value || seededFor.value === id) return; + draft.value = serverTargets.value.map((t) => ({ + type: t.type, + checkItems: t.checkItems.map((c) => ({ ...c, uriList: c.uriList ? [...c.uriList] : undefined })), + })); + seededFor.value = id; + saved.value = false; + saveError.value = null; + } + + async function save(): Promise<boolean> { + const id = serviceId.value; + if (!id) return false; + saving.value = true; + saveError.value = null; + saved.value = false; + try { + const res = await bffClient.continuousProfiling.savePolicies(id, draft.value); + // Three ways this fails and they are NOT the same: transport (reachable), + // our own validation / OAP's refusal (errorReason), and a bare false. + if (!res.reachable) { + saveError.value = res.error ?? 'unreachable'; + return false; + } + if (!res.status) { + saveError.value = res.errorReason ?? 'rejected'; + return false; + } + saved.value = true; + await q.refetch(); + seededFor.value = null; + return true; + } catch (err) { + saveError.value = err instanceof Error ? err.message : String(err); + return false; + } finally { + saving.value = false; + } + } + + return { + draft, + seed, + serverTargets, + reachable, + isLoading: q.isLoading, + isFetching: q.isFetching, + error: computed<string | null>(() => q.data.value?.error ?? null), + refetch: q.refetch, + save, + saving, + saveError, + saved, + }; +} + +/** Monitored instances for one target — read-only evidence that a policy is + * actually matching processes (and when it last fired). */ +export function useContinuousProfilingInstances( + serviceId: Ref<string | null>, + target: Ref<ContinuousProfilingTargetType | null>, +) { + const q = useQuery({ + queryKey: ['continuous-profiling-instances', serviceId, target], + queryFn: () => + bffClient.continuousProfiling.instances( + serviceId.value as string, + target.value as ContinuousProfilingTargetType, + ), + enabled: computed(() => !!serviceId.value && !!target.value), + staleTime: 30_000, + refetchOnWindowFocus: false, + }); + + return { + instances: computed(() => q.data.value?.instances ?? []), + reachable: computed<boolean>(() => q.data.value?.reachable ?? false), + error: computed<string | null>(() => q.data.value?.error ?? null), + isLoading: q.isLoading, + isFetching: q.isFetching, + refetch: q.refetch, + }; +} diff --git a/apps/ui/src/i18n/locales/de.json b/apps/ui/src/i18n/locales/de.json index 4d5f102..c08c61c 100644 --- a/apps/ui/src/i18n/locales/de.json +++ b/apps/ui/src/i18n/locales/de.json @@ -1693,5 +1693,37 @@ "This conversation was also continued in another tab, so it was not saved here.": "Diese Konversation wurde auch in einem anderen Tab fortgesetzt und daher hier nicht gespeichert.", "Keep this version to overwrite the other one, or load the other version to discard the turns shown here.": "Behalten Sie diese Fassung, um die andere zu überschreiben, oder laden Sie die andere Fassung und verwerfen Sie die hier gezeigten Beiträge.", "Keep this version": "Diese Fassung behalten", - "Load the other version": "Andere Fassung laden" + "Load the other version": "Andere Fassung laden", + "Add condition": "Bedingung hinzufügen", + "Add target": "Ziel hinzufügen", + "Clear the URI list to use a regex": "URI-Liste leeren, um einen regulären Ausdruck zu verwenden", + "Clear the URI regex to use a list": "URI-Regex leeren, um eine Liste zu verwenden", + "Continuous profiling": "Kontinuierliches Profiling", + "Continuous profiling policies": "Richtlinien für kontinuierliches Profiling", + "Could not read monitored instances.": "Überwachte Instanzen konnten nicht gelesen werden.", + "Could not read the policy for this service.": "Die Richtlinie dieses Service konnte nicht gelesen werden.", + "Last": "Zuletzt", + "Monitor type": "Überwachungstyp", + "Monitored instances": "Überwachte Instanzen", + "No instance matches this policy yet. A policy only evaluates processes an eBPF agent reports.": "Noch passt keine Instanz zu dieser Richtlinie. Eine Richtlinie bewertet nur Prozesse, die ein eBPF-Agent meldet.", + "No policy is armed for this service. Add a target to start one.": "Für diesen Service ist keine Richtlinie aktiv. Füge ein Ziel hinzu, um zu beginnen.", + "Operate · Continuous profiling": "Betrieb · Kontinuierliches Profiling", + "Period (seconds)": "Zeitraum (Sekunden)", + "Pick a layer and a service to see and edit its continuous-profiling policy.": "Wähle einen Layer und einen Service, um dessen Richtlinie für kontinuierliches Profiling zu sehen und zu bearbeiten.", + "Policy saved.": "Richtlinie gespeichert.", + "Remove condition": "Bedingung entfernen", + "Remove target": "Ziel entfernen", + "Replace policy": "Richtlinie ersetzen", + "Rules that let OAP start a profiling task on its own when a process crosses a threshold — no approval, no operator present. Policies are stored per service; saving replaces that service’s whole policy, so keep every rule you want to survive.": "Regeln, mit denen OAP von sich aus eine Profiling-Aufgabe startet, sobald ein Prozess einen Schwellenwert überschreitet – ohne Freigabe und ohne anwesende Person. Richtlinien werden pro Service gespeichert; beim Speichern wird die gesamte Richt [...] + "Select a layer": "Layer auswählen", + "Select a service": "Service auswählen", + "Threshold": "Schwellenwert", + "Times before triggering": "Treffer bis zur Auslösung", + "Triggered": "Ausgelöst", + "URI list — one per line": "URI-Liste – eine pro Zeile", + "URI regex": "URI-Regex", + "a decimal load average, e.g. 3.5": "ein dezimaler Load-Average, z. B. 3,5", + "a positive integer, e.g. 200": "eine positive ganze Zahl, z. B. 200", + "milliseconds, e.g. 500": "Millisekunden, z. B. 500", + "percentage, e.g. 75": "Prozentwert, z. B. 75" } diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json index 8effcce..4b53d56 100644 --- a/apps/ui/src/i18n/locales/en.json +++ b/apps/ui/src/i18n/locales/en.json @@ -1693,5 +1693,37 @@ "This conversation was also continued in another tab, so it was not saved here.": "This conversation was also continued in another tab, so it was not saved here.", "Keep this version to overwrite the other one, or load the other version to discard the turns shown here.": "Keep this version to overwrite the other one, or load the other version to discard the turns shown here.", "Keep this version": "Keep this version", - "Load the other version": "Load the other version" + "Load the other version": "Load the other version", + "Add condition": "Add condition", + "Add target": "Add target", + "Clear the URI list to use a regex": "Clear the URI list to use a regex", + "Clear the URI regex to use a list": "Clear the URI regex to use a list", + "Continuous profiling": "Continuous profiling", + "Continuous profiling policies": "Continuous profiling policies", + "Could not read monitored instances.": "Could not read monitored instances.", + "Could not read the policy for this service.": "Could not read the policy for this service.", + "Last": "Last", + "Monitor type": "Monitor type", + "Monitored instances": "Monitored instances", + "No instance matches this policy yet. A policy only evaluates processes an eBPF agent reports.": "No instance matches this policy yet. A policy only evaluates processes an eBPF agent reports.", + "No policy is armed for this service. Add a target to start one.": "No policy is armed for this service. Add a target to start one.", + "Operate · Continuous profiling": "Operate · Continuous profiling", + "Period (seconds)": "Period (seconds)", + "Pick a layer and a service to see and edit its continuous-profiling policy.": "Pick a layer and a service to see and edit its continuous-profiling policy.", + "Policy saved.": "Policy saved.", + "Remove condition": "Remove condition", + "Remove target": "Remove target", + "Replace policy": "Replace policy", + "Rules that let OAP start a profiling task on its own when a process crosses a threshold — no approval, no operator present. Policies are stored per service; saving replaces that service’s whole policy, so keep every rule you want to survive.": "Rules that let OAP start a profiling task on its own when a process crosses a threshold — no approval, no operator present. Policies are stored per service; saving replaces that service’s whole policy, so keep every rule you want to survive.", + "Select a layer": "Select a layer", + "Select a service": "Select a service", + "Threshold": "Threshold", + "Times before triggering": "Times before triggering", + "Triggered": "Triggered", + "URI list — one per line": "URI list — one per line", + "URI regex": "URI regex", + "a decimal load average, e.g. 3.5": "a decimal load average, e.g. 3.5", + "a positive integer, e.g. 200": "a positive integer, e.g. 200", + "milliseconds, e.g. 500": "milliseconds, e.g. 500", + "percentage, e.g. 75": "percentage, e.g. 75" } diff --git a/apps/ui/src/i18n/locales/es.json b/apps/ui/src/i18n/locales/es.json index f7a42b3..60f44dc 100644 --- a/apps/ui/src/i18n/locales/es.json +++ b/apps/ui/src/i18n/locales/es.json @@ -1693,5 +1693,37 @@ "This conversation was also continued in another tab, so it was not saved here.": "Esta conversación también continuó en otra pestaña, por lo que no se guardó aquí.", "Keep this version to overwrite the other one, or load the other version to discard the turns shown here.": "Conserva esta versión para sobrescribir la otra, o carga la otra versión para descartar los turnos que se muestran aquí.", "Keep this version": "Conservar esta versión", - "Load the other version": "Cargar la otra versión" + "Load the other version": "Cargar la otra versión", + "Add condition": "Añadir condición", + "Add target": "Añadir objetivo", + "Clear the URI list to use a regex": "Vacía la lista de URI para usar una expresión regular", + "Clear the URI regex to use a list": "Vacía la expresión regular de URI para usar una lista", + "Continuous profiling": "Perfilado continuo", + "Continuous profiling policies": "Políticas de perfilado continuo", + "Could not read monitored instances.": "No se pudieron leer las instancias monitorizadas.", + "Could not read the policy for this service.": "No se pudo leer la política de este servicio.", + "Last": "Última vez", + "Monitor type": "Tipo de monitorización", + "Monitored instances": "Instancias monitorizadas", + "No instance matches this policy yet. A policy only evaluates processes an eBPF agent reports.": "Todavía no hay ninguna instancia que coincida con esta política. Una política solo evalúa procesos reportados por un agente eBPF.", + "No policy is armed for this service. Add a target to start one.": "Este servicio no tiene ninguna política activa. Añade un objetivo para empezar.", + "Operate · Continuous profiling": "Operar · Perfilado continuo", + "Period (seconds)": "Periodo (segundos)", + "Pick a layer and a service to see and edit its continuous-profiling policy.": "Elige una capa y un servicio para ver y editar su política de perfilado continuo.", + "Policy saved.": "Política guardada.", + "Remove condition": "Eliminar condición", + "Remove target": "Eliminar objetivo", + "Replace policy": "Reemplazar política", + "Rules that let OAP start a profiling task on its own when a process crosses a threshold — no approval, no operator present. Policies are stored per service; saving replaces that service’s whole policy, so keep every rule you want to survive.": "Reglas que permiten a OAP iniciar una tarea de perfilado por sí mismo cuando un proceso supera un umbral, sin aprobación ni nadie presente. Las políticas se guardan por servicio; al guardar se reemplaza la política completa de ese servicio, así [...] + "Select a layer": "Selecciona una capa", + "Select a service": "Selecciona un servicio", + "Threshold": "Umbral", + "Times before triggering": "Veces antes de disparar", + "Triggered": "Disparada", + "URI list — one per line": "Lista de URI: una por línea", + "URI regex": "Expresión regular de URI", + "a decimal load average, e.g. 3.5": "una carga media decimal, p. ej. 3,5", + "a positive integer, e.g. 200": "un entero positivo, p. ej. 200", + "milliseconds, e.g. 500": "milisegundos, p. ej. 500", + "percentage, e.g. 75": "porcentaje, p. ej. 75" } diff --git a/apps/ui/src/i18n/locales/fr.json b/apps/ui/src/i18n/locales/fr.json index fef9be5..cf1584a 100644 --- a/apps/ui/src/i18n/locales/fr.json +++ b/apps/ui/src/i18n/locales/fr.json @@ -1693,5 +1693,37 @@ "This conversation was also continued in another tab, so it was not saved here.": "Cette conversation a également été poursuivie dans un autre onglet, elle n'a donc pas été enregistrée ici.", "Keep this version to overwrite the other one, or load the other version to discard the turns shown here.": "Conservez cette version pour écraser l'autre, ou chargez l'autre version pour abandonner les tours affichés ici.", "Keep this version": "Conserver cette version", - "Load the other version": "Charger l'autre version" + "Load the other version": "Charger l'autre version", + "Add condition": "Ajouter une condition", + "Add target": "Ajouter une cible", + "Clear the URI list to use a regex": "Videz la liste d’URI pour utiliser une expression régulière", + "Clear the URI regex to use a list": "Videz l’expression régulière d’URI pour utiliser une liste", + "Continuous profiling": "Profilage continu", + "Continuous profiling policies": "Politiques de profilage continu", + "Could not read monitored instances.": "Impossible de lire les instances surveillées.", + "Could not read the policy for this service.": "Impossible de lire la politique de ce service.", + "Last": "Dernière fois", + "Monitor type": "Type de surveillance", + "Monitored instances": "Instances surveillées", + "No instance matches this policy yet. A policy only evaluates processes an eBPF agent reports.": "Aucune instance ne correspond encore à cette politique. Une politique n’évalue que les processus signalés par un agent eBPF.", + "No policy is armed for this service. Add a target to start one.": "Aucune politique n’est active pour ce service. Ajoutez une cible pour commencer.", + "Operate · Continuous profiling": "Exploitation · Profilage continu", + "Period (seconds)": "Période (secondes)", + "Pick a layer and a service to see and edit its continuous-profiling policy.": "Choisissez une couche et un service pour consulter et modifier sa politique de profilage continu.", + "Policy saved.": "Politique enregistrée.", + "Remove condition": "Supprimer la condition", + "Remove target": "Supprimer la cible", + "Replace policy": "Remplacer la politique", + "Rules that let OAP start a profiling task on its own when a process crosses a threshold — no approval, no operator present. Policies are stored per service; saving replaces that service’s whole policy, so keep every rule you want to survive.": "Règles qui permettent à OAP de lancer lui-même une tâche de profilage lorsqu’un processus dépasse un seuil — sans validation ni personne aux commandes. Les politiques sont enregistrées par service ; l’enregistrement remplace toute la politique [...] + "Select a layer": "Sélectionnez une couche", + "Select a service": "Sélectionnez un service", + "Threshold": "Seuil", + "Times before triggering": "Occurrences avant déclenchement", + "Triggered": "Déclenchée", + "URI list — one per line": "Liste d’URI — une par ligne", + "URI regex": "Expression régulière d’URI", + "a decimal load average, e.g. 3.5": "une charge moyenne décimale, p. ex. 3,5", + "a positive integer, e.g. 200": "un entier positif, p. ex. 200", + "milliseconds, e.g. 500": "millisecondes, p. ex. 500", + "percentage, e.g. 75": "pourcentage, p. ex. 75" } diff --git a/apps/ui/src/i18n/locales/ja.json b/apps/ui/src/i18n/locales/ja.json index fc3bcad..8746804 100644 --- a/apps/ui/src/i18n/locales/ja.json +++ b/apps/ui/src/i18n/locales/ja.json @@ -1693,5 +1693,37 @@ "This conversation was also continued in another tab, so it was not saved here.": "この会話は別のタブでも続行されたため、ここには保存されませんでした。", "Keep this version to overwrite the other one, or load the other version to discard the turns shown here.": "このバージョンを残すともう一方を上書きします。もう一方のバージョンを読み込むと、ここに表示されているやり取りは破棄されます。", "Keep this version": "このバージョンを残す", - "Load the other version": "もう一方のバージョンを読み込む" + "Load the other version": "もう一方のバージョンを読み込む", + "Add condition": "条件を追加", + "Add target": "ターゲットを追加", + "Clear the URI list to use a regex": "正規表現を使うには URI リストを空にしてください", + "Clear the URI regex to use a list": "リストを使うには URI 正規表現を空にしてください", + "Continuous profiling": "継続的プロファイリング", + "Continuous profiling policies": "継続的プロファイリングのポリシー", + "Could not read monitored instances.": "監視対象インスタンスを読み取れませんでした。", + "Could not read the policy for this service.": "このサービスのポリシーを読み取れませんでした。", + "Last": "最終", + "Monitor type": "監視タイプ", + "Monitored instances": "監視対象インスタンス", + "No instance matches this policy yet. A policy only evaluates processes an eBPF agent reports.": "このポリシーに一致するインスタンスはまだありません。ポリシーは eBPF エージェントが報告したプロセスのみを評価します。", + "No policy is armed for this service. Add a target to start one.": "このサービスにはポリシーがありません。ターゲットを追加して開始してください。", + "Operate · Continuous profiling": "運用 · 継続的プロファイリング", + "Period (seconds)": "評価期間(秒)", + "Pick a layer and a service to see and edit its continuous-profiling policy.": "レイヤーとサービスを選ぶと、その継続的プロファイリングのポリシーを確認・編集できます。", + "Policy saved.": "ポリシーを保存しました。", + "Remove condition": "条件を削除", + "Remove target": "ターゲットを削除", + "Replace policy": "ポリシーを置き換える", + "Rules that let OAP start a profiling task on its own when a process crosses a threshold — no approval, no operator present. Policies are stored per service; saving replaces that service’s whole policy, so keep every rule you want to survive.": "プロセスがしきい値を超えたときに、OAP 自身がプロファイリングタスクを開始するためのルールです。承認も担当者の常駐も必要ありません。ポリシーはサービス単位で保存され、保存するとそのサービスのポリシー全体が置き換わります。残したいルールはすべて含めてください。", + "Select a layer": "レイヤーを選択", + "Select a service": "サービスを選択", + "Threshold": "しきい値", + "Times before triggering": "発動までの回数", + "Triggered": "発動回数", + "URI list — one per line": "URI リスト — 1 行に 1 つ", + "URI regex": "URI 正規表現", + "a decimal load average, e.g. 3.5": "小数のロードアベレージ(例: 3.5)", + "a positive integer, e.g. 200": "正の整数(例: 200)", + "milliseconds, e.g. 500": "ミリ秒(例: 500)", + "percentage, e.g. 75": "パーセント(例: 75)" } diff --git a/apps/ui/src/i18n/locales/ko.json b/apps/ui/src/i18n/locales/ko.json index c0e937b..d378351 100644 --- a/apps/ui/src/i18n/locales/ko.json +++ b/apps/ui/src/i18n/locales/ko.json @@ -1693,5 +1693,37 @@ "This conversation was also continued in another tab, so it was not saved here.": "이 대화는 다른 탭에서도 이어졌기 때문에 여기에는 저장되지 않았습니다.", "Keep this version to overwrite the other one, or load the other version to discard the turns shown here.": "이 버전을 유지하면 다른 버전을 덮어쓰고, 다른 버전을 불러오면 여기에 표시된 대화가 사라집니다.", "Keep this version": "이 버전 유지", - "Load the other version": "다른 버전 불러오기" + "Load the other version": "다른 버전 불러오기", + "Add condition": "조건 추가", + "Add target": "대상 추가", + "Clear the URI list to use a regex": "정규식을 사용하려면 URI 목록을 비우세요", + "Clear the URI regex to use a list": "목록을 사용하려면 URI 정규식을 비우세요", + "Continuous profiling": "지속적 프로파일링", + "Continuous profiling policies": "지속적 프로파일링 정책", + "Could not read monitored instances.": "모니터링 중인 인스턴스를 읽을 수 없습니다.", + "Could not read the policy for this service.": "이 서비스의 정책을 읽을 수 없습니다.", + "Last": "마지막", + "Monitor type": "모니터링 유형", + "Monitored instances": "모니터링 중인 인스턴스", + "No instance matches this policy yet. A policy only evaluates processes an eBPF agent reports.": "아직 이 정책에 해당하는 인스턴스가 없습니다. 정책은 eBPF 에이전트가 보고한 프로세스만 평가합니다.", + "No policy is armed for this service. Add a target to start one.": "이 서비스에는 활성화된 정책이 없습니다. 대상을 추가해 시작하세요.", + "Operate · Continuous profiling": "운영 · 지속적 프로파일링", + "Period (seconds)": "평가 주기(초)", + "Pick a layer and a service to see and edit its continuous-profiling policy.": "레이어와 서비스를 선택하면 해당 서비스의 지속적 프로파일링 정책을 보고 편집할 수 있습니다.", + "Policy saved.": "정책을 저장했습니다.", + "Remove condition": "조건 삭제", + "Remove target": "대상 삭제", + "Replace policy": "정책 교체", + "Rules that let OAP start a profiling task on its own when a process crosses a threshold — no approval, no operator present. Policies are stored per service; saving replaces that service’s whole policy, so keep every rule you want to survive.": "프로세스가 임계값을 넘을 때 OAP가 스스로 프로파일링 작업을 시작하도록 하는 규칙입니다. 승인도, 담당자의 상주도 필요하지 않습니다. 정책은 서비스 단위로 저장되며, 저장하면 해당 서비스의 정책 전체가 교체되므로 유지하려는 규칙을 모두 포함하세요.", + "Select a layer": "레이어 선택", + "Select a service": "서비스 선택", + "Threshold": "임계값", + "Times before triggering": "트리거까지 횟수", + "Triggered": "트리거됨", + "URI list — one per line": "URI 목록 — 한 줄에 하나", + "URI regex": "URI 정규식", + "a decimal load average, e.g. 3.5": "소수 형태의 부하 평균, 예: 3.5", + "a positive integer, e.g. 200": "양의 정수, 예: 200", + "milliseconds, e.g. 500": "밀리초, 예: 500", + "percentage, e.g. 75": "백분율, 예: 75" } diff --git a/apps/ui/src/i18n/locales/pt.json b/apps/ui/src/i18n/locales/pt.json index c9edbfe..668b28d 100644 --- a/apps/ui/src/i18n/locales/pt.json +++ b/apps/ui/src/i18n/locales/pt.json @@ -1693,5 +1693,37 @@ "This conversation was also continued in another tab, so it was not saved here.": "Esta conversa também foi continuada em outra aba, então não foi salva aqui.", "Keep this version to overwrite the other one, or load the other version to discard the turns shown here.": "Mantenha esta versão para sobrescrever a outra, ou carregue a outra versão para descartar os turnos exibidos aqui.", "Keep this version": "Manter esta versão", - "Load the other version": "Carregar a outra versão" + "Load the other version": "Carregar a outra versão", + "Add condition": "Adicionar condição", + "Add target": "Adicionar alvo", + "Clear the URI list to use a regex": "Limpe a lista de URI para usar uma expressão regular", + "Clear the URI regex to use a list": "Limpe a expressão regular de URI para usar uma lista", + "Continuous profiling": "Profiling contínuo", + "Continuous profiling policies": "Políticas de profiling contínuo", + "Could not read monitored instances.": "Não foi possível ler as instâncias monitoradas.", + "Could not read the policy for this service.": "Não foi possível ler a política deste serviço.", + "Last": "Última vez", + "Monitor type": "Tipo de monitoramento", + "Monitored instances": "Instâncias monitoradas", + "No instance matches this policy yet. A policy only evaluates processes an eBPF agent reports.": "Ainda não há instância correspondente a esta política. Uma política só avalia processos reportados por um agente eBPF.", + "No policy is armed for this service. Add a target to start one.": "Este serviço não tem nenhuma política ativa. Adicione um alvo para começar.", + "Operate · Continuous profiling": "Operar · Profiling contínuo", + "Period (seconds)": "Período (segundos)", + "Pick a layer and a service to see and edit its continuous-profiling policy.": "Escolha uma camada e um serviço para ver e editar sua política de profiling contínuo.", + "Policy saved.": "Política salva.", + "Remove condition": "Remover condição", + "Remove target": "Remover alvo", + "Replace policy": "Substituir política", + "Rules that let OAP start a profiling task on its own when a process crosses a threshold — no approval, no operator present. Policies are stored per service; saving replaces that service’s whole policy, so keep every rule you want to survive.": "Regras que permitem ao OAP iniciar uma tarefa de profiling sozinho quando um processo cruza um limite — sem aprovação e sem ninguém presente. As políticas são guardadas por serviço; salvar substitui a política inteira desse serviço, então mante [...] + "Select a layer": "Selecione uma camada", + "Select a service": "Selecione um serviço", + "Threshold": "Limite", + "Times before triggering": "Vezes antes de disparar", + "Triggered": "Disparada", + "URI list — one per line": "Lista de URI — uma por linha", + "URI regex": "Expressão regular de URI", + "a decimal load average, e.g. 3.5": "uma média de carga decimal, p. ex. 3,5", + "a positive integer, e.g. 200": "um inteiro positivo, p. ex. 200", + "milliseconds, e.g. 500": "milissegundos, p. ex. 500", + "percentage, e.g. 75": "porcentagem, p. ex. 75" } diff --git a/apps/ui/src/i18n/locales/zh-CN.json b/apps/ui/src/i18n/locales/zh-CN.json index eba1954..a201faa 100644 --- a/apps/ui/src/i18n/locales/zh-CN.json +++ b/apps/ui/src/i18n/locales/zh-CN.json @@ -1693,5 +1693,37 @@ "This conversation was also continued in another tab, so it was not saved here.": "该对话在另一个标签页中也被继续过,因此未在此处保存。", "Keep this version to overwrite the other one, or load the other version to discard the turns shown here.": "保留此版本会覆盖另一个版本;载入另一个版本则会丢弃这里显示的对话轮次。", "Keep this version": "保留此版本", - "Load the other version": "载入另一个版本" + "Load the other version": "载入另一个版本", + "Add condition": "添加条件", + "Add target": "添加目标", + "Clear the URI list to use a regex": "清空 URI 列表后才能使用正则", + "Clear the URI regex to use a list": "清空 URI 正则后才能使用列表", + "Continuous profiling": "持续性能剖析", + "Continuous profiling policies": "持续性能剖析策略", + "Could not read monitored instances.": "无法读取被监控的实例。", + "Could not read the policy for this service.": "无法读取该服务的策略。", + "Last": "最近一次", + "Monitor type": "监控类型", + "Monitored instances": "被监控的实例", + "No instance matches this policy yet. A policy only evaluates processes an eBPF agent reports.": "暂无实例匹配该策略。策略只会评估 eBPF 代理上报的进程。", + "No policy is armed for this service. Add a target to start one.": "该服务尚未启用任何策略。添加一个目标即可开始。", + "Operate · Continuous profiling": "运维 · 持续性能剖析", + "Period (seconds)": "评估周期(秒)", + "Pick a layer and a service to see and edit its continuous-profiling policy.": "先选择层级与服务,即可查看并编辑其持续性能剖析策略。", + "Policy saved.": "策略已保存。", + "Remove condition": "删除条件", + "Remove target": "删除目标", + "Replace policy": "替换策略", + "Rules that let OAP start a profiling task on its own when a process crosses a threshold — no approval, no operator present. Policies are stored per service; saving replaces that service’s whole policy, so keep every rule you want to survive.": "当进程越过阈值时,让 OAP 自动发起性能剖析任务的规则 —— 无需审批,也无需有人值守。策略按服务存储;保存会替换该服务的整个策略,因此请保留所有希望继续生效的规则。", + "Select a layer": "选择层级", + "Select a service": "选择服务", + "Threshold": "阈值", + "Times before triggering": "触发所需次数", + "Triggered": "已触发", + "URI list — one per line": "URI 列表 —— 每行一个", + "URI regex": "URI 正则", + "a decimal load average, e.g. 3.5": "小数形式的负载均值,例如 3.5", + "a positive integer, e.g. 200": "正整数,例如 200", + "milliseconds, e.g. 500": "毫秒,例如 500", + "percentage, e.g. 75": "百分比,例如 75" } diff --git a/apps/ui/src/shell/router/index.ts b/apps/ui/src/shell/router/index.ts index af902de..281b46a 100644 --- a/apps/ui/src/shell/router/index.ts +++ b/apps/ui/src/shell/router/index.ts @@ -151,6 +151,15 @@ const shellRoutes: RouteRecordRaw[] = [ component: () => import('@/features/operate/alerting-rules/AlertingRulesView.vue'), meta: { verb: 'alarm-rule:read' }, }, + // Continuous-profiling policies — auto-trigger rules, distinct from the + // per-layer Profiling tabs which start tasks on demand. Reading is a + // profiling read; arming is gated at the route (profile:enable) in the BFF. + { + path: 'operate/continuous-profiling', + name: 'continuous-profiling', + component: () => import('@/features/operate/continuous-profiling/ContinuousProfilingView.vue'), + meta: { verb: 'profile:read' }, + }, // Static sub-routes are declared first so they aren't shadowed by // the catalog alternation regex (which would otherwise grab `edit` // / `dump`). Each gated on `receiver-runtime-rule` at the page-body diff --git a/apps/ui/src/shell/useSidebarMenu.ts b/apps/ui/src/shell/useSidebarMenu.ts index 1f70dca..3620550 100644 --- a/apps/ui/src/shell/useSidebarMenu.ts +++ b/apps/ui/src/shell/useSidebarMenu.ts @@ -87,6 +87,12 @@ export function useSidebarMenu() { kicker: t('Operate'), links: [ { icon: 'alert', label: t('Alerting rules'), to: '/operate/alerting-rules', verb: 'alarm-rule:read' }, + { + icon: 'prof', + label: t('Continuous profiling'), + to: '/operate/continuous-profiling', + verb: 'profile:read', + }, { icon: 'set', label: t('DSL management'), diff --git a/docs/operate/profiling.md b/docs/operate/profiling.md index 52a46ca..4d79f07 100644 --- a/docs/operate/profiling.md +++ b/docs/operate/profiling.md @@ -116,8 +116,32 @@ Each sampling rule scopes the capture — by URI pattern, by HTTP 4xx / 5xx resp The result is a **honeycomb topology**: each cell is a process, and the edges between them are the observed inter-process calls. Selecting an edge opens a detail panel with that process-to-process relation's metrics (call rate, latency, and bytes transferred) charted over the task's run window. The topology that drives this layout is the same process-relation data that powers the [3D Infrastructure Map](infra-3d-map.md). +## Continuous Profiling + +Everything above starts a profiling task **on demand** — you pick a target and start it. Continuous profiling is the opposite: you arm a policy once, and OAP starts the profiling task **by itself** whenever a process crosses a threshold, with nobody present. It is how you catch a problem that only appears at 3 a.m. + +Policies are edited under **Operate → Continuous profiling**. Pick a layer and a service, and the page shows that service's policy plus the instances OAP is currently evaluating it against. + +A policy is a set of **targets** — `ON_CPU`, `OFF_CPU`, or `NETWORK` — and each target carries one or more **conditions**. A condition is: + +- a **monitor type** — `PROCESS_CPU`, `PROCESS_THREAD_COUNT`, `SYSTEM_LOAD`, `HTTP_ERROR_RATE`, or `HTTP_AVG_RESPONSE_TIME`; +- a **threshold**, whose unit follows the monitor type — a percentage for CPU and error rate, a plain integer for thread count, a decimal load average for system load, milliseconds for response time; +- a **period**, the number of seconds of metrics to evaluate; +- a **count**, how many matching evaluations must occur before profiling is triggered. + +The two HTTP monitors can additionally be scoped to specific traffic by a **URI list** or a **URI regex** — one or the other, never both. + +Two things are worth knowing before you save: + +- **Saving replaces the service's whole policy.** OAP stores one policy per service, and the page sends everything you see. A target you delete is deleted; keep every rule you want to survive. +- **A policy only evaluates processes an eBPF agent reports.** The Monitored instances panel beside the editor is how you tell a working policy from a merely saved one — it lists the instances and processes OAP is actually evaluating, with how often each has fired and when it last did. An empty panel means the rules are stored but nothing is being watched. + +Reading policies needs `profile:read`; saving one needs `profile:enable`, the same permission as starting a task by hand — because that is what a policy eventually does. + ## Troubleshooting +- **A continuous-profiling policy never fires** — check the Monitored instances panel first. If it is empty, no eBPF agent is reporting processes for that service and the thresholds are irrelevant; deploy [Rover](https://github.com/apache/skywalking-rover) for the service. If instances are listed but the trigger count stays at zero, the threshold is not being crossed — lower it, lengthen the period, or reduce the required count. + - **No profiling tabs on a layer** — OAP did not report profiling support for that service. Each tab requires the corresponding capability (trace, eBPF, async-profiler, network, or pprof), which depends on the agent or [Rover](https://github.com/apache/skywalking-rover) deployment behind the service. - **New Task is unavailable** — you have not selected a service (or, for Network Profiling, an instance), or you lack `profile:enable`. diff --git a/packages/api-client/src/continuous-profiling.ts b/packages/api-client/src/continuous-profiling.ts new file mode 100644 index 0000000..d6b5ef9 --- /dev/null +++ b/packages/api-client/src/continuous-profiling.ts @@ -0,0 +1,109 @@ +/* + * 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. + */ + +/** + * Wire types for continuous (auto-triggered) profiling policies. + * + * A policy is what makes OAP start a profiling task BY ITSELF when a process + * crosses a threshold — as opposed to the on-demand tasks the profiling tabs + * create. It is stored per SERVICE and carries one entry per eBPF target + * (ON_CPU / OFF_CPU / NETWORK); each target holds the check items that arm it. + * + * `setContinuousProfilingPolicy` replaces the WHOLE policy for a service — the + * targets you send become the targets it has. Sending a subset deletes the rest, + * so always send the full desired state. + */ + +export type ContinuousProfilingTargetType = 'ON_CPU' | 'OFF_CPU' | 'NETWORK'; + +/** OAP's `ContinuousProfilingMonitorType`, minus the non-selectable UNKNOWN(0). */ +export type ContinuousProfilingMonitorType = + | 'PROCESS_CPU' + | 'PROCESS_THREAD_COUNT' + | 'SYSTEM_LOAD' + | 'HTTP_ERROR_RATE' + | 'HTTP_AVG_RESPONSE_TIME'; + +/** + * One armed condition. `threshold` is a STRING on the wire because its meaning + * follows `type` — a percentage for CPU / error rate, a plain integer for thread + * count, a float for system load, milliseconds for response time. + * + * `uriList` and `uriRegex` apply only to the HTTP_* types and are mutually + * exclusive; OAP rejects a check item carrying both. + */ +export interface ContinuousProfilingPolicyItem { + type: ContinuousProfilingMonitorType; + threshold: string; + /** Seconds of metrics to evaluate. */ + period: number; + /** How many evaluations must match before profiling is triggered. */ + count: number; + uriList?: string[]; + uriRegex?: string; +} + +export interface ContinuousProfilingPolicyTarget { + type: ContinuousProfilingTargetType; + checkItems: ContinuousProfilingPolicyItem[]; + /** Read-only: how often this target has fired. Ignored on write. */ + triggeredCount?: number; + /** Read-only: epoch ms of the last trigger, null if never. Ignored on write. */ + lastTriggerTimestamp?: number | null; +} + +export interface ContinuousProfilingPoliciesResponse { + targets: ContinuousProfilingPolicyTarget[]; + reachable: boolean; + error?: string; +} + +export interface ContinuousProfilingPolicyRequest { + serviceId: string; + targets: ContinuousProfilingPolicyTarget[]; +} + +export interface ContinuousProfilingSetResponse { + /** OAP's own verdict — false means it refused, with `errorReason`. */ + status: boolean; + errorReason?: string | null; + reachable: boolean; + error?: string; +} + +export interface ContinuousProfilingMonitoringProcess { + id: string; + name: string; + detectType: string; + labels: string[]; + triggeredCount: number; + lastTriggerTimestamp?: number | null; +} + +export interface ContinuousProfilingMonitoringInstance { + id: string; + name: string; + triggeredCount: number; + lastTriggerTimestamp?: number | null; + processes: ContinuousProfilingMonitoringProcess[]; +} + +export interface ContinuousProfilingInstancesResponse { + instances: ContinuousProfilingMonitoringInstance[]; + reachable: boolean; + error?: string; +} diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index e2170a3..9a18697 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -197,6 +197,18 @@ export type { NetworkProfilingCreateResponse, NetworkProfilingKeepAliveResponse, } from './ebpf.js'; +export type { + ContinuousProfilingTargetType, + ContinuousProfilingMonitorType, + ContinuousProfilingPolicyItem, + ContinuousProfilingPolicyTarget, + ContinuousProfilingPoliciesResponse, + ContinuousProfilingPolicyRequest, + ContinuousProfilingSetResponse, + ContinuousProfilingMonitoringProcess, + ContinuousProfilingMonitoringInstance, + ContinuousProfilingInstancesResponse, +} from './continuous-profiling.js'; export type { AsyncProfilingEvent, AsyncJFREventType,
