This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a commit to branch feat/service-events-popout
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git

commit 6a3476b2f50f7b39647d57566600489431c875ff
Author: Wu Sheng <[email protected]>
AuthorDate: Wed Jul 1 10:44:42 2026 +0800

    feat(events): add a per-service events popout (queryEvents swimlane)
    
    Open a per-service Events popout from a layer's service banner: it shows 
that
    service's lifecycle events (agent restarts, Kubernetes events, and other OAP
    `queryEvents` records) as an instance × time swimlane — one row per service
    instance, each in its own color, bars spanning start→end, instant events as
    diamond markers, Error events ringed red, and overlapping events on an 
instance
    stacked into sub-lanes so nothing hides.
    
    The popout owns its own time window (6h / 1d / 2d presets plus a custom 
range up
    to 7 days, second precision), scrolls internally (sticky time-axis header +
    sticky instance column, horizontal scroll for long ranges, opened at the 
newest
    events), marks the date at day boundaries, filters instances by name via a
    search box, and opens a detail panel on bar click (instance, Started / 
Ended /
    Duration, message, parameters). The newest events are fetched up to a
    configurable cap (`performance.limits.maxPageSize.events`, default 200), 
with an
    explicit "all in range shown / more available — narrow the range" indicator.
    
    BFF adds a thin `POST /api/events` route wrapping `queryEvents` (SECOND 
step,
    DES, layer/service filters, server-side page-size clamp, absolute-window 
cap),
    gated by a new `events:read` verb granted to the viewer / maintainer / 
operator
    built-in roles. UI strings are translated across all 8 locales; the 
CHANGELOG,
    operator docs, and both RBAC docs are updated. Unit tests cover the BFF 
query
    shaping (window clamp, condition building, map/sort) and the Gantt packing
    (layer→service→instance tree, sub-lane overlap, service-scoped collapse).
---
 CHANGELOG.md                                      |   6 +
 apps/bff/src/config/schema.ts                     |   6 +
 apps/bff/src/http/query/events.test.ts            | 145 +++++++++
 apps/bff/src/http/query/events.ts                 | 252 +++++++++++++++
 apps/bff/src/rbac/route-policy.ts                 |   2 +
 apps/bff/src/rbac/verbs.ts                        |   1 +
 apps/bff/src/server.ts                            |   2 +
 apps/ui/src/api/client.ts                         |   8 +
 apps/ui/src/api/scopes/events.ts                  |  32 ++
 apps/ui/src/features/admin/roles/RolesView.vue    |   6 +-
 apps/ui/src/features/events/EventsDetailPanel.vue | 167 ++++++++++
 apps/ui/src/features/events/EventsGantt.vue       | 365 ++++++++++++++++++++++
 apps/ui/src/features/events/EventsPopout.vue      | 162 ++++++++++
 apps/ui/src/features/events/ganttLayout.test.ts   | 117 +++++++
 apps/ui/src/features/events/ganttLayout.ts        | 165 ++++++++++
 apps/ui/src/features/events/useEvents.ts          | 124 ++++++++
 apps/ui/src/features/events/useEventsPopout.ts    |  55 ++++
 apps/ui/src/features/events/useEventsWindow.ts    | 165 ++++++++++
 apps/ui/src/i18n/locales/de.json                  |  30 +-
 apps/ui/src/i18n/locales/en.json                  |  30 +-
 apps/ui/src/i18n/locales/es.json                  |  30 +-
 apps/ui/src/i18n/locales/fr.json                  |  30 +-
 apps/ui/src/i18n/locales/ja.json                  |  30 +-
 apps/ui/src/i18n/locales/ko.json                  |  30 +-
 apps/ui/src/i18n/locales/pt.json                  |  30 +-
 apps/ui/src/i18n/locales/zh-CN.json               |  30 +-
 apps/ui/src/layer/LayerShell.vue                  |  27 ++
 apps/ui/src/shell/AppShell.vue                    |   5 +
 docs/access-control/rbac.md                       |   3 +-
 docs/menu.yml                                     |   2 +
 docs/operate/events.md                            |  66 ++++
 docs/setup/rbac.md                                |   6 +-
 packages/api-client/src/events.ts                 |  99 ++++++
 packages/api-client/src/index.ts                  |   8 +
 34 files changed, 2222 insertions(+), 14 deletions(-)

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

Reply via email to