This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
The following commit(s) were added to refs/heads/main by this push:
new 048305f feat(events): add a per-service events popout (queryEvents
swimlane) (#89)
048305f is described below
commit 048305fbb517e3f05b0b2aa58f80f846936ff834
Author: 吴晟 Wu Sheng <[email protected]>
AuthorDate: Wed Jul 1 14:13:13 2026 +0800
feat(events): add a per-service events popout (queryEvents swimlane) (#89)
---
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 | 370 ++++++++++++++++++++++
apps/ui/src/features/events/EventsPopout.vue | 161 ++++++++++
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, 2226 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..a92a524
--- /dev/null
+++ b/apps/ui/src/features/events/EventsGantt.vue
@@ -0,0 +1,370 @@
+<!--
+ 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;
+ /** 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 || t('(service-scoped)'), 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">
+ <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 only scroll surface (both axes). max-height reserves the modal chrome
+ (header + bar + optional range editor) so many rows scroll HERE, not in a
+ nested modal-body scrollbar; min() lets the 240px floor yield on very short
+ viewports so it never exceeds the ceiling. */
+.gantt-scroll { overflow: auto; min-height: min(240px, calc(100vh - 260px));
max-height: calc(100vh - 260px); }
+.gantt-scroll::-webkit-scrollbar { width: 8px; height: 8px; }
+.gantt-scroll::-webkit-scrollbar-thumb { background: var(--sw-line-2);
border-radius: 4px; }
+.gantt-scroll::-webkit-scrollbar-track { background: transparent; }
+.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..a091bf2
--- /dev/null
+++ b/apps/ui/src/features/events/EventsPopout.vue
@@ -0,0 +1,161 @@
+<!--
+ 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)"
@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"
+ :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; }
+.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; }
+@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,