This is an automated email from the ASF dual-hosted git repository. wu-sheng pushed a commit to branch feat/service-events-popout in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
commit 6a3476b2f50f7b39647d57566600489431c875ff Author: Wu Sheng <[email protected]> AuthorDate: Wed Jul 1 10:44:42 2026 +0800 feat(events): add a per-service events popout (queryEvents swimlane) Open a per-service Events popout from a layer's service banner: it shows that service's lifecycle events (agent restarts, Kubernetes events, and other OAP `queryEvents` records) as an instance × time swimlane — one row per service instance, each in its own color, bars spanning start→end, instant events as diamond markers, Error events ringed red, and overlapping events on an instance stacked into sub-lanes so nothing hides. The popout owns its own time window (6h / 1d / 2d presets plus a custom range up to 7 days, second precision), scrolls internally (sticky time-axis header + sticky instance column, horizontal scroll for long ranges, opened at the newest events), marks the date at day boundaries, filters instances by name via a search box, and opens a detail panel on bar click (instance, Started / Ended / Duration, message, parameters). The newest events are fetched up to a configurable cap (`performance.limits.maxPageSize.events`, default 200), with an explicit "all in range shown / more available — narrow the range" indicator. BFF adds a thin `POST /api/events` route wrapping `queryEvents` (SECOND step, DES, layer/service filters, server-side page-size clamp, absolute-window cap), gated by a new `events:read` verb granted to the viewer / maintainer / operator built-in roles. UI strings are translated across all 8 locales; the CHANGELOG, operator docs, and both RBAC docs are updated. Unit tests cover the BFF query shaping (window clamp, condition building, map/sort) and the Gantt packing (layer→service→instance tree, sub-lane overlap, service-scoped collapse). --- CHANGELOG.md | 6 + apps/bff/src/config/schema.ts | 6 + apps/bff/src/http/query/events.test.ts | 145 +++++++++ apps/bff/src/http/query/events.ts | 252 +++++++++++++++ apps/bff/src/rbac/route-policy.ts | 2 + apps/bff/src/rbac/verbs.ts | 1 + apps/bff/src/server.ts | 2 + apps/ui/src/api/client.ts | 8 + apps/ui/src/api/scopes/events.ts | 32 ++ apps/ui/src/features/admin/roles/RolesView.vue | 6 +- apps/ui/src/features/events/EventsDetailPanel.vue | 167 ++++++++++ apps/ui/src/features/events/EventsGantt.vue | 365 ++++++++++++++++++++++ apps/ui/src/features/events/EventsPopout.vue | 162 ++++++++++ apps/ui/src/features/events/ganttLayout.test.ts | 117 +++++++ apps/ui/src/features/events/ganttLayout.ts | 165 ++++++++++ apps/ui/src/features/events/useEvents.ts | 124 ++++++++ apps/ui/src/features/events/useEventsPopout.ts | 55 ++++ apps/ui/src/features/events/useEventsWindow.ts | 165 ++++++++++ apps/ui/src/i18n/locales/de.json | 30 +- apps/ui/src/i18n/locales/en.json | 30 +- apps/ui/src/i18n/locales/es.json | 30 +- apps/ui/src/i18n/locales/fr.json | 30 +- apps/ui/src/i18n/locales/ja.json | 30 +- apps/ui/src/i18n/locales/ko.json | 30 +- apps/ui/src/i18n/locales/pt.json | 30 +- apps/ui/src/i18n/locales/zh-CN.json | 30 +- apps/ui/src/layer/LayerShell.vue | 27 ++ apps/ui/src/shell/AppShell.vue | 5 + docs/access-control/rbac.md | 3 +- docs/menu.yml | 2 + docs/operate/events.md | 66 ++++ docs/setup/rbac.md | 6 +- packages/api-client/src/events.ts | 99 ++++++ packages/api-client/src/index.ts | 8 + 34 files changed, 2222 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd8d027..90ae914 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,12 @@ The version line is shared by every package in the monorepo (apps + shared packa - **The alarm timeline reads more clearly** — a clearer selection band and legend, and the detail sidebar reflows cleanly on narrow windows. Hovering the timeline now hints both affordances — click a minute to filter, or drag across the timeline to select a range — so range-selection is no longer hidden. +### Events + +- **A per-service events popout on the service banner.** Every layer drill-down's service banner gains an Events button that opens a modal for that one service's lifecycle events — agent restarts, Kubernetes events, and other point-in-time records from OAP `queryEvents` — without leaving the page you're on. The service is fixed, so the view is a swimlane of **instance × time**: one row per service instance in its own color, each event a bar on a time axis (an event with no end time is an [...] + +- **Built for scale and honest about limits.** A rolling restart of a large service is one bar per instance stacked at the same moment — the granularity is the point, and a search box filters the instance rows by name for services that run hundreds of them. Scrolling is fully internal (sticky time-axis header + sticky instance column, horizontal scroll for long ranges, opened scrolled to the newest events). The newest events are fetched up to a configurable cap (200 by default); the popo [...] + ### User experience - **Escape closes any dismissible panel** — modals, row popouts, and the topology focus / node-filter dropdowns all dismiss on Esc. diff --git a/apps/bff/src/config/schema.ts b/apps/bff/src/config/schema.ts index c48a7b9..bbdf8aa 100644 --- a/apps/bff/src/config/schema.ts +++ b/apps/bff/src/config/schema.ts @@ -172,6 +172,7 @@ const rbacSchema = z viewer: [ 'metrics:read', 'alarms:read', + 'events:read', 'traces:read', 'logs:read', 'browser-errors:read', @@ -187,6 +188,7 @@ const rbacSchema = z maintainer: [ 'metrics:read', 'alarms:read', + 'events:read', 'traces:read', 'logs:read', 'browser-errors:read', @@ -205,6 +207,7 @@ const rbacSchema = z operator: [ 'metrics:read', 'alarms:read', + 'events:read', 'traces:read', 'logs:read', 'browser-errors:read', @@ -448,6 +451,9 @@ const performanceSchema = z traces: z.number().int().min(1).max(500).default(100), logs: z.number().int().min(1).max(500).default(100), browserLogs: z.number().int().min(1).max(500).default(100), + // Events are grouped client-side (one deploy = many per-instance + // rows), so we fetch a deeper raw page than the other feeds. + events: z.number().int().min(1).max(500).default(200), }) .strict() .default({}), diff --git a/apps/bff/src/http/query/events.test.ts b/apps/bff/src/http/query/events.test.ts new file mode 100644 index 0000000..5dc3357 --- /dev/null +++ b/apps/bff/src/http/query/events.test.ts @@ -0,0 +1,145 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect } from 'vitest'; +import { + clampPageSize, + clampWindowMs, + buildEventsCondition, + mapAndSortEvents, + type OapEventRow, +} from './events.js'; + +const DAY_MS = 24 * 60 * 60_000; +const WEEK_MS = 7 * DAY_MS; +const NOW = 1_782_800_000_000; // fixed epoch for deterministic window math +const WIN = { start: '2026-06-30 000000', end: '2026-06-30 120000' }; + +describe('clampPageSize', () => { + it('falls back when unset / below 1 / non-finite', () => { + expect(clampPageSize(undefined, 200, 500)).toBe(200); + expect(clampPageSize(0, 200, 500)).toBe(200); + expect(clampPageSize(-5, 200, 500)).toBe(200); + expect(clampPageSize(Number.NaN, 200, 500)).toBe(200); + }); + it('clamps to the max and rounds', () => { + expect(clampPageSize(999, 200, 500)).toBe(500); + expect(clampPageSize(42.6, 200, 500)).toBe(43); + expect(clampPageSize(120, 200, 500)).toBe(120); + }); +}); + +describe('clampWindowMs', () => { + it('defaults to a rolling 1-day window ending at now', () => { + expect(clampWindowMs(undefined, undefined, NOW)).toEqual({ startMs: NOW - DAY_MS, endMs: NOW }); + }); + it('honours a rolling window in minutes, capped at 7 days', () => { + expect(clampWindowMs(360, undefined, NOW)).toEqual({ startMs: NOW - 360 * 60_000, endMs: NOW }); + // 30 days requested → clamped to a week + expect(clampWindowMs(30 * 24 * 60, undefined, NOW)).toEqual({ startMs: NOW - WEEK_MS, endMs: NOW }); + }); + it('keeps an explicit absolute range within the cap unchanged', () => { + const explicit = { startMs: NOW - 2 * DAY_MS, endMs: NOW }; + expect(clampWindowMs(undefined, explicit, NOW)).toEqual(explicit); + }); + it('clamps an over-wide absolute range to its NEWEST 7 days', () => { + const explicit = { startMs: NOW - 60 * DAY_MS, endMs: NOW }; + expect(clampWindowMs(undefined, explicit, NOW)).toEqual({ startMs: NOW - WEEK_MS, endMs: NOW }); + }); + it('ignores an inverted explicit range and falls back to rolling', () => { + const explicit = { startMs: NOW, endMs: NOW - DAY_MS }; + expect(clampWindowMs(undefined, explicit, NOW)).toEqual({ startMs: NOW - DAY_MS, endMs: NOW }); + }); +}); + +describe('buildEventsCondition', () => { + const paging = { pageNum: 1, pageSize: 200 }; + + it('omits source / type / name / layer entirely when the scope is empty', () => { + const c = buildEventsCondition({}, WIN, 'DES', paging, false); + expect(c).not.toHaveProperty('source'); + expect(c).not.toHaveProperty('type'); + expect(c).not.toHaveProperty('name'); + expect(c).not.toHaveProperty('layer'); + expect(c.order).toBe('DES'); + expect(c.paging).toEqual(paging); + expect(c.time).toEqual({ start: WIN.start, end: WIN.end, step: 'SECOND' }); + }); + + it('includes only the source fields that are set', () => { + const c = buildEventsCondition({ service: 'agent::songs' }, WIN, 'DES', paging, false); + expect(c.source).toEqual({ service: 'agent::songs' }); + + const c2 = buildEventsCondition( + { layer: 'MESH', service: 'svc', serviceInstance: 'i-1', endpoint: '/x', type: 'Error', name: 'Start' }, + WIN, + 'ASC', + paging, + false, + ); + expect(c2.layer).toBe('MESH'); + expect(c2.source).toEqual({ service: 'svc', serviceInstance: 'i-1', endpoint: '/x' }); + expect(c2.type).toBe('Error'); + expect(c2.name).toBe('Start'); + expect(c2.order).toBe('ASC'); + }); + + it('adds coldStage to the time only when asked', () => { + const hot = buildEventsCondition({}, WIN, 'DES', paging, false) as { time: Record<string, unknown> }; + expect(hot.time).not.toHaveProperty('coldStage'); + const cold = buildEventsCondition({}, WIN, 'DES', paging, true) as { time: Record<string, unknown> }; + expect(cold.time.coldStage).toBe(true); + }); +}); + +describe('mapAndSortEvents', () => { + const row = (over: Partial<OapEventRow>): OapEventRow => ({ + uuid: 'u', + source: { service: 'svc', serviceInstance: '', endpoint: '' }, + name: 'Start', + type: 'Normal', + startTime: 0, + endTime: 0, + layer: 'GENERAL', + ...over, + }); + + it('maps endTime 0 to null, keeps a real end, and defaults nullable fields', () => { + const [a, b] = mapAndSortEvents( + [ + row({ uuid: 'a', startTime: 100, endTime: 0, message: null, parameters: null, source: null }), + row({ uuid: 'b', startTime: 200, endTime: 250 }), + ], + 'ASC', + ); + expect(a!.endTime).toBeNull(); + expect(a!.message).toBeNull(); + expect(a!.parameters).toEqual([]); + expect(a!.source).toEqual({ service: '', serviceInstance: '', endpoint: '' }); + expect(b!.endTime).toBe(250); + }); + + it('sorts DES newest-first and ASC oldest-first by the effective timestamp (endTime else startTime)', () => { + const rows = [ + row({ uuid: 'old', startTime: 100, endTime: 0 }), // ts 100 + row({ uuid: 'new', startTime: 300, endTime: 350 }), // ts 350 + row({ uuid: 'mid', startTime: 200, endTime: 0 }), // ts 200 + ]; + expect(mapAndSortEvents(rows, 'DES').map((e) => e.uuid)).toEqual(['new', 'mid', 'old']); + expect(mapAndSortEvents(rows, 'ASC').map((e) => e.uuid)).toEqual(['old', 'mid', 'new']); + }); +}); diff --git a/apps/bff/src/http/query/events.ts b/apps/bff/src/http/query/events.ts new file mode 100644 index 0000000..ba979fe --- /dev/null +++ b/apps/bff/src/http/query/events.ts @@ -0,0 +1,252 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * `POST /api/events` + * + * Wraps OAP's `queryEvents(EventQueryCondition)` for the per-service events + * popout. Body shape is `EventsQueryRequest` from + * `@skywalking-horizon-ui/api-client`. + * + * Events are event-style records, so we query at SECOND precision (MINUTE + * rounding would drop the most recent rows) and default to DES (newest-first). + * Unlike the logs / browser-errors feeds there is NO service-name→id + * resolution: `source.service` stores and filters on the literal service + * name, so the `service` filter is forwarded verbatim. `layer` is OAP's + * single-valued filter (the popout always passes one). + */ + +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import type { + EventOrder, + EventRow, + EventType, + EventsQueryRequest, + EventsResponse, + 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, type GraphqlOptions } from '../../client/graphql.js'; +import { fmtSecond, getServerOffsetMinutes } from '../../util/window.js'; + +export interface EventsRouteDeps { + config: ConfigSource; + sessions: SessionStore; + fetch?: FetchLike; +} + +const DEFAULT_WINDOW_MIN = 60 * 24; // 1 day — events default to a recent window +const MAX_WINDOW_MIN = 60 * 24 * 7; // clamp rolling windows to a week + +/** OAP feeds `paging.pageSize` straight to storage as a LIMIT. Mirror the + * configured cap server-side so the cap holds against direct API callers. */ +export function clampPageSize(requested: number | undefined, fallback: number, max: number): number { + if (!Number.isFinite(requested as number) || (requested as number) < 1) return fallback; + return Math.min(max, Math.round(requested as number)); +} + +/** Resolve the query window as epoch-ms, clamped to MAX_WINDOW_MIN. An explicit + * absolute range keeps its NEWEST slice; otherwise a rolling window of + * `minutes` (capped, default 1 day) ends at `nowMs`. Pure — `nowMs` is passed + * in so it stays testable — so a direct API caller can't ask for an unbounded + * range the UI picker would never allow. */ +export function clampWindowMs( + minutes: number | undefined, + explicit: { startMs?: number; endMs?: number } | undefined, + nowMs: number, +): { startMs: number; endMs: number } { + const maxMs = MAX_WINDOW_MIN * 60_000; + if ( + typeof explicit?.startMs === 'number' && + typeof explicit.endMs === 'number' && + explicit.startMs < explicit.endMs + ) { + return { startMs: Math.max(explicit.startMs, explicit.endMs - maxMs), endMs: explicit.endMs }; + } + const m = + Number.isFinite(minutes) && (minutes as number) > 0 + ? Math.min(MAX_WINDOW_MIN, Math.round(minutes as number)) + : DEFAULT_WINDOW_MIN; + return { startMs: nowMs - m * 60_000, endMs: nowMs }; +} + +/** Render the resolved window into OAP-server-local SECOND strings. Sending + * bare browser-local strings would be read by OAP as OAP-local and miss the + * data by the TZ delta. */ +function resolveWindow( + offsetMinutes: number, + minutes?: number, + explicit?: { startMs?: number; endMs?: number }, +): { start: string; end: string } { + const { startMs, endMs } = clampWindowMs(minutes, explicit, Date.now()); + return { start: fmtSecond(startMs, offsetMinutes), end: fmtSecond(endMs, offsetMinutes) }; +} + +const QUERY_EVENTS = /* GraphQL */ ` + query QueryEvents($condition: EventQueryCondition) { + data: queryEvents(condition: $condition) { + events { + uuid + source { service serviceInstance endpoint } + name + type + message + parameters { key value } + startTime + endTime + layer + } + } + } +`; + +export interface OapEventRow { + uuid: string; + source: { service?: string | null; serviceInstance?: string | null; endpoint?: string | null } | null; + name: string; + type: EventType; + message?: string | null; + parameters?: { key: string; value: string }[] | null; + startTime: number; + endTime?: number | null; + layer: string; +} + +/** The entity scope an event filter targets. All fields verbatim — events + * store + match on the literal service / instance / endpoint NAME. */ +export interface EventScope { + layer?: string; + service?: string; + serviceInstance?: string; + endpoint?: string; + type?: EventType; + name?: string; +} + +/** Build the `EventQueryCondition` sent to OAP. `source` is omitted entirely + * when no entity field is set; `type` / `name` / `layer` appear only when + * present; time is always SECOND precision (+ `coldStage` when asked). */ +export function buildEventsCondition( + scope: EventScope, + window: { start: string; end: string }, + order: EventOrder, + paging: { pageNum: number; pageSize: number }, + coldStage: boolean, +): Record<string, unknown> { + const source = + scope.service || scope.serviceInstance || scope.endpoint + ? { + ...(scope.service ? { service: scope.service } : {}), + ...(scope.serviceInstance ? { serviceInstance: scope.serviceInstance } : {}), + ...(scope.endpoint ? { endpoint: scope.endpoint } : {}), + } + : undefined; + return { + ...(scope.layer ? { layer: scope.layer } : {}), + ...(source ? { source } : {}), + ...(scope.type ? { type: scope.type } : {}), + ...(scope.name ? { name: scope.name } : {}), + order, + time: { start: window.start, end: window.end, step: 'SECOND', ...(coldStage ? { coldStage: true } : {}) }, + paging, + }; +} + +/** Map OAP rows to {@link EventRow} (endTime `0` → `null`) and re-sort to a + * strict order — BanyanDB concatenates per-segment results, so a multi-segment + * response is not globally ordered. `ts` mirrors OAP's own timestamp + * definition (endTime if finished, else startTime). */ +export function mapAndSortEvents(rows: OapEventRow[], order: EventOrder): EventRow[] { + const events: EventRow[] = rows.map((r) => ({ + uuid: r.uuid, + source: { + service: r.source?.service ?? '', + serviceInstance: r.source?.serviceInstance ?? '', + endpoint: r.source?.endpoint ?? '', + }, + name: r.name, + type: r.type, + message: r.message ?? null, + parameters: (r.parameters ?? []).map((p) => ({ key: p.key, value: p.value })), + startTime: r.startTime, + endTime: r.endTime && r.endTime > 0 ? r.endTime : null, + layer: r.layer, + })); + const ts = (e: EventRow): number => (e.endTime && e.endTime > 0 ? e.endTime : e.startTime); + events.sort((a, b) => (order === 'ASC' ? ts(a) - ts(b) : ts(b) - ts(a))); + return events; +} + +/** Run OAP's `queryEvents` for a scope + SECOND-precision window + page. + * Soft-fails to `reachable: false` on any OAP error. */ +async function fetchEvents( + opts: GraphqlOptions, + scope: EventScope, + window: { start: string; end: string }, + order: EventOrder, + paging: { pageNum: number; pageSize: number }, + coldStage: boolean, +): Promise<{ events: EventRow[]; reachable: boolean; error?: string }> { + try { + const env = await graphqlPost<{ data: { events: OapEventRow[] } }>(opts, QUERY_EVENTS, { + condition: buildEventsCondition(scope, window, order, paging, coldStage), + }); + return { events: mapAndSortEvents(env.data?.events ?? [], order), reachable: true }; + } catch (err) { + return { events: [], reachable: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +export function registerEventsRoute(app: FastifyInstance, deps: EventsRouteDeps): void { + const auth = requireAuth(deps); + app.post('/api/events', { preHandler: auth }, async (req: FastifyRequest, reply: FastifyReply) => { + const body = (req.body ?? {}) as EventsQueryRequest; + const opts = buildOapOpts(deps.config.current, deps.fetch); + const offset = await getServerOffsetMinutes(deps.config, deps.fetch); + const window = resolveWindow(offset, body.windowMinutes, { startMs: body.startMs, endMs: body.endMs }); + const order: EventOrder = body.order === 'ASC' ? 'ASC' : 'DES'; + const pageSize = clampPageSize(body.pageSize, 200, deps.config.current.performance.limits.maxPageSize.events); + + const res = await fetchEvents( + opts, + { + layer: body.layer ? body.layer.toUpperCase() : undefined, + service: body.service, + serviceInstance: body.serviceInstance, + endpoint: body.endpoint, + type: body.type, + name: body.name, + }, + window, + order, + { pageNum: Math.max(1, Math.round(body.page ?? 1)), pageSize }, + !!req.coldStage, + ); + + return reply.send({ + generatedAt: Date.now(), + query: body, + total: res.events.length, + pageSize, + events: res.events, + reachable: res.reachable, + ...(res.error ? { error: res.error } : {}), + } satisfies EventsResponse); + }); +} diff --git a/apps/bff/src/rbac/route-policy.ts b/apps/bff/src/rbac/route-policy.ts index 0327871..9817113 100644 --- a/apps/bff/src/rbac/route-policy.ts +++ b/apps/bff/src/rbac/route-policy.ts @@ -123,6 +123,8 @@ export const ROUTE_POLICY: Record<string, RoutePolicy> = { 'GET /api/browser-errors/source-maps': 'browser-errors:read', 'POST /api/browser-errors/resolve': 'browser-errors:read', + 'POST /api/events': 'events:read', + 'POST /api/explore/query': 'inspect:read', 'GET /api/layer/:key/topology': 'topology:read', diff --git a/apps/bff/src/rbac/verbs.ts b/apps/bff/src/rbac/verbs.ts index 6661d87..76cb1ab 100644 --- a/apps/bff/src/rbac/verbs.ts +++ b/apps/bff/src/rbac/verbs.ts @@ -29,6 +29,7 @@ export const VERBS = { tracesRead: 'traces:read', logsRead: 'logs:read', browserErrorsRead: 'browser-errors:read', + eventsRead: 'events:read', topologyRead: 'topology:read', profileRead: 'profile:read', infra3dRead: 'infra-3d:read', diff --git a/apps/bff/src/server.ts b/apps/bff/src/server.ts index beea85f..32fae77 100644 --- a/apps/bff/src/server.ts +++ b/apps/bff/src/server.ts @@ -46,6 +46,7 @@ import { registerTraceTagRoutes } from './http/query/trace-tag.js'; import { registerZipkinRoutes } from './http/query/zipkin.js'; import { registerLogRoute } from './http/query/log.js'; import { registerBrowserErrorsRoute } from './http/query/browser-errors.js'; +import { registerEventsRoute } from './http/query/events.js'; import { registerExploreRoutes } from './http/query/explore.js'; import { registerPodLogRoutes } from './http/query/pod-log.js'; import { registerDashboardQueryRoute } from './http/query/dashboard.js'; @@ -269,6 +270,7 @@ registerTraceTagRoutes(app, { config: source, sessions }); registerZipkinRoutes(app, { config: source, sessions }); registerLogRoute(app, { config: source, sessions }); registerBrowserErrorsRoute(app, { config: source, sessions }); +registerEventsRoute(app, { config: source, sessions }); registerExploreRoutes(app, { config: source, sessions }); registerPodLogRoutes(app, { config: source, sessions }); registerDashboardQueryRoute(app, { diff --git a/apps/ui/src/api/client.ts b/apps/ui/src/api/client.ts index f17c912..cf4cf9b 100644 --- a/apps/ui/src/api/client.ts +++ b/apps/ui/src/api/client.ts @@ -80,6 +80,7 @@ import { TraceApi } from './scopes/trace'; import { ZipkinApi } from './scopes/zipkin'; import { LogApi } from './scopes/log'; import { BrowserErrorsApi } from './scopes/browser-errors'; +import { EventsApi } from './scopes/events'; import { ProfileApi } from './scopes/profile'; import { EbpfApi } from './scopes/ebpf'; import { NetworkProfileApi } from './scopes/network-profile'; @@ -182,6 +183,12 @@ export type { ResolveRequest, ResolvedFrame, ResolveResponse, + EventType, + EventOrder, + EventSource, + EventRow, + EventsQueryRequest, + EventsResponse, ProfileTask, ProfileTaskLog, ProfileTaskListResponse, @@ -913,6 +920,7 @@ export class BffClient { readonly zipkin = new ZipkinApi(this); readonly log = new LogApi(this); readonly browserErrors = new BrowserErrorsApi(this); + readonly events = new EventsApi(this); readonly profile = new ProfileApi(this); readonly ebpf = new EbpfApi(this); readonly networkProfile = new NetworkProfileApi(this); diff --git a/apps/ui/src/api/scopes/events.ts b/apps/ui/src/api/scopes/events.ts new file mode 100644 index 0000000..a99b856 --- /dev/null +++ b/apps/ui/src/api/scopes/events.ts @@ -0,0 +1,32 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { BffClient } from '../client'; +import type { EventsQueryRequest, EventsResponse } from '@skywalking-horizon-ui/api-client'; + +/** + * Per-service events feed — OAP `queryEvents`. One `query` call returns the raw + * newest-first event stream for a window + layer/service filters; the popout + * lays them out as an instance × time swimlane. + */ +export class EventsApi { + constructor(private readonly bff: BffClient) {} + + query(body: EventsQueryRequest = {}): Promise<EventsResponse> { + return this.bff.request<EventsResponse>('POST', '/api/events', body); + } +} diff --git a/apps/ui/src/features/admin/roles/RolesView.vue b/apps/ui/src/features/admin/roles/RolesView.vue index d5c67b0..11afbb8 100644 --- a/apps/ui/src/features/admin/roles/RolesView.vue +++ b/apps/ui/src/features/admin/roles/RolesView.vue @@ -115,7 +115,7 @@ function roleBlurb(role: string): string { if (role === 'admin') return t('Full access including user & access management.'); if (role === 'operator') return t('Configures alerts, dashboards, rules, and runs diagnostics.'); if (role === 'maintainer') return t('Watches the SkyWalking platform itself (cluster, internals).'); - if (role === 'viewer') return t('Reads dashboards, traces, logs, and alarms.'); + if (role === 'viewer') return t('Reads dashboards, traces, logs, alarms, and events.'); return ''; } @@ -124,6 +124,7 @@ function roleBlurb(role: string): string { const VERB_LABELS = computed<Record<string, { label: string; hint?: string }>>(() => ({ 'metrics:read': { label: t('See metric dashboards') }, 'alarms:read': { label: t('See alarms') }, + 'events:read': { label: t('See service events') }, 'traces:read': { label: t('See traces') }, 'logs:read': { label: t('See logs') }, 'topology:read': { label: t('See service & endpoint topology') }, @@ -186,8 +187,9 @@ const VERB_GROUPS = computed<VerbGroup[]>(() => [ { label: t('Profiling results'), icon: '▦' }, { label: t('3D infrastructure map'), icon: '⬡' }, { label: t('Alarms'), icon: '!' }, + { label: t('Service events'), icon: '◔' }, ], - verbs: ['metrics:read', 'alarms:read', 'traces:read', 'logs:read', 'topology:read', 'profile:read', 'infra-3d:read'], + verbs: ['metrics:read', 'alarms:read', 'events:read', 'traces:read', 'logs:read', 'topology:read', 'profile:read', 'infra-3d:read'], }, { title: t('Platform monitoring'), diff --git a/apps/ui/src/features/events/EventsDetailPanel.vue b/apps/ui/src/features/events/EventsDetailPanel.vue new file mode 100644 index 0000000..a96e59b --- /dev/null +++ b/apps/ui/src/features/events/EventsDetailPanel.vue @@ -0,0 +1,167 @@ +<!-- + 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. +--> +<!-- + Events detail panel. Shows the single event picked on the swimlane — its + name/type, the service + instance it was reported for, the layer, the time + (a span when it has an end, otherwise the instant), the message, and the + reporter parameters. OAP-supplied strings (service, instance, message, + parameter values) render verbatim. +--> +<script setup lang="ts"> +import { computed } from 'vue'; +import { useI18n } from 'vue-i18n'; +import type { EventRow } from '@/api/client'; +import { eventTs } from './ganttLayout'; + +const { t } = useI18n(); +const props = defineProps<{ event: EventRow | null }>(); + +function fmtFull(ms: number): string { + const d = new Date(ms); + const p = (n: number): string => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`; +} +function fmtDuration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`; + if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`; + return `${Math.floor(ms / 3_600_000)}h ${Math.round((ms % 3_600_000) / 60_000)}m`; +} +/** The reporter's start (falling back to the event timestamp when the start + * event was lost) and the end, or null for an instantaneous event. */ +const startMs = computed<number>(() => { + const e = props.event; + if (!e) return 0; + return e.startTime && e.startTime > 0 ? e.startTime : eventTs(e); +}); +const endMs = computed<number | null>(() => { + const e = props.event; + if (!e || !e.endTime || e.endTime <= startMs.value) return null; + return e.endTime; +}); +</script> + +<template> + <aside class="evt-detail"> + <div v-if="!event" class="evt-detail__empty"> + {{ t('Select an event to inspect its details and instances.') }} + </div> + <template v-else> + <header class="evt-detail__head"> + <span class="sw-badge" :class="event.type === 'Error' ? 'is-err' : 'is-info'"> + <span class="state-dot" />{{ event.type }} + </span> + <h3 class="evt-detail__name">{{ event.name }}</h3> + </header> + + <dl class="evt-detail__meta"> + <div><dt>{{ t('Service') }}</dt><dd><code>{{ event.source.service }}</code></dd></div> + <div> + <dt>{{ t('Instance') }}</dt> + <dd><code>{{ event.source.serviceInstance || t('(service-scoped)') }}</code></dd> + </div> + <div v-if="event.source.endpoint"> + <dt>{{ t('Endpoint') }}</dt><dd><code>{{ event.source.endpoint }}</code></dd> + </div> + <div><dt>{{ t('Layer') }}</dt><dd>{{ event.layer }}</dd></div> + <template v-if="endMs !== null"> + <div><dt>{{ t('Started') }}</dt><dd class="mono">{{ fmtFull(startMs) }}</dd></div> + <div><dt>{{ t('Ended') }}</dt><dd class="mono">{{ fmtFull(endMs) }}</dd></div> + <div><dt>{{ t('Duration') }}</dt><dd class="mono">{{ fmtDuration(endMs - startMs) }}</dd></div> + </template> + <div v-else><dt>{{ t('Time') }}</dt><dd class="mono">{{ fmtFull(startMs) }}</dd></div> + </dl> + + <div v-if="event.message" class="evt-detail__msg">{{ event.message }}</div> + + <section v-if="event.parameters.length > 0" class="evt-detail__section"> + <h4>{{ t('Parameters') }}</h4> + <dl class="evt-detail__params"> + <div v-for="(p, i) in event.parameters" :key="i"> + <dt>{{ p.key }}</dt> + <dd><code>{{ p.value }}</code></dd> + </div> + </dl> + </section> + </template> + </aside> +</template> + +<style scoped> +.evt-detail { + background: var(--sw-bg-1); + border: 1px solid var(--sw-line); + border-radius: 8px; + padding: 14px; + align-self: start; + position: sticky; + top: 16px; +} +.evt-detail__empty { padding: 24px 8px; text-align: center; font-size: 12px; color: var(--sw-fg-3); } +.evt-detail__head { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; } +.evt-detail__name { + margin: 0; + font-size: 14px; + font-weight: 600; + color: var(--sw-fg-0); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.evt-detail__meta { display: grid; grid-template-columns: max-content 1fr; gap: 4px 12px; margin: 0 0 12px; font-size: 11.5px; } +.evt-detail__meta > div { display: contents; } +.evt-detail__meta dt { color: var(--sw-fg-3); text-transform: uppercase; font-size: 9.5px; letter-spacing: 0.06em; align-self: center; } +.evt-detail__meta dd { margin: 0; color: var(--sw-fg-1); overflow: hidden; text-overflow: ellipsis; } +.evt-detail__meta code { font-family: var(--sw-mono); font-size: 11px; color: var(--sw-fg-0); } +.evt-detail__msg { + font-size: 12px; + color: var(--sw-fg-1); + background: var(--sw-bg-2); + border: 1px solid var(--sw-line); + border-radius: 5px; + padding: 8px 10px; + margin-bottom: 12px; + word-break: break-word; +} +.evt-detail__section { margin-top: 12px; } +.evt-detail__section h4 { + margin: 0 0 6px; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--sw-fg-3); + font-weight: 600; +} +.evt-detail__params { display: grid; grid-template-columns: max-content 1fr; gap: 4px 12px; margin: 0; font-size: 11.5px; } +.evt-detail__params > div { display: contents; } +.evt-detail__params dt { color: var(--sw-fg-2); font-family: var(--sw-mono); font-size: 10.5px; } +.evt-detail__params dd { margin: 0; min-width: 0; } +.evt-detail__params code { font-family: var(--sw-mono); font-size: 10.5px; color: var(--sw-fg-1); word-break: break-all; } + +.sw-badge { + display: inline-flex; + align-items: center; + font-size: 10.5px; + font-weight: 600; + padding: 2px 8px; + border-radius: 10px; + border: 1px solid transparent; +} +.sw-badge .state-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; margin-right: 4px; display: inline-block; vertical-align: middle; } +.sw-badge.is-err { color: var(--sw-err); background: var(--sw-err-soft); border-color: rgba(239,68,68,0.3); } +.sw-badge.is-info { color: var(--sw-accent-2); background: var(--sw-accent-soft); border-color: var(--sw-accent-line); } +</style> diff --git a/apps/ui/src/features/events/EventsGantt.vue b/apps/ui/src/features/events/EventsGantt.vue new file mode 100644 index 0000000..e5c72d0 --- /dev/null +++ b/apps/ui/src/features/events/EventsGantt.vue @@ -0,0 +1,365 @@ +<!-- + 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. +--> +<!-- + Events swimlane (Gantt). Rows are grouped Layer → Service → Instance, or (in + `flat` mode, used by the per-service popout) one row per instance. An event + with a duration is a bar spanning start→end; one without an end is a diamond + marker; overlapping events on one instance stack into sub-lanes; Error events + carry a red ring. + + Scrolling is fully internal so the outer page never scrolls: a sticky time + header + sticky label column, plus a per-hour minimum canvas width so a long + range scrolls horizontally (opened at the newest) instead of smearing. The + axis marks the date at day boundaries. Pure DOM/CSS, no chart library. +--> +<script setup lang="ts"> +import { computed, nextTick, ref, watch } from 'vue'; +import { useI18n } from 'vue-i18n'; +import type { EventRow } from '@/api/client'; +import { buildGantt, eventTs, type GanttBar } from './ganttLayout'; + +const { t } = useI18n(); + +const props = defineProps<{ + events: EventRow[]; + startTime: number; + endTime: number; + selectedUuid?: string | null; + maxBodyHeight?: number; + /** Flatten to instance rows only, dropping the Layer / Service headers. Used + * by the per-service popout, where the service is fixed (shown in the title) + * so the only axes that matter are instance (rows) and time (columns). */ + flat?: boolean; + /** Flat mode only: case-insensitive substring; keeps only instance rows whose + * name matches. Empty shows every row. */ + rowFilter?: string; +}>(); + +const emit = defineEmits<{ (e: 'select-event', ev: EventRow): void }>(); + +const BAR_H = 20; // px per sub-lane +// Horizontal density: 6h / 1d fit the column; 2d+ overflow into a scroll at a +// legible bar spacing instead of smearing. +const PX_PER_HOUR = 26; +const MIN_TIME_PX = 480; + +const layers = computed(() => buildGantt(props.events)); +const isEmpty = computed<boolean>(() => layers.value.length === 0); + +/** Minimum width (px) of the time canvas — grows with the window so bars stay + * legible on a long range; the column scrolls horizontally past the viewport. */ +const timeMinPx = computed<number>(() => { + const hours = (props.endTime - props.startTime) / 3_600_000; + return Math.max(MIN_TIME_PX, Math.round(hours * PX_PER_HOUR)); +}); + +const collapsed = ref<Set<string>>(new Set()); +function isCollapsed(key: string): boolean { + return collapsed.value.has(key); +} +function toggle(key: string): void { + const next = new Set(collapsed.value); + if (next.has(key)) next.delete(key); + else next.add(key); + collapsed.value = next; +} + +interface DisplayRow { + kind: 'layer' | 'service' | 'lane'; + key: string; + label: string; + indent: number; + count?: number; + color?: string; + dot?: boolean; + collapseKey?: string; + collapsed?: boolean; + bars?: GanttBar[]; + subLanes?: number; +} +const rows = computed<DisplayRow[]>(() => { + const out: DisplayRow[] = []; + // Flat: fixed service → no layer/service headers, one row per instance. Color + // is a golden-angle hue per row (distinct at any count, starting at blue to + // avoid the Error red), assigned over the FULL list so a row keeps its color + // when the search filter narrows. + if (props.flat) { + let i = 0; + for (const lg of layers.value) { + for (const svc of lg.services) { + for (const r of svc.rows) { + out.push({ kind: 'lane', key: `${svc.key}/${r.instance}`, label: r.instance || svc.service, indent: 10, color: `hsl(${Math.round((210 + i * 137.508) % 360)}, 60%, 62%)`, dot: true, bars: r.bars, subLanes: r.subLanes }); + i++; + } + } + } + const q = (props.rowFilter ?? '').trim().toLowerCase(); + return q ? out.filter((row) => row.label.toLowerCase().includes(q)) : out; + } + for (const lg of layers.value) { + const lk = `L:${lg.key}`; + out.push({ kind: 'layer', key: lk, collapseKey: lk, collapsed: isCollapsed(lk), label: lg.layer, count: lg.eventCount, indent: 8 }); + if (isCollapsed(lk)) continue; + for (const svc of lg.services) { + if (svc.serviceScoped) { + const r = svc.rows[0]!; + out.push({ kind: 'lane', key: svc.key, label: svc.service, indent: 24, color: svc.color, dot: true, bars: r.bars, subLanes: r.subLanes }); + continue; + } + const sk = `S:${svc.key}`; + out.push({ kind: 'service', key: sk, collapseKey: sk, collapsed: isCollapsed(sk), label: svc.service, count: svc.eventCount, color: svc.color, indent: 22 }); + if (isCollapsed(sk)) continue; + for (const r of svc.rows) { + out.push({ kind: 'lane', key: `${svc.key}/${r.instance}`, label: r.instance, indent: 40, color: svc.color, dot: false, bars: r.bars, subLanes: r.subLanes }); + } + } + } + return out; +}); + +function clampPct(ts: number): number { + const span = props.endTime - props.startTime; + if (span <= 0) return 0; + return Math.min(100, Math.max(0, ((ts - props.startTime) / span) * 100)); +} +function widthPct(bar: GanttBar): number { + if (bar.end === null) return 0; + return Math.max(0, clampPct(bar.end) - clampPct(bar.start)); +} +function rowH(subLanes: number): number { + return subLanes * BAR_H; +} + +const TICK_STEPS = [ + 60_000, 5 * 60_000, 15 * 60_000, 30 * 60_000, 60 * 60_000, + 2 * 3_600_000, 6 * 3_600_000, 12 * 3_600_000, 24 * 3_600_000, 2 * 86_400_000, 7 * 86_400_000, +]; +const ticks = computed<Array<{ pct: number; time: string; date: string }>>(() => { + const span = props.endTime - props.startTime; + if (span <= 0) return []; + // ~1 tick per 130px of the (possibly wide) canvas. + const target = Math.max(6, Math.round((timeMinPx.value + 240) / 130)); + const step = TICK_STEPS.find((s) => span / s <= target) ?? TICK_STEPS[TICK_STEPS.length - 1]!; + const dayStep = step >= 86_400_000; + const p = (n: number): string => String(n).padStart(2, '0'); + const out: Array<{ pct: number; time: string; date: string }> = []; + let prevDay = ''; + const first = Math.ceil(props.startTime / step) * step; + for (let ts = first; ts <= props.endTime; ts += step) { + const d = new Date(ts); + const dayKey = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`; + const date = `${p(d.getMonth() + 1)}-${p(d.getDate())}`; + const showDate = dayStep || dayKey !== prevDay; + prevDay = dayKey; + out.push({ pct: clampPct(ts), time: dayStep ? '' : `${p(d.getHours())}:${p(d.getMinutes())}`, date: showDate ? date : '' }); + } + return out; +}); + +function fmtTime(ms: number): string { + const d = new Date(ms); + const p = (n: number): string => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`; +} +function barTitle(bar: GanttBar, service: string): string { + const e = bar.event; + const who = e.source.serviceInstance || service; + const when = bar.end ? `${fmtTime(bar.start)} → ${fmtTime(bar.end)}` : fmtTime(eventTs(e)); + return `${e.name} · ${e.type}\n${who}\n${when}${e.message ? '\n' + e.message : ''}`; +} + +// Open scrolled to the newest events (right edge) on a range that overflows. +const scrollEl = ref<HTMLElement | null>(null); +function scrollToNewest(): void { + const el = scrollEl.value; + if (el && el.scrollWidth > el.clientWidth) el.scrollLeft = el.scrollWidth; +} +watch( + () => [props.startTime, props.endTime, props.events], + () => void nextTick(scrollToNewest), + { immediate: true }, +); +</script> + +<template> + <div class="gantt"> + <div v-if="isEmpty" class="gantt-empty">{{ t('No events in the current window.') }}</div> + <div v-else ref="scrollEl" class="gantt-scroll" :style="{ maxHeight: (maxBodyHeight ?? 420) + 'px' }"> + <div class="gantt-canvas" :style="{ '--time-min': timeMinPx + 'px' }"> + <!-- Full-height vertical gridlines behind the lanes. --> + <div class="gantt-grid"> + <div v-for="(tk, i) in ticks" :key="i" class="gantt-gline" :style="{ left: tk.pct + '%' }" /> + </div> + + <!-- Sticky time-axis header. --> + <div class="gantt-headrow"> + <div class="gantt-corner" /> + <div class="gantt-axis"> + <div v-for="(tk, i) in ticks" :key="i" class="gantt-tick" :style="{ left: tk.pct + '%' }"> + <span v-if="tk.date" class="gantt-tick-date">{{ tk.date }}</span> + <span v-if="tk.time" class="gantt-tick-time">{{ tk.time }}</span> + </div> + </div> + </div> + + <template v-for="r in rows" :key="r.key"> + <!-- Layer header --> + <div v-if="r.kind === 'layer'" class="gantt-lhead" @click="toggle(r.collapseKey!)"> + <div class="gantt-lhead-label"> + <span class="gantt-caret" :class="{ collapsed: r.collapsed }">▾</span> + <span class="gantt-lhead-name">{{ r.label }}</span> + <span class="gantt-lhead-count mono">{{ r.count }}</span> + </div> + <div class="gantt-band" /> + </div> + + <!-- Service header (instanced service) --> + <div v-else-if="r.kind === 'service'" class="gantt-shead" @click="toggle(r.collapseKey!)"> + <div class="gantt-shead-label" :style="{ paddingLeft: r.indent + 'px' }"> + <span class="gantt-caret" :class="{ collapsed: r.collapsed }">▾</span> + <span class="gantt-dot" :style="{ background: r.color }" /> + <code class="gantt-shead-name">{{ r.label }}</code> + <span class="gantt-shead-count mono">{{ r.count }}</span> + </div> + <div class="gantt-band gantt-band--svc" /> + </div> + + <!-- Lane row --> + <div v-else class="gantt-row"> + <div class="gantt-row-label" :style="{ paddingLeft: r.indent + 'px' }"> + <span v-if="r.dot" class="gantt-dot" :style="{ background: r.color }" /> + <code class="gantt-row-label-text">{{ r.label }}</code> + </div> + <div class="gantt-row-lane" :style="{ height: rowH(r.subLanes!) + 'px' }"> + <template v-for="(bar, bi) in r.bars" :key="bi"> + <div + v-if="bar.end !== null" + class="gantt-bar" + :class="{ 'is-error': bar.event.type === 'Error', 'is-selected': bar.event.uuid === selectedUuid }" + :style="{ left: clampPct(bar.start) + '%', width: widthPct(bar) + '%', top: bar.subLane * BAR_H + 2 + 'px', background: r.color }" + :title="barTitle(bar, r.label)" + @click="emit('select-event', bar.event)" + > + <span class="gantt-bar-label">{{ bar.event.name }}</span> + </div> + <div + v-else + class="gantt-mark" + :class="{ 'is-error': bar.event.type === 'Error', 'is-selected': bar.event.uuid === selectedUuid }" + :style="{ left: clampPct(bar.start) + '%', top: bar.subLane * BAR_H + BAR_H / 2 + 'px', background: r.color }" + :title="barTitle(bar, r.label)" + @click="emit('select-event', bar.event)" + /> + </template> + </div> + </div> + </template> + </div> + </div> + </div> +</template> + +<style scoped> +.gantt { + --label-w: 240px; + --head-h: 34px; + --tick-color: rgba(255, 255, 255, 0.05); + background: var(--sw-bg-1); + border: 1px solid var(--sw-line); + border-radius: 8px; + overflow: hidden; +} +.gantt-empty { padding: 28px; text-align: center; font-size: 12px; color: var(--sw-fg-3); } +/* The one scroll surface — both axes live here so the page never scrolls. */ +.gantt-scroll { overflow: auto; } +.gantt-canvas { position: relative; width: max(100%, calc(var(--label-w) + var(--time-min))); } + +.gantt-grid { position: absolute; top: var(--head-h); left: var(--label-w); right: 0; bottom: 0; pointer-events: none; } +.gantt-gline { position: absolute; top: 0; bottom: 0; width: 1px; background: var(--tick-color); transform: translateX(-50%); } + +.gantt-headrow { display: flex; position: sticky; top: 0; z-index: 3; height: var(--head-h); } +.gantt-corner { + width: var(--label-w); flex-shrink: 0; + position: sticky; left: 0; z-index: 4; + background: var(--sw-bg-1); border-right: 1px solid var(--sw-line); border-bottom: 1px solid var(--sw-line); +} +.gantt-axis { flex: 1 0 var(--time-min); position: relative; background: var(--sw-bg-1); border-bottom: 1px solid var(--sw-line); } +.gantt-tick { position: absolute; top: 0; bottom: 0; } +.gantt-tick-date { position: absolute; top: 3px; left: 0; transform: translateX(-50%); font-size: 9px; font-weight: 600; letter-spacing: 0.03em; color: var(--sw-fg-2); white-space: nowrap; font-variant-numeric: tabular-nums; } +.gantt-tick-time { position: absolute; top: 17px; left: 0; transform: translateX(-50%); font-size: 10px; color: var(--sw-fg-3); white-space: nowrap; font-variant-numeric: tabular-nums; } + +.gantt-caret { font-size: 9px; color: var(--sw-fg-3); transition: transform 0.12s; flex-shrink: 0; } +.gantt-caret.collapsed { transform: rotate(-90deg); } +.gantt-dot { width: 8px; height: 8px; border-radius: 2px; flex-shrink: 0; } + +.gantt-lhead, .gantt-shead, .gantt-row { display: flex; } +.gantt-lhead, .gantt-shead { cursor: pointer; } + +.gantt-lhead-label { + width: var(--label-w); flex-shrink: 0; + position: sticky; left: 0; z-index: 2; + display: flex; align-items: center; gap: 8px; + height: 26px; padding: 0 10px; + background: var(--sw-bg-2); border-right: 1px solid var(--sw-line); border-bottom: 1px solid var(--sw-line); +} +.gantt-lhead:hover .gantt-lhead-label { background: var(--sw-bg-3); } +.gantt-lhead-name { font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--sw-fg-1); font-weight: 700; } +.gantt-lhead-count { margin-left: auto; font-size: 10.5px; color: var(--sw-fg-2); background: var(--sw-bg-1); padding: 0 6px; border-radius: 8px; } + +.gantt-shead-label { + width: var(--label-w); flex-shrink: 0; + position: sticky; left: 0; z-index: 2; + display: flex; align-items: center; gap: 7px; + height: 24px; padding-right: 10px; + background: var(--sw-bg-1); border-right: 1px solid var(--sw-line); border-bottom: 1px solid var(--sw-line); +} +.gantt-shead:hover .gantt-shead-label { background: var(--sw-bg-2); } +.gantt-shead-name { font-family: var(--sw-mono); font-size: 11.5px; color: var(--sw-fg-0); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.gantt-shead-count { margin-left: auto; font-size: 10.5px; color: var(--sw-fg-2); background: var(--sw-bg-2); padding: 0 6px; border-radius: 8px; } + +.gantt-band { flex: 1 0 var(--time-min); height: 26px; background: var(--sw-bg-2); border-bottom: 1px solid var(--sw-line); } +.gantt-band--svc { height: 24px; background: var(--sw-bg-1); } + +.gantt-row-label { + width: var(--label-w); flex-shrink: 0; + position: sticky; left: 0; z-index: 2; + display: flex; align-items: center; gap: 7px; + padding-right: 10px; + background: var(--sw-bg-1); border-right: 1px solid var(--sw-line); border-bottom: 1px solid var(--sw-line); +} +.gantt-row-label-text { font-family: var(--sw-mono); font-size: 10.5px; color: var(--sw-fg-1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.gantt-row-lane { flex: 1 0 var(--time-min); position: relative; border-bottom: 1px solid var(--sw-line); } + +.gantt-bar { + position: absolute; height: 15px; min-width: 6px; border-radius: 3px; + display: flex; align-items: center; padding: 0 4px; overflow: hidden; cursor: pointer; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.25); +} +.gantt-bar:hover { filter: brightness(1.15); } +.gantt-bar-label { font-size: 9.5px; font-weight: 600; color: #0a0d12; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.gantt-bar.is-error { box-shadow: 0 0 0 1.5px var(--sw-err); } +.gantt-bar.is-selected { box-shadow: 0 0 0 2px var(--sw-fg-0); z-index: 2; } + +.gantt-mark { + position: absolute; width: 10px; height: 10px; + transform: translate(-50%, -50%) rotate(45deg); border-radius: 2px; cursor: pointer; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.3); +} +.gantt-mark:hover { filter: brightness(1.15); } +.gantt-mark.is-error { box-shadow: 0 0 0 1.5px var(--sw-err); } +.gantt-mark.is-selected { box-shadow: 0 0 0 2px var(--sw-fg-0); z-index: 2; } +</style> diff --git a/apps/ui/src/features/events/EventsPopout.vue b/apps/ui/src/features/events/EventsPopout.vue new file mode 100644 index 0000000..f06e4d9 --- /dev/null +++ b/apps/ui/src/features/events/EventsPopout.vue @@ -0,0 +1,162 @@ +<!-- + 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. +--> +<!-- + Per-service events popout. Mounted once in AppShell; opens when any surface + (a layer's service banner) calls `useEventsPopout().open(layer, service)`. + Shows that one service's events as a swimlane — one row per instance — with + its own time window, without leaving the current page. Reuses EventsGantt (in + `flat` mode) + the detail panel, scoped to the single service. +--> +<script setup lang="ts"> +import { computed, ref, watch } from 'vue'; +import { useI18n } from 'vue-i18n'; +import Modal from '@/features/operate/_shared/Modal.vue'; +import EventsGantt from './EventsGantt.vue'; +import EventsDetailPanel from './EventsDetailPanel.vue'; +import { useEventsWindow, PRESETS, MAX_CUSTOM_MS } from './useEventsWindow'; +import { useEvents, type EventFilterValues } from './useEvents'; +import { useEventsPopout } from './useEventsPopout'; +import type { EventRow } from '@/api/client'; + +const { t } = useI18n(); +const { target, close } = useEventsPopout(); + +const win = useEventsWindow(); +const applied = computed<EventFilterValues>(() => ({ + layer: target.value?.layer ?? '', + service: target.value?.service ?? '', + type: '', + name: '', +})); +const enabled = computed<boolean>(() => !!target.value?.service); +const ev = useEvents(win.startTime, win.endTime, applied, enabled); + +const open = computed<boolean>(() => target.value !== null); +const title = computed<string>(() => t('Events · {service}', { service: target.value?.service ?? '' })); + +const selectedUuid = ref<string | null>(null); +const search = ref(''); +const selectedEvent = computed<EventRow | null>( + () => ev.events.value.find((e) => e.uuid === selectedUuid.value) ?? null, +); +function selectEvent(e: EventRow): void { + selectedUuid.value = e.uuid; +} +watch(target, () => { + selectedUuid.value = null; + search.value = ''; +}); + +function onClose(): void { + close(); +} +</script> + +<template> + <Modal :open="open" :title="title" width="min(1200px, 92vw)" :fit-body="true" @close="onClose"> + <div class="evtp"> + <div class="evtp__bar"> + <div class="evtp__window"> + <button + v-for="p in PRESETS" + :key="p" + type="button" + class="evtp__window-btn" + :class="{ active: win.windowMode.value === p }" + @click="win.pickPreset(p)" + >{{ p }}</button> + <button + type="button" + class="evtp__window-btn" + :class="{ active: win.windowMode.value === 'custom' }" + @click="win.openCustom()" + >{{ t('custom') }}</button> + </div> + <input v-model="search" type="text" class="evtp__search" :placeholder="t('Search instance…')" /> + <span v-if="ev.isFetching.value" class="evtp__hint">{{ t('Reading data…') }}</span> + <template v-else-if="ev.reachable.value"> + <span v-if="ev.truncated.value" class="evtp__warn">{{ t('Showing newest {n} — more available, narrow the range', { n: ev.events.value.length }) }}</span> + <span v-else-if="ev.events.value.length > 0" class="evtp__hint">{{ t('{n} events · all in range shown', { n: ev.events.value.length }) }}</span> + </template> + </div> + + <div v-if="win.customOpen.value" class="evtp__custom"> + <label class="evtp__custom-field"> + <span>{{ t('Start') }}</span> + <input v-model="win.customStartInput.value" type="datetime-local" step="60" /> + </label> + <label class="evtp__custom-field"> + <span>{{ t('End') }}</span> + <input v-model="win.customEndInput.value" type="datetime-local" step="60" /> + </label> + <div v-if="win.customError.value" class="evtp__custom-err">{{ win.customError.value }}</div> + <div class="evtp__custom-actions"> + <span class="evtp__custom-hint">{{ t('max {d}d', { d: MAX_CUSTOM_MS / 60_000 / 60 / 24 }) }}</span> + <button type="button" class="evtp__custom-btn" @click="win.closeCustom()">{{ t('cancel') }}</button> + <button type="button" class="evtp__custom-btn evtp__custom-btn--primary" @click="win.applyCustom()">{{ t('apply') }}</button> + </div> + </div> + + <div class="evtp__split"> + <div class="evtp__main"> + <div v-if="!ev.reachable.value" class="evtp__empty evtp__empty--err"> + {{ t('OAP is unreachable.') }} <span v-if="ev.errorMsg.value">{{ ev.errorMsg.value }}</span> + </div> + <div v-else-if="ev.isPending.value" class="evtp__empty">{{ t('Reading data…') }}</div> + <EventsGantt + v-else + :events="ev.events.value" + :start-time="win.startTime.value" + :end-time="win.endTime.value" + :selected-uuid="selectedUuid" + :max-body-height="480" + :flat="true" + :row-filter="search" + @select-event="selectEvent" + /> + </div> + <EventsDetailPanel :event="selectedEvent" /> + </div> + </div> + </Modal> +</template> + +<style scoped> +.evtp { display: flex; flex-direction: column; gap: 12px; min-height: 0; height: 100%; } +.evtp__bar { display: flex; align-items: center; gap: 12px; } +.evtp__window { display: flex; gap: 2px; background: var(--sw-bg-1); border: 1px solid var(--sw-line); border-radius: 6px; padding: 3px; } +.evtp__window-btn { background: transparent; border: 0; color: var(--sw-fg-2); font: inherit; font-size: 11.5px; padding: 4px 12px; border-radius: 4px; cursor: pointer; } +.evtp__window-btn:hover { color: var(--sw-fg-0); } +.evtp__window-btn.active { background: var(--sw-bg-3); color: var(--sw-fg-0); } +.evtp__search { background: var(--sw-bg-2); border: 1px solid var(--sw-line-2); color: var(--sw-fg-0); font: inherit; font-size: 12px; padding: 4px 8px; border-radius: 4px; min-width: 180px; } +.evtp__search:focus { outline: none; border-color: var(--sw-accent); } +.evtp__hint { font-size: 11px; color: var(--sw-fg-3); font-style: italic; } +.evtp__warn { font-size: 11px; color: var(--sw-warn); } +.evtp__custom { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 10px 12px; background: var(--sw-bg-1); border: 1px solid var(--sw-line); border-radius: 8px; } +.evtp__custom-field { display: flex; flex-direction: column; gap: 2px; font-size: 10.5px; color: var(--sw-fg-3); text-transform: uppercase; letter-spacing: 0.08em; } +.evtp__custom-field input { background: var(--sw-bg-2); border: 1px solid var(--sw-line); color: var(--sw-fg-0); font: inherit; font-size: 12px; padding: 4px 6px; border-radius: 4px; } +.evtp__custom-err { color: var(--sw-err); font-size: 11px; } +.evtp__custom-actions { margin-left: auto; display: flex; align-items: center; gap: 8px; } +.evtp__custom-hint { font-size: 10.5px; color: var(--sw-fg-3); } +.evtp__custom-btn { background: transparent; border: 1px solid var(--sw-line-2); color: var(--sw-fg-1); font: inherit; font-size: 11.5px; padding: 4px 12px; border-radius: 4px; cursor: pointer; } +.evtp__custom-btn--primary { background: var(--sw-accent); border-color: var(--sw-accent); color: #0a0d12; font-weight: 600; } +.evtp__split { display: grid; grid-template-columns: 1fr 320px; gap: 14px; align-items: start; min-height: 0; flex: 1; } +@media (max-width: 900px) { .evtp__split { grid-template-columns: 1fr; } } +.evtp__main { min-width: 0; } +.evtp__empty { padding: 28px; text-align: center; font-size: 12px; color: var(--sw-fg-3); background: var(--sw-bg-1); border: 1px dashed var(--sw-line); border-radius: 8px; } +.evtp__empty--err { color: var(--sw-err); border-color: rgba(239,68,68,0.3); } +</style> diff --git a/apps/ui/src/features/events/ganttLayout.test.ts b/apps/ui/src/features/events/ganttLayout.test.ts new file mode 100644 index 0000000..d556611 --- /dev/null +++ b/apps/ui/src/features/events/ganttLayout.test.ts @@ -0,0 +1,117 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect } from 'vitest'; +import type { EventRow } from '@skywalking-horizon-ui/api-client'; +import { buildGantt, eventTs } from './ganttLayout'; + +function ev(o: { + uuid?: string; + service?: string; + instance?: string; + name?: string; + type?: 'Normal' | 'Error'; + start?: number; + end?: number | null; + layer?: string; +}): EventRow { + return { + uuid: o.uuid ?? 'u', + source: { service: o.service ?? 'svc', serviceInstance: o.instance ?? '', endpoint: '' }, + name: o.name ?? 'Start', + type: o.type ?? 'Normal', + message: null, + parameters: [], + startTime: o.start ?? 0, + endTime: o.end ?? null, + layer: o.layer ?? 'GENERAL', + }; +} + +describe('eventTs', () => { + it('uses endTime when finished, else startTime', () => { + expect(eventTs(ev({ start: 100, end: 250 }))).toBe(250); + expect(eventTs(ev({ start: 100, end: null }))).toBe(100); + expect(eventTs(ev({ start: 100, end: 0 }))).toBe(100); + }); +}); + +describe('buildGantt — Layer → Service → Instance tree', () => { + it('returns an empty tree for no events', () => { + expect(buildGantt([])).toEqual([]); + }); + + it('collapses a service-scoped service (empty instance) to one row', () => { + const t = buildGantt([ + ev({ service: 'a::b', instance: '', start: 100, end: 108 }), + ev({ service: 'a::b', instance: '', start: 3_600_000 }), + ]); + expect(t).toHaveLength(1); + expect(t[0]!.layer).toBe('GENERAL'); + expect(t[0]!.eventCount).toBe(2); + const svc = t[0]!.services[0]!; + expect(svc.service).toBe('a::b'); + expect(svc.serviceScoped).toBe(true); + expect(svc.rows).toHaveLength(1); + expect(svc.rows[0]!.instance).toBe(''); + }); + + it('gives an instanced service one row per instance, sorted by name', () => { + const t = buildGantt([ + ev({ service: 's', instance: 'i2', start: 100 }), + ev({ service: 's', instance: 'i1', start: 200 }), + ev({ service: 's', instance: 'i1', start: 300 }), + ]); + const svc = t[0]!.services[0]!; + expect(svc.serviceScoped).toBe(false); + expect(svc.rows.map((r) => r.instance)).toEqual(['i1', 'i2']); + expect(svc.eventCount).toBe(3); + }); + + it('sorts layers then services by name and colors each service distinctly', () => { + const t = buildGantt([ + ev({ layer: 'MESH', service: 'z' }), + ev({ layer: 'GENERAL', service: 'b' }), + ev({ layer: 'GENERAL', service: 'a' }), + ]); + expect(t.map((l) => l.layer)).toEqual(['GENERAL', 'MESH']); + expect(t[0]!.services.map((s) => s.service)).toEqual(['a', 'b']); + const colors = t.flatMap((l) => l.services.map((s) => s.color)); + expect(new Set(colors).size).toBe(colors.length); + }); + + it('stacks overlapping events on one instance into sub-lanes', () => { + const overlap = buildGantt([ + ev({ service: 's', instance: 'i', start: 100, end: 200 }), + ev({ service: 's', instance: 'i', start: 150, end: 250 }), + ]); + expect(overlap[0]!.services[0]!.rows[0]!.subLanes).toBe(2); + + const sequential = buildGantt([ + ev({ service: 's', instance: 'i', start: 100, end: 200 }), + ev({ service: 's', instance: 'i', start: 300, end: 400 }), + ]); + expect(sequential[0]!.services[0]!.rows[0]!.subLanes).toBe(1); + }); + + it('renders an event with no end as an instant bar (end null)', () => { + const t = buildGantt([ev({ service: 's', instance: 'i', start: 100, end: null })]); + const bar = t[0]!.services[0]!.rows[0]!.bars[0]!; + expect(bar.end).toBeNull(); + expect(bar.start).toBe(100); + }); +}); diff --git a/apps/ui/src/features/events/ganttLayout.ts b/apps/ui/src/features/events/ganttLayout.ts new file mode 100644 index 0000000..b1a2bee --- /dev/null +++ b/apps/ui/src/features/events/ganttLayout.ts @@ -0,0 +1,165 @@ +/* + * 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. + */ + +/** + * Swimlane (Gantt) layout for the events timeline — a Layer → Service → + * Instance tree. Layers are the top group; each service under a layer is + * colored and (when it reports real instances) foldable into one row per + * instance. A service-scoped service — every event with an empty instance — + * collapses to a SINGLE row instead of a redundant header plus a same-named + * row. Bars are positioned on a shared time axis; an event with no end time is + * an instantaneous marker rather than a bar; overlapping events on one instance + * stack into sub-lanes. + */ + +import { ENTITY_PALETTE } from '@/utils/metricColor'; +import type { EventRow } from '@/api/client'; + +/** Event timestamp, mirroring OAP: `endTime` if finished, else `startTime`. */ +export function eventTs(e: EventRow): number { + return e.endTime && e.endTime > 0 ? e.endTime : e.startTime; +} + +export interface GanttBar { + event: EventRow; + /** Bar start (ms). Falls back to `eventTs` when the reporter sent no start. */ + start: number; + /** Bar end (ms), or `null` for an instantaneous event (rendered as a marker). */ + end: number | null; + /** Sub-lane index within the instance row (for overlapping events). */ + subLane: number; +} + +export interface GanttInstanceRow { + /** Instance name, or '' for a service-scoped event with no instance. */ + instance: string; + bars: GanttBar[]; + /** Number of stacked sub-lanes this row needs (row height driver). */ + subLanes: number; +} + +export interface GanttServiceGroup { + key: string; + service: string; + color: string; + rows: GanttInstanceRow[]; + eventCount: number; + /** True when the only row is the '' (no-instance) row — render inline. */ + serviceScoped: boolean; +} + +export interface GanttLayerGroup { + key: string; + layer: string; + services: GanttServiceGroup[]; + eventCount: number; +} + +function barStart(e: EventRow): number { + return e.startTime && e.startTime > 0 ? e.startTime : eventTs(e); +} +function barEnd(e: EventRow): number | null { + return e.endTime && e.endTime > barStart(e) ? e.endTime : null; +} + +/** Pack an instance's events into sub-lanes so overlapping intervals never + * share a lane. An instantaneous event occupies a zero-width slot at its + * start, so only events at the same instant collide. */ +function packSubLanes(events: EventRow[]): { bars: GanttBar[]; subLanes: number } { + const sorted = [...events].sort((a, b) => barStart(a) - barStart(b)); + const laneEnds: number[] = []; // last occupied end (ms) per sub-lane + const bars: GanttBar[] = []; + for (const e of sorted) { + const s = barStart(e); + const en = barEnd(e); + const occupiedUntil = en ?? s; + let lane = laneEnds.findIndex((end) => end < s); + if (lane === -1) { + lane = laneEnds.length; + laneEnds.push(occupiedUntil); + } else { + laneEnds[lane] = occupiedUntil; + } + bars.push({ event: e, start: s, end: en, subLane: lane }); + } + return { bars, subLanes: Math.max(1, laneEnds.length) }; +} + +// Layer / service map keys use a control-char delimiter that can't appear in a +// layer key or service name (written as an escape so the source stays plain). +const SEP = '\u0000'; + +/** + * Build the Layer → Service → Instance tree. Layers sort by name; services sort + * by name within a layer; instances sort by name within a service. Colors are + * assigned per service across the whole view so neighbouring services stay + * distinct. Feed this the events for the window; a scoped popout passes one + * service's events. + */ +export function buildGantt(events: readonly EventRow[]): GanttLayerGroup[] { + const tree = new Map<string, Map<string, Map<string, EventRow[]>>>(); + for (const e of events) { + let byService = tree.get(e.layer); + if (!byService) { + byService = new Map(); + tree.set(e.layer, byService); + } + let byInstance = byService.get(e.source.service); + if (!byInstance) { + byInstance = new Map(); + byService.set(e.source.service, byInstance); + } + const ik = e.source.serviceInstance ?? ''; + const arr = byInstance.get(ik); + if (arr) arr.push(e); + else byInstance.set(ik, [e]); + } + + const layers: GanttLayerGroup[] = []; + for (const [layer, byService] of tree) { + const services: GanttServiceGroup[] = []; + let layerCount = 0; + for (const [service, byInstance] of byService) { + const rows: GanttInstanceRow[] = []; + let svcCount = 0; + for (const [instance, evs] of byInstance) { + const { bars, subLanes } = packSubLanes(evs); + svcCount += evs.length; + rows.push({ instance, bars, subLanes }); + } + rows.sort((a, b) => a.instance.localeCompare(b.instance)); + layerCount += svcCount; + services.push({ + key: `${layer}${SEP}${service}`, + service, + color: ENTITY_PALETTE[0]!, // reassigned per-service below + rows, + eventCount: svcCount, + serviceScoped: rows.length === 1 && rows[0]!.instance === '', + }); + } + services.sort((a, b) => a.service.localeCompare(b.service)); + layers.push({ key: layer, layer, services, eventCount: layerCount }); + } + layers.sort((a, b) => a.layer.localeCompare(b.layer)); + + // Distinct color per service across the whole view (palette is small, so + // assign by position rather than hashing to avoid neighbour collisions). + let ci = 0; + for (const l of layers) for (const s of l.services) s.color = ENTITY_PALETTE[ci++ % ENTITY_PALETTE.length]!; + return layers; +} diff --git a/apps/ui/src/features/events/useEvents.ts b/apps/ui/src/features/events/useEvents.ts new file mode 100644 index 0000000..e111968 --- /dev/null +++ b/apps/ui/src/features/events/useEvents.ts @@ -0,0 +1,124 @@ +/* + * 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. + */ + +/** + * Events data feed for the per-service events popout. Wraps the BFF + * `queryEvents` call, keyed on the window + applied filters so a change + * cascade-clears and refires. Returns the raw newest-first rows; the popout + * lays them out as an instance × time swimlane. `truncated` is true when the + * raw fetch hit the page-size cap — older events beyond it aren't shown until + * the window narrows. + * + * Failure handling: the BFF soft-fails an OAP error to a `reachable: false` + * envelope (HTTP 200), but an auth (403) / 5xx makes `bff.request` THROW, which + * lands the query in its error state with no `data`. Both paths collapse into + * `reachable === false` + `errorMsg` here, so the popout shows an error rather + * than a misleading "no events". + */ + +import { computed, type ComputedRef, type Ref } from 'vue'; +import { useQuery } from '@tanstack/vue-query'; +import { bff, type EventRow, type EventType, type EventsResponse } from '@/api/client'; + +/** The scope the events query filters by. `''` means unset for each field. + * The popout supplies a fixed `layer` + `service` (the banner's selection). */ +export interface EventFilterValues { + layer: string; + service: string; + type: '' | EventType; + name: string; +} + +/** Raw rows requested per query — the popout's working set. Matches the BFF + * `maxPageSize.events` default; the BFF clamps to its configured cap. */ +export const EVENTS_PAGE_SIZE = 200; + +export interface UseEventsResult { + events: ComputedRef<EventRow[]>; + truncated: ComputedRef<boolean>; + reachable: ComputedRef<boolean>; + errorMsg: ComputedRef<string | null>; + isPending: ComputedRef<boolean>; + isFetching: ComputedRef<boolean>; + refetch: () => void; +} + +export function useEvents( + startTime: Ref<number>, + endTime: Ref<number>, + applied: Ref<EventFilterValues>, + enabled?: Ref<boolean>, +): UseEventsResult { + const query = useQuery({ + queryKey: computed(() => [ + 'events', + startTime.value, + endTime.value, + applied.value.layer, + applied.value.service, + applied.value.type, + applied.value.name, + ]), + queryFn: (): Promise<EventsResponse> => + bff.events.query({ + startMs: startTime.value, + endMs: endTime.value, + layer: applied.value.layer || undefined, + service: applied.value.service || undefined, + type: applied.value.type || undefined, + name: applied.value.name || undefined, + pageSize: EVENTS_PAGE_SIZE, + }), + // Mounted permanently in the shell — gate closed so it only queries open. + enabled: computed(() => enabled?.value ?? true), + staleTime: Infinity, + refetchOnWindowFocus: false, + }); + + const events = computed<EventRow[]>(() => query.data.value?.events ?? []); + // True once the fetch hit the cap. Compare against the BFF's echoed pageSize + // (not the constant), so a sub-default `maxPageSize.events` still flips it. + const truncated = computed<boolean>(() => { + const data = query.data.value; + if (!data) return false; + return data.total >= (data.pageSize ?? EVENTS_PAGE_SIZE); + }); + // A thrown request (403 / 5xx) → error state, no data → NOT reachable. + const reachable = computed<boolean>(() => { + if (query.isError.value) return false; + return query.data.value?.reachable ?? true; + }); + const errorMsg = computed<string | null>(() => { + if (query.isError.value) { + const e = query.error.value; + return e instanceof Error ? e.message : String(e ?? 'request failed'); + } + return query.data.value?.error ?? null; + }); + + return { + events, + truncated, + reachable, + errorMsg, + isPending: computed(() => query.isPending.value), + isFetching: computed(() => query.isFetching.value), + refetch: () => { + void query.refetch(); + }, + }; +} diff --git a/apps/ui/src/features/events/useEventsPopout.ts b/apps/ui/src/features/events/useEventsPopout.ts new file mode 100644 index 0000000..3e9b508 --- /dev/null +++ b/apps/ui/src/features/events/useEventsPopout.ts @@ -0,0 +1,55 @@ +/* + * 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. + */ + +/** + * Per-service events popout coordinator. Events are a single-service peek, so + * they open as an in-context modal — the operator stays on the layer they were + * reading and sees just that service's instances, with no separate page to + * navigate to. + * + * Any place with a `(layerKey, serviceName)` calls `open(...)`; the popout + * component (`EventsPopout.vue`, mounted once in `AppShell`) reacts. State is a + * module-level ref so caller and popout share one target without prop drilling. + */ + +import { readonly, ref, type DeepReadonly, type Ref } from 'vue'; + +export interface EventsPopoutTarget { + /** Lowercase layer key (the BFF uppercases it for OAP). */ + layer: string; + /** Full OAP service NAME (events filter on the literal name). */ + service: string; +} + +const target = ref<EventsPopoutTarget | null>(null); + +export function useEventsPopout(): { + target: DeepReadonly<Ref<EventsPopoutTarget | null>>; + open: (layer: string, service: string) => void; + close: () => void; +} { + return { + target: readonly(target), + open: (layer: string, service: string): void => { + if (!layer || !service) return; + target.value = { layer, service }; + }, + close: (): void => { + target.value = null; + }, + }; +} diff --git a/apps/ui/src/features/events/useEventsWindow.ts b/apps/ui/src/features/events/useEventsWindow.ts new file mode 100644 index 0000000..2e008a6 --- /dev/null +++ b/apps/ui/src/features/events/useEventsWindow.ts @@ -0,0 +1,165 @@ +/* + * 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. + */ + +/** + * Events time-window picker — a small, events-agnostic state machine, the + * day-scale sibling of `useAlarmWindow`. + * + * Events are lifecycle records (restarts, deploys, k8s events). The popout + * shows a recent window — 6h / 1d / 2d presets + custom up to 7d. The BFF + * caps rolling windows at the same 7d; data beyond the record TTL simply + * comes back empty. Owns the preset/custom mode + the custom-range draft + * inputs + their validation; exposes `startTime` / `endTime` (epoch ms) the + * events query reads, and `resetEndToNow()` for manual refresh. + */ + +import { computed, ref, type ComputedRef, type Ref } from 'vue'; +import { useI18n } from 'vue-i18n'; + +export type PresetKey = '6h' | '1d' | '2d'; +const PRESET_MS: Record<PresetKey, number> = { + '6h': 6 * 60 * 60_000, + '1d': 24 * 60 * 60_000, + '2d': 2 * 24 * 60 * 60_000, +}; +export const PRESETS: readonly PresetKey[] = ['6h', '1d', '2d'] as const; +export const MAX_CUSTOM_MS = 7 * 24 * 60 * 60_000; + +export type WindowMode = PresetKey | 'custom'; + +export interface EventsWindow { + windowMode: Ref<WindowMode>; + customStartInput: Ref<string>; + customEndInput: Ref<string>; + customError: Ref<string | null>; + customOpen: Ref<boolean>; + startTime: ComputedRef<number>; + endTime: ComputedRef<number>; + pickPreset: (p: PresetKey) => void; + openCustom: () => void; + applyCustom: () => void; + closeCustom: () => void; + /** On manual refresh, slide a preset window forward to now. No-op in + * custom mode (the operator pinned an absolute range). */ + resetEndToNow: () => void; + formatWindowLabel: () => string; +} + +export function useEventsWindow(): EventsWindow { + const { t } = useI18n(); + + const windowMode = ref<WindowMode>('1d'); + const windowEndAt = ref<number>(Date.now()); + /** Custom range — only consulted when `windowMode === 'custom'`. */ + const customStart = ref<number>(Date.now() - PRESET_MS['1d']); + const customEnd = ref<number>(Date.now()); + const customError = ref<string | null>(null); + const customOpen = ref<boolean>(false); + + const startTime = computed<number>(() => { + if (windowMode.value === 'custom') return customStart.value; + return windowEndAt.value - PRESET_MS[windowMode.value]; + }); + const endTime = computed<number>(() => { + if (windowMode.value === 'custom') return customEnd.value; + return windowEndAt.value; + }); + + function pickPreset(p: PresetKey): void { + windowMode.value = p; + windowEndAt.value = Date.now(); + customOpen.value = false; + customError.value = null; + } + + /* Format `epochMs → 'YYYY-MM-DDTHH:mm'` (datetime-local). Browser TZ — + * display is browser-local; the BFF converts to OAP TZ on send. */ + function toLocalInput(ms: number): string { + const d = new Date(ms); + const y = d.getFullYear(); + const mo = String(d.getMonth() + 1).padStart(2, '0'); + const dd = String(d.getDate()).padStart(2, '0'); + const h = String(d.getHours()).padStart(2, '0'); + const mi = String(d.getMinutes()).padStart(2, '0'); + return `${y}-${mo}-${dd}T${h}:${mi}`; + } + const customStartInput = ref<string>(toLocalInput(customStart.value)); + const customEndInput = ref<string>(toLocalInput(customEnd.value)); + + function openCustom(): void { + windowMode.value = 'custom'; + customOpen.value = true; + customStartInput.value = toLocalInput(customStart.value); + customEndInput.value = toLocalInput(customEnd.value); + customError.value = null; + } + function applyCustom(): void { + const s = new Date(customStartInput.value).getTime(); + const e = new Date(customEndInput.value).getTime(); + if (!Number.isFinite(s) || !Number.isFinite(e)) { + customError.value = t('Invalid date'); + return; + } + if (e <= s) { + customError.value = t('End must be after start'); + return; + } + if (e - s > MAX_CUSTOM_MS) { + customError.value = t('Window exceeds {d}d cap', { d: MAX_CUSTOM_MS / 60_000 / 60 / 24 }); + return; + } + customStart.value = s; + customEnd.value = e; + customError.value = null; + customOpen.value = false; + } + function closeCustom(): void { + customOpen.value = false; + } + + function resetEndToNow(): void { + if (windowMode.value !== 'custom') windowEndAt.value = Date.now(); + } + + function fmtStamp(ms: number): string { + const d = new Date(ms); + const p = (n: number) => String(n).padStart(2, '0'); + return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`; + } + function formatWindowLabel(): string { + if (windowMode.value === 'custom') { + return `${fmtStamp(customStart.value)} → ${fmtStamp(customEnd.value)}`; + } + return t('last {window}', { window: windowMode.value }); + } + + return { + windowMode, + customStartInput, + customEndInput, + customError, + customOpen, + startTime, + endTime, + pickPreset, + openCustom, + applyCustom, + closeCustom, + resetEndToNow, + formatWindowLabel, + }; +} diff --git a/apps/ui/src/i18n/locales/de.json b/apps/ui/src/i18n/locales/de.json index 4a9b649..151d68f 100644 --- a/apps/ui/src/i18n/locales/de.json +++ b/apps/ui/src/i18n/locales/de.json @@ -1574,5 +1574,33 @@ "No instances available for this service — an async profiling task cannot be created.": "Keine Instanzen für diesen Service verfügbar – es kann keine asynchrone Profiling-Aufgabe erstellt werden.", "No instances available for this service — a pprof task cannot be created.": "Keine Instanzen für diesen Service verfügbar – es kann keine pprof-Aufgabe erstellt werden.", "OAP reports no profilable processes for this service.": "OAP meldet keine profilierbaren Prozesse für diesen Service.", - "OAP reports no profilable processes for this service": "OAP meldet keine profilierbaren Prozesse für diesen Service" + "OAP reports no profilable processes for this service": "OAP meldet keine profilierbaren Prozesse für diesen Service", + "(service-scoped)": "(auf Dienstebene)", + "Clear the selected time range": "Ausgewählten Zeitbereich löschen", + "Events": "Ereignisse", + "Instances": "Instanzen", + "Instances ({n})": "Instanzen ({n})", + "Lifecycle events reported across every layer — agent restarts, Kubernetes events, and other point-in-time records. Near-simultaneous per-instance events are grouped.": "Lebenszyklus-Ereignisse aus allen Ebenen — Agent-Neustarts, Kubernetes-Ereignisse und andere punktuelle Datensätze. Nahezu gleichzeitige Ereignisse pro Instanz werden gruppiert.", + "No events in the current window.": "Keine Ereignisse im aktuellen Zeitfenster.", + "OAP is unreachable.": "OAP ist nicht erreichbar.", + "Parameters": "Parameter", + "Select an event to inspect its details and instances.": "Wählen Sie ein Ereignis aus, um Details und Instanzen anzuzeigen.", + "Services": "Dienste", + "Triage": "Triage", + "Window exceeds {d}d cap": "Zeitfenster überschreitet das Limit von {d} Tagen", + "any type": "beliebiger Typ", + "exact event name, e.g. Start": "genauer Ereignisname, z. B. Start", + "max {d}d": "max. {d} Tage", + "showing newest {n} — narrow the time range or filters for older events": "Es werden die neuesten {n} angezeigt — grenzen Sie den Zeitbereich oder die Filter ein, um ältere Ereignisse zu sehen", + "{n} instances": "{n} Instanzen", + "Events · {service}": "Ereignisse · {service}", + "Lifecycle events reported across every layer — agent restarts, Kubernetes events, and other point-in-time records. Each service instance is a row on the timeline.": "Lebenszyklus-Ereignisse aus allen Ebenen — Agent-Neustarts, Kubernetes-Ereignisse und andere punktuelle Datensätze. Jede Dienstinstanz ist eine Zeile in der Zeitleiste.", + "Started": "Gestartet", + "Ended": "Beendet", + "Showing newest {n} — more available, narrow the range": "Die neuesten {n} werden angezeigt — mehr verfügbar, Bereich eingrenzen", + "{n} events · all in range shown": "{n} Ereignisse · alle im Bereich angezeigt", + "Search instance…": "Instanz suchen…", + "See service events": "Service-Ereignisse ansehen", + "Service events": "Service-Ereignisse", + "Reads dashboards, traces, logs, alarms, and events.": "Liest Dashboards, Traces, Logs, Alarme und Ereignisse." } diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json index 59d366e..0232c97 100644 --- a/apps/ui/src/i18n/locales/en.json +++ b/apps/ui/src/i18n/locales/en.json @@ -1574,5 +1574,33 @@ "No instances available for this service — an async profiling task cannot be created.": "No instances available for this service — an async profiling task cannot be created.", "No instances available for this service — a pprof task cannot be created.": "No instances available for this service — a pprof task cannot be created.", "OAP reports no profilable processes for this service.": "OAP reports no profilable processes for this service.", - "OAP reports no profilable processes for this service": "OAP reports no profilable processes for this service" + "OAP reports no profilable processes for this service": "OAP reports no profilable processes for this service", + "(service-scoped)": "(service-scoped)", + "Clear the selected time range": "Clear the selected time range", + "Events": "Events", + "Instances": "Instances", + "Instances ({n})": "Instances ({n})", + "Lifecycle events reported across every layer — agent restarts, Kubernetes events, and other point-in-time records. Near-simultaneous per-instance events are grouped.": "Lifecycle events reported across every layer — agent restarts, Kubernetes events, and other point-in-time records. Near-simultaneous per-instance events are grouped.", + "No events in the current window.": "No events in the current window.", + "OAP is unreachable.": "OAP is unreachable.", + "Parameters": "Parameters", + "Select an event to inspect its details and instances.": "Select an event to inspect its details and instances.", + "Services": "Services", + "Triage": "Triage", + "Window exceeds {d}d cap": "Window exceeds {d}d cap", + "any type": "any type", + "exact event name, e.g. Start": "exact event name, e.g. Start", + "max {d}d": "max {d}d", + "showing newest {n} — narrow the time range or filters for older events": "showing newest {n} — narrow the time range or filters for older events", + "{n} instances": "{n} instances", + "Events · {service}": "Events · {service}", + "Lifecycle events reported across every layer — agent restarts, Kubernetes events, and other point-in-time records. Each service instance is a row on the timeline.": "Lifecycle events reported across every layer — agent restarts, Kubernetes events, and other point-in-time records. Each service instance is a row on the timeline.", + "Started": "Started", + "Ended": "Ended", + "Showing newest {n} — more available, narrow the range": "Showing newest {n} — more available, narrow the range", + "{n} events · all in range shown": "{n} events · all in range shown", + "Search instance…": "Search instance…", + "See service events": "See service events", + "Service events": "Service events", + "Reads dashboards, traces, logs, alarms, and events.": "Reads dashboards, traces, logs, alarms, and events." } diff --git a/apps/ui/src/i18n/locales/es.json b/apps/ui/src/i18n/locales/es.json index 9a8da2c..69444ee 100644 --- a/apps/ui/src/i18n/locales/es.json +++ b/apps/ui/src/i18n/locales/es.json @@ -1574,5 +1574,33 @@ "No instances available for this service — an async profiling task cannot be created.": "No hay instancias disponibles para este servicio: no se puede crear una tarea de perfilado asíncrono.", "No instances available for this service — a pprof task cannot be created.": "No hay instancias disponibles para este servicio: no se puede crear una tarea de pprof.", "OAP reports no profilable processes for this service.": "OAP informa de que no hay procesos perfilables para este servicio.", - "OAP reports no profilable processes for this service": "OAP informa de que no hay procesos perfilables para este servicio" + "OAP reports no profilable processes for this service": "OAP informa de que no hay procesos perfilables para este servicio", + "(service-scoped)": "(a nivel de servicio)", + "Clear the selected time range": "Borrar el rango de tiempo seleccionado", + "Events": "Eventos", + "Instances": "Instancias", + "Instances ({n})": "Instancias ({n})", + "Lifecycle events reported across every layer — agent restarts, Kubernetes events, and other point-in-time records. Near-simultaneous per-instance events are grouped.": "Eventos de ciclo de vida reportados en todas las capas: reinicios de agentes, eventos de Kubernetes y otros registros puntuales. Los eventos por instancia casi simultáneos se agrupan.", + "No events in the current window.": "No hay eventos en la ventana actual.", + "OAP is unreachable.": "OAP no es accesible.", + "Parameters": "Parámetros", + "Select an event to inspect its details and instances.": "Selecciona un evento para inspeccionar sus detalles e instancias.", + "Services": "Servicios", + "Triage": "Triaje", + "Window exceeds {d}d cap": "La ventana supera el límite de {d} días", + "any type": "cualquier tipo", + "exact event name, e.g. Start": "nombre exacto del evento, p. ej. Start", + "max {d}d": "máx. {d} días", + "showing newest {n} — narrow the time range or filters for older events": "mostrando los {n} más recientes: acota el rango de tiempo o los filtros para ver eventos más antiguos", + "{n} instances": "{n} instancias", + "Events · {service}": "Eventos · {service}", + "Lifecycle events reported across every layer — agent restarts, Kubernetes events, and other point-in-time records. Each service instance is a row on the timeline.": "Eventos de ciclo de vida reportados en todas las capas: reinicios de agentes, eventos de Kubernetes y otros registros puntuales. Cada instancia de servicio es una fila en la línea de tiempo.", + "Started": "Iniciado", + "Ended": "Finalizado", + "Showing newest {n} — more available, narrow the range": "Mostrando los {n} más recientes — hay más, acota el rango", + "{n} events · all in range shown": "{n} eventos · todos en el rango mostrados", + "Search instance…": "Buscar instancia…", + "See service events": "Ver eventos de servicio", + "Service events": "Eventos de servicio", + "Reads dashboards, traces, logs, alarms, and events.": "Lee paneles, trazas, registros, alarmas y eventos." } diff --git a/apps/ui/src/i18n/locales/fr.json b/apps/ui/src/i18n/locales/fr.json index ed816cb..64e2cc8 100644 --- a/apps/ui/src/i18n/locales/fr.json +++ b/apps/ui/src/i18n/locales/fr.json @@ -1574,5 +1574,33 @@ "No instances available for this service — an async profiling task cannot be created.": "Aucune instance disponible pour ce service — impossible de créer une tâche de profilage asynchrone.", "No instances available for this service — a pprof task cannot be created.": "Aucune instance disponible pour ce service — impossible de créer une tâche pprof.", "OAP reports no profilable processes for this service.": "OAP signale qu'aucun processus profilable n'est disponible pour ce service.", - "OAP reports no profilable processes for this service": "OAP signale qu'aucun processus profilable n'est disponible pour ce service" + "OAP reports no profilable processes for this service": "OAP signale qu'aucun processus profilable n'est disponible pour ce service", + "(service-scoped)": "(au niveau du service)", + "Clear the selected time range": "Effacer la plage de temps sélectionnée", + "Events": "Événements", + "Instances": "Instances", + "Instances ({n})": "Instances ({n})", + "Lifecycle events reported across every layer — agent restarts, Kubernetes events, and other point-in-time records. Near-simultaneous per-instance events are grouped.": "Événements de cycle de vie remontés depuis toutes les couches — redémarrages d'agents, événements Kubernetes et autres enregistrements ponctuels. Les événements par instance quasi simultanés sont regroupés.", + "No events in the current window.": "Aucun événement dans la fenêtre actuelle.", + "OAP is unreachable.": "OAP est injoignable.", + "Parameters": "Paramètres", + "Select an event to inspect its details and instances.": "Sélectionnez un événement pour examiner ses détails et ses instances.", + "Services": "Services", + "Triage": "Triage", + "Window exceeds {d}d cap": "La fenêtre dépasse la limite de {d} jours", + "any type": "tout type", + "exact event name, e.g. Start": "nom exact de l'événement, par ex. Start", + "max {d}d": "max. {d} jours", + "showing newest {n} — narrow the time range or filters for older events": "affichage des {n} plus récents — réduisez la plage de temps ou les filtres pour voir les événements plus anciens", + "{n} instances": "{n} instances", + "Events · {service}": "Événements · {service}", + "Lifecycle events reported across every layer — agent restarts, Kubernetes events, and other point-in-time records. Each service instance is a row on the timeline.": "Événements de cycle de vie remontés depuis toutes les couches — redémarrages d'agents, événements Kubernetes et autres enregistrements ponctuels. Chaque instance de service est une ligne sur la chronologie.", + "Started": "Démarré", + "Ended": "Terminé", + "Showing newest {n} — more available, narrow the range": "Affichage des {n} plus récents — plus disponibles, réduisez la plage", + "{n} events · all in range shown": "{n} événements · tous ceux de la plage affichés", + "Search instance…": "Rechercher une instance…", + "See service events": "Voir les événements de service", + "Service events": "Événements de service", + "Reads dashboards, traces, logs, alarms, and events.": "Consulte les tableaux de bord, traces, journaux, alarmes et événements." } diff --git a/apps/ui/src/i18n/locales/ja.json b/apps/ui/src/i18n/locales/ja.json index a18b2e6..504e2ef 100644 --- a/apps/ui/src/i18n/locales/ja.json +++ b/apps/ui/src/i18n/locales/ja.json @@ -1574,5 +1574,33 @@ "No instances available for this service — an async profiling task cannot be created.": "このサービスには利用可能なインスタンスがありません — 非同期プロファイリングタスクを作成できません。", "No instances available for this service — a pprof task cannot be created.": "このサービスには利用可能なインスタンスがありません — pprof タスクを作成できません。", "OAP reports no profilable processes for this service.": "OAP はこのサービスにプロファイル可能なプロセスがないと報告しています。", - "OAP reports no profilable processes for this service": "OAP はこのサービスにプロファイル可能なプロセスがないと報告しています" + "OAP reports no profilable processes for this service": "OAP はこのサービスにプロファイル可能なプロセスがないと報告しています", + "(service-scoped)": "(サービス単位)", + "Clear the selected time range": "選択した時間範囲をクリア", + "Events": "イベント", + "Instances": "インスタンス", + "Instances ({n})": "インスタンス ({n})", + "Lifecycle events reported across every layer — agent restarts, Kubernetes events, and other point-in-time records. Near-simultaneous per-instance events are grouped.": "すべてのレイヤーから報告されるライフサイクルイベント — エージェントの再起動、Kubernetes イベント、その他の時点記録です。ほぼ同時に発生したインスタンスごとのイベントはグループ化されます。", + "No events in the current window.": "現在の時間枠にイベントはありません。", + "OAP is unreachable.": "OAP に到達できません。", + "Parameters": "パラメータ", + "Select an event to inspect its details and instances.": "イベントを選択すると、詳細とインスタンスを確認できます。", + "Services": "サービス", + "Triage": "トリアージ", + "Window exceeds {d}d cap": "時間枠が {d} 日の上限を超えています", + "any type": "すべてのタイプ", + "exact event name, e.g. Start": "正確なイベント名(例: Start)", + "max {d}d": "最大 {d} 日", + "showing newest {n} — narrow the time range or filters for older events": "最新の {n} 件を表示中 — 古いイベントを表示するには時間範囲またはフィルターを絞り込んでください", + "{n} instances": "{n} 個のインスタンス", + "Events · {service}": "イベント · {service}", + "Lifecycle events reported across every layer — agent restarts, Kubernetes events, and other point-in-time records. Each service instance is a row on the timeline.": "すべてのレイヤーから報告されるライフサイクルイベント — エージェントの再起動、Kubernetes イベント、その他の時点記録です。各サービスインスタンスがタイムライン上の 1 行になります。", + "Started": "開始", + "Ended": "終了", + "Showing newest {n} — more available, narrow the range": "最新の {n} 件を表示中 — さらにあります。範囲を絞り込んでください", + "{n} events · all in range shown": "{n} 件のイベント · 範囲内をすべて表示", + "Search instance…": "インスタンスを検索…", + "See service events": "サービスイベントを表示", + "Service events": "サービスイベント", + "Reads dashboards, traces, logs, alarms, and events.": "ダッシュボード、トレース、ログ、アラーム、イベントを閲覧します。" } diff --git a/apps/ui/src/i18n/locales/ko.json b/apps/ui/src/i18n/locales/ko.json index 61155d6..3a3b3fa 100644 --- a/apps/ui/src/i18n/locales/ko.json +++ b/apps/ui/src/i18n/locales/ko.json @@ -1574,5 +1574,33 @@ "No instances available for this service — an async profiling task cannot be created.": "이 서비스에 사용할 수 있는 인스턴스가 없습니다 — 비동기 프로파일링 작업을 생성할 수 없습니다.", "No instances available for this service — a pprof task cannot be created.": "이 서비스에 사용할 수 있는 인스턴스가 없습니다 — pprof 작업을 생성할 수 없습니다.", "OAP reports no profilable processes for this service.": "OAP가 이 서비스에 프로파일링 가능한 프로세스가 없다고 보고합니다.", - "OAP reports no profilable processes for this service": "OAP가 이 서비스에 프로파일링 가능한 프로세스가 없다고 보고합니다" + "OAP reports no profilable processes for this service": "OAP가 이 서비스에 프로파일링 가능한 프로세스가 없다고 보고합니다", + "(service-scoped)": "(서비스 범위)", + "Clear the selected time range": "선택한 시간 범위 지우기", + "Events": "이벤트", + "Instances": "인스턴스", + "Instances ({n})": "인스턴스 ({n})", + "Lifecycle events reported across every layer — agent restarts, Kubernetes events, and other point-in-time records. Near-simultaneous per-instance events are grouped.": "모든 레이어에서 보고된 수명 주기 이벤트 — 에이전트 재시작, Kubernetes 이벤트 및 기타 시점 기록입니다. 거의 동시에 발생한 인스턴스별 이벤트는 그룹화됩니다.", + "No events in the current window.": "현재 기간에 이벤트가 없습니다.", + "OAP is unreachable.": "OAP에 연결할 수 없습니다.", + "Parameters": "매개변수", + "Select an event to inspect its details and instances.": "이벤트를 선택하면 세부 정보와 인스턴스를 확인할 수 있습니다.", + "Services": "서비스", + "Triage": "트리아지", + "Window exceeds {d}d cap": "기간이 {d}일 제한을 초과합니다", + "any type": "모든 유형", + "exact event name, e.g. Start": "정확한 이벤트 이름, 예: Start", + "max {d}d": "최대 {d}일", + "showing newest {n} — narrow the time range or filters for older events": "최신 {n}개 표시 중 — 이전 이벤트를 보려면 시간 범위나 필터를 좁히세요", + "{n} instances": "인스턴스 {n}개", + "Events · {service}": "이벤트 · {service}", + "Lifecycle events reported across every layer — agent restarts, Kubernetes events, and other point-in-time records. Each service instance is a row on the timeline.": "모든 레이어에서 보고된 수명 주기 이벤트 — 에이전트 재시작, Kubernetes 이벤트 및 기타 시점 기록입니다. 각 서비스 인스턴스가 타임라인의 한 행입니다.", + "Started": "시작됨", + "Ended": "종료됨", + "Showing newest {n} — more available, narrow the range": "최신 {n}개 표시 중 — 더 있습니다. 범위를 좁히세요", + "{n} events · all in range shown": "이벤트 {n}개 · 범위 내 전체 표시", + "Search instance…": "인스턴스 검색…", + "See service events": "서비스 이벤트 보기", + "Service events": "서비스 이벤트", + "Reads dashboards, traces, logs, alarms, and events.": "대시보드, 트레이스, 로그, 알람, 이벤트를 봅니다." } diff --git a/apps/ui/src/i18n/locales/pt.json b/apps/ui/src/i18n/locales/pt.json index a691189..2a9efc9 100644 --- a/apps/ui/src/i18n/locales/pt.json +++ b/apps/ui/src/i18n/locales/pt.json @@ -1574,5 +1574,33 @@ "No instances available for this service — an async profiling task cannot be created.": "Nenhuma instância disponível para este serviço — não é possível criar uma tarefa de perfilamento assíncrono.", "No instances available for this service — a pprof task cannot be created.": "Nenhuma instância disponível para este serviço — não é possível criar uma tarefa de pprof.", "OAP reports no profilable processes for this service.": "O OAP informa que não há processos analisáveis para este serviço.", - "OAP reports no profilable processes for this service": "O OAP informa que não há processos analisáveis para este serviço" + "OAP reports no profilable processes for this service": "O OAP informa que não há processos analisáveis para este serviço", + "(service-scoped)": "(no nível do serviço)", + "Clear the selected time range": "Limpar o intervalo de tempo selecionado", + "Events": "Eventos", + "Instances": "Instâncias", + "Instances ({n})": "Instâncias ({n})", + "Lifecycle events reported across every layer — agent restarts, Kubernetes events, and other point-in-time records. Near-simultaneous per-instance events are grouped.": "Eventos de ciclo de vida relatados em todas as camadas — reinícios de agentes, eventos do Kubernetes e outros registros pontuais. Eventos por instância quase simultâneos são agrupados.", + "No events in the current window.": "Nenhum evento na janela atual.", + "OAP is unreachable.": "OAP está inacessível.", + "Parameters": "Parâmetros", + "Select an event to inspect its details and instances.": "Selecione um evento para inspecionar seus detalhes e instâncias.", + "Services": "Serviços", + "Triage": "Triagem", + "Window exceeds {d}d cap": "A janela excede o limite de {d} dias", + "any type": "qualquer tipo", + "exact event name, e.g. Start": "nome exato do evento, ex.: Start", + "max {d}d": "máx. {d} dias", + "showing newest {n} — narrow the time range or filters for older events": "mostrando os {n} mais recentes — restrinja o intervalo de tempo ou os filtros para ver eventos mais antigos", + "{n} instances": "{n} instâncias", + "Events · {service}": "Eventos · {service}", + "Lifecycle events reported across every layer — agent restarts, Kubernetes events, and other point-in-time records. Each service instance is a row on the timeline.": "Eventos de ciclo de vida relatados em todas as camadas — reinícios de agentes, eventos do Kubernetes e outros registros pontuais. Cada instância de serviço é uma linha na linha do tempo.", + "Started": "Iniciado", + "Ended": "Finalizado", + "Showing newest {n} — more available, narrow the range": "Mostrando os {n} mais recentes — há mais, restrinja o intervalo", + "{n} events · all in range shown": "{n} eventos · todos no intervalo exibidos", + "Search instance…": "Pesquisar instância…", + "See service events": "Ver eventos do serviço", + "Service events": "Eventos do serviço", + "Reads dashboards, traces, logs, alarms, and events.": "Lê painéis, rastros, logs, alarmes e eventos." } diff --git a/apps/ui/src/i18n/locales/zh-CN.json b/apps/ui/src/i18n/locales/zh-CN.json index d6923ed..0a59f8d 100644 --- a/apps/ui/src/i18n/locales/zh-CN.json +++ b/apps/ui/src/i18n/locales/zh-CN.json @@ -1574,5 +1574,33 @@ "No instances available for this service — an async profiling task cannot be created.": "该服务没有可用的实例 —— 无法创建异步剖析任务。", "No instances available for this service — a pprof task cannot be created.": "该服务没有可用的实例 —— 无法创建 pprof 任务。", "OAP reports no profilable processes for this service.": "OAP 报告该服务没有可分析的进程。", - "OAP reports no profilable processes for this service": "OAP 报告该服务没有可分析的进程" + "OAP reports no profilable processes for this service": "OAP 报告该服务没有可分析的进程", + "(service-scoped)": "(服务级别)", + "Clear the selected time range": "清除所选时间范围", + "Events": "事件", + "Instances": "实例", + "Instances ({n})": "实例({n})", + "Lifecycle events reported across every layer — agent restarts, Kubernetes events, and other point-in-time records. Near-simultaneous per-instance events are grouped.": "来自各层的生命周期事件 —— 代理重启、Kubernetes 事件以及其他时间点记录。几乎同时发生的各实例事件会被分组。", + "No events in the current window.": "当前时间窗口内没有事件。", + "OAP is unreachable.": "OAP 不可达。", + "Parameters": "参数", + "Select an event to inspect its details and instances.": "选择一个事件以查看其详情和实例。", + "Services": "服务", + "Triage": "排查", + "Window exceeds {d}d cap": "时间窗口超过 {d} 天上限", + "any type": "任意类型", + "exact event name, e.g. Start": "精确事件名称,例如 Start", + "max {d}d": "最多 {d} 天", + "showing newest {n} — narrow the time range or filters for older events": "显示最新的 {n} 条 —— 缩小时间范围或筛选条件以查看更早的事件", + "{n} instances": "{n} 个实例", + "Events · {service}": "事件 · {service}", + "Lifecycle events reported across every layer — agent restarts, Kubernetes events, and other point-in-time records. Each service instance is a row on the timeline.": "来自各层的生命周期事件 —— 代理重启、Kubernetes 事件以及其他时间点记录。每个服务实例在时间线上占一行。", + "Started": "开始", + "Ended": "结束", + "Showing newest {n} — more available, narrow the range": "显示最新的 {n} 条 —— 还有更多,请缩小范围", + "{n} events · all in range shown": "{n} 条事件 · 已显示范围内全部", + "Search instance…": "搜索实例…", + "See service events": "查看服务事件", + "Service events": "服务事件", + "Reads dashboards, traces, logs, alarms, and events.": "查看仪表板、追踪、日志、告警和事件。" } diff --git a/apps/ui/src/layer/LayerShell.vue b/apps/ui/src/layer/LayerShell.vue index f0883d0..12bb523 100644 --- a/apps/ui/src/layer/LayerShell.vue +++ b/apps/ui/src/layer/LayerShell.vue @@ -32,6 +32,7 @@ import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router'; import type { LayerDef, LandingServiceRow } from '@skywalking-horizon-ui/api-client'; import { bffClient } from '@/api/client'; import { useAuthStore } from '@/state/auth'; +import { useEventsPopout } from '@/features/events/useEventsPopout'; import Icon from '@/components/icons/Icon.vue'; import Sparkline from '@/components/charts/Sparkline.vue'; import LayerServiceSelector from './LayerServiceSelector.vue'; @@ -123,6 +124,7 @@ const menuLayer = computed<LayerDef | null>( // viewers still hit the normal gate. Empty metrics are expected — there // are no services to query. const auth = useAuthStore(); +const eventsPopout = useEventsPopout(); const isAdmin = computed<boolean>(() => !!auth.user?.roles?.includes('admin')); // Single canonical form: `?mode=preview`. The admin "Open live page" // link generates exactly this. @@ -594,6 +596,20 @@ const serviceKpis = computed<HeaderKpi[]>(() => { <Icon name="share" /> <span v-if="shareCopied" class="svc-share-flash">copied</span> </button> + <!-- Per-service events — open the popout scoped to THIS service so the + operator sees its instances without leaving the layer. Gated on + events:read so it never opens a modal whose query would 403. + Passes the FULL OAP service NAME (events filter on the literal + `<group>::<base>` name, not the group-stripped display label). --> + <button + v-if="auth.hasVerb('events:read') && selectedRow?.serviceName" + class="sw-btn ghost svc-events" + type="button" + :title="`View events for ${selectedName}`" + @click="() => eventsPopout.open(layerKey, selectedRow!.serviceName)" + > + <Icon name="event" /> + </button> <div v-if="serviceKpis.length > 0" class="kpi-strip service-kpis"> <div v-for="(k, i) in serviceKpis" :key="i" class="kpi compact"> <span class="kpi-label inline"> @@ -687,6 +703,17 @@ const serviceKpis = computed<HeaderKpi[]>(() => { justify-content: center; padding: 0; } +/* Match the refresh/share icon buttons — the Events deep-link is a + RouterLink (`<a>`), so it needs the same 32×32 flex box; without it the + `<a>` shrink-wraps the icon and renders it undersized/off-center. */ +.svc-events { + width: 32px; + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; +} .svc-share-flash { position: absolute; top: -22px; diff --git a/apps/ui/src/shell/AppShell.vue b/apps/ui/src/shell/AppShell.vue index 8d88dc7..0f28044 100644 --- a/apps/ui/src/shell/AppShell.vue +++ b/apps/ui/src/shell/AppShell.vue @@ -25,6 +25,7 @@ import GlobalConnectivityBanner from './GlobalConnectivityBanner.vue'; import ColdStageTrapBanner from './ColdStageTrapBanner.vue'; import PreviewModeBanner from './PreviewModeBanner.vue'; import TracePopout from '@/layer/traces/TracePopout.vue'; +import EventsPopout from '@/features/events/EventsPopout.vue'; import ZipkinTracePopout from '@/layer/traces/ZipkinTracePopout.vue'; import TemplateConflictPrompt from './TemplateConflictPrompt.vue'; import { ensureConfigBundle, useConfigBundle } from '@/controls/configBundle'; @@ -156,6 +157,10 @@ function startSidebarResize(e: PointerEvent): void { <!-- Shares `?traceId=`; native vs Zipkin self-select by ID shape (see isZipkinTraceId). --> <ZipkinTracePopout /> + <!-- Per-service events popout: a layer's service banner calls + useEventsPopout().open(layer, service) to peek that service's + instance events without leaving the page. --> + <EventsPopout /> <TemplateConflictPrompt /> <!-- Always mounted (even when hidden) so the Admin "Debug events" toggle responds without a re-mount race. --> diff --git a/docs/access-control/rbac.md b/docs/access-control/rbac.md index 3056c26..f14b8bd 100644 --- a/docs/access-control/rbac.md +++ b/docs/access-control/rbac.md @@ -21,6 +21,7 @@ Known verbs are grouped into areas: |---|---| | `metrics:read` | Layer dashboards, overview widgets that fetch MQE values. | | `alarms:read` | Alarms page, alarm widgets on overviews. | +| `events:read` | Events popout on a service banner: that service's lifecycle events. | | `traces:read` | Traces tab on any layer, trace detail page. | | `logs:read` | Logs tab on any layer, log detail page. | | `browser-errors:read` | Browser Logs tab (BROWSER layer): list JS error logs, list source maps, resolve a stack. | @@ -97,7 +98,7 @@ Default definitions (used when `rbac.roles` is not overridden): Read-only data catalog. Deliberately limited — does not include `*:read` so a viewer cannot peek at rule definitions, live-debug sessions, setup screens, or platform internals. ``` -metrics:read, alarms:read, traces:read, logs:read, browser-errors:read, topology:read, profile:read, overview:read, infra-3d:read +metrics:read, alarms:read, events:read, traces:read, logs:read, browser-errors:read, topology:read, profile:read, overview:read, infra-3d:read ``` ### `maintainer` diff --git a/docs/menu.yml b/docs/menu.yml index c67de00..60ff038 100644 --- a/docs/menu.yml +++ b/docs/menu.yml @@ -67,6 +67,8 @@ catalog: path: "/operate/browser-source-maps" - name: "Alarms" path: "/operate/alarms" + - name: "Events" + path: "/operate/events" - name: "Profiling" path: "/operate/profiling" - name: "Metrics Inspect" diff --git a/docs/operate/events.md b/docs/operate/events.md new file mode 100644 index 0000000..20214fb --- /dev/null +++ b/docs/operate/events.md @@ -0,0 +1,66 @@ +<!-- +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. +--> +# Events + +Events are the lifecycle records OAP has collected for a service — agent restarts, Kubernetes events, and other point-in-time facts reported by SkyWalking agents, the SkyWalking CLI, and the Kubernetes Event Exporter. Each event has a name, a type (`Normal` / `Error`), a message, and any reporter-supplied parameters. Events are distinct from alarms: an event records that something happened, not that a threshold was breached — for alerting, see [Alarms](alarms.md). + +## Opening the events popout + +Events are scoped to a single service and shown in a popout, so you review them without leaving the layer you're on. On any layer drill-down, pick a service in the **service banner** at the top, then click the **Events** button next to the banner's Share control. A modal opens for that service. The button appears only for users with the `events:read` permission (the built-in viewer, maintainer, and operator roles all have it). + +## The swimlane — instance × time + +The service is fixed (it's in the popout title), so the view has two axes: **each service instance is a row**, and time runs left to right. + +- An event with a duration is a **bar** spanning its start to its end. +- An event with no end time is an **instant marker** (a small diamond). +- Each **instance row is a distinct color**, so the rows read apart at a glance. +- **Error** events carry a red ring so they stand out. +- If one instance reports overlapping events, they **stack** into sub-rows so nothing is hidden. +- A service that reports events without an instance shows a single row for the service. + +A rolling restart of a large service therefore shows as many bars at the same moment — one per instance — rather than a single summarised line. When a service runs many instances, use the **search box** at the top of the popout to filter the rows to the instances whose name matches. + +## Time window and scrolling + +The popout owns its own window — `6h`, `1d`, `2d` presets — queried at second precision so the most recent events are never rounded out. Events are stored under OAP's record retention; a window reaching past it simply returns fewer rows. + +Scrolling stays inside the popout: the time-axis header stays pinned at the top and the instance column stays pinned at the left. A long range (a multi-day window) gets a wider, horizontally-scrollable canvas so bars keep a legible spacing instead of collapsing together, and the view opens scrolled to the **newest** events — scroll left for history. The time axis marks the **date at day boundaries**, so a range that crosses midnight is unambiguous. + +## How many events are shown + +The popout fetches the newest events up to a cap (200 by default; configurable under the server's page-size limits). It tells you which case you're in: + +- **"N events · all in range shown"** — everything in the window is on screen. +- **"Showing newest N — more available, narrow the range"** — the window holds more than the cap; tighten the time range to reach older events. + +## Event detail + +Click a bar to open the detail panel: + +- **Header** — the event type (`Normal` / `Error`) and name. +- **Scope** — the service, the instance (or "service-scoped"), the endpoint if present, and the layer. +- **Started / Ended / Duration** — for an event with a duration; a single **Time** for an instantaneous event. +- **Message** — the human-readable text the reporter attached. +- **Parameters** — the key/value details carried with the event (for example a Java agent's startup options). + +Service names, instance names, messages, and parameter values are shown exactly as OAP reported them. + +## Related + +- [Alarms](alarms.md) — threshold breaches from OAP's alerting engine, a separate read-only triage surface. +- [Traces](traces.md) and [Logs](logs.md) — the other per-entity triage surfaces. diff --git a/docs/setup/rbac.md b/docs/setup/rbac.md index 346c119..7cd7b2c 100644 --- a/docs/setup/rbac.md +++ b/docs/setup/rbac.md @@ -8,8 +8,8 @@ Role-Based Access Control. Defines the role → verb grants and the post-login l rbac: enabled: true roles: - viewer: [metrics:read, alarms:read, traces:read, logs:read, browser-errors:read, topology:read, profile:read, overview:read, infra-3d:read] - maintainer: [metrics:read, alarms:read, traces:read, logs:read, browser-errors:read, topology:read, profile:read, overview:read, infra-3d:read, cluster:read, ttl:read, config:read, inspect:read] + viewer: [metrics:read, alarms:read, events:read, traces:read, logs:read, browser-errors:read, topology:read, profile:read, overview:read, infra-3d:read] + maintainer: [metrics:read, alarms:read, events:read, traces:read, logs:read, browser-errors:read, topology:read, profile:read, overview:read, infra-3d:read, cluster:read, ttl:read, config:read, inspect:read] operator: [metrics:read, ..., rule:*, live-debug:*, profile:enable] admin: ["*"] landingByRole: @@ -31,7 +31,7 @@ rbac: | Role | Purpose | Grants | |---|---|---| -| `viewer` | Read-only data catalog and public overviews. | `metrics:read`, `alarms:read`, `traces:read`, `logs:read`, `browser-errors:read`, `topology:read`, `profile:read`, `overview:read`, `infra-3d:read`. Deliberately not `*:read` so the viewer cannot see rule definitions, live-debug sessions, setup screens, or platform internals. | +| `viewer` | Read-only data catalog and public overviews. | `metrics:read`, `alarms:read`, `events:read`, `traces:read`, `logs:read`, `browser-errors:read`, `topology:read`, `profile:read`, `overview:read`, `infra-3d:read`. Deliberately not `*:read` so the viewer cannot see rule definitions, live-debug sessions, setup screens, or platform internals. | | `maintainer` | Viewer + platform monitoring. | viewer baseline + `cluster:read`, `ttl:read`, `config:read`, `inspect:read`. | | `operator` | Configures observability. | maintainer baseline + `overview:write`, `setup:read/write`, `dashboard:read/write`, `alarm-setup:read/write`, `alarm-rule:read/write`, `rule:*` (including `rule:write:structural`, `rule:delete`, `rule:debug`), `live-debug:*`, `profile:enable`. | | `admin` | Unrestricted. | `*`. | diff --git a/packages/api-client/src/events.ts b/packages/api-client/src/events.ts new file mode 100644 index 0000000..d471bad --- /dev/null +++ b/packages/api-client/src/events.ts @@ -0,0 +1,99 @@ +/* + * 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 the per-service events popout. + * + * A verbatim mirror of OAP's `queryEvents(EventQueryCondition)` shape. The BFF + * returns the RAW event stream (newest-first); laying it out as an instance × + * time swimlane is the UI's concern, not the wire's. `source.service` is the + * literal service NAME (events store + filter by name — no id resolution like + * the logs / browser-errors feeds). + */ + +/** OAP `EventType` enum, verbatim. */ +export type EventType = 'Normal' | 'Error'; + +/** Sort order over the event timestamp (`endTime` if set, else `startTime`). */ +export type EventOrder = 'ASC' | 'DES'; + +/** Entity scope an event was reported at. The lower levels are empty strings + * when the event is service- or instance-scoped. */ +export interface EventSource { + service: string; + serviceInstance: string; + endpoint: string; +} + +/** One `Event` row. Fields mirror the GraphQL type 1:1. */ +export interface EventRow { + uuid: string; + source: EventSource; + name: string; + type: EventType; + message: string | null; + /** Free-form key/values the reporter attached (agent OPTS, k8s reason …). */ + parameters: { key: string; value: string }[]; + /** Unix millis. `0` when the reporter never sent a `start` event. */ + startTime: number; + /** Unix millis, or `null` when the event has not finished. */ + endTime: number | null; + /** Layer enum name, e.g. `GENERAL`, `MESH`. */ + layer: string; +} + +export interface EventsQueryRequest { + /** Single layer enum name (UPPERCASE). Omit for all layers (global). OAP's + * `layer` filter is single-valued — there is no multi-layer query. */ + layer?: string; + /** Service NAME (e.g. `agent::songs`) — passed straight to `source.service`. + * Only meaningful alongside a `layer`. */ + service?: string; + serviceInstance?: string; + endpoint?: string; + type?: EventType; + name?: string; + /** Defaults to `DES` (newest-first) server-side. */ + order?: EventOrder; + page?: number; + pageSize?: number; + /** Rolling window in minutes ending at "now". Ignored when an explicit + * `startMs` / `endMs` pair is supplied. */ + windowMinutes?: number; + /** Absolute range as epoch millis (TZ-unambiguous). The BFF renders these + * into OAP-server-local time using the OAP offset — send ms, not + * pre-formatted local strings. */ + startMs?: number; + endMs?: number; +} + +export interface EventsResponse { + generatedAt: number; + query: EventsQueryRequest; + /** OAP exposes no cross-page total for events; the BFF reports the returned + * row count, same as the logs / browser-errors feeds. When `total` reaches + * the effective `pageSize` the window is likely truncated — the UI shows a + * "narrow the range" hint. */ + total: number; + /** The effective page-size cap actually used (the request clamped to + * `maxPageSize.events`). The UI compares `total` against this — NOT a + * hardcoded constant — so a sub-default cap still flips `truncated`. */ + pageSize: number; + events: EventRow[]; + reachable: boolean; + error?: string; +} diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index 2ef5a1a..d49208b 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -137,6 +137,14 @@ export type { ResolvedFrame, ResolveResponse, } from './browser-errors.js'; +export type { + EventType, + EventOrder, + EventSource, + EventRow, + EventsQueryRequest, + EventsResponse, +} from './events.js'; export type { OapInfo, OapCapabilities, OapBackend } from './oap-info.js'; export type { RecordsTTL,
