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