You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by wu...@apache.org on 2026/05/16 14:27:27 UTC

(skywalking-horizon-ui) branch main updated: 0.3.0: unified sidebar + alarms / inspect / live-debug / overview dashboards

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

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


The following commit(s) were added to refs/heads/main by this push:
     new a3fab42  0.3.0: unified sidebar + alarms / inspect / live-debug / overview dashboards
a3fab42 is described below

commit a3fab421160b272fe1e2114ff72fc4ae5469ef11
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Sat May 16 22:27:17 2026 +0800

    0.3.0: unified sidebar + alarms / inspect / live-debug / overview dashboards
    
    UI
    - Unified sidebar: L0/L1/L2 spec rhythm, layer-icons on every row,
      Alarms folded into Overviews, Platform monitoring as its own L0,
      L1 expandables for Live debugger / DSL Management.
    - New views: alarms (timeline + table), operate/inspect, operate/dsl
      (MAL OTEL / MAL Telegraf / LAL / LAL→MAL / OAL / Dump & restore),
      operate/live-debug (MAL/LAL/OAL + capture history), overview
      landing + per-dashboard view, alert-page-setup admin.
    - New primitives, monaco editor wiring (MQE / MAL / LAL), shared
      AlarmsTimeline chart, native trace waterfall, AdminFeatureWarning
      + GlobalConnectivityBanner shell additions.
    - Time-range store + new composables (admin-features, client-id,
      debug-history/session, overview-dashboard(s), rule-editor).
    
    BFF
    - New alarms store + routes (timeline + service↔layer cache).
    - New inspect parsers (OAL / MAL) + attribution, inspect + debug
      HTTP routes for live debugger + metric catalog.
    - Overview loader / routes + services overview template; mesh
      overview adjusted; legacy general overview removed.
    - Layer template fixes: k8s_service service-dash relayout
      (4 HTTP on 2 rows, 5 TCP on 2 rows), so11y_satellite + others
      scope-corrected to instance-only.
    
    Design tokens
    - L0 spec values (10/700/0.1em uppercase fg-3, 14px padding).
    - Booster-ui `--rr-*` alias layer for migrated pages.
    
    Docs
    - CLAUDE.md: comment-style principle (intro APIs + non-obvious
      gotchas only; no paraphrase / history-as-comment).
---
 CLAUDE.md                                          |  187 +-
 apps/bff/src/alarms/routes.ts                      |  601 ++++++
 apps/bff/src/alarms/service-layer-map.ts           |  128 ++
 apps/bff/src/alarms/store.ts                       |  124 ++
 .../src/bundled_templates/layers/k8s_service.json  |    4 +-
 .../bundled_templates/layers/so11y_satellite.json  |   33 +-
 .../src/bundled_templates/overviews/general.json   |   82 -
 apps/bff/src/bundled_templates/overviews/mesh.json |  116 +-
 .../src/bundled_templates/overviews/services.json  |  138 ++
 apps/bff/src/config/schema.ts                      |    8 +
 apps/bff/src/inspect/attribution.ts                |  279 +++
 apps/bff/src/inspect/parser-mal.ts                 |   92 +
 apps/bff/src/inspect/parser-oal.ts                 |   56 +
 apps/bff/src/layers/loader.ts                      |    3 +
 apps/bff/src/oap/debug-routes.ts                   |  337 +++
 apps/bff/src/oap/inspect-routes.ts                 |  407 ++++
 apps/bff/src/oap/landing-routes.ts                 |   13 +-
 apps/bff/src/oap/menu-routes.ts                    |    1 +
 apps/bff/src/oap/preflight.ts                      |   31 +-
 apps/bff/src/overview/loader.ts                    |   86 +-
 apps/bff/src/overview/routes.ts                    |    4 +
 apps/bff/src/server.ts                             |   14 +
 apps/ui/src/api/client.ts                          |  551 ++++-
 apps/ui/src/components/charts/AlarmsTimeline.vue   |  402 ++++
 apps/ui/src/components/icons/Icon.vue              |   43 +-
 .../src/components/operate/DestructiveConfirm.vue  |  122 ++
 apps/ui/src/components/operate/Modal.vue           |  124 ++
 apps/ui/src/components/operate/MonacoDiff.vue      |   87 +
 apps/ui/src/components/operate/MonacoYaml.vue      |  171 ++
 apps/ui/src/components/operate/RuleCard.vue        |  278 +++
 apps/ui/src/components/operate/grouping.ts         |   82 +
 .../components/overview/widgets/AlarmsWidget.vue   |  104 +
 .../overview/widgets/K8sSummaryWidget.vue          |  115 ++
 .../components/overview/widgets/KpiTileWidget.vue  |  111 +
 .../components/overview/widgets/MetricWidget.vue   |   45 +
 .../overview/widgets/PilotSummaryWidget.vue        |   87 +
 .../widgets/SectionBreak.vue}                      |   48 +-
 .../overview/widgets/ServiceCountWidget.vue        |   45 +
 .../src/components/overview/widgets/ValueFormat.ts |   35 +
 apps/ui/src/components/primitives/Btn.vue          |  101 +
 apps/ui/src/components/primitives/CatalogNav.vue   |  232 +++
 apps/ui/src/components/primitives/Pill.vue         |   68 +
 apps/ui/src/components/primitives/Section.vue      |   80 +
 apps/ui/src/components/primitives/StatusDot.vue    |   57 +
 .../src/components/profile/ProfileFlameGraph.vue   |  249 ++-
 .../src/components/profile/ProfileStackTable.vue   |   71 +-
 .../src/components/shell/AdminFeatureWarning.vue   |  225 ++
 apps/ui/src/components/shell/AppShell.vue          |    5 +
 apps/ui/src/components/shell/AppSidebar.vue        |  674 +++---
 apps/ui/src/components/shell/AppTopbar.vue         |  468 ++++-
 .../components/shell/GlobalConnectivityBanner.vue  |  274 +++
 .../src/components/trace/NativeTraceWaterfall.vue  |  410 ++++
 apps/ui/src/composables/useAdminFeatures.ts        |   91 +
 apps/ui/src/composables/useClientId.ts             |   70 +
 apps/ui/src/composables/useDebugHistory.ts         |  198 ++
 apps/ui/src/composables/useDebugSession.ts         |  313 +++
 apps/ui/src/composables/useOverviewDashboard.ts    |  295 +++
 apps/ui/src/composables/useOverviewDashboards.ts   |   72 +
 apps/ui/src/composables/useRuleEditor.ts           |  224 ++
 apps/ui/src/monaco/lal-grammar.ts                  |   89 +
 apps/ui/src/monaco/mal-grammar.ts                  |  133 ++
 apps/ui/src/monaco/setup.ts                        |  149 ++
 apps/ui/src/router/index.ts                        |   80 +-
 apps/ui/src/stores/setup.ts                        |   44 +-
 apps/ui/src/stores/timeRange.ts                    |  186 ++
 apps/ui/src/views/admin/AlertPageSetupView.vue     |  424 ++++
 apps/ui/src/views/admin/LayerDashboardsAdmin.vue   |   91 +-
 apps/ui/src/views/alarms/AlarmDetailPanel.vue      |  282 +++
 apps/ui/src/views/alarms/AlarmsView.vue            | 1165 +++++++++++
 .../ui/src/views/layer/LayerAsyncProfilingView.vue |    6 +-
 apps/ui/src/views/layer/LayerEBPFProfilingView.vue |    6 +-
 .../src/views/layer/LayerNetworkProfilingView.vue  |   10 +-
 .../ui/src/views/layer/LayerPprofProfilingView.vue |    6 +-
 apps/ui/src/views/layer/LayerServiceMapView.vue    |   34 +-
 apps/ui/src/views/layer/LayerShell.vue             |   14 +-
 .../ui/src/views/layer/LayerTraceProfilingView.vue |  169 +-
 apps/ui/src/views/operate/ClusterStatusView.vue    |  364 +++-
 apps/ui/src/views/operate/InspectView.vue          | 2140 ++++++++++++++++++++
 apps/ui/src/views/operate/dsl/DslCatalogView.vue   |  625 ++++++
 apps/ui/src/views/operate/dsl/DslDumpView.vue      |  154 ++
 apps/ui/src/views/operate/dsl/DslEditorView.vue    |  544 +++++
 apps/ui/src/views/operate/dsl/OalCatalogView.vue   |  450 ++++
 apps/ui/src/views/operate/dsl/syntaxHighlight.ts   |  243 +++
 .../views/operate/live-debug/DebugHistoryView.vue  |  531 +++++
 apps/ui/src/views/operate/live-debug/DebugLal.vue  | 1666 +++++++++++++++
 apps/ui/src/views/operate/live-debug/DebugMal.vue  | 1522 ++++++++++++++
 apps/ui/src/views/operate/live-debug/DebugOal.vue  |  970 +++++++++
 apps/ui/src/views/operate/live-debug/DebugView.vue |  320 +++
 .../views/operate/live-debug/LiveDebuggerView.vue  |  144 ++
 .../src/views/operate/live-debug/NodeCoverage.vue  |  234 +++
 apps/ui/src/views/operate/live-debug/constants.ts  |   37 +
 .../ui/src/views/operate/live-debug/oalEntityId.ts |  207 ++
 apps/ui/src/views/operate/live-debug/payload.ts    |  136 ++
 apps/ui/src/views/overview/AlarmsPanel.vue         |  171 --
 apps/ui/src/views/overview/LayerKpiStripCard.vue   |  507 -----
 apps/ui/src/views/overview/OverviewBody.vue        |  177 --
 .../src/views/overview/OverviewDashboardView.vue   |  190 ++
 apps/ui/src/views/overview/OverviewLanding.vue     |   94 +
 apps/ui/src/views/overview/OverviewView.vue        |  149 --
 apps/ui/src/views/setup/SetupView.vue              |  727 +++----
 horizon.example.yaml                               |    7 +
 packages/api-client/src/index.ts                   |    3 +
 packages/api-client/src/menu.ts                    |    5 +
 packages/api-client/src/overview.ts                |   76 +-
 packages/api-client/src/preflight.ts               |   55 +
 packages/api-client/src/setup.ts                   |    6 +
 packages/design-tokens/src/tokens.css              |   47 +-
 107 files changed, 22432 insertions(+), 2328 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 35846c5..ccfc2c4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,166 +1,81 @@
 # CLAUDE.md - AI Assistant Guide for Apache SkyWalking Horizon UI
 
-This file provides guidance for AI assistants working with the **skywalking-horizon-ui** codebase.
-
-## Project Overview
-
-**Horizon UI** is the next-generation web UI for [Apache SkyWalking](https://github.com/apache/skywalking). The goal is **feature parity** with [skywalking-booster-ui](https://github.com/apache/skywalking-booster-ui) on the **same OAP GraphQL query-protocol and MQE**, with a modernized, dense, dark-first design.
-
-This is a **greenfield rewrite**, not a fork. Backend APIs do not change.
-
-## Repository layout (target)
-
-```
-skywalking-horizon-ui/
-├── .github/workflows/         # CI (skywalking-eyes header + dep-license, lint, type-check, build)
-├── docs/                      # (gitignored) local design + planning docs during the build-out
-├── public/                    # Static assets (favicon, logo)
-├── src/
-│   ├── main.ts                # entry
-│   ├── App.vue                # shell
-│   ├── assets/
-│   │   └── styles/
-│   │       └── tokens.css     # design tokens (lifted from design bundle, do NOT edit casually)
-│   ├── router/                # vue-router routes (layer-first hierarchy)
-│   ├── stores/                # Pinia stores (auth, layer, dashboard, alarm, …)
-│   ├── graphql/
-│   │   ├── client.ts          # graphql-request client pointed at /graphql
-│   │   ├── queries/           # one file per query-protocol module
-│   │   └── fragments/
-│   ├── views/                 # top-level routed pages
-│   │   ├── landing/           # global + per-layer landing
-│   │   ├── metrics/           # service metrics deep-dive (multi-layer strip)
-│   │   ├── topology/          # 3 variants: force / DAG / hex
-│   │   ├── endpoint/          # multi-hop dependency
-│   │   ├── dashboards/        # widget grid + per-scope templates (Glance/Service/Instance/Endpoint/Topology/Metric)
-│   │   ├── traces/            # trace explorer
-│   │   ├── logs/              # log explorer with condition bar, facets, JSON/YAML/raw payload
-│   │   ├── alarms/            # active alarms (read-only) + live debug; recovery is backend-auto
-│   │   ├── profiling/         # flame graph (later)
-│   │   └── admin/             # LDAP/RBAC, users, roles, layer config — see "Admin & RBAC" below
-│   ├── components/
-│   │   ├── shell/             # sidebar, topbar, breadcrumbs, search
-│   │   ├── charts/            # ECharts wrappers (TimeSeries, Heatmap, Sankey, Scatter, Stacked, Sparkline)
-│   │   ├── topology/          # D3 wrappers (ForceGraph, DAG, HexGrid)
-│   │   ├── trace/             # waterfall, span detail
-│   │   ├── log/               # row, expanded panel, facets, condition bar
-│   │   ├── dashboard/         # grid, widget, widget-editor, mqe-editor
-│   │   ├── primitives/        # Button, Badge, Card, Table, Tag, KPI
-│   │   └── icons/             # shared icon set (matches design bundle's Icon.*)
-│   ├── composables/           # useTimeRange, useCrosshair (synced cursors), useLayer, …
-│   ├── types/                 # auto-imports.d.ts, components.d.ts, query-protocol types
-│   └── utils/                 # color scales, formatters, MQE token highlighter
-├── tests/                     # Vitest, Cypress
-├── LICENSE  NOTICE  HEADER
-├── .licenserc.yaml            # skywalking-eyes config
-├── .asf.yaml
-├── package.json
-├── vite.config.ts
-└── tsconfig.json
-```
+This file states the **principles** for working in this codebase. It is not a how-to. Code details (directory layout, tech-stack versions, function names, step-by-step recipes) belong in the code itself — read the code when you need them.
 
-## Design source of truth
+## What this project is
 
-During the build-out, the visual spec (HTML/JSX prototypes), design tokens snapshot, tech-stack rationale, and admin/RBAC design notes live **locally only** under `docs/` (gitignored). **The HTML/JSX prototypes are the visual spec.** Recreate them pixel-perfectly in Vue; don't blindly port the prototype's component structure — match the rendered output.
+**Horizon UI** is the next-generation web UI for [Apache SkyWalking](https://github.com/apache/skywalking). The goal is **feature parity** with [skywalking-booster-ui](https://github.com/apache/skywalking-booster-ui) on the **same OAP GraphQL query-protocol and MQE**, with a modernized, dense, dark-first design. This is a **greenfield rewrite**, not a fork. Backend APIs do not change.
 
-If you're an AI assistant and the `docs/` directory is missing, ask the user to share the design bundle before implementing visual work — do not invent layouts.
+## How to work (the only workflow that matters)
 
-Design tokens have been lifted verbatim into `src/assets/styles/tokens.css` — that copy in `src/` is the canonical runtime version. The orange accent `#f97316` is the SkyWalking primary; dark surface ramps `--sw-bg-0..4`, line ramps `--sw-line / --sw-line-2 / --sw-line-3`, semantic colors (ok / warn / err / info / purple / pink / cyan).
+> **Correctness comes first. Speed comes from getting it right the first time, not from skipping steps.**
 
-The local `docs/` directory is temporary — it will be removed once every screen has a corresponding Vue implementation and visual sign-off. Don't reference its files from production code (other than the one-time `tokens.css` lift, already done).
+Every non-trivial change follows the same loop:
 
-## Tech stack
+1. **Read the code.** Read it end-to-end along the path you intend to change: call site → handler → response shape → consumer. Comments, this file, and prior conversation are *reference* — the source of truth is the current code on disk.
+2. **Implement.** Match what the surrounding code already does. If you have to invent a pattern, you probably haven't finished step 1.
+3. **Validate against a live OAP.** Run the change against a real OAP server and confirm the wire request/response and the rendered UI. Type-checks and unit tests verify code, not feature behavior.
 
-Locked decisions:
+If no OAP is available to validate against, **stop and ask the developer to provide one** (URL, credentials, demo endpoint, port-forward — whatever). Do not guess at wire shapes, do not mock the data and call it done, do not declare the work complete from a green type-check alone. "I couldn't validate" is an honest, acceptable outcome — silently shipping unvalidated changes is not.
 
-- **Vue 3 + TS** with `<script setup>` (matches booster-ui maintainer skillset)
-- **Vite 5** build
-- **Pinia** state, **vue-router 4** routing
-- **ECharts 5** for time series / heatmap / sankey / scatter / stacked bar / donut
-- **D3 v7** for topology (force-directed w/ traffic particles, DAG, hex), polar constellation, custom sparklines
-- **d3-flame-graph** for profiling (later)
-- **vue-grid-layout** for draggable / resizable dashboard widgets
-- **Monaco editor** for MQE
-- **graphql-request** for OAP GraphQL
-- **vue-i18n** for i18n (mirror booster-ui locales)
-- **Element Plus** sparingly — for things like complex selects/dropdowns; otherwise hand-rolled primitives match the design better
+When you can't reproduce the user's symptom locally, say so. Don't invent a fix.
 
 ## Backend compatibility
 
-The UI calls into the OAP server's GraphQL query-protocol (same as booster-ui). Source of truth: [skywalking-query-protocol](https://github.com/apache/skywalking-query-protocol) (`*.graphqls` schemas under `oap-server/server-query-plugin/.../query-protocol` in the skywalking repo).
-
-Do NOT invent new fields. If a screen needs data that the protocol doesn't expose, flag it — the right fix is a query-protocol change in skywalking, not a UI hack.
-
-## Admin & RBAC
-
-The SkyWalking OAP server exposes an **admin host** (admin-grade APIs). Horizon UI must include first-class admin views:
-
-- **LDAP integration** — bind config, search-base, attribute mapping, test-bind
-- **RBAC** — roles, permissions per layer / per dashboard / per action (view / edit / debug alarm / edit layer config), user-role bindings. Alarms have no acknowledge / close / silence action — they are read-only and auto-recover on the backend.
-- **Users** — local users + LDAP-backed users, group sync state
-- **Audit log** — who edited what dashboard / changed which role / updated which auth provider
-- **Layer admin** — per-layer menu mode (full / lite) + entity-term mapping (FaaS → Function/Version/Invocation, MQ → Cluster/Broker/Topic, …)
-- **System health** — OAP cluster status, storage backend status (read from query-protocol `getOAPInfo` / cluster query)
-
-These pages live under `src/views/admin/` and are guarded by a `requiresRole` route meta. The visual treatment matches the rest of Horizon UI (dark, dense, design tokens) — not a separate look.
+The UI talks to OAP through the **GraphQL query-protocol** (same as booster-ui) and through OAP's admin REST surface. Both contracts are **fixed** — owned by the skywalking repo, not this one.
 
-## Code conventions
+- **Do not invent fields.** If a screen needs data the protocol doesn't expose, flag it. The right fix is a query-protocol change upstream, not a UI hack or a BFF-side fabrication.
+- **The schemas and Java implementations are the authoritative spec** — read them (`oap-server/server-query-plugin/.../query-protocol/*.graphqls` and `oap-server/server-core/.../query/`) before guessing at a wire shape. The demo at `demo.skywalking.apache.org/graphql` (basic-auth `skywalking:skywalking`) is a fine smoke-test target.
+- **Booster-ui is the working reference.** When in doubt about how a query is shaped or paged, look at how booster-ui does it.
 
-- **TypeScript strict** — no `any` outside `*.d.ts` shims
-- **`<script setup>` + `<style scoped>`** in `.vue` files
-- **Pinia** with `defineStore` (composition syntax)
-- **Composables** are named `useXxx`, return `readonly()` where appropriate
-- **Charts** are wrapped — never instantiate ECharts directly in a view; always go through `components/charts/*`. This is so we can swap library later and so theming/disposal is centralized.
-- **D3** mounts into a `ref()` element from a composable; the composable owns the lifecycle and tears down on unmount.
-- **Icon usage** — single icon component sourced from `components/icons/` (matching the design's `Icon.*` set). No icon font, no random SVG inlining.
-- **No CSS-in-JS** — design uses CSS custom properties. Tailwind is NOT used (would fight the token system).
+### Metric entity-scope is load-bearing
 
-## Commits & PRs
-
-**Never** add `Co-Authored-By: Claude` (or any AI / Anthropic / claude.com / `noreply@anthropic.com` line) to commit messages or PR bodies. Do not append the "🤖 Generated with Claude Code" footer. Per-project directive.
+Every OAP metric lives under exactly one entity scope (Service / ServiceInstance / Endpoint / relations / Process / All). OAP does not auto-rollup between scopes — querying at the wrong scope returns empty results regardless of MQE wrapping. Before adding or moving a metric, verify its scope against the OAP catalog and confirm it matches the page that will render it. Never invent a BFF-side rollup to bridge a scope mismatch — move the metric or leave the slot empty.
 
-## License header
+### Time, step, and timezone
 
-Every `.ts`, `.vue`, `.js`, `.yaml`, `.yml`, `.css`, `.scss` file must carry the Apache-2.0 header from `HEADER`. JSON, Markdown, lock files, and `.d.ts` files in `src/types/` are excluded — see `.licenserc.yaml`.
+- **Step precision is page-family-specific.** Dashboards / overviews / landing scale step with the rolling window (MINUTE / HOUR / DAY). Alarms / traces / logs / live debugger use SECOND because they query event-style data anchored at second precision — MINUTE rounding chops off the most recent (most interesting) events. MQE traffic backdrops use MINUTE because metrics are aggregated at minute granularity.
+- **String format is determined by step.** Mixing them throws `verifyDateTimeString` on OAP. Read `DurationUtils.java` in the skywalking repo for the canonical mapping.
+- **OAP has a per-request bucket cap.** Long windows must be chunked. Storage backends impose stricter caps that vary by backend — probe, don't assume.
+- **All time strings are OAP-server local.** Not UTC, not browser-local. The server's offset is exposed via `getTimeInfo`. The BFF owns this conversion; the UI displays in browser-local (echarts handles ms → local natively).
+- **Per-page vs. global time.** The topbar time picker applies only to layer dashboards + overviews. Triage / investigation pages (alarms, traces, logs, profilings, live debugger) own their own time range — do not subscribe them to the global ticker.
+- **Picker wiring is a two-sided contract.** The time range only reaches OAP when the UI composable forwards it AND the BFF route accepts it. Verify the actual request, not the intent.
 
-Run `license-eye -c .licenserc.yaml header check` locally before pushing.
+### Other OAP sharp edges
 
-## Common tasks
+- **Storage backends have undocumented limits.** Page sizes, nested selections, and per-record sub-queries fail at backend-specific thresholds. Degrade list queries to the cheapest selection that satisfies the screen; probe before defaulting.
+- **OAP IDs are not always per-record unique.** Some wire `id` fields key on the alarmed/related entity, not the firing instance. Disambiguate composite keys with timestamp before using `id` as a row key.
 
-### Add a new GraphQL query
+## Design source of truth
 
-1. Add `.graphql` (or `gql\`\`` literal) under `src/graphql/queries/<module>.ts`
-2. Type the result with hand-written types in `src/graphql/types/` (until codegen is wired up)
-3. Call via the shared `graphqlClient` from `src/graphql/client.ts`
-4. Surface as a composable `useXxxQuery()` returning `{ data, isLoading, error, refetch }`
+The visual spec (HTML/JSX prototypes, tokens snapshot, planning notes) lives **locally only** under `docs/` (gitignored). The prototypes are the visual spec — recreate them pixel-perfectly in Vue; don't blindly port their component structure, match the rendered output.
 
-### Add a new chart
+If `docs/` is missing on your machine, **ask the user to share the design bundle** before doing visual work. Do not invent layouts.
 
-1. Drop the ECharts/D3 wrapper into `src/components/charts/` (e.g. `LatencyHeatmap.vue`)
-2. Accept `data`, `loading`, time-range props; emit `crosshair-move` if it participates in synced cursors
-3. Register with `useCrosshair()` if it should sync with siblings on the page (see "Linked dashboard" design)
+Design tokens have been lifted into the runtime token CSS — that copy is canonical. `docs/` will be removed once every screen has a Vue implementation with visual sign-off; don't reference its files from production code.
 
-### Add a new dashboard scope
+## Things that are non-negotiable
 
-A "dashboard scope" = the entity the dashboard binds to (Service, Instance, Endpoint, Topology, Metric drill, Glance). Add it as:
-1. Template under `src/views/dashboards/templates/<scope>.json` (widget grid JSON spec)
-2. Route under `src/router/dashboards.ts`
-3. Sidebar entry in the per-layer menu config (`src/stores/layer.ts`)
+- **TypeScript strict.** No `any` outside `.d.ts` shims.
+- **Charts are wrapped.** Never instantiate ECharts directly in a view — always go through the chart components. Same rule for D3: composable owns the lifecycle, tears down on unmount. (This lets us swap libraries and centralize theming/disposal.)
+- **One icon component.** No icon font, no inlined SVG one-offs.
+- **No CSS-in-JS, no Tailwind.** The design is built on CSS custom properties; Tailwind fights the token system.
+- **License headers** on every `.ts` / `.vue` / `.js` / `.yaml` / `.yml` / `.css` / `.scss`. JSON, Markdown, lock files, and generated `.d.ts` are excluded — see `.licenserc.yaml`. CI enforces; run `license-eye -c .licenserc.yaml header check` before pushing.
+- **Multi-layer is the spine.** Almost every screen has "this could appear in multiple layers" semantics. Build for that from day one.
+- **Synced crosshairs.** Multiple time-series on a dashboard share one cursor. Don't build charts that ignore this.
+- **Density beats whitespace.** This is an observability-class UI; information density is a feature.
+- **MQE is a core capability**, not a config-screen afterthought. User-editable, syntax-highlighted, debuggable.
+- **Admin views use the same look.** LDAP/RBAC/admin are dark, dense, design-tokens — not a separate "settings" UI. Alarms are read-only on the UI side; recovery is backend-automatic (no acknowledge/close/silence actions).
+- **Comments earn their keep.** Only write a comment when it does one of two things: (1) introduces an API — what a module / component / exported function is for and how callers should think about it; (2) highlights a non-obvious gotcha — a hidden invariant, a workaround for a specific upstream quirk, a subtle scope/timing constraint. Do **not** write comments that paraphrase the code (`// loop over layers`, `// click handler — toggles open state`, `// returns true when active`). Do **no [...]
 
-### Add a new layer
+## Commits & PRs
 
-Layers are first-class navigation. Adding one means:
-1. Register the data source + default menu mode (full / lite) in `src/stores/layer.ts`
-2. Map entity terms (Service/Instance/Endpoint slots) if the layer uses non-standard terminology
-3. Provide a layer-overview page or default to the generic one
+**Never** add `Co-Authored-By: Claude` (or any AI / Anthropic / claude.com / `noreply@anthropic.com` line) to commit messages or PR bodies. Do not append the "🤖 Generated with Claude Code" footer. Per-project directive.
 
-## Tips for AI assistants
+## Common AI failure modes to avoid
 
-1. **Read the design first** — the prototypes in the local `docs/` directory (gitignored) are authoritative for visuals. Don't invent layouts; if `docs/` is missing, ask for it.
-2. **Don't change backend contracts** — OAP query-protocol is fixed. If you need a new field, flag it; don't fake it client-side.
-3. **Multi-layer is the spine** — almost every screen has "this could appear in multiple layers" semantics. Build for that from day one, not as an afterthought.
-4. **Synced crosshairs matter** — multiple time-series on a dashboard share one cursor (see linked dashboard design). Don't build charts that ignore this.
-5. **Density beats whitespace** — this is an observability-class UI. Information density is a feature.
-6. **MQE everywhere** — metric expressions are user-editable, syntax-highlighted, debuggable (see Alarms › Live debug). Treat MQE editing as a core capability, not a config-screen afterthought.
-7. **License headers** — required on all source files. CI enforces.
-8. **Admin = same look** — LDAP/RBAC/admin views use the same dark dense tokens, not a separate "settings" UI.
+1. **Skipping the read.** If you change a route, composable, store, or template without reading it end-to-end first, you will break something subtle. Read first, every time.
+2. **Validating with type-checks instead of OAP.** Green `tsc` and green tests do not prove the feature works. If you can't hit a real OAP, ask for one.
+3. **Blanket reverts.** `git checkout <file>` discards anyone else's uncommitted changes too. Diff first, revert by hunk.
+4. **Faking backend data to "unblock" yourself.** A mocked response that shapes nothing real is worse than no response — it hides the real wire mismatch until much later. Ask for OAP access.
+5. **Inventing layouts when `docs/` is missing.** Stop and ask.
diff --git a/apps/bff/src/alarms/routes.ts b/apps/bff/src/alarms/routes.ts
new file mode 100644
index 0000000..faf101b
--- /dev/null
+++ b/apps/bff/src/alarms/routes.ts
@@ -0,0 +1,601 @@
+/*
+ * 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.
+ */
+
+/**
+ * `/api/alarms/*` routes — alarm list + background-traffic series for
+ * the alarms page + admin config.
+ *
+ *   GET  /api/alarms                            — OAP getAlarm proxy
+ *                                                 + layer tag per row.
+ *   GET  /api/alarms/traffic                    — per-configured-layer
+ *                                                 RPM time-series.
+ *   GET  /api/alarms/config                     — current alarm-page
+ *                                                 setup.
+ *   POST /api/alarms/config                     — save alarm-page setup.
+ *
+ * Notes:
+ *   - All times on the wire are millisecond epoch. OAP's Duration
+ *     takes a `yyyy-MM-dd HHmm` string in OAP-server-TZ; the helper
+ *     below converts ms → that shape using the server tz from
+ *     `getTimeInfo` (cached upstream).
+ *   - Layer tagging uses the cached service-name → layer index. If
+ *     the index doesn't know the service (e.g. instance / endpoint
+ *     scope), `layerKey` is `null` and the UI groups under "Other".
+ */
+
+import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
+import type { FetchLike } from '@skywalking-horizon-ui/api-client';
+import { z } from 'zod';
+import { requireAuth } from '../auth/middleware.js';
+import type { ConfigSource } from '../config/loader.js';
+import type { SessionStore } from '../auth/sessions.js';
+import type { AuditLogger } from '../audit/logger.js';
+import { badRequest } from '../errors.js';
+import { buildOapOpts, graphqlPost } from '../oap/graphql-client.js';
+import { ServiceLayerMap } from './service-layer-map.js';
+import type { AlarmsStore, AlarmsConfig } from './store.js';
+
+export interface AlarmsRouteDeps {
+  config: ConfigSource;
+  sessions: SessionStore;
+  audit: AuditLogger;
+  store: AlarmsStore;
+  fetch?: FetchLike;
+}
+
+// ── Wire types (mirror alarm.graphqls) ───────────────────────────────
+
+export interface MqeKeyValue {
+  key: string;
+  value: string;
+}
+
+export interface MqeValueRow {
+  id?: string | null;
+  value: string | null;
+  traceID?: string | null;
+}
+
+export interface MqeValuesGroup {
+  metric?: { labels?: MqeKeyValue[] } | null;
+  values: MqeValueRow[];
+}
+
+export interface MqeMetric {
+  name: string;
+  results: MqeValuesGroup[];
+}
+
+export interface AlarmSnapshot {
+  expression: string;
+  metrics: MqeMetric[];
+}
+
+export type AlarmScope =
+  | 'All'
+  | 'Service'
+  | 'ServiceInstance'
+  | 'Endpoint'
+  | 'Process'
+  | 'ServiceRelation'
+  | 'ServiceInstanceRelation'
+  | 'EndpointRelation'
+  | 'ProcessRelation'
+  | null;
+
+export interface AlarmMessage {
+  id: string;
+  /** ms epoch */
+  startTime: number;
+  /** ms epoch, null = still firing */
+  recoveryTime: number | null;
+  scope: AlarmScope;
+  name: string;
+  message: string;
+  tags: MqeKeyValue[];
+  events?: Array<Record<string, unknown>>;
+  snapshot: AlarmSnapshot;
+  /** Best-effort layer tag derived from service-layer-map. Null when
+   *  the entity isn't a known service (instance / endpoint / etc.
+   *  fall through if their service prefix doesn't match). */
+  layerKey: string | null;
+}
+
+export interface AlarmsResponse {
+  total: number;
+  pageNum: number;
+  pageSize: number;
+  generatedAt: number;
+  msgs: AlarmMessage[];
+}
+
+export interface AlarmTrafficPoint {
+  /** ms epoch */
+  ts: number;
+  value: number | null;
+}
+
+export interface AlarmTrafficSeries {
+  layerKey: string;
+  label: string;
+  /** True when OAP recognised the layer AND `listServices` returned
+   *  at least one service. False slots still appear in the response
+   *  so the UI can show the empty / unavailable state. */
+  present: boolean;
+  /** ms-keyed points across the requested window. */
+  points: AlarmTrafficPoint[];
+  /** Per-layer error string when OAP returned a graphql error or the
+   *  layer is missing. */
+  error?: string;
+}
+
+export interface AlarmTrafficResponse {
+  generatedAt: number;
+  /** Window start / end, ms epoch — echoed back so the UI can align
+   *  the alarm markers and the traffic lines on a single x-axis. */
+  startTime: number;
+  endTime: number;
+  step: 'MINUTE';
+  series: AlarmTrafficSeries[];
+}
+
+// ── Helpers ──────────────────────────────────────────────────────────
+
+interface ServerTzInfo {
+  /** Minutes from UTC (e.g. 480 for UTC+8). 0 fallback. */
+  offsetMinutes: number;
+  /** ms when this snapshot was taken. */
+  fetchedAt: number;
+}
+let tzCache: ServerTzInfo | null = null;
+const TZ_TTL_MS = 60_000;
+
+const TIME_INFO_QUERY = /* GraphQL */ `
+  query HorizonAlarmsTime {
+    time: getTimeInfo {
+      timezone
+      currentTimestamp
+    }
+  }
+`;
+
+async function getServerOffsetMinutes(
+  config: ConfigSource,
+  fetchImpl?: FetchLike,
+): Promise<number> {
+  const now = Date.now();
+  if (tzCache && now - tzCache.fetchedAt < TZ_TTL_MS) return tzCache.offsetMinutes;
+  try {
+    const got = await graphqlPost<{ time?: { timezone?: string | null } | null }>(
+      buildOapOpts(config.current, fetchImpl),
+      TIME_INFO_QUERY,
+    );
+    const raw = got.time?.timezone ?? '+0000';
+    // Format `±HHmm`, e.g. `+0800` for UTC+8.
+    const m = /^([+-])(\d{2})(\d{2})$/.exec(raw);
+    if (m) {
+      const sign = m[1] === '-' ? -1 : 1;
+      const h = parseInt(m[2], 10);
+      const mi = parseInt(m[3], 10);
+      const offset = sign * (h * 60 + mi);
+      tzCache = { offsetMinutes: offset, fetchedAt: now };
+      return offset;
+    }
+  } catch {
+    /* fall through to 0 */
+  }
+  tzCache = { offsetMinutes: 0, fetchedAt: now };
+  return 0;
+}
+
+/** Format an epoch-ms into OAP's `yyyy-MM-dd HHmm` (MINUTE granularity)
+ *  in the server's timezone. */
+function fmtMinute(epochMs: number, offsetMinutes: number): string {
+  const shifted = new Date(epochMs + offsetMinutes * 60_000);
+  const y = shifted.getUTCFullYear();
+  const mo = String(shifted.getUTCMonth() + 1).padStart(2, '0');
+  const d = String(shifted.getUTCDate()).padStart(2, '0');
+  const h = String(shifted.getUTCHours()).padStart(2, '0');
+  const mi = String(shifted.getUTCMinutes()).padStart(2, '0');
+  return `${y}-${mo}-${d} ${h}${mi}`;
+}
+
+/** Format an epoch-ms into OAP's `yyyy-MM-dd HHmmss` (SECOND
+ *  granularity). Used for the alarms query so the window includes
+ *  alarms that fire in the current minute — MINUTE-precision rounds
+ *  the end down and chops them off. */
+function fmtSecond(epochMs: number, offsetMinutes: number): string {
+  const shifted = new Date(epochMs + offsetMinutes * 60_000);
+  const y = shifted.getUTCFullYear();
+  const mo = String(shifted.getUTCMonth() + 1).padStart(2, '0');
+  const d = String(shifted.getUTCDate()).padStart(2, '0');
+  const h = String(shifted.getUTCHours()).padStart(2, '0');
+  const mi = String(shifted.getUTCMinutes()).padStart(2, '0');
+  const s = String(shifted.getUTCSeconds()).padStart(2, '0');
+  return `${y}-${mo}-${d} ${h}${mi}${s}`;
+}
+
+function safeFloat(v: string | null | undefined): number | null {
+  if (v === null || v === undefined) return null;
+  const n = Number(v);
+  return Number.isFinite(n) ? n : null;
+}
+
+/** Minutes between two MINUTE-rounded epochs (inclusive endpoints). */
+function bucketCount(startMs: number, endMs: number): number {
+  return Math.max(1, Math.round((endMs - startMs) / 60_000) + 1);
+}
+
+function buildTimeline(startMs: number, endMs: number): number[] {
+  const n = bucketCount(startMs, endMs);
+  const out: number[] = [];
+  for (let i = 0; i < n; i++) out.push(startMs + i * 60_000);
+  return out;
+}
+
+// ── GraphQL queries ───────────────────────────────────────────────────
+
+/* `events` is deliberately OMITTED — demo OAP (and likely any OAP
+ *  backed by some storage variants) throws `java.io.IOException:
+ *  fail to query stream` when the events resolver streams per-alarm
+ *  events alongside a 30-row alarm list. The snapshot already
+ *  carries everything the detail panel needs (expression + the MQE
+ *  values that crossed threshold). When per-alarm events are needed
+ *  later, fetch them via a separate keyword-filtered call against
+ *  this same endpoint, or via the Events query directly. */
+const GET_ALARM_QUERY = /* GraphQL */ `
+  query HorizonGetAlarm(
+    $duration: Duration!
+    $scope: Scope
+    $keyword: String
+    $paging: Pagination!
+    $tags: [AlarmTag]
+  ) {
+    getAlarm(duration: $duration, scope: $scope, keyword: $keyword, paging: $paging, tags: $tags) {
+      msgs {
+        id
+        startTime
+        recoveryTime
+        scope
+        name
+        message
+        tags { key value }
+        snapshot {
+          expression
+          metrics {
+            name
+            results {
+              metric { labels { key value } }
+              values { id value traceID }
+            }
+          }
+        }
+      }
+    }
+  }
+`;
+
+const LIST_SERVICES_QUERY = /* GraphQL */ `
+  query HorizonAlarmServices($layer: String!) {
+    listServices(layer: $layer) { name normal }
+  }
+`;
+
+interface ListServicesRaw {
+  listServices: Array<{ name: string; normal: boolean | null }>;
+}
+
+interface GetAlarmRaw {
+  getAlarm?: { msgs?: AlarmMessage[] } | null;
+}
+
+// ── Routes ───────────────────────────────────────────────────────────
+
+const alarmsQuerySchema = z.object({
+  startTime: z.coerce.number().int().positive(),
+  endTime: z.coerce.number().int().positive(),
+  scope: z.string().optional(),
+  keyword: z.string().optional(),
+  pageNum: z.coerce.number().int().min(1).default(1),
+  pageSize: z.coerce.number().int().min(1).max(200).default(100),
+  service: z.string().optional(),
+  instance: z.string().optional(),
+  endpoint: z.string().optional(),
+});
+
+const trafficQuerySchema = z.object({
+  startTime: z.coerce.number().int().positive(),
+  endTime: z.coerce.number().int().positive(),
+});
+
+const configSaveSchema = z.object({
+  trafficLayers: z
+    .array(
+      z.object({
+        layerKey: z.string().min(1),
+        mqe: z.string().min(1),
+        label: z.string().optional(),
+      }).strict(),
+    )
+    .max(8),
+});
+
+export function registerAlarmsRoutes(app: FastifyInstance, deps: AlarmsRouteDeps): void {
+  const auth = requireAuth(deps);
+  const serviceLayer = new ServiceLayerMap({ config: deps.config, fetch: deps.fetch });
+
+  // ── GET /api/alarms ────────────────────────────────────────────────
+  app.get('/api/alarms', { preHandler: auth }, async (req: FastifyRequest, reply: FastifyReply) => {
+    const parsed = alarmsQuerySchema.safeParse(req.query);
+    if (!parsed.success) {
+      return reply.code(400).send({ error: 'invalid_query', detail: parsed.error.flatten() });
+    }
+    const q = parsed.data;
+    if (q.endTime <= q.startTime) return badRequest('endTime must be greater than startTime');
+
+    const offset = await getServerOffsetMinutes(deps.config, deps.fetch);
+    // SECOND-precision duration window. Booster-ui uses SECOND here
+    // and it's the right call: MINUTE-rounding the end excludes any
+    // alarm whose startTime is in the current/upcoming minute, which
+    // for OAP installs that emit alarms continuously means the UI
+    // perpetually misses the most-recent firing. Per-second precision
+    // keeps the latest alarm in the window.
+    const start = fmtSecond(q.startTime, offset);
+    const end = fmtSecond(q.endTime, offset);
+
+    const opts = buildOapOpts(deps.config.current, deps.fetch);
+    const variables: Record<string, unknown> = {
+      duration: { start, end, step: 'SECOND' },
+      paging: { pageNum: q.pageNum, pageSize: q.pageSize },
+    };
+    if (q.scope) variables.scope = q.scope;
+    if (q.keyword) variables.keyword = q.keyword;
+
+    let raw: GetAlarmRaw;
+    try {
+      raw = await graphqlPost<GetAlarmRaw>(opts, GET_ALARM_QUERY, variables);
+    } catch (err) {
+      return reply.code(502).send({
+        error: 'oap_unreachable',
+        message: err instanceof Error ? err.message : String(err),
+      });
+    }
+    const msgsRaw = raw.getAlarm?.msgs ?? [];
+
+    // Service-name → layer tag (best-effort, see service-layer-map docs).
+    const layerIdx = await serviceLayer.get();
+
+    // Post-filter — OAP doesn't expose service/instance/endpoint filters
+    // on `getAlarm` directly, so we filter the page client-side. The
+    // page-size cap (200) keeps this O(pageSize).
+    const filtered = msgsRaw.filter((m) => {
+      if (q.service && !m.name.toLowerCase().includes(q.service.toLowerCase())) return false;
+      if (q.instance && !m.name.toLowerCase().includes(q.instance.toLowerCase())) return false;
+      if (q.endpoint && !m.name.toLowerCase().includes(q.endpoint.toLowerCase())) return false;
+      return true;
+    });
+
+    const tagged: AlarmMessage[] = filtered.map((m) => {
+      // Entity name on `Service` scope is the service name directly;
+      // on `ServiceInstance` / `Endpoint` the wire packs the service
+      // name as a prefix before a separator — try the literal name
+      // first, then strip after common separators.
+      const candidates = [m.name, m.name.split('::')[0], m.name.split(' ')[0]];
+      let layerKey: string | null = null;
+      for (const cand of candidates) {
+        const hit = layerIdx.byName.get(cand.toLowerCase());
+        if (hit) {
+          layerKey = hit;
+          break;
+        }
+      }
+      return { ...m, layerKey };
+    });
+
+    const body: AlarmsResponse = {
+      total: tagged.length,
+      pageNum: q.pageNum,
+      pageSize: q.pageSize,
+      generatedAt: Date.now(),
+      msgs: tagged,
+    };
+    return reply.send(body);
+  });
+
+  // ── GET /api/alarms/traffic ────────────────────────────────────────
+  app.get(
+    '/api/alarms/traffic',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      const parsed = trafficQuerySchema.safeParse(req.query);
+      if (!parsed.success) {
+        return reply.code(400).send({ error: 'invalid_query', detail: parsed.error.flatten() });
+      }
+      const q = parsed.data;
+      if (q.endTime <= q.startTime) return badRequest('endTime must be greater than startTime');
+
+      // Round to minute boundaries to match the OAP MINUTE step.
+      const startMs = Math.floor(q.startTime / 60_000) * 60_000;
+      const endMs = Math.floor(q.endTime / 60_000) * 60_000;
+
+      const offset = await getServerOffsetMinutes(deps.config, deps.fetch);
+      const start = fmtMinute(startMs, offset);
+      const end = fmtMinute(endMs, offset);
+      const timeline = buildTimeline(startMs, endMs);
+
+      const cfg = await deps.store.load();
+      const opts = buildOapOpts(deps.config.current, deps.fetch);
+
+      const series: AlarmTrafficSeries[] = await Promise.all(
+        cfg.trafficLayers.map<Promise<AlarmTrafficSeries>>(async (L) => {
+          const label = L.label ?? prettyLayer(L.layerKey);
+          // 1) list services in the layer.
+          let services: Array<{ name: string; normal: boolean | null }>;
+          try {
+            const ls = await graphqlPost<ListServicesRaw>(opts, LIST_SERVICES_QUERY, {
+              layer: L.layerKey,
+            });
+            services = Array.isArray(ls.listServices) ? ls.listServices : [];
+          } catch (err) {
+            return {
+              layerKey: L.layerKey,
+              label,
+              present: false,
+              points: timeline.map((ts) => ({ ts, value: null })),
+              error: err instanceof Error ? err.message : String(err),
+            };
+          }
+          if (services.length === 0) {
+            return {
+              layerKey: L.layerKey,
+              label,
+              present: false,
+              points: timeline.map((ts) => ({ ts, value: null })),
+            };
+          }
+
+          // 2) fan out MQE for each service. Cap at 40 services to
+          //    bound the upstream graphql blast — the alarms timeline
+          //    is a glanceable backdrop, not a precise dashboard.
+          const cap = 40;
+          const sliced = services.slice(0, cap);
+          const aliased = sliced
+            .map(
+              (s, i) =>
+                `s${i}: execExpression(\n` +
+                `      expression: ${JSON.stringify(L.mqe)},\n` +
+                `      entity: { scope: Service, serviceName: ${JSON.stringify(s.name)}, normal: ${s.normal === false ? 'false' : 'true'} },\n` +
+                `      duration: { start: ${JSON.stringify(start)}, end: ${JSON.stringify(end)}, step: MINUTE }\n` +
+                `    ) { type error results { values { value } } }`,
+            )
+            .join('\n');
+          const query = `query HorizonAlarmTrafficLayer { ${aliased} }`;
+          let data: Record<string, { error?: string | null; results?: Array<{ values: Array<{ value: string | null }> }> }>;
+          try {
+            data = await graphqlPost(opts, query);
+          } catch (err) {
+            return {
+              layerKey: L.layerKey,
+              label,
+              present: false,
+              points: timeline.map((ts) => ({ ts, value: null })),
+              error: err instanceof Error ? err.message : String(err),
+            };
+          }
+          // 3) Sum per-bucket across services.
+          const summed: Array<number | null> = timeline.map(() => null);
+          for (let i = 0; i < sliced.length; i++) {
+            const row = data[`s${i}`];
+            const values = row?.results?.[0]?.values ?? [];
+            for (let b = 0; b < timeline.length; b++) {
+              const v = safeFloat(values[b]?.value);
+              if (v === null) continue;
+              summed[b] = (summed[b] ?? 0) + v;
+            }
+          }
+          return {
+            layerKey: L.layerKey,
+            label,
+            present: true,
+            points: timeline.map((ts, i) => ({ ts, value: summed[i] })),
+          };
+        }),
+      );
+
+      const body: AlarmTrafficResponse = {
+        generatedAt: Date.now(),
+        startTime: startMs,
+        endTime: endMs,
+        step: 'MINUTE',
+        series,
+      };
+      return reply.send(body);
+    },
+  );
+
+  // ── GET /api/alarms/services?layer=X ──────────────────────────────
+  // Cascading-filter helper for the alarms page. Returns the service
+  // roster for one OAP layer in alpha order so the UI can populate
+  // a dropdown without re-implementing the listServices wire. The
+  // instance + endpoint pickers reuse the existing
+  // /api/layer/:key/instances and /api/layer/:key/endpoints endpoints.
+  app.get(
+    '/api/alarms/services',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      const layer = (req.query as { layer?: string } | undefined)?.layer;
+      if (!layer || typeof layer !== 'string') {
+        return reply.code(400).send({ error: 'missing_layer' });
+      }
+      const opts = buildOapOpts(deps.config.current, deps.fetch);
+      try {
+        const got = await graphqlPost<ListServicesRaw>(opts, LIST_SERVICES_QUERY, { layer });
+        const services = (got.listServices ?? [])
+          .filter((s) => typeof s?.name === 'string' && s.name.length > 0)
+          .sort((a, b) => a.name.localeCompare(b.name));
+        return reply.send({ layer, services });
+      } catch (err) {
+        return reply.code(502).send({
+          error: 'oap_unreachable',
+          message: err instanceof Error ? err.message : String(err),
+        });
+      }
+    },
+  );
+
+  // ── GET /api/alarms/config ─────────────────────────────────────────
+  app.get(
+    '/api/alarms/config',
+    { preHandler: auth },
+    async (_req: FastifyRequest, reply: FastifyReply) => {
+      const cfg = await deps.store.load();
+      return reply.send(cfg);
+    },
+  );
+
+  // ── POST /api/alarms/config ────────────────────────────────────────
+  app.post(
+    '/api/alarms/config',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      const parsed = configSaveSchema.safeParse(req.body);
+      if (!parsed.success) {
+        return reply.code(400).send({ error: 'invalid_body', detail: parsed.error.flatten() });
+      }
+      const next: AlarmsConfig = { trafficLayers: parsed.data.trafficLayers };
+      await deps.store.save(next);
+      serviceLayer.invalidate();
+      deps.audit.record({
+        action: 'alarms.config.save',
+        actor: req.session?.username ?? null,
+        outcome: 'ok',
+        details: { layers: next.trafficLayers.map((l) => `${l.layerKey}:${l.mqe}`) },
+        fromIp: req.ip,
+        sessionId: req.session?.sid,
+      });
+      return reply.send(next);
+    },
+  );
+}
+
+function prettyLayer(key: string): string {
+  return key
+    .toLowerCase()
+    .split('_')
+    .map((w) => (w.length > 0 ? w[0]!.toUpperCase() + w.slice(1) : ''))
+    .join(' ');
+}
diff --git a/apps/bff/src/alarms/service-layer-map.ts b/apps/bff/src/alarms/service-layer-map.ts
new file mode 100644
index 0000000..f4792b6
--- /dev/null
+++ b/apps/bff/src/alarms/service-layer-map.ts
@@ -0,0 +1,128 @@
+/*
+ * 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.
+ */
+
+/**
+ * Service-name → layer-key index used by the alarms route to tag each
+ * AlarmMessage with the layer it belongs to. OAP's alarm wire only
+ * carries `scope` (Service / Instance / Endpoint) and `name` (the
+ * entity name); the layer is derived from the service-name lookup.
+ *
+ * Refresh on demand with a 60s TTL — service rosters move slowly
+ * enough that staleness for a minute is fine, and it spares OAP from
+ * a `listServices` fan-out on every alarms poll.
+ */
+
+import { buildOapOpts, graphqlPost } from '../oap/graphql-client.js';
+import type { ConfigSource } from '../config/loader.js';
+import type { FetchLike } from '@skywalking-horizon-ui/api-client';
+import { logger } from '../logger.js';
+
+interface ServiceLayerMapDeps {
+  config: ConfigSource;
+  fetch?: FetchLike;
+}
+
+interface ListResult {
+  layers: string[];
+  /** Lower-case service name → canonical layer key. Multiple layers
+   *  for the same name collapse to the last one seen — operators
+   *  shouldn't be running the same service name across layers, but
+   *  if they do, the layer tag becomes best-effort. */
+  byName: Map<string, string>;
+}
+
+const LAYERS_QUERY = /* GraphQL */ `
+  query HorizonAlarmsLayers {
+    layers: listLayers
+  }
+`;
+
+interface LayersRaw {
+  layers: string[];
+}
+
+function fragment(idx: number, layer: string): string {
+  return `_${idx}: listServices(layer: ${JSON.stringify(layer)}) { name }`;
+}
+
+interface ServiceRow {
+  name: string;
+}
+
+export class ServiceLayerMap {
+  private cached: ListResult | null = null;
+  private lastFetchAt = 0;
+  private inflight: Promise<ListResult> | null = null;
+  /** ms */
+  private readonly ttl = 60_000;
+
+  constructor(private readonly deps: ServiceLayerMapDeps) {}
+
+  async get(): Promise<ListResult> {
+    const now = Date.now();
+    if (this.cached && now - this.lastFetchAt < this.ttl) return this.cached;
+    if (this.inflight) return this.inflight;
+    this.inflight = this.refresh()
+      .then((r) => {
+        this.cached = r;
+        this.lastFetchAt = Date.now();
+        return r;
+      })
+      .finally(() => {
+        this.inflight = null;
+      });
+    return this.inflight;
+  }
+
+  /** Force a refresh on the next `get()`. Called after the alarms-config
+   *  layer list changes — the new layer might not be in the existing
+   *  cache. */
+  invalidate(): void {
+    this.cached = null;
+    this.lastFetchAt = 0;
+  }
+
+  private async refresh(): Promise<ListResult> {
+    const cfg = this.deps.config.current;
+    const opts = buildOapOpts(cfg, this.deps.fetch);
+    let layers: string[];
+    try {
+      const got = await graphqlPost<LayersRaw>(opts, LAYERS_QUERY);
+      layers = Array.isArray(got.layers) ? got.layers : [];
+    } catch (err) {
+      logger.warn({ err }, 'alarms service-layer-map: listLayers failed');
+      return { layers: [], byName: new Map() };
+    }
+    if (layers.length === 0) return { layers, byName: new Map() };
+    const aliased = layers.map((l, i) => fragment(i, l)).join('\n');
+    const query = `query HorizonAlarmsServices { ${aliased} }`;
+    try {
+      const data = await graphqlPost<Record<string, ServiceRow[]>>(opts, query);
+      const byName = new Map<string, string>();
+      layers.forEach((layer, i) => {
+        const rows = data[`_${i}`] ?? [];
+        for (const row of rows) {
+          if (row?.name) byName.set(row.name.toLowerCase(), layer);
+        }
+      });
+      return { layers, byName };
+    } catch (err) {
+      logger.warn({ err }, 'alarms service-layer-map: listServices fan-out failed');
+      return { layers, byName: new Map() };
+    }
+  }
+}
diff --git a/apps/bff/src/alarms/store.ts b/apps/bff/src/alarms/store.ts
new file mode 100644
index 0000000..1534da3
--- /dev/null
+++ b/apps/bff/src/alarms/store.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.
+ */
+
+/**
+ * File-backed store for the Alarms-page setup. Holds the list of OAP
+ * layers whose RPM traffic is rendered as the background series on
+ * the alarms timeline, with the MQE expression to fire per layer.
+ *
+ * Defaults (when no override file exists) seed agent + mesh so a
+ * fresh install shows traffic out-of-the-box. Operators add / remove
+ * layers and tune the MQE via the Alert Page Setup admin view.
+ *
+ * Storage convention mirrors `SetupStore`: tolerant of `{layers:...}`
+ * envelope OR bare object; serialised writes with a `.tmp` rename so
+ * the file never appears half-written to a watcher.
+ */
+
+import { existsSync } from 'node:fs';
+import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
+import { dirname, resolve } from 'node:path';
+import { logger } from '../logger.js';
+
+/** One background-traffic series the alarms timeline renders. */
+export interface AlarmTrafficLayer {
+  /** OAP layer key in canonical form — `GENERAL`, `MESH`, `K8S`,
+   *  `MESH_DP`, etc. Used both as the entity scope's layer filter and
+   *  the display label fallback. */
+  layerKey: string;
+  /** MQE expression to fire for this layer. Must yield a per-service
+   *  labeled time-series; the BFF sums across services to produce a
+   *  single layer-aggregate line. */
+  mqe: string;
+  /** Optional display label override. If absent the BFF derives it
+   *  from the layer's canonical name. */
+  label?: string;
+}
+
+export interface AlarmsConfig {
+  trafficLayers: AlarmTrafficLayer[];
+}
+
+/* Default seed — agent (`GENERAL` in OAP) + Istio data plane (`MESH`).
+ * Both use `service_cpm` because that's the RPM metric every service-
+ * scoped layer reports. Layers that aren't active on the OAP install
+ * are silently skipped by the traffic route. */
+const DEFAULT_CONFIG: AlarmsConfig = {
+  trafficLayers: [
+    { layerKey: 'GENERAL', mqe: 'service_cpm', label: 'Agent' },
+    { layerKey: 'MESH', mqe: 'service_cpm', label: 'Mesh' },
+  ],
+};
+
+export class AlarmsStore {
+  private readonly absPath: string;
+  private cache: AlarmsConfig | null = null;
+  private writing: Promise<void> | null = null;
+
+  constructor(filePath: string) {
+    this.absPath = resolve(filePath);
+  }
+
+  async load(): Promise<AlarmsConfig> {
+    if (this.cache) return this.cache;
+    if (!existsSync(this.absPath)) {
+      this.cache = { ...DEFAULT_CONFIG, trafficLayers: [...DEFAULT_CONFIG.trafficLayers] };
+      return this.cache;
+    }
+    try {
+      const raw = await readFile(this.absPath, 'utf8');
+      const parsed = JSON.parse(raw) as Partial<AlarmsConfig> & { generatedAt?: number };
+      const trafficLayers = Array.isArray(parsed.trafficLayers)
+        ? parsed.trafficLayers.filter(
+            (l): l is AlarmTrafficLayer =>
+              typeof l === 'object' &&
+              l !== null &&
+              typeof (l as AlarmTrafficLayer).layerKey === 'string' &&
+              typeof (l as AlarmTrafficLayer).mqe === 'string',
+          )
+        : [...DEFAULT_CONFIG.trafficLayers];
+      this.cache = { trafficLayers };
+      return this.cache;
+    } catch (err) {
+      logger.warn({ err, path: this.absPath }, 'alarms store unreadable; using defaults');
+      this.cache = { ...DEFAULT_CONFIG, trafficLayers: [...DEFAULT_CONFIG.trafficLayers] };
+      return this.cache;
+    }
+  }
+
+  async save(next: AlarmsConfig): Promise<void> {
+    while (this.writing) await this.writing;
+    const tmp = `${this.absPath}.tmp`;
+    const snapshot: AlarmsConfig = JSON.parse(JSON.stringify(next));
+    const work = (async () => {
+      await mkdir(dirname(this.absPath), { recursive: true });
+      await writeFile(
+        tmp,
+        JSON.stringify({ generatedAt: Date.now(), ...snapshot }, null, 2),
+        'utf8',
+      );
+      await rename(tmp, this.absPath);
+      this.cache = snapshot;
+    })();
+    this.writing = work;
+    try {
+      await work;
+    } finally {
+      this.writing = null;
+    }
+  }
+}
diff --git a/apps/bff/src/bundled_templates/layers/k8s_service.json b/apps/bff/src/bundled_templates/layers/k8s_service.json
index 5922e65..ad8aba5 100644
--- a/apps/bff/src/bundled_templates/layers/k8s_service.json
+++ b/apps/bff/src/bundled_templates/layers/k8s_service.json
@@ -187,7 +187,7 @@
         "expressions": [
           "kubernetes_service_http_call_cpm"
         ],
-        "span": 3,
+        "span": 6,
         "rowSpan": 2
       },
       {
@@ -198,7 +198,7 @@
         "expressions": [
           "kubernetes_service_http_call_time"
         ],
-        "span": 3,
+        "span": 6,
         "rowSpan": 2
       },
       {
diff --git a/apps/bff/src/bundled_templates/layers/so11y_satellite.json b/apps/bff/src/bundled_templates/layers/so11y_satellite.json
index b15b6ea..4ad0baf 100644
--- a/apps/bff/src/bundled_templates/layers/so11y_satellite.json
+++ b/apps/bff/src/bundled_templates/layers/so11y_satellite.json
@@ -1,7 +1,8 @@
 {
   "key": "SO11Y_SATELLITE",
-  "alias": "Satellite (self-obs)",
+  "alias": "Satellite",
   "group": "Self-Observability",
+  "visibility": "operate",
   "color": "var(--sw-fg-2)",
   "documentLink": "https://skywalking.apache.org/docs/skywalking-satellite/next/readme/",
   "aliases": {
@@ -16,35 +17,7 @@
     "logs": false
   },
   "layer-header": {
-    "orderBy": "conns",
-    "columns": [
-      {
-        "metric": "conns",
-        "label": "Connections",
-        "mqe": "sum(satellite_service_grpc_connect_count)",
-        "aggregation": "sum"
-      },
-      {
-        "metric": "cpu",
-        "label": "CPU",
-        "unit": "%",
-        "mqe": "avg(satellite_service_server_cpu_utilization)",
-        "aggregation": "avg"
-      },
-      {
-        "metric": "queueUsed",
-        "label": "Queue used",
-        "mqe": "sum(satellite_service_queue_used_count)",
-        "aggregation": "sum"
-      },
-      {
-        "metric": "recvEvents",
-        "label": "Events",
-        "unit": "/min",
-        "mqe": "sum(satellite_service_receive_event_count)",
-        "aggregation": "sum"
-      }
-    ]
+    "columns": []
   },
   "overview": {
     "groups": [
diff --git a/apps/bff/src/bundled_templates/overviews/general.json b/apps/bff/src/bundled_templates/overviews/general.json
deleted file mode 100644
index fe17da1..0000000
--- a/apps/bff/src/bundled_templates/overviews/general.json
+++ /dev/null
@@ -1,82 +0,0 @@
-{
-  "id": "general",
-  "title": "General service",
-  "description": "Overview of services on the General layer with the Kubernetes service-count cross-referenced.",
-  "widgets": [
-    {
-      "id": "g_count",
-      "title": "Active services",
-      "tip": "Number of services reporting on this layer.",
-      "layer": "GENERAL",
-      "type": "service-count",
-      "span": 3,
-      "rowSpan": 1
-    },
-    {
-      "id": "g_rpm",
-      "title": "Throughput",
-      "tip": "Layer-wide requests per minute.",
-      "layer": "GENERAL",
-      "type": "metric",
-      "mqe": "service_cpm",
-      "unit": "rpm",
-      "aggregation": "sum",
-      "span": 3,
-      "rowSpan": 1
-    },
-    {
-      "id": "g_p99",
-      "title": "P99 latency",
-      "tip": "99th-percentile response time across the layer.",
-      "layer": "GENERAL",
-      "type": "metric",
-      "mqe": "service_percentile{p='99'}",
-      "unit": "ms",
-      "aggregation": "avg",
-      "span": 3,
-      "rowSpan": 1
-    },
-    {
-      "id": "g_err",
-      "title": "Error rate",
-      "tip": "100 − SLA. Higher = worse.",
-      "layer": "GENERAL",
-      "type": "metric",
-      "mqe": "100 - service_sla/100",
-      "unit": "%",
-      "aggregation": "avg",
-      "span": 3,
-      "rowSpan": 1
-    },
-    {
-      "id": "k8s_count",
-      "title": "K8s services",
-      "tip": "Services reporting through the K8s service layer.",
-      "layer": "K8S_SERVICE",
-      "type": "service-count",
-      "span": 3,
-      "rowSpan": 1
-    },
-    {
-      "id": "k8s_p99",
-      "title": "K8s P99",
-      "tip": "99th-percentile latency for K8s-routed traffic.",
-      "layer": "K8S_SERVICE",
-      "type": "metric",
-      "mqe": "k8s_service_resp_time_percentile{p='99'}",
-      "unit": "ms",
-      "aggregation": "avg",
-      "span": 3,
-      "rowSpan": 1
-    },
-    {
-      "id": "g_topo",
-      "title": "General service topology",
-      "tip": "Live service map for GENERAL — same renderer as the per-layer Topology tab.",
-      "layer": "GENERAL",
-      "type": "topology",
-      "span": 12,
-      "rowSpan": 4
-    }
-  ]
-}
diff --git a/apps/bff/src/bundled_templates/overviews/mesh.json b/apps/bff/src/bundled_templates/overviews/mesh.json
index a9c5ae9..9d209fe 100644
--- a/apps/bff/src/bundled_templates/overviews/mesh.json
+++ b/apps/bff/src/bundled_templates/overviews/mesh.json
@@ -1,82 +1,80 @@
 {
   "id": "mesh",
-  "title": "Istio Managed SVCs",
-  "description": "Istio-mesh overview — control-plane + data-plane signals with the K8s service-count cross-referenced.",
+  "title": "Mesh Dashboard",
+  "description": "Istio data-plane service health, pilot push activity, and the underlying Kubernetes capacity. Mesh deployments always ride on K8s, so the cluster block is folded in.",
+  "visibility": "public",
+  "icon": "topo",
+  "order": 20,
+  "layers": ["MESH", "MESH_CP", "K8S"],
   "widgets": [
     {
-      "id": "m_count",
-      "title": "Active Istio services",
-      "tip": "Services routed through the Istio data-plane.",
-      "layer": "MESH",
-      "type": "service-count",
-      "span": 3,
-      "rowSpan": 1
+      "id": "svc_break",
+      "title": "Mesh services",
+      "type": "section-break",
+      "cols": 12
     },
     {
-      "id": "m_rpm",
-      "title": "Istio RPM",
-      "tip": "Istio-mesh-wide requests per minute.",
+      "id": "tile_mesh",
+      "title": "Istio-managed services",
+      "tip": "Services routed through the Istio data plane.",
+      "type": "kpi-tile",
       "layer": "MESH",
-      "type": "metric",
-      "mqe": "service_mesh_cpm",
-      "unit": "rpm",
-      "aggregation": "sum",
-      "span": 3,
-      "rowSpan": 1
+      "showCount": true,
+      "kpis": [
+        { "label": "RPM", "mqe": "service_cpm", "unit": "rpm", "aggregation": "sum" },
+        { "label": "P95", "mqe": "service_percentile{p='95'}", "unit": "ms", "aggregation": "avg" },
+        { "label": "SLA", "mqe": "service_sla/100", "unit": "%", "aggregation": "avg" }
+      ],
+      "span": 4,
+      "rowSpan": 2
     },
     {
-      "id": "m_p99",
-      "title": "P99 latency",
-      "tip": "99th-percentile response time across Istio-routed traffic.",
-      "layer": "MESH",
-      "type": "metric",
-      "mqe": "service_mesh_percentile{p='99'}",
-      "unit": "ms",
-      "aggregation": "avg",
-      "span": 3,
-      "rowSpan": 1
+      "id": "pilot_summary",
+      "title": "Istio pilot",
+      "tip": "xDS push count (cds / eds / lds / rds), reject errors, and proxy push time.",
+      "type": "pilot-summary",
+      "layer": "MESH_CP",
+      "span": 8,
+      "rowSpan": 2
+    },
+    {
+      "id": "topo_alarms_break",
+      "title": "Topology & active alarms",
+      "type": "section-break",
+      "cols": 12
     },
     {
-      "id": "m_err",
-      "title": "Error rate",
-      "tip": "100 − mesh SLA.",
+      "id": "mesh_topo",
+      "title": "Mesh service topology",
+      "tip": "Live service map — same renderer as the per-layer Topology tab, without the detail sidebar.",
+      "type": "topology",
       "layer": "MESH",
-      "type": "metric",
-      "mqe": "100 - service_mesh_sla/100",
-      "unit": "%",
-      "aggregation": "avg",
-      "span": 3,
-      "rowSpan": 1
+      "span": 9,
+      "rowSpan": 6
     },
     {
-      "id": "k8s_count",
-      "title": "K8s services",
-      "tip": "Services reporting through the K8s service layer.",
-      "layer": "K8S_SERVICE",
-      "type": "service-count",
+      "id": "mesh_alarms",
+      "title": "Active alarms",
+      "tip": "All currently firing alarms (layer filter is best-effort in v1).",
+      "type": "alarms",
+      "limit": 12,
       "span": 3,
-      "rowSpan": 1
+      "rowSpan": 6
     },
     {
-      "id": "k8s_p99",
-      "title": "K8s P99",
-      "tip": "99th-percentile latency for K8s-routed traffic.",
-      "layer": "K8S_SERVICE",
-      "type": "metric",
-      "mqe": "k8s_service_resp_time_percentile{p='99'}",
-      "unit": "ms",
-      "aggregation": "avg",
-      "span": 3,
-      "rowSpan": 1
+      "id": "k8s_break",
+      "title": "Kubernetes",
+      "type": "section-break",
+      "cols": 12
     },
     {
-      "id": "m_topo",
-      "title": "Istio service topology",
-      "tip": "Live service map for the Istio data-plane.",
-      "layer": "MESH",
-      "type": "topology",
+      "id": "k8s_summary",
+      "title": "Cluster capacity & utilisation",
+      "tip": "Nodes / namespaces / workloads + cluster-wide CPU, memory, and disk usage.",
+      "type": "k8s-summary",
+      "layer": "K8S",
       "span": 12,
-      "rowSpan": 4
+      "rowSpan": 3
     }
   ]
 }
diff --git a/apps/bff/src/bundled_templates/overviews/services.json b/apps/bff/src/bundled_templates/overviews/services.json
new file mode 100644
index 0000000..6ad9943
--- /dev/null
+++ b/apps/bff/src/bundled_templates/overviews/services.json
@@ -0,0 +1,138 @@
+{
+  "id": "services",
+  "title": "Services Dashboard",
+  "description": "Cross-layer service health: traced services plus virtual databases / caches / message queues / GenAI backends, with the underlying Kubernetes capacity.",
+  "visibility": "public",
+  "icon": "svc",
+  "order": 10,
+  "layers": [
+    "GENERAL",
+    "VIRTUAL_DATABASE",
+    "VIRTUAL_CACHE",
+    "VIRTUAL_MQ",
+    "VIRTUAL_GENAI",
+    "K8S"
+  ],
+  "widgets": [
+    {
+      "id": "svc_break",
+      "title": "Services",
+      "type": "section-break",
+      "cols": 5
+    },
+    {
+      "id": "tile_general",
+      "title": "General services",
+      "tip": "Traced application services on the General layer.",
+      "type": "kpi-tile",
+      "layer": "GENERAL",
+      "showCount": true,
+      "kpis": [
+        { "label": "RPM", "mqe": "service_cpm", "unit": "rpm", "aggregation": "sum" },
+        { "label": "Latency", "mqe": "service_resp_time", "unit": "ms", "aggregation": "avg" },
+        { "label": "SLA", "mqe": "service_sla/100", "unit": "%", "aggregation": "avg" }
+      ],
+      "span": 1,
+      "rowSpan": 2
+    },
+    {
+      "id": "tile_db",
+      "title": "Virtual databases",
+      "tip": "Backend databases observed via client-side spans.",
+      "type": "kpi-tile",
+      "layer": "VIRTUAL_DATABASE",
+      "showCount": true,
+      "kpis": [
+        { "label": "RPM", "mqe": "database_access_cpm", "unit": "rpm", "aggregation": "sum" },
+        { "label": "Latency", "mqe": "database_access_resp_time", "unit": "ms", "aggregation": "avg" },
+        { "label": "SLA", "mqe": "database_access_sla/100", "unit": "%", "aggregation": "avg" }
+      ],
+      "span": 1,
+      "rowSpan": 2
+    },
+    {
+      "id": "tile_cache",
+      "title": "Virtual caches",
+      "tip": "Caches (Redis / Memcached / …) observed via client-side spans.",
+      "type": "kpi-tile",
+      "layer": "VIRTUAL_CACHE",
+      "showCount": true,
+      "kpis": [
+        { "label": "RPM", "mqe": "cache_access_cpm", "unit": "rpm", "aggregation": "sum" },
+        { "label": "Latency", "mqe": "cache_access_resp_time", "unit": "ms", "aggregation": "avg" },
+        { "label": "SLA", "mqe": "cache_access_sla/100", "unit": "%", "aggregation": "avg" }
+      ],
+      "span": 1,
+      "rowSpan": 2
+    },
+    {
+      "id": "tile_mq",
+      "title": "Virtual MQs",
+      "tip": "Message queues observed via consume + produce spans.",
+      "type": "kpi-tile",
+      "layer": "VIRTUAL_MQ",
+      "showCount": true,
+      "kpis": [
+        { "label": "Consume", "mqe": "mq_service_consume_cpm", "unit": "rpm", "aggregation": "sum" },
+        { "label": "Produce", "mqe": "mq_service_produce_cpm", "unit": "rpm", "aggregation": "sum" },
+        { "label": "Consume latency", "mqe": "mq_service_consume_latency", "unit": "ms", "aggregation": "avg" }
+      ],
+      "span": 1,
+      "rowSpan": 2
+    },
+    {
+      "id": "tile_genai",
+      "title": "Virtual GenAI",
+      "tip": "GenAI backends observed via instrumented client spans.",
+      "type": "kpi-tile",
+      "layer": "VIRTUAL_GENAI",
+      "showCount": true,
+      "kpis": [
+        { "label": "RPM", "mqe": "gen_ai_provider_cpm", "unit": "rpm", "aggregation": "sum" },
+        { "label": "Latency", "mqe": "gen_ai_provider_resp_time", "unit": "ms", "aggregation": "avg" },
+        { "label": "SLA", "mqe": "gen_ai_provider_sla/100", "unit": "%", "aggregation": "avg" }
+      ],
+      "span": 1,
+      "rowSpan": 2
+    },
+    {
+      "id": "topo_alarms_break",
+      "title": "Topology & active alarms",
+      "type": "section-break",
+      "cols": 12
+    },
+    {
+      "id": "svc_topo",
+      "title": "General service topology",
+      "tip": "Live service map — same renderer as the per-layer Topology tab, without the detail sidebar.",
+      "type": "topology",
+      "layer": "GENERAL",
+      "span": 9,
+      "rowSpan": 6
+    },
+    {
+      "id": "svc_alarms",
+      "title": "Active alarms",
+      "tip": "All currently firing alarms (layer filter is best-effort in v1).",
+      "type": "alarms",
+      "limit": 12,
+      "span": 3,
+      "rowSpan": 6
+    },
+    {
+      "id": "k8s_break",
+      "title": "Kubernetes",
+      "type": "section-break",
+      "cols": 12
+    },
+    {
+      "id": "k8s_summary",
+      "title": "Cluster capacity & utilisation",
+      "tip": "Nodes / namespaces / workloads + cluster-wide CPU, memory, and disk usage.",
+      "type": "k8s-summary",
+      "layer": "K8S",
+      "span": 12,
+      "rowSpan": 3
+    }
+  ]
+}
diff --git a/apps/bff/src/config/schema.ts b/apps/bff/src/config/schema.ts
index 97d68fe..e2f8eef 100644
--- a/apps/bff/src/config/schema.ts
+++ b/apps/bff/src/config/schema.ts
@@ -119,6 +119,13 @@ const setupSchema = z
   .strict()
   .default({ file: './horizon-setup.json' });
 
+const alarmsSchema = z
+  .object({
+    file: z.string().default('./horizon-alarms.json'),
+  })
+  .strict()
+  .default({ file: './horizon-alarms.json' });
+
 const debugLogSchema = z
   .object({
     enabled: z.boolean().default(false),
@@ -143,6 +150,7 @@ export const configSchema = z
     session: sessionSchema,
     audit: auditSchema,
     setup: setupSchema,
+    alarms: alarmsSchema,
     debugLog: debugLogSchema,
   })
   .strict();
diff --git a/apps/bff/src/inspect/attribution.ts b/apps/bff/src/inspect/attribution.ts
new file mode 100644
index 0000000..c32c6c2
--- /dev/null
+++ b/apps/bff/src/inspect/attribution.ts
@@ -0,0 +1,279 @@
+/*
+ * 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.
+ */
+
+/**
+ * Build an in-process index of `metric name → { source, file }`.
+ *
+ * SWIP-14's `/inspect/metrics` does not return rule provenance — the
+ * catalog only knows type / scope / downsamplings. Studio cross-
+ * references that catalog against the rule files it already manages
+ * (via the existing `/runtime/oal/*` and `/runtime/rule/*` admin
+ * APIs) to add a `source` + `file` dimension that the operator can
+ * use to filter the Inspect board's catalog drawer.
+ *
+ * Fingerprint: `oalFiles[]` (list of file names) + `runtime/rule/list`
+ * rows (each row carries `contentHash`). The cache rebuilds whenever
+ * any input changes; an explicit `refresh()` busts the cache so the
+ * SPA's manual refresh button picks up newly added rules.
+ */
+
+import type { OalClient, RuntimeRuleClient } from '@skywalking-horizon-ui/api-client';
+import { parseOalMetricNames } from './parser-oal.js';
+import { parseMalMetricNames } from './parser-mal.js';
+
+export type AttributionSource = 'OAL' | 'MAL·OTEL' | 'MAL·Telegraf' | 'LAL→MAL' | 'unknown';
+
+export interface MetricAttribution {
+  source: AttributionSource;
+  /** Full file path, e.g. `core.oal`, `otel-rules/jvm-memory`,
+   *  `telegraf-rules/cpu`, `log-mal-rules/log-status`. Includes the
+   *  catalog prefix for MAL/LAL→MAL so different catalogs don't
+   *  collide on duplicate file names. `null` only when a metric has
+   *  multiple ambiguous owners (currently rare). */
+  file: string | null;
+  /** Populated when more than one rule file claimed this metric;
+   *  empty otherwise. Lets the SPA surface "ambiguous" with the
+   *  candidate list. */
+  candidates?: string[];
+}
+
+export interface AttributionIndex {
+  /** Stringly fingerprint of the inputs that produced this index.
+   *  Two indexes are interchangeable iff their fingerprints match. */
+  fingerprint: string;
+  /** Metric name → attribution. Unknown metrics are not in the map;
+   *  callers should default to `source: 'unknown', file: null`. */
+  byMetric: Map<string, MetricAttribution>;
+}
+
+export interface AttributionDeps {
+  oal(): OalClient;
+  rules(): RuntimeRuleClient;
+}
+
+interface CacheState {
+  index: AttributionIndex | null;
+  /** A second-tier guard against thundering-herd rebuilds: while a
+   *  rebuild is in flight, every caller awaits the same promise. */
+  inflight: Promise<AttributionIndex> | null;
+}
+
+export class AttributionCache {
+  private state: CacheState = { index: null, inflight: null };
+
+  /** Force the next call to `get()` to fully rebuild the index. */
+  invalidate(): void {
+    this.state = { index: null, inflight: null };
+  }
+
+  /** Returns the cached index when its fingerprint still matches the
+   *  current inputs; rebuilds otherwise. Concurrent callers share one
+   *  in-flight rebuild. */
+  async get(deps: AttributionDeps): Promise<AttributionIndex> {
+    const cached = this.state.index;
+    if (cached) {
+      try {
+        const fp = await computeFingerprint(deps);
+        if (fp === cached.fingerprint) return cached;
+      } catch {
+        // If fingerprint computation fails (admin unreachable), serve
+        // the stale index — the catalog endpoints would have failed
+        // by now anyway, so this only matters when admin recovers.
+        return cached;
+      }
+    }
+    if (this.state.inflight) return this.state.inflight;
+    const p = buildIndex(deps).then((idx) => {
+      this.state = { index: idx, inflight: null };
+      return idx;
+    });
+    this.state.inflight = p;
+    return p;
+  }
+}
+
+const MAL_CATALOGS = ['otel-rules', 'telegraf-rules', 'log-mal-rules'] as const;
+
+/** Compute a fingerprint that uniquely identifies the current rule
+ *  set. Either side is best-effort — if `/runtime/oal/*` or
+ *  `/runtime/rule/*` is disabled on this OAP, that side is treated
+ *  as empty rather than failing the whole attribution.
+ *
+ *  We fingerprint over:
+ *   - the list of OAL file names (their content only changes on OAP
+ *     restart, so the file list is a sufficient proxy);
+ *   - runtime-rule rows from `/runtime/rule/list` (operator-pushed
+ *     + already-touched bundled rules; each carries a contentHash);
+ *   - bundled rule names + content hashes from
+ *     `/runtime/rule/bundled?catalog=...` for each MAL catalog (the
+ *     baked-in defaults, almost always the bulk of MAL attribution).
+ */
+async function computeFingerprint(deps: AttributionDeps): Promise<string> {
+  const rules = deps.rules();
+  const [oalFiles, malRuntimeRows, ...bundledLists] = await Promise.all([
+    deps
+      .oal()
+      .listFiles()
+      .then((r) => r.files)
+      .catch(() => [] as string[]),
+    rules
+      .list()
+      .then((r) => r.rules.map((row) => `${row.catalog}/${row.name}@${row.contentHash}`))
+      .catch(() => [] as string[]),
+    ...MAL_CATALOGS.map((c) =>
+      rules
+        .listBundled(c, false)
+        .then((rows) => rows.map((b) => `${c}/${b.name}@${b.contentHash}`))
+        .catch(() => [] as string[]),
+    ),
+  ]);
+  const oalPart = [...oalFiles].sort().join('|');
+  const runtimePart = [...malRuntimeRows].sort().join('|');
+  const bundledPart = bundledLists.flat().sort().join('|');
+  return `oal:${oalPart}||rt:${runtimePart}||bn:${bundledPart}`;
+}
+
+async function buildIndex(deps: AttributionDeps): Promise<AttributionIndex> {
+  const oal = deps.oal();
+  const rules = deps.rules();
+
+  // Pull every rule source in parallel. Each side is best-effort —
+  // when an OAP module is disabled (e.g. `SW_RECEIVER_RUNTIME_RULE`
+  // unset), the call fails and we drop that bucket without failing
+  // the whole attribution.
+  const [filesEnvSafe, listEnvSafe, ...bundledLists] = await Promise.all([
+    oal.listFiles().catch(() => ({ files: [] as string[], count: 0 })),
+    rules.list().catch(() => ({
+      generatedAt: 0,
+      loaderStats: { active: 0, pending: 0 },
+      rules: [] as Awaited<ReturnType<typeof rules.list>>['rules'],
+    })),
+    ...MAL_CATALOGS.map((c) =>
+      rules
+        .listBundled(c, true)
+        .then((rows) => ({ catalog: c, rows }))
+        .catch(() => ({ catalog: c, rows: [] })),
+    ),
+  ]);
+
+  // OAL: fetch every file's content and extract LHS metric names.
+  // Per-file getFileContent errors are tolerated.
+  const oalContents = await Promise.all(
+    filesEnvSafe.files.map(async (name) => {
+      try {
+        return { name, content: await oal.getFileContent(name) };
+      } catch {
+        return { name, content: null };
+      }
+    }),
+  );
+
+  /* MAL: combine runtime + bundled sources.
+   *
+   * 1. `/runtime/rule/list` rows are operator-pushed runtime rules
+   *    plus bundled rules the dslManager has already touched. We
+   *    fetch their content via `/runtime/rule?catalog=…&name=…`.
+   * 2. `/runtime/rule/bundled?catalog=…&withContent=true` gives us
+   *    every baked-in rule for each MAL catalog, with content inline
+   *    — no per-rule fetch needed. This is the bulk of attribution
+   *    in a vanilla OAP install.
+   *
+   * `lal` is excluded — LAL files emit logs (not metrics); the
+   * `log-mal-rules` catalog covers the LAL→MAL bridge metrics. */
+  const malRuntimeRows = listEnvSafe.rules.filter((r) => r.catalog !== 'lal');
+  const malRuntimeContents = await Promise.all(
+    malRuntimeRows.map(async (row) => {
+      try {
+        const got = await rules.get({ catalog: row.catalog, name: row.name });
+        if ('notModified' in got) return { catalog: row.catalog, name: row.name, content: null };
+        return { catalog: row.catalog, name: row.name, content: got.content };
+      } catch {
+        return { catalog: row.catalog, name: row.name, content: null };
+      }
+    }),
+  );
+
+  const malBundledContents = bundledLists.flatMap((b) =>
+    b.rows.map((row) => ({ catalog: b.catalog, name: row.name, content: row.content ?? null })),
+  );
+
+  const malContents = [...malRuntimeContents, ...malBundledContents];
+
+  // Build the index. Detect conflicts (one metric claimed by multiple
+  // files) so the SPA can surface them.
+  const byMetric = new Map<string, MetricAttribution>();
+  const claims = new Map<string, { source: AttributionSource; file: string }[]>();
+
+  const claim = (metric: string, source: AttributionSource, file: string) => {
+    const arr = claims.get(metric) ?? [];
+    arr.push({ source, file });
+    claims.set(metric, arr);
+  };
+
+  for (const { name, content } of oalContents) {
+    if (content === null) continue;
+    for (const m of parseOalMetricNames(content)) claim(m, 'OAL', name);
+  }
+
+  /* Dedup runtime + bundled (an operator-pushed override and the
+   * underlying bundled twin are the same logical file; we just want
+   * the metric → file mapping, not version tracking). */
+  const seenMal = new Set<string>();
+  for (const { catalog, name, content } of malContents) {
+    if (content === null) continue;
+    const file = `${catalog}/${name}`;
+    if (seenMal.has(file)) continue;
+    seenMal.add(file);
+    const source: AttributionSource =
+      catalog === 'otel-rules'
+        ? 'MAL·OTEL'
+        : catalog === 'telegraf-rules'
+          ? 'MAL·Telegraf'
+          : catalog === 'log-mal-rules'
+            ? 'LAL→MAL'
+            : 'unknown';
+    for (const m of parseMalMetricNames(content)) claim(m, source, file);
+  }
+
+  for (const [metric, arr] of claims) {
+    if (arr.length === 1) {
+      const only = arr[0]!;
+      byMetric.set(metric, { source: only.source, file: only.file });
+    } else {
+      // Ambiguous: keep the first claim's source for display, expose
+      // the full candidate list. (In practice this should be zero —
+      // OAL and MAL use disjoint name spaces — but the UI is honest.)
+      const first = arr[0]!;
+      byMetric.set(metric, {
+        source: first.source,
+        file: first.file,
+        candidates: arr.map((c) => `${c.source}:${c.file}`),
+      });
+    }
+  }
+
+  const fingerprint = await computeFingerprint(deps);
+  return { fingerprint, byMetric };
+}
+
+/** Look up a metric and fall back to `unknown` when the metric is
+ *  not in the index (typical for OAP-bundled core metrics that Studio
+ *  doesn't manage — though /runtime/oal/files does return those too,
+ *  so the fallthrough is rare). */
+export function attributeOrUnknown(index: AttributionIndex, metric: string): MetricAttribution {
+  return index.byMetric.get(metric) ?? { source: 'unknown', file: null };
+}
diff --git a/apps/bff/src/inspect/parser-mal.ts b/apps/bff/src/inspect/parser-mal.ts
new file mode 100644
index 0000000..b553b2e
--- /dev/null
+++ b/apps/bff/src/inspect/parser-mal.ts
@@ -0,0 +1,92 @@
+/*
+ * 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.
+ */
+
+/**
+ * Extract metric names from a MAL rule file body. The wire shape:
+ *
+ *   filter: ...
+ *   expSuffix: ...
+ *   metricPrefix: ...
+ *   metricsRules:
+ *     - name: <metric_name>
+ *       exp: ...
+ *
+ *   # OR (legacy)
+ *   rules:
+ *     - metricsName: <metric_name>
+ *       ...
+ *
+ * The two shapes coexist in OAP's MAL configs (`metricsRules` is the
+ * 10.x rewrite; `rules` is the original). Both yield the same metric
+ * name per rule, optionally prefixed by `metricPrefix`. We walk
+ * either array and emit `prefix + name` when both are present.
+ *
+ * On parse failure (malformed YAML) we return an empty list rather
+ * than throwing — the attribution layer should keep going for the
+ * other files instead of failing the whole catalog merge.
+ */
+
+import { parse as parseYaml } from 'yaml';
+
+interface MalRuleNode {
+  name?: unknown;
+  metricsName?: unknown;
+}
+
+interface MalRoot {
+  metricPrefix?: unknown;
+  metricsRules?: unknown;
+  rules?: unknown;
+}
+
+export function parseMalMetricNames(content: string): string[] {
+  let doc: unknown;
+  try {
+    doc = parseYaml(content);
+  } catch {
+    return [];
+  }
+  if (typeof doc !== 'object' || doc === null) return [];
+  const root = doc as MalRoot;
+  const prefix =
+    typeof root.metricPrefix === 'string' && root.metricPrefix.length > 0
+      ? `${root.metricPrefix}_`
+      : '';
+  const out: string[] = [];
+  const seen = new Set<string>();
+  const collect = (rules: unknown) => {
+    if (!Array.isArray(rules)) return;
+    for (const r of rules) {
+      if (typeof r !== 'object' || r === null) continue;
+      const node = r as MalRuleNode;
+      const raw =
+        typeof node.name === 'string'
+          ? node.name
+          : typeof node.metricsName === 'string'
+            ? node.metricsName
+            : null;
+      if (!raw) continue;
+      const full = prefix + raw;
+      if (seen.has(full)) continue;
+      seen.add(full);
+      out.push(full);
+    }
+  };
+  collect(root.metricsRules);
+  collect(root.rules);
+  return out;
+}
diff --git a/apps/bff/src/inspect/parser-oal.ts b/apps/bff/src/inspect/parser-oal.ts
new file mode 100644
index 0000000..ef72e02
--- /dev/null
+++ b/apps/bff/src/inspect/parser-oal.ts
@@ -0,0 +1,56 @@
+/*
+ * 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.
+ */
+
+/**
+ * Extract metric names from raw OAL text — every statement is of the
+ * form `<metric_name> = from(...).<aggregation>(...);` so the LHS of
+ * the first `=` on each (non-comment) line is the metric name.
+ *
+ * Used by the inspect attribution layer to map metric names returned
+ * by `/inspect/metrics` back to the .oal file that declared them.
+ *
+ * The parser handles line comments (`//`) and block comments (`/* … *\/`).
+ * It does not need to fully tokenise OAL — only LHS-before-equals
+ * matters; the RHS can be arbitrarily complex.
+ */
+
+const METRIC_NAME_RE = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/;
+
+/** Strip C-style line and block comments from a source string. */
+function stripComments(src: string): string {
+  // Block comments first — non-greedy, multiline.
+  const noBlock = src.replace(/\/\*[\s\S]*?\*\//g, '');
+  // Line comments next.
+  return noBlock.replace(/\/\/[^\n]*/g, '');
+}
+
+/** Extract every metric name (LHS of `=`) from a `.oal` file body.
+ *  Returns deduplicated metric names in declaration order. */
+export function parseOalMetricNames(content: string): string[] {
+  const cleaned = stripComments(content);
+  const out: string[] = [];
+  const seen = new Set<string>();
+  for (const line of cleaned.split('\n')) {
+    const m = METRIC_NAME_RE.exec(line);
+    if (!m) continue;
+    const name = m[1]!;
+    if (seen.has(name)) continue;
+    seen.add(name);
+    out.push(name);
+  }
+  return out;
+}
diff --git a/apps/bff/src/layers/loader.ts b/apps/bff/src/layers/loader.ts
index 7964eea..43da91c 100644
--- a/apps/bff/src/layers/loader.ts
+++ b/apps/bff/src/layers/loader.ts
@@ -178,6 +178,9 @@ export interface LayerTemplate {
    *  → "Istio"; so11y_* → "Self-Observability"). Layers without a
    *  group hang at the top level on their own. */
   group?: string;
+  /** Sidebar placement — `public` (default) → regular Layers section;
+   *  `operate` → operations section (e.g. self-observability layers). */
+  visibility?: 'public' | 'operate';
   /** Layer-dot color (CSS var or hex). */
   color?: string;
   /** Doc link surfaced as a chip on the layer page. */
diff --git a/apps/bff/src/oap/debug-routes.ts b/apps/bff/src/oap/debug-routes.ts
new file mode 100644
index 0000000..6021998
--- /dev/null
+++ b/apps/bff/src/oap/debug-routes.ts
@@ -0,0 +1,337 @@
+/*
+ * 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.
+ */
+
+/**
+ * `/api/debug/*` routes — passthrough proxy for the actual SWIP-13
+ * `/dsl-debugging/*` wire surface (see
+ * `reference_swip13_actual_wire.md`).
+ *
+ *   POST /api/debug/session              — start (audited)
+ *   GET  /api/debug/session/:id          — poll
+ *   POST /api/debug/session/:id/stop     — stop  (audited)
+ *   GET  /api/debug/sessions             — active list (JSON object)
+ *   GET  /api/debug/status               — per-cluster fan-out
+ *
+ * The handler accepts a JSON body for input convenience (so the SPA
+ * doesn't need to query-string-encode params) and translates to the
+ * upstream's query-param shape on the wire. RBAC verb `rule:debug`.
+ */
+
+import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
+import {
+  MAX_RECORD_CAP,
+  RuntimeRuleApiError,
+  isDebugCatalog,
+  isGranularity,
+  type DebugCatalog,
+  type DslDebuggingStatus,
+  type Granularity,
+  type StartSessionArgs,
+} from '@skywalking-horizon-ui/api-client';
+import type { ConfigSource } from '../config/loader.js';
+import type { AuditLogger } from '../audit/logger.js';
+import { requireAuth } from '../auth/middleware.js';
+import { sessionHasVerb } from '../rbac/policy.js';
+import type { Session } from '../auth/sessions.js';
+import type { SessionStore } from '../auth/sessions.js';
+import { buildOapClients, type OapClients } from './clients.js';
+import type { FetchLike } from '@skywalking-horizon-ui/api-client';
+
+export interface DebugRouteDeps {
+  config: ConfigSource;
+  sessions: SessionStore;
+  audit: AuditLogger;
+  fetch?: FetchLike;
+}
+
+/** Per-node `/dsl-debugging/status` slice — keyed by admin URL. */
+export interface ClusterDebugStatus {
+  generatedAt: number;
+  nodes: { url: string; ok: boolean; status?: DslDebuggingStatus; error?: string }[];
+}
+
+export function registerDebugRoutes(app: FastifyInstance, deps: DebugRouteDeps): void {
+  const auth = requireAuth(deps);
+
+  function clients(): OapClients {
+    return buildOapClients(deps.config.current, { fetch: deps.fetch });
+  }
+
+  // ── start session ────────────────────────────────────────────────
+  app.post(
+    '/api/debug/session',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      if (!ensureVerb(req, reply, deps, 'rule:debug')) return;
+      const parsed = parseStartArgs(req.body, reply);
+      if (!parsed) return;
+      try {
+        const result = await clients().debug().startSession(parsed);
+        const local = result.priorCleanup?.local;
+        const peers = result.priorCleanup?.peers ?? [];
+        const totalPriorCleanup =
+          (local?.stoppedCount ?? 0) +
+          peers.reduce((n, p) => n + (p.stoppedCount ?? p.stoppedSessionIds?.length ?? 0), 0);
+        deps.audit.record({
+          action: 'debug.start',
+          verb: 'rule:debug',
+          actor: req.session?.username ?? null,
+          outcome: 'ok',
+          details: {
+            sessionId: result.sessionId,
+            clientId: parsed.clientId,
+            catalog: parsed.catalog,
+            name: parsed.name,
+            ruleName: parsed.ruleName,
+            granularity: result.granularity,
+            localInstalled: result.localInstalled,
+            installedCreated: result.installed?.created,
+            installedTotal: result.installed?.total,
+            recordCap: parsed.recordCap,
+            retentionMillis: parsed.retentionMillis,
+            priorCleanupCount: totalPriorCleanup,
+          },
+          fromIp: req.ip,
+          sessionId: req.session?.sid,
+        });
+        return reply.send(result);
+      } catch (err) {
+        deps.audit.record({
+          action: 'debug.start',
+          verb: 'rule:debug',
+          actor: req.session?.username ?? null,
+          outcome: outcomeOf(err),
+          details: {
+            clientId: parsed.clientId,
+            catalog: parsed.catalog,
+            name: parsed.name,
+            ruleName: parsed.ruleName,
+          },
+          fromIp: req.ip,
+          sessionId: req.session?.sid,
+        });
+        return passOapError(err, reply);
+      }
+    },
+  );
+
+  // ── poll session ─────────────────────────────────────────────────
+  app.get(
+    '/api/debug/session/:id',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      if (!ensureVerb(req, reply, deps, 'rule:debug')) return;
+      const params = req.params as { id: string };
+      if (!params.id) return reply.code(400).send({ error: 'missing_id' });
+      try {
+        const got = await clients().debug().getSession(params.id);
+        if (got === null) return reply.code(404).send({ error: 'not_found' });
+        return reply.send(got);
+      } catch (err) {
+        return passOapError(err, reply);
+      }
+    },
+  );
+
+  // ── stop session ─────────────────────────────────────────────────
+  app.post(
+    '/api/debug/session/:id/stop',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      if (!ensureVerb(req, reply, deps, 'rule:debug')) return;
+      const params = req.params as { id: string };
+      if (!params.id) return reply.code(400).send({ error: 'missing_id' });
+      try {
+        const result = await clients().debug().stopSession(params.id);
+        deps.audit.record({
+          action: 'debug.stop',
+          verb: 'rule:debug',
+          actor: req.session?.username ?? null,
+          outcome: 'ok',
+          details: { sessionId: params.id, localStopped: result.localStopped },
+          fromIp: req.ip,
+          sessionId: req.session?.sid,
+        });
+        return reply.send(result);
+      } catch (err) {
+        deps.audit.record({
+          action: 'debug.stop',
+          verb: 'rule:debug',
+          actor: req.session?.username ?? null,
+          outcome: outcomeOf(err),
+          details: { sessionId: params.id },
+          fromIp: req.ip,
+          sessionId: req.session?.sid,
+        });
+        return passOapError(err, reply);
+      }
+    },
+  );
+
+  // ── list active sessions ─────────────────────────────────────────
+  app.get(
+    '/api/debug/sessions',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      if (!ensureVerb(req, reply, deps, 'rule:debug')) return;
+      try {
+        const list = await clients().debug().listSessions();
+        return reply.send(list);
+      } catch (err) {
+        return passOapError(err, reply);
+      }
+    },
+  );
+
+  // ── per-cluster status fan-out ───────────────────────────────────
+  // /dsl-debugging/status is a per-node read; the cluster pane wants
+  // all nodes' posture, so the BFF fans out.
+  app.get(
+    '/api/debug/status',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      if (!ensureVerb(req, reply, deps, 'cluster:read')) return;
+      const c = clients();
+      const nodes = await Promise.all(
+        c.adminUrls().map(async (url) => {
+          try {
+            const status = await c.debugForUrl(url).getStatus();
+            return { url, ok: true as const, status };
+          } catch (err) {
+            return {
+              url,
+              ok: false as const,
+              error: err instanceof Error ? err.message : String(err),
+            };
+          }
+        }),
+      );
+      const body: ClusterDebugStatus = { generatedAt: Date.now(), nodes };
+      return reply.send(body);
+    },
+  );
+}
+
+// ── helpers ─────────────────────────────────────────────────────────
+
+function ensureVerb(
+  req: FastifyRequest,
+  reply: FastifyReply,
+  deps: DebugRouteDeps,
+  verb: string,
+): boolean {
+  const session: Session | undefined = req.session;
+  if (!session) {
+    reply.code(401).send({ error: 'unauthenticated' });
+    return false;
+  }
+  if (!sessionHasVerb(deps.config.current, session.roles, verb)) {
+    reply.code(403).send({ error: 'permission_denied', verb });
+    return false;
+  }
+  return true;
+}
+
+/** Validate the JSON body the SPA posts. Translates to the wire's
+ *  query-param + optional-body shape inside `DslDebuggingClient`. */
+function parseStartArgs(raw: unknown, reply: FastifyReply): StartSessionArgs | null {
+  if (typeof raw !== 'object' || raw === null) {
+    reply.code(400).send({ error: 'invalid_body' });
+    return null;
+  }
+  const b = raw as Record<string, unknown>;
+  if (typeof b.clientId !== 'string' || b.clientId.length === 0) {
+    reply.code(400).send({ error: 'missing_clientId' });
+    return null;
+  }
+  if (typeof b.catalog !== 'string' || !isDebugCatalog(b.catalog)) {
+    reply.code(400).send({ error: 'invalid_catalog', value: b.catalog });
+    return null;
+  }
+  if (typeof b.name !== 'string' || b.name.length === 0) {
+    reply.code(400).send({ error: 'missing_name' });
+    return null;
+  }
+  if (typeof b.ruleName !== 'string' || b.ruleName.length === 0) {
+    reply.code(400).send({ error: 'missing_ruleName' });
+    return null;
+  }
+  const out: StartSessionArgs = {
+    clientId: b.clientId,
+    catalog: b.catalog as DebugCatalog,
+    name: b.name,
+    ruleName: b.ruleName,
+  };
+  if (b.granularity !== undefined) {
+    if (!isGranularity(b.granularity)) {
+      reply.code(400).send({ error: 'invalid_granularity', value: b.granularity });
+      return null;
+    }
+    out.granularity = b.granularity as Granularity;
+  }
+  // Bounds match upstream `SessionLimits.java` so we surface a 400
+  // here rather than letting OAP reject the install with a
+  // `invalid_limits` error after the round-trip.
+  if (b.recordCap !== undefined) {
+    if (
+      typeof b.recordCap !== 'number' ||
+      !Number.isFinite(b.recordCap) ||
+      b.recordCap <= 0 ||
+      b.recordCap > MAX_RECORD_CAP
+    ) {
+      reply.code(400).send({ error: 'invalid_recordCap' });
+      return null;
+    }
+    out.recordCap = b.recordCap;
+  }
+  if (b.retentionMillis !== undefined) {
+    if (
+      typeof b.retentionMillis !== 'number' ||
+      !Number.isFinite(b.retentionMillis) ||
+      b.retentionMillis <= 0 ||
+      b.retentionMillis > 3_600_000
+    ) {
+      reply.code(400).send({ error: 'invalid_retentionMillis' });
+      return null;
+    }
+    out.retentionMillis = b.retentionMillis;
+  }
+  return out;
+}
+
+function outcomeOf(err: unknown): string {
+  if (err instanceof RuntimeRuleApiError) {
+    const body: unknown = err.body;
+    if (typeof body === 'object' && body !== null) {
+      const o = body as Record<string, unknown>;
+      if (typeof o.code === 'string') return o.code;
+      if (typeof o.applyStatus === 'string') return o.applyStatus;
+    }
+    return `http_${err.status}`;
+  }
+  return 'oap_unreachable';
+}
+
+function passOapError(err: unknown, reply: FastifyReply): FastifyReply {
+  if (err instanceof RuntimeRuleApiError) {
+    return reply.code(err.status).send(err.body);
+  }
+  return reply.code(502).send({
+    error: 'oap_unreachable',
+    message: err instanceof Error ? err.message : String(err),
+  });
+}
diff --git a/apps/bff/src/oap/inspect-routes.ts b/apps/bff/src/oap/inspect-routes.ts
new file mode 100644
index 0000000..cbafcbc
--- /dev/null
+++ b/apps/bff/src/oap/inspect-routes.ts
@@ -0,0 +1,407 @@
+/*
+ * 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.
+ */
+
+/**
+ * SWIP-14 Inspect API surface — BFF proxy for the admin-only routes
+ * exposed by OAP's `inspect` feature module (port 17128):
+ *
+ *   GET /api/inspect/metrics
+ *   GET /api/inspect/entities
+ *
+ * The MQE values themselves are NOT served here — those go via the
+ * regular GraphQL `execExpression` mutation, which the BFF proxies in
+ * `inspect-exec.ts` (Phase 4). The merged catalog endpoint
+ * `/api/inspect/catalog` (Phase 2) layers Studio-side rule attribution
+ * on top of `/inspect/metrics`.
+ *
+ * Inspect is read-only and metadata-ish — every route is gated on a
+ * single `inspect:read` verb. There is no write surface.
+ *
+ * INSPECT_NOT_ENABLED disambiguation: OAP returns a plain 404 for the
+ * inspect routes when `SW_INSPECT=default` was not set. The plain
+ * `oap_unreachable` envelope from the generic error handler would be
+ * misleading there, so we sniff for 404s on these specific paths and
+ * promote them to a structured `inspect_not_enabled` code that the SPA
+ * surfaces as an actionable banner.
+ */
+
+import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
+import {
+  InspectApiError,
+  INSPECT_ENTITY_LIMIT_MAX,
+  INSPECT_STEPS,
+  isInspectDate,
+  type FetchLike,
+  type InspectCatalog,
+  type InspectMetricType,
+  type InspectStep,
+} from '@skywalking-horizon-ui/api-client';
+import type { ConfigSource } from '../config/loader.js';
+import type { AuditLogger } from '../audit/logger.js';
+import { requireAuth } from '../auth/middleware.js';
+import { sessionHasVerb } from '../rbac/policy.js';
+import type { Session, SessionStore } from '../auth/sessions.js';
+import { buildOapClients, type OapClients } from './clients.js';
+import { AttributionCache, attributeOrUnknown } from '../inspect/attribution.js';
+import { MqeTargetCache } from './mqe-target.js';
+import { parseExecBody, fireMqe, MqeFireError } from './inspect-exec.js';
+import { ServerTimeCache } from './server-time.js';
+
+export interface InspectRouteDeps {
+  config: ConfigSource;
+  sessions: SessionStore;
+  audit: AuditLogger;
+  fetch?: FetchLike;
+}
+
+const VALID_METRIC_TYPES = new Set<InspectMetricType>([
+  'REGULAR_VALUE',
+  'LABELED_VALUE',
+  'HEATMAP',
+  'SAMPLED_RECORD',
+]);
+
+const TRUTHY = new Set(['true', '1', 'yes']);
+
+export function registerInspectRoutes(app: FastifyInstance, deps: InspectRouteDeps): void {
+  const auth = requireAuth(deps);
+  /* One cache per server. The cache's `get()` is fingerprint-aware,
+   * so it auto-invalidates whenever rules change; `refresh=true` on
+   * /api/inspect/catalog busts it explicitly for the SPA's manual
+   * refresh button. */
+  const attribution = new AttributionCache();
+  const mqeTarget = new MqeTargetCache();
+  const serverTime = new ServerTimeCache();
+
+  function clients(): OapClients {
+    return buildOapClients(deps.config.current, { fetch: deps.fetch });
+  }
+
+  // ── /api/inspect/metrics ─────────────────────────────────────────
+
+  app.get(
+    '/api/inspect/metrics',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      if (!ensureVerb(req, reply, deps, 'inspect:read')) return;
+      const q = req.query as Record<string, string | string[] | undefined>;
+
+      const types = parseTypes(q.type, reply);
+      if (types === null) return;
+      const catalogs = parseCatalogs(q.catalog);
+
+      const args: Parameters<ReturnType<OapClients['inspect']>['listMetrics']>[0] = {};
+      if (typeof q.regex === 'string' && q.regex.length > 0) args.regex = q.regex;
+      if (types.length > 0) args.type = types;
+      if (catalogs.length > 0) args.catalog = catalogs;
+      if (typeof q.mqeQueryable === 'string' && TRUTHY.has(q.mqeQueryable.toLowerCase())) {
+        args.mqeQueryable = true;
+      }
+
+      try {
+        const got = await clients().inspect().listMetrics(args);
+        return reply.send(got);
+      } catch (err) {
+        return passInspectError(err, reply, '/inspect/metrics');
+      }
+    },
+  );
+
+  // ── /api/inspect/catalog ─────────────────────────────────────────
+  // Merges `/inspect/metrics` with Studio's rule-file attribution
+  // (source + file per metric). This is the endpoint the Inspect
+  // page's catalog drawer hits — the raw `/api/inspect/metrics`
+  // proxy above stays for scripting / debugging.
+
+  app.get(
+    '/api/inspect/catalog',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      if (!ensureVerb(req, reply, deps, 'inspect:read')) return;
+      const q = req.query as Record<string, string | undefined>;
+      if (q.refresh === 'true' || q.refresh === '1') attribution.invalidate();
+
+      try {
+        const c = clients();
+        const [metricsRes, idx] = await Promise.all([
+          c.inspect().listMetrics(),
+          attribution.get({ oal: () => c.oal(), rules: () => c.primary() }),
+        ]);
+
+        const entries = metricsRes.metrics.map((m) => {
+          const attr = attributeOrUnknown(idx, m.name);
+          return {
+            ...m,
+            attribution: attr,
+          };
+        });
+
+        const summary: Record<string, number> = {};
+        for (const e of entries) {
+          summary[e.attribution.source] = (summary[e.attribution.source] ?? 0) + 1;
+        }
+
+        return reply.send({
+          metrics: entries,
+          summary,
+          attributionFingerprint: idx.fingerprint,
+        });
+      } catch (err) {
+        return passInspectError(err, reply, '/inspect/metrics');
+      }
+    },
+  );
+
+  // ── /api/inspect/mqe-target ──────────────────────────────────────
+  // Resolves the GraphQL base URL for MQE `execExpression` calls.
+  // The result is cached for ~60s; `?refresh=true` busts the cache
+  // so the operator can re-pull after reconfiguring OAP.
+
+  app.get(
+    '/api/inspect/mqe-target',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      if (!ensureVerb(req, reply, deps, 'inspect:read')) return;
+      const q = req.query as Record<string, string | undefined>;
+      if (q.refresh === 'true' || q.refresh === '1') mqeTarget.invalidate();
+      try {
+        const target = await mqeTarget.resolve({
+          config: () => deps.config.current,
+          fetch: deps.fetch ?? globalThis.fetch.bind(globalThis),
+        });
+        return reply.send(target);
+      } catch (err) {
+        return reply.code(502).send({
+          error: 'mqe_target_unresolved',
+          message: err instanceof Error ? err.message : String(err),
+        });
+      }
+    },
+  );
+
+  // ── /api/inspect/server-time ─────────────────────────────────────
+  // Caches OAP's `getTimeInfo` so the SPA can display dates in browser
+  // local TZ while sending server-TZ strings to OAP.
+
+  app.get(
+    '/api/inspect/server-time',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      if (!ensureVerb(req, reply, deps, 'inspect:read')) return;
+      const q = req.query as Record<string, string | undefined>;
+      if (q.refresh === 'true' || q.refresh === '1') serverTime.invalidate();
+      const fetchImpl = deps.fetch ?? globalThis.fetch.bind(globalThis);
+      const value = await serverTime.get({
+        config: () => deps.config.current,
+        fetch: fetchImpl,
+        mqeTarget,
+      });
+      return reply.send(value);
+    },
+  );
+
+  // ── /api/inspect/exec ────────────────────────────────────────────
+  // Fires `mutation execExpression` against the resolved MQE base
+  // and returns the `ExpressionResult` payload verbatim.
+
+  app.post(
+    '/api/inspect/exec',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      if (!ensureVerb(req, reply, deps, 'inspect:read')) return;
+      const body = parseExecBody(req.body, reply);
+      if (!body) return; // 400 already sent.
+
+      const cfg = deps.config.current;
+      const fetchImpl = deps.fetch ?? globalThis.fetch.bind(globalThis);
+      try {
+        const target = await mqeTarget.resolve({
+          config: () => cfg,
+          fetch: fetchImpl,
+        });
+        const result = await fireMqe(target, body, {
+          fetch: fetchImpl,
+          timeoutMs: cfg.oap.timeoutMs,
+        });
+        return reply.send(result);
+      } catch (err) {
+        if (err instanceof MqeFireError) {
+          return reply.code(502).send({
+            error: 'mqe_error',
+            message: err.message,
+            graphqlErrors: err.graphqlErrors,
+          });
+        }
+        return reply.code(502).send({
+          error: 'mqe_target_unresolved',
+          message: err instanceof Error ? err.message : String(err),
+        });
+      }
+    },
+  );
+
+  // ── /api/inspect/entities ────────────────────────────────────────
+
+  app.get(
+    '/api/inspect/entities',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      if (!ensureVerb(req, reply, deps, 'inspect:read')) return;
+      const q = req.query as Record<string, string | undefined>;
+
+      if (!q.metric) return reply.code(400).send({ error: 'missing_metric' });
+      if (!q.start) return reply.code(400).send({ error: 'missing_start' });
+      if (!q.end) return reply.code(400).send({ error: 'missing_end' });
+      if (!q.step) return reply.code(400).send({ error: 'missing_step' });
+
+      const step = parseStep(q.step, reply);
+      if (step === null) return;
+
+      // We validate dates client-side too. OAP also validates and
+      // returns a 400 with a helpful message, but pre-validating keeps
+      // a misformatted UI input from showing as a generic error.
+      if (!isInspectDate(q.start, step)) {
+        return reply.code(400).send({ error: 'invalid_start_format', step, value: q.start });
+      }
+      if (!isInspectDate(q.end, step)) {
+        return reply.code(400).send({ error: 'invalid_end_format', step, value: q.end });
+      }
+
+      let limit: number | undefined;
+      if (q.limit !== undefined) {
+        const parsed = Number.parseInt(q.limit, 10);
+        if (!Number.isInteger(parsed) || parsed < 1 || parsed > INSPECT_ENTITY_LIMIT_MAX) {
+          return reply.code(400).send({
+            error: 'invalid_limit',
+            min: 1,
+            max: INSPECT_ENTITY_LIMIT_MAX,
+            value: q.limit,
+          });
+        }
+        limit = parsed;
+      }
+
+      try {
+        const got = await clients()
+          .inspect()
+          .listEntities({
+            metric: q.metric,
+            start: q.start,
+            end: q.end,
+            step,
+            ...(limit !== undefined ? { limit } : {}),
+          });
+        return reply.send(got);
+      } catch (err) {
+        return passInspectError(err, reply, '/inspect/entities');
+      }
+    },
+  );
+}
+
+// ── helpers ───────────────────────────────────────────────────────
+
+function parseStep(raw: string, reply: FastifyReply): InspectStep | null {
+  const upper = raw.toUpperCase();
+  if (INSPECT_STEPS.includes(upper as InspectStep)) return upper as InspectStep;
+  reply.code(400).send({
+    error: 'invalid_step',
+    value: raw,
+    allowed: INSPECT_STEPS,
+  });
+  return null;
+}
+
+/** Returns the (deduped) `InspectMetricType[]` parsed from the query;
+ *  returns `null` after sending a 400 if any value is unrecognised. */
+function parseTypes(
+  raw: string | string[] | undefined,
+  reply: FastifyReply,
+): InspectMetricType[] | null {
+  if (raw === undefined) return [];
+  const arr = Array.isArray(raw) ? raw : [raw];
+  const out: InspectMetricType[] = [];
+  for (const v of arr) {
+    const upper = v.toUpperCase();
+    if (!VALID_METRIC_TYPES.has(upper as InspectMetricType)) {
+      reply.code(400).send({ error: 'invalid_type', value: v });
+      return null;
+    }
+    if (!out.includes(upper as InspectMetricType)) out.push(upper as InspectMetricType);
+  }
+  return out;
+}
+
+/** Catalog values are open-ended on the OAP side (`DefaultScopeDefine`
+ *  can add new catalogs without an OAP-side enum change), so we don't
+ *  validate the value itself — just upper-case for consistency and
+ *  pass through. */
+function parseCatalogs(raw: string | string[] | undefined): InspectCatalog[] {
+  if (raw === undefined) return [];
+  const arr = Array.isArray(raw) ? raw : [raw];
+  const out: InspectCatalog[] = [];
+  for (const v of arr) {
+    const upper = v.toUpperCase();
+    if (!out.includes(upper)) out.push(upper);
+  }
+  return out;
+}
+
+function ensureVerb(
+  req: FastifyRequest,
+  reply: FastifyReply,
+  deps: InspectRouteDeps,
+  verb: string,
+): boolean {
+  const session: Session | undefined = req.session;
+  if (!session) {
+    reply.code(401).send({ error: 'unauthenticated' });
+    return false;
+  }
+  if (!sessionHasVerb(deps.config.current, session.roles, verb)) {
+    reply.code(403).send({ error: 'permission_denied', verb });
+    return false;
+  }
+  return true;
+}
+
+/** Translate every Inspect error into the BFF's envelope. The unique
+ *  case is OAP returning 404 for an inspect path — that means the
+ *  inspect module isn't enabled on OAP (no handler bound). Promote
+ *  to a structured `inspect_not_enabled` code so the SPA can surface
+ *  an actionable banner instead of a generic 404.
+ *
+ *  All other InspectApiError shapes (400 from validation, 500 from
+ *  storage) are forwarded with their original status + body, so
+ *  OAP's error.message reaches the operator unchanged. */
+function passInspectError(err: unknown, reply: FastifyReply, path: string): FastifyReply {
+  if (err instanceof InspectApiError) {
+    if (err.status === 404) {
+      return reply.code(404).send({
+        error: 'inspect_not_enabled',
+        message: 'OAP did not bind the inspect routes. Set SW_INSPECT=default on the admin-server.',
+        path,
+      });
+    }
+    return reply.code(err.status).send(err.body);
+  }
+  return reply.code(502).send({
+    error: 'oap_unreachable',
+    message: err instanceof Error ? err.message : String(err),
+    path,
+  });
+}
diff --git a/apps/bff/src/oap/landing-routes.ts b/apps/bff/src/oap/landing-routes.ts
index 06f40d0..d0bdb91 100644
--- a/apps/bff/src/oap/landing-routes.ts
+++ b/apps/bff/src/oap/landing-routes.ts
@@ -300,7 +300,16 @@ export function registerLandingRoute(app: FastifyInstance, deps: LandingRouteDep
       }
 
       const totalServiceCount = services.length;
-      if (services.length === 0 || cfg.columns.length === 0) {
+      // Only short-circuit when the layer truly has no services — empty
+      // `columns` still needs to flow through so the response carries
+      // the service-list rows (with empty `metrics` objects). Without
+      // those rows the SPA can't resolve `serviceName` from
+      // `?service=<id>` and every downstream widget query stays gated
+      // on `service.value` being truthy and never fires. This bit the
+      // so11y_* layers when their header columns were intentionally
+      // empty (their meters are SERVICE_INSTANCE-only — see
+      // CLAUDE.md "Metric entity-scope validation").
+      if (services.length === 0) {
         const body: LandingResponse = {
           layer: layerKey,
           topN: cfg.topN,
@@ -310,7 +319,7 @@ export function registerLandingRoute(app: FastifyInstance, deps: LandingRouteDep
           durationStart: window.start,
           durationEnd: window.end,
           rows: [],
-          aggregates: { serviceCount: totalServiceCount, metrics: {}, seriesByMetric: {} },
+          aggregates: { serviceCount: 0, metrics: {}, seriesByMetric: {} },
           reachable: true,
         };
         return reply.send(body);
diff --git a/apps/bff/src/oap/menu-routes.ts b/apps/bff/src/oap/menu-routes.ts
index 3d00a5a..25b8e48 100644
--- a/apps/bff/src/oap/menu-routes.ts
+++ b/apps/bff/src/oap/menu-routes.ts
@@ -190,6 +190,7 @@ function deriveLayer(
       level,
       normal,
       group: tpl.group,
+      visibility: tpl.visibility,
       documentLink: tpl.documentLink ?? item?.documentLink ?? undefined,
       slots: tpl.slots,
       caps: componentsToCaps(tpl.components),
diff --git a/apps/bff/src/oap/preflight.ts b/apps/bff/src/oap/preflight.ts
index 71e08b6..d10623c 100644
--- a/apps/bff/src/oap/preflight.ts
+++ b/apps/bff/src/oap/preflight.ts
@@ -31,33 +31,14 @@
  * "set selectors".
  */
 
-import type { FetchLike } from '@skywalking-horizon-ui/api-client';
+import type {
+  FetchLike,
+  PreflightModule,
+  PreflightResult,
+} from '@skywalking-horizon-ui/api-client';
 import type { HorizonConfig } from '../config/schema.js';
 
-export interface PreflightModule {
-  /** OAP module name as it appears in the config-dump key prefix. */
-  name: string;
-  /** The env var that controls this module's selector. */
-  envVar: string;
-  /** True when Studio depends on this module being on. */
-  required: boolean;
-  /** True iff the dump carries at least one key with this module's prefix. */
-  enabled: boolean;
-  /** What part of Studio's UI breaks when this module is off. */
-  affects: string;
-}
-
-export interface PreflightResult {
-  adminUrl: string;
-  /** True iff `/debugging/config/dump` responded 2xx. */
-  adminReachable: boolean;
-  /** Short reason when `adminReachable` is false. */
-  adminError?: string;
-  modules: PreflightModule[];
-  /** Total keys in the dump. Diagnostic only. */
-  dumpKeyCount: number;
-  generatedAt: number;
-}
+export type { PreflightModule, PreflightResult };
 
 interface ModuleDef {
   name: string;
diff --git a/apps/bff/src/overview/loader.ts b/apps/bff/src/overview/loader.ts
index 680c07c..200722e 100644
--- a/apps/bff/src/overview/loader.ts
+++ b/apps/bff/src/overview/loader.ts
@@ -27,7 +27,12 @@
 import fs from 'node:fs';
 import path from 'node:path';
 import { fileURLToPath } from 'node:url';
-import type { OverviewDashboard } from '@skywalking-horizon-ui/api-client';
+import type {
+  OverviewDashboard,
+  OverviewKpi,
+  OverviewVisibility,
+  OverviewWidgetType,
+} from '@skywalking-horizon-ui/api-client';
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
 // Bundled overview dashboards live under `bundled_templates/overviews/`
@@ -42,6 +47,41 @@ function isString(v: unknown): v is string {
   return typeof v === 'string' && v.length > 0;
 }
 
+const WIDGET_TYPES: ReadonlySet<OverviewWidgetType> = new Set([
+  'service-count',
+  'metric',
+  'topology',
+  'section-break',
+  'kpi-tile',
+  'alarms',
+  'k8s-summary',
+  'pilot-summary',
+]);
+// `layer` is required for data-bound widgets; layout-only / aggregate
+// widgets resolve their data without an explicit layer binding.
+const LAYERLESS_WIDGETS: ReadonlySet<OverviewWidgetType> = new Set([
+  'section-break',
+  'alarms',
+]);
+
+function parseKpis(raw: unknown): OverviewKpi[] | undefined {
+  if (!Array.isArray(raw)) return undefined;
+  const out: OverviewKpi[] = [];
+  for (const k of raw) {
+    if (!k || typeof k !== 'object') continue;
+    const rec = k as Record<string, unknown>;
+    if (!isString(rec.label) || !isString(rec.mqe)) continue;
+    out.push({
+      label: rec.label,
+      mqe: rec.mqe,
+      unit: isString(rec.unit) ? rec.unit : undefined,
+      aggregation:
+        rec.aggregation === 'sum' ? 'sum' : rec.aggregation === 'avg' ? 'avg' : undefined,
+    });
+  }
+  return out.length > 0 ? out : undefined;
+}
+
 function validate(raw: unknown, file: string): OverviewDashboard | null {
   if (!raw || typeof raw !== 'object') {
     console.warn(`overview/${file}: not an object, skipped`);
@@ -55,26 +95,39 @@ function validate(raw: unknown, file: string): OverviewDashboard | null {
   const widgets = (r.widgets as unknown[]).filter((w): w is Record<string, unknown> => {
     if (!w || typeof w !== 'object') return false;
     const rec = w as Record<string, unknown>;
-    return (
-      isString(rec.id) &&
-      isString(rec.title) &&
-      isString(rec.layer) &&
-      (rec.type === 'service-count' || rec.type === 'metric' || rec.type === 'topology')
-    );
+    if (!isString(rec.id) || !isString(rec.title)) return false;
+    if (!WIDGET_TYPES.has(rec.type as OverviewWidgetType)) return false;
+    if (!LAYERLESS_WIDGETS.has(rec.type as OverviewWidgetType) && !isString(rec.layer)) {
+      return false;
+    }
+    return true;
   });
+  const visibility: OverviewVisibility | undefined =
+    r.visibility === 'operate' ? 'operate' : r.visibility === 'public' ? 'public' : undefined;
+  const layers = Array.isArray(r.layers)
+    ? (r.layers as unknown[]).filter(isString)
+    : undefined;
   return {
     id: r.id,
     title: r.title,
     description: isString(r.description) ? r.description : undefined,
+    visibility,
+    icon: isString(r.icon) ? r.icon : undefined,
+    order: typeof r.order === 'number' ? r.order : undefined,
+    layers,
     widgets: widgets.map((w) => ({
       id: w.id as string,
       title: w.title as string,
       tip: isString(w.tip) ? w.tip : undefined,
-      layer: w.layer as string,
-      type: w.type as 'service-count' | 'metric' | 'topology',
+      layer: isString(w.layer) ? w.layer : undefined,
+      type: w.type as OverviewWidgetType,
       mqe: isString(w.mqe) ? w.mqe : undefined,
       unit: isString(w.unit) ? w.unit : undefined,
       aggregation: w.aggregation === 'sum' ? 'sum' : w.aggregation === 'avg' ? 'avg' : undefined,
+      cols: typeof w.cols === 'number' ? w.cols : undefined,
+      kpis: parseKpis(w.kpis),
+      showCount: w.showCount === true ? true : undefined,
+      limit: typeof w.limit === 'number' ? w.limit : undefined,
       span: typeof w.span === 'number' ? w.span : undefined,
       rowSpan: typeof w.rowSpan === 'number' ? w.rowSpan : undefined,
     })),
@@ -103,9 +156,18 @@ export function loadOverviewDashboards(): OverviewDashboard[] {
       console.warn(`overview/${f}: parse error`, err instanceof Error ? err.message : err);
     }
   }
-  // Keep stable ordering by id so the SPA's tab strip doesn't flicker
-  // on reload.
-  out.sort((a, b) => a.id.localeCompare(b.id));
+  // Stable sort: explicit `order` first, then visibility (public before
+  // operate), then id. Operators bumping `order` on a single dashboard
+  // shouldn't need to renumber the rest of the bundle.
+  out.sort((a, b) => {
+    const ao = a.order ?? 1000;
+    const bo = b.order ?? 1000;
+    if (ao !== bo) return ao - bo;
+    const av = a.visibility ?? 'public';
+    const bv = b.visibility ?? 'public';
+    if (av !== bv) return av === 'public' ? -1 : 1;
+    return a.id.localeCompare(b.id);
+  });
   cache = out;
   return cache;
 }
diff --git a/apps/bff/src/overview/routes.ts b/apps/bff/src/overview/routes.ts
index 1da30c9..e0c8326 100644
--- a/apps/bff/src/overview/routes.ts
+++ b/apps/bff/src/overview/routes.ts
@@ -56,6 +56,10 @@ export function registerOverviewRoutes(app: FastifyInstance, deps: OverviewRoute
         id: d.id,
         title: d.title,
         description: d.description,
+        visibility: d.visibility,
+        icon: d.icon,
+        order: d.order,
+        layers: d.layers,
         widgetCount: d.widgets.length,
       })),
     };
diff --git a/apps/bff/src/server.ts b/apps/bff/src/server.ts
index 8659da2..3e9c50c 100644
--- a/apps/bff/src/server.ts
+++ b/apps/bff/src/server.ts
@@ -34,6 +34,10 @@ import { registerLogRoute } from './oap/log-routes.js';
 import { registerMenuRoute } from './oap/menu-routes.js';
 import { registerOapRoutes } from './oap/routes.js';
 import { registerPreflightRoutes } from './oap/preflight-routes.js';
+import { registerDebugRoutes } from './oap/debug-routes.js';
+import { registerInspectRoutes } from './oap/inspect-routes.js';
+import { registerAlarmsRoutes } from './alarms/routes.js';
+import { AlarmsStore } from './alarms/store.js';
 import { registerProfileRoutes } from './oap/profile-routes.js';
 import { registerEBPFRoutes } from './oap/ebpf-routes.js';
 import { registerAsyncProfileRoutes } from './oap/async-profile-routes.js';
@@ -69,6 +73,8 @@ const audit = new AuditLogger(source.current.audit.file);
 await audit.open();
 const setupStore = new SetupStore(source.current.setup.file);
 await setupStore.load();
+const alarmsStore = new AlarmsStore(source.current.alarms.file);
+await alarmsStore.load();
 
 await app.register(cookie);
 
@@ -92,6 +98,14 @@ registerDashboardRoute(app, { config: source, sessions });
 registerSetupRoutes(app, { config: source, sessions, audit, store: setupStore });
 registerOapRoutes(app, { config: source, sessions, audit });
 registerPreflightRoutes(app, { config: source, sessions });
+// Live debugger — `/api/debug/*` proxies for SWIP-13's
+// `/dsl-debugging/*` wire (start / poll / stop session, list active
+// sessions, per-node status fan-out).
+registerDebugRoutes(app, { config: source, sessions, audit });
+// Inspect — OAP metric catalog browse + MQE ad-hoc execution.
+registerInspectRoutes(app, { config: source, sessions, audit });
+// Alarms — getAlarm proxy + traffic-background fan-out + config CRUD.
+registerAlarmsRoutes(app, { config: source, sessions, audit, store: alarmsStore });
 registerProfileRoutes(app, { config: source, sessions });
 registerEBPFRoutes(app, { config: source, sessions });
 registerAsyncProfileRoutes(app, { config: source, sessions });
diff --git a/apps/ui/src/api/client.ts b/apps/ui/src/api/client.ts
index 834d93d..df2a0c9 100644
--- a/apps/ui/src/api/client.ts
+++ b/apps/ui/src/api/client.ts
@@ -28,6 +28,8 @@ import type {
   LogsResponse,
   MenuResponse,
   OapInfo,
+  OverviewDashboardListResponse,
+  OverviewDashboardResponse,
   ProfileAnalyzationResponse,
   ProfileAnalyzeQuery,
   ProfileSegmentsResponse,
@@ -67,6 +69,26 @@ import type {
   TracesConfig,
   ZipkinTraceListResponse,
   ZipkinTraceDetailResponse,
+  PreflightResult,
+  Catalog,
+  RuleSource,
+  RuleResponse,
+  ApplyResult,
+  DeleteMode,
+  ListEnvelope,
+  BundledEntry,
+  OalFilesResponse,
+  OalRulesResponse,
+  OalSourceDetail,
+  StartSessionArgs,
+  StartSessionResponse,
+  SessionResponse,
+  StopSessionResponse,
+  ActiveSessionsResponse,
+  InspectStep,
+  EntitiesResponse,
+  InspectExecRequest,
+  ExpressionResult,
 } from '@skywalking-horizon-ui/api-client';
 
 /** Query shape for `/api/zipkin/traces`. Mirrors the Zipkin v2 REST
@@ -217,6 +239,10 @@ export interface AdminLayerTemplate {
   alias?: string;
   color?: string;
   documentLink?: string;
+  /** Whether this layer surfaces in the user-facing Layers section
+   *  (`public`, default) or in the Operate group's Self-Observability
+   *  block (`operate` — OAP / Satellite / agent self-obs). */
+  visibility?: 'public' | 'operate';
   slots: { services?: string; instances?: string; endpoints?: string; endpointDependency?: string };
   components: {
     service?: boolean;
@@ -278,6 +304,112 @@ export class BffApiError extends Error {
   }
 }
 
+/** Reduce an unknown thrown value to a short user-facing string.
+ *  Knows the {@link BffApiError} envelope (status / body) AND the
+ *  upstream `{ status: 'error', code, message }` shape that surfaces
+ *  through it. Used by composables that want to display poll / start
+ *  failures without each one re-implementing the decoder. */
+export function describeApiError(err: unknown): string {
+  if (err instanceof BffApiError || (typeof err === 'object' && err !== null && 'status' in err && 'body' in err)) {
+    const e = err as { status: number; body: unknown };
+    if (typeof e.body === 'object' && e.body !== null) {
+      const o = e.body as Record<string, unknown>;
+      if (typeof o.code === 'string' && typeof o.message === 'string') {
+        return `${e.status} (${o.code}): ${o.message}`;
+      }
+      if (typeof o.message === 'string') {
+        return `${e.status}: ${o.message}`;
+      }
+      if (typeof o.applyStatus === 'string') {
+        return `${e.status} (${o.applyStatus})`;
+      }
+    }
+    if (typeof e.body === 'string' && e.body.length > 0) {
+      return `${e.status}: ${e.body}`;
+    }
+    return `HTTP ${e.status}`;
+  }
+  if (err instanceof Error) return err.message;
+  return String(err);
+}
+
+// ── BFF-only response shapes ──────────────────────────────────────────
+// These wrap OAP wire types with BFF-side joins (rule attribution,
+// per-node fan-out matrices, server-time fallbacks). Kept here rather
+// than in @skywalking-horizon-ui/api-client because they're BFF-computed,
+// not OAP wire types.
+
+import type {
+  DslDebuggingStatus,
+  MetricRow,
+  RuleStatus,
+  LocalState,
+} from '@skywalking-horizon-ui/api-client';
+
+/** Per-cluster fan-out of `/dsl-debugging/status` — drives the Live
+ *  Debugger health pane. */
+export interface ClusterDebugStatus {
+  generatedAt: number;
+  nodes: { url: string; ok: boolean; status?: DslDebuggingStatus; error?: string }[];
+}
+
+/** Per-rule × per-node convergence matrix — the cluster pane reads
+ *  this to render the rule grid. */
+export interface ClusterRulePerNode {
+  status: RuleStatus | null;
+  localState: LocalState | null;
+  contentHash: string | null;
+  lastApplyError: string;
+}
+
+export interface ClusterRule {
+  catalog: Catalog;
+  name: string;
+  converged: boolean;
+  perNode: Record<string, ClusterRulePerNode>;
+}
+
+export interface ClusterStateResponse {
+  generatedAt: number;
+  nodes: { url: string; ok: boolean; error?: string }[];
+  rules: ClusterRule[];
+}
+
+/** OAP server-time discovery — sourced from `getTimeInfo` with
+ *  fallback to the BFF's local clock when OAP is unreachable. */
+export interface InspectServerTimeResponse {
+  offsetMinutes: number;
+  currentTimestampMillis: number;
+  source: 'oap' | 'fallback';
+  mqeBaseUrl?: string;
+  error?: string;
+}
+
+/** Inspect catalog row — `/inspect/metrics` payload joined with the
+ *  rule-file each metric was declared in. */
+export interface InspectCatalogEntry extends MetricRow {
+  attribution: {
+    source: 'OAL' | 'MAL·OTEL' | 'MAL·Telegraf' | 'LAL→MAL' | 'unknown';
+    file: string | null;
+    candidates?: string[];
+  };
+}
+
+export interface InspectCatalogResponse {
+  metrics: InspectCatalogEntry[];
+  summary: Record<string, number>;
+  attributionFingerprint: string;
+}
+
+/** Discovered (or operator-overridden) base URL the BFF uses to fire
+ *  MQE `execExpression` mutations. Echoed back to the UI's MQE-target
+ *  diagnostic panel. */
+export interface InspectMqeTargetResponse {
+  baseUrl: string;
+  via: string;
+  configured: { host?: string; port?: number };
+}
+
 type On401 = () => void;
 
 export class BffClient {
@@ -341,6 +473,18 @@ export class BffClient {
     return this.request<OapInfo>('GET', '/api/oap/info');
   }
 
+  // ── overview dashboards (Services / Mesh / SO11Y ops) ────────────────
+  overviewDashboards(): Promise<OverviewDashboardListResponse> {
+    return this.request<OverviewDashboardListResponse>('GET', '/api/overview/dashboards');
+  }
+
+  overviewDashboard(id: string): Promise<OverviewDashboardResponse> {
+    return this.request<OverviewDashboardResponse>(
+      'GET',
+      `/api/overview/dashboards/${encodeURIComponent(id)}`,
+    );
+  }
+
   // ── setup (per-layer overrides) ──────────────────────────────────────
   loadSetup(): Promise<SetupResponse> {
     return this.request<SetupResponse>('GET', '/api/setup');
@@ -351,16 +495,25 @@ export class BffClient {
   }
 
   // ── landing (per-layer top-N) ────────────────────────────────────────
-  layerLanding(layerKey: string, cfg: LandingConfig): Promise<LandingResponse> {
+  layerLanding(
+    layerKey: string,
+    cfg: LandingConfig,
+    range?: { step: 'MINUTE' | 'HOUR' | 'DAY'; startMs: number; endMs: number },
+  ): Promise<LandingResponse> {
     // The wire payload mirrors LandingConfig minus the priority/style
     // bits the BFF doesn't care about.
-    const body = {
+    const body: Record<string, unknown> = {
       topN: cfg.topN,
       orderBy: cfg.orderBy,
       columns: cfg.columns,
       ...(cfg.spark ? { spark: cfg.spark } : {}),
       ...(cfg.throughput ? { throughput: cfg.throughput } : {}),
     };
+    if (range) {
+      body.step = range.step;
+      body.startMs = range.startMs;
+      body.endMs = range.endMs;
+    }
     return this.request<LandingResponse>(
       'POST',
       `/api/layer/${encodeURIComponent(layerKey)}/landing`,
@@ -388,6 +541,15 @@ export class BffClient {
       endpoint?: string;
       widgets?: DashboardWidget[];
       scope?: string;
+      /** OAP bucket size. Sent together with `startMs`/`endMs`. The
+       *  BFF snaps the range to whole buckets and queries OAP with
+       *  `Duration.step = step`. When omitted the BFF falls back to
+       *  its legacy 60-minute MINUTE window. */
+      step?: 'MINUTE' | 'HOUR' | 'DAY';
+      /** Range start in ms since epoch. */
+      startMs?: number;
+      /** Range end in ms since epoch. */
+      endMs?: number;
     } = {},
     /** Dev-mode mock: pad every TopList result to N entries with
      *  synthetic rows so operators can verify widget sizing without
@@ -821,13 +983,390 @@ export class BffClient {
   }
 
   // ── cluster / preflight ──────────────────────────────────────────────
-  preflight(): Promise<unknown> {
-    return this.request('GET', '/api/preflight');
+  /** Admin-port preflight — interrogates OAP's `/debugging/config/dump`
+   *  and reports which SWIP-13 selectors are on. Drives the admin
+   *  section of /operate/cluster and the per-page warning headers on
+   *  admin-host routes. */
+  preflight(): Promise<PreflightResult> {
+    return this.request<PreflightResult>('GET', '/api/preflight');
   }
 
-  clusterState(): Promise<unknown> {
-    return this.request('GET', '/api/cluster/state');
+  clusterState(): Promise<ClusterStateResponse> {
+    return this.request<ClusterStateResponse>('GET', '/api/cluster/state');
+  }
+
+  // ── DSL Management (runtime-rule) ────────────────────────────────────
+
+  /** `GET /api/catalog/list[?catalog=]` — proxied envelope from OAP's
+   *  `/runtime/rule/list`. Each row carries enough fields for badges
+   *  (`bundled`, `bundledContentHash`, `contentHash`) so no second
+   *  call is needed for the catalog browse. */
+  catalogList(catalog?: Catalog): Promise<ListEnvelope> {
+    const path = catalog
+      ? `/api/catalog/list?catalog=${encodeURIComponent(catalog)}`
+      : '/api/catalog/list';
+    return this.request<ListEnvelope>('GET', path);
+  }
+
+  /** `GET /api/rule?catalog=&name=[&source=]` — fetches YAML body
+   *  + metadata headers, normalised into a {@link RuleResponse}.
+   *  Returns `null` on 404 (rule doesn't exist anywhere). */
+  async getRule(args: {
+    catalog: Catalog;
+    name: string;
+    source?: RuleSource;
+  }): Promise<RuleResponse | null> {
+    const params = new URLSearchParams({ catalog: args.catalog, name: args.name });
+    if (args.source) params.set('source', args.source);
+    const path = `/api/rule?${params.toString()}`;
+    const res = await fetch(path, {
+      method: 'GET',
+      credentials: 'include',
+      headers: { Accept: 'application/x-yaml' },
+    });
+    if (res.status === 401) {
+      this.on401?.();
+      throw new BffApiError(401, 'unauthenticated', null);
+    }
+    if (res.status === 404) return null;
+    if (!res.ok) {
+      let parsed: unknown = null;
+      try { parsed = await res.json(); } catch { parsed = await res.text(); }
+      throw new BffApiError(res.status, `GET ${path} failed (${res.status})`, parsed);
+    }
+    const content = await res.text();
+    return {
+      status: (res.headers.get('x-sw-status') as RuleResponse['status']) ?? 'n/a',
+      source: (res.headers.get('x-sw-source') as RuleResponse['source']) ?? 'runtime',
+      contentHash: res.headers.get('x-sw-content-hash') ?? '',
+      updateTime: Number(res.headers.get('x-sw-update-time') ?? '0'),
+      etag: res.headers.get('etag') ?? '',
+      content,
+    };
+  }
+
+  /** `POST /api/rule?catalog=&name=[&allowStorageChange=][&force=]`.
+   *  The body is the raw YAML text. */
+  async saveRule(args: {
+    catalog: Catalog;
+    name: string;
+    body: string;
+    allowStorageChange?: boolean;
+    force?: boolean;
+  }): Promise<ApplyResult> {
+    const params = new URLSearchParams({ catalog: args.catalog, name: args.name });
+    if (args.allowStorageChange) params.set('allowStorageChange', 'true');
+    if (args.force) params.set('force', 'true');
+    const path = `/api/rule?${params.toString()}`;
+    const res = await fetch(path, {
+      method: 'POST',
+      credentials: 'include',
+      headers: { 'Content-Type': 'text/plain' },
+      body: args.body,
+    });
+    if (res.status === 401) {
+      this.on401?.();
+      throw new BffApiError(401, 'unauthenticated', null);
+    }
+    if (!res.ok) {
+      let parsed: unknown = null;
+      try { parsed = await res.json(); } catch { parsed = await res.text(); }
+      throw new BffApiError(res.status, `POST ${path} failed (${res.status})`, parsed);
+    }
+    return (await res.json()) as ApplyResult;
+  }
+
+  /** `POST /api/rule/inactivate?catalog=&name=` */
+  inactivateRule(catalog: Catalog, name: string): Promise<ApplyResult> {
+    const params = new URLSearchParams({ catalog, name });
+    return this.request<ApplyResult>('POST', `/api/rule/inactivate?${params.toString()}`);
+  }
+
+  /** `POST /api/rule/delete?catalog=&name=[&mode=revertToBundled]` */
+  deleteRule(catalog: Catalog, name: string, mode: DeleteMode = ''): Promise<ApplyResult> {
+    const params = new URLSearchParams({ catalog, name });
+    if (mode) params.set('mode', mode);
+    return this.request<ApplyResult>('POST', `/api/rule/delete?${params.toString()}`);
+  }
+
+  /** `GET /api/catalog/bundled?catalog=` */
+  catalogBundled(catalog: Catalog, withContent = true): Promise<BundledEntry[]> {
+    const params = new URLSearchParams({ catalog, withContent: String(withContent) });
+    return this.request<BundledEntry[]>('GET', `/api/catalog/bundled?${params.toString()}`);
+  }
+
+  // ── OAL (read-only) ──────────────────────────────────────────────────
+
+  oalFiles(): Promise<OalFilesResponse> {
+    return this.request<OalFilesResponse>('GET', '/api/oal/files');
+  }
+
+  /** Returns the raw `.oal` text or `null` on 404. */
+  async oalFileContent(name: string): Promise<string | null> {
+    const res = await fetch(`/api/oal/files/${encodeURIComponent(name)}`, {
+      method: 'GET',
+      credentials: 'include',
+      headers: { Accept: 'text/plain' },
+    });
+    if (res.status === 404) return null;
+    if (!res.ok) {
+      let parsed: unknown = null;
+      try { parsed = await res.json(); } catch { parsed = await res.text(); }
+      throw new BffApiError(res.status, `oal/files/${name} failed`, parsed);
+    }
+    return res.text();
+  }
+
+  oalSources(): Promise<OalRulesResponse> {
+    return this.request<OalRulesResponse>('GET', '/api/oal/rules');
+  }
+
+  async oalSource(source: string): Promise<OalSourceDetail | null> {
+    try {
+      return await this.request<OalSourceDetail>(
+        'GET',
+        `/api/oal/rules/${encodeURIComponent(source)}`,
+      );
+    } catch (err) {
+      if (err instanceof BffApiError && err.status === 404) return null;
+      throw err;
+    }
+  }
+
+  // ── Live debugger (SWIP-13 `/dsl-debugging/*`) ───────────────────────
+
+  debugStart(args: StartSessionArgs): Promise<StartSessionResponse> {
+    return this.request<StartSessionResponse>('POST', '/api/debug/session', args);
+  }
+
+  async debugSession(id: string): Promise<SessionResponse | null> {
+    try {
+      return await this.request<SessionResponse>(
+        'GET',
+        `/api/debug/session/${encodeURIComponent(id)}`,
+      );
+    } catch (err) {
+      if (err instanceof BffApiError && err.status === 404) return null;
+      throw err;
+    }
+  }
+
+  debugStop(id: string): Promise<StopSessionResponse> {
+    return this.request<StopSessionResponse>(
+      'POST',
+      `/api/debug/session/${encodeURIComponent(id)}/stop`,
+    );
+  }
+
+  debugSessions(): Promise<ActiveSessionsResponse> {
+    return this.request<ActiveSessionsResponse>('GET', '/api/debug/sessions');
+  }
+
+  debugStatus(): Promise<ClusterDebugStatus> {
+    return this.request<ClusterDebugStatus>('GET', '/api/debug/status');
+  }
+
+  // ── Inspect (SWIP-14) ────────────────────────────────────────────────
+
+  inspectCatalog(refresh = false): Promise<InspectCatalogResponse> {
+    const path = refresh ? '/api/inspect/catalog?refresh=true' : '/api/inspect/catalog';
+    return this.request<InspectCatalogResponse>('GET', path);
+  }
+
+  inspectEntities(args: {
+    metric: string;
+    start: string;
+    end: string;
+    step: InspectStep;
+    limit?: number;
+  }): Promise<EntitiesResponse> {
+    const params = new URLSearchParams({
+      metric: args.metric,
+      start: args.start,
+      end: args.end,
+      step: args.step,
+    });
+    if (args.limit !== undefined) params.set('limit', String(args.limit));
+    return this.request<EntitiesResponse>('GET', `/api/inspect/entities?${params.toString()}`);
+  }
+
+  inspectMqeTarget(refresh = false): Promise<InspectMqeTargetResponse> {
+    const path = refresh ? '/api/inspect/mqe-target?refresh=true' : '/api/inspect/mqe-target';
+    return this.request<InspectMqeTargetResponse>('GET', path);
+  }
+
+  inspectExec(req: InspectExecRequest): Promise<ExpressionResult> {
+    return this.request<ExpressionResult>('POST', '/api/inspect/exec', req);
+  }
+
+  inspectServerTime(refresh = false): Promise<InspectServerTimeResponse> {
+    const path = refresh ? '/api/inspect/server-time?refresh=true' : '/api/inspect/server-time';
+    return this.request<InspectServerTimeResponse>('GET', path);
+  }
+
+  /** Trigger an `/api/dump[/{catalog}]` download. Uses an invisible
+   *  anchor click — the BFF session cookie is HttpOnly and gets sent
+   *  with the same-origin request automatically. */
+  triggerDump(catalog?: Catalog): void {
+    const path = catalog
+      ? `/api/dump/${encodeURIComponent(catalog)}`
+      : '/api/dump';
+    const a = document.createElement('a');
+    a.href = path;
+    a.style.display = 'none';
+    document.body.appendChild(a);
+    a.click();
+    document.body.removeChild(a);
+  }
+
+  // ── Alarms ───────────────────────────────────────────────────────────
+
+  alarms(q: AlarmsQuery): Promise<AlarmsResponse> {
+    const p = new URLSearchParams({
+      startTime: String(q.startTime),
+      endTime: String(q.endTime),
+      pageNum: String(q.pageNum ?? 1),
+      pageSize: String(q.pageSize ?? 100),
+    });
+    if (q.scope) p.set('scope', q.scope);
+    if (q.keyword) p.set('keyword', q.keyword);
+    if (q.service) p.set('service', q.service);
+    if (q.instance) p.set('instance', q.instance);
+    if (q.endpoint) p.set('endpoint', q.endpoint);
+    return this.request<AlarmsResponse>('GET', `/api/alarms?${p.toString()}`);
+  }
+
+  alarmsTraffic(startTime: number, endTime: number): Promise<AlarmTrafficResponse> {
+    const p = new URLSearchParams({
+      startTime: String(startTime),
+      endTime: String(endTime),
+    });
+    return this.request<AlarmTrafficResponse>('GET', `/api/alarms/traffic?${p.toString()}`);
+  }
+
+  /** Service roster for a single OAP layer — feeds the alarms-page
+   *  filter cascade. Returned alpha-sorted by the BFF. */
+  alarmsServices(layer: string): Promise<{
+    layer: string;
+    services: Array<{ name: string; normal: boolean | null }>;
+  }> {
+    const p = new URLSearchParams({ layer });
+    return this.request('GET', `/api/alarms/services?${p.toString()}`);
+  }
+
+  alarmsConfig(): Promise<AlarmsConfig> {
+    return this.request<AlarmsConfig>('GET', '/api/alarms/config');
+  }
+
+  saveAlarmsConfig(next: AlarmsConfig): Promise<AlarmsConfig> {
+    return this.request<AlarmsConfig>('POST', '/api/alarms/config', next);
   }
 }
 
+// ── Alarms wire types (BFF-only) ──────────────────────────────────────
+// Kept inline (not in @skywalking-horizon-ui/api-client) because every
+// shape here is BFF-shaped — getAlarm's raw output gets layer-tagged
+// per row, traffic is BFF-aggregated across services, and the config
+// is a BFF JSON file. None of these belong on the OAP wire side of
+// the boundary.
+
+export type AlarmScope =
+  | 'All'
+  | 'Service'
+  | 'ServiceInstance'
+  | 'Endpoint'
+  | 'Process'
+  | 'ServiceRelation'
+  | 'ServiceInstanceRelation'
+  | 'EndpointRelation'
+  | 'ProcessRelation'
+  | null;
+
+export interface AlarmMqeKeyValue {
+  key: string;
+  value: string;
+}
+export interface AlarmMqeValueRow {
+  id?: string | null;
+  value: string | null;
+  traceID?: string | null;
+}
+export interface AlarmMqeValuesGroup {
+  metric?: { labels?: AlarmMqeKeyValue[] } | null;
+  values: AlarmMqeValueRow[];
+}
+export interface AlarmMqeMetric {
+  name: string;
+  results: AlarmMqeValuesGroup[];
+}
+export interface AlarmSnapshot {
+  expression: string;
+  metrics: AlarmMqeMetric[];
+}
+export interface AlarmMessage {
+  id: string;
+  startTime: number;
+  recoveryTime: number | null;
+  scope: AlarmScope;
+  name: string;
+  message: string;
+  tags: AlarmMqeKeyValue[];
+  /** Optional — the BFF list query omits `events` because some OAP
+   *  storage backends throw `fail to query stream` when streaming
+   *  events alongside an alarm page. Available on dedicated per-
+   *  alarm fetches if those land later. */
+  events?: Array<Record<string, unknown>>;
+  snapshot: AlarmSnapshot;
+  /** BFF-attached layer tag derived from the service roster. Null when
+   *  the entity name isn't a known service. */
+  layerKey: string | null;
+}
+export interface AlarmsResponse {
+  total: number;
+  pageNum: number;
+  pageSize: number;
+  generatedAt: number;
+  msgs: AlarmMessage[];
+}
+export interface AlarmsQuery {
+  startTime: number;
+  endTime: number;
+  scope?: string;
+  keyword?: string;
+  pageNum?: number;
+  pageSize?: number;
+  service?: string;
+  instance?: string;
+  endpoint?: string;
+}
+export interface AlarmTrafficPoint {
+  ts: number;
+  value: number | null;
+}
+export interface AlarmTrafficSeries {
+  layerKey: string;
+  label: string;
+  present: boolean;
+  points: AlarmTrafficPoint[];
+  error?: string;
+}
+export interface AlarmTrafficResponse {
+  generatedAt: number;
+  startTime: number;
+  endTime: number;
+  step: 'MINUTE';
+  series: AlarmTrafficSeries[];
+}
+export interface AlarmTrafficLayerConfig {
+  layerKey: string;
+  mqe: string;
+  label?: string;
+}
+export interface AlarmsConfig {
+  trafficLayers: AlarmTrafficLayerConfig[];
+}
+
 export const bffClient = new BffClient();
+
+/** Vantage-compatible alias — imported as `bff` by ported views. */
+export const bff = bffClient;
diff --git a/apps/ui/src/components/charts/AlarmsTimeline.vue b/apps/ui/src/components/charts/AlarmsTimeline.vue
new file mode 100644
index 0000000..ca5a9b7
--- /dev/null
+++ b/apps/ui/src/components/charts/AlarmsTimeline.vue
@@ -0,0 +1,402 @@
+<!--
+  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.
+-->
+<!--
+  Alarms timeline. Multi-line background traffic (RPM) over the window
+  with alarm markers overlaid as colored dots at each alarm's start
+  time. The X axis is real epoch-ms (time type), not category-indexed
+  buckets, so alarms anchor at their actual timestamp without needing
+  the caller to align them to traffic buckets.
+
+  Interactions:
+   - Click an alarm dot          → emits `select-alarm` with that id.
+   - Drag a region on the chart  → emits `select-time-range` with
+                                   `{startTime, endTime}` (ms).
+   - Click the chart background  → emits `clear-selection`.
+
+  This is the page's headline widget — kept inline rather than in
+  TimeChart because the alarm-flag overlay + brush behavior don't
+  belong in the per-layer dashboards' generic line chart wrapper.
+-->
+<script setup lang="ts">
+import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
+import * as echarts from 'echarts/core';
+import { LineChart, ScatterChart } from 'echarts/charts';
+import {
+  BrushComponent,
+  GridComponent,
+  LegendComponent,
+  MarkLineComponent,
+  TooltipComponent,
+  ToolboxComponent,
+} from 'echarts/components';
+import { CanvasRenderer } from 'echarts/renderers';
+import type { EChartsType } from 'echarts/core';
+import type { AlarmMessage, AlarmTrafficSeries } from '@/api/client';
+
+echarts.use([
+  LineChart,
+  ScatterChart,
+  BrushComponent,
+  GridComponent,
+  LegendComponent,
+  MarkLineComponent,
+  TooltipComponent,
+  ToolboxComponent,
+  CanvasRenderer,
+]);
+
+const props = defineProps<{
+  /** Aligned, per-layer RPM series (one line per layer). */
+  traffic: AlarmTrafficSeries[];
+  /** Alarms to overlay as dots at their startTime. */
+  alarms: AlarmMessage[];
+  startTime: number;
+  endTime: number;
+  height?: number;
+  /** Composite key `${alarm.id}::${alarm.startTime}` — OAP's `id`
+   *  alone collapses every firing of the same rule on the same
+   *  entity, so we key selection by (id + startTime) instead. */
+  selectedAlarmKey?: string | null;
+}>();
+
+const emit = defineEmits<{
+  (e: 'select-alarm', alarmKey: string): void;
+  (e: 'select-time-range', range: { startTime: number; endTime: number }): void;
+  (e: 'clear-selection'): void;
+}>();
+
+function keyFor(a: AlarmMessage): string {
+  return `${a.id}::${a.startTime}`;
+}
+
+const PALETTE = [
+  '#f97316', // orange (accent)
+  '#60a5fa', // blue
+  '#22d3ee', // cyan
+  '#a78bfa', // purple
+  '#34d399', // green
+  '#f472b6', // pink
+];
+
+const container = ref<HTMLDivElement | null>(null);
+let chart: EChartsType | null = null;
+
+function trafficSeriesOption(s: AlarmTrafficSeries, idx: number, addNowMark: boolean) {
+  const color = PALETTE[idx % PALETTE.length];
+  return {
+    name: s.label,
+    type: 'line',
+    showSymbol: false,
+    smooth: true,
+    lineStyle: { width: 1.5, color, opacity: s.present ? 0.85 : 0.25 },
+    areaStyle: s.present
+      ? {
+          color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+            { offset: 0, color: color + '33' },
+            { offset: 1, color: color + '00' },
+          ]),
+        }
+      : undefined,
+    data: s.points.map((p) => [p.ts, p.value]),
+    // Anchor the `now` reference line on the first series only —
+    // ECharts collapses markLines from multiple series so we only
+    // need it once. Drawn as a faint vertical dashed rule the
+    // operator uses to gauge "is the data current or stale?".
+    markLine: addNowMark
+      ? {
+          symbol: 'none',
+          silent: true,
+          lineStyle: { color: 'rgba(255,255,255,0.25)', type: 'dashed', width: 1 },
+          label: {
+            show: true,
+            position: 'insideEndTop',
+            color: 'rgba(255,255,255,0.5)',
+            fontSize: 10,
+            formatter: 'now',
+          },
+          data: [{ xAxis: Date.now() }],
+        }
+      : undefined,
+    z: 1,
+  };
+}
+
+/* Bucket alarms by layerKey AND pair each layer with the same color
+ * the traffic series uses for that layer. Result is one scatter
+ * series per layer, color-matched to its traffic line, so the
+ * legend reads layer-by-layer instead of one undifferentiated
+ * "Alarms" lump. Unmatched alarms (layerKey null, or layer not in
+ * the traffic config) fall into an "Other" bucket with a neutral
+ * grey + a diamond marker so the operator can still see them. */
+interface AlarmBucket {
+  layerKey: string;
+  label: string;
+  color: string;
+  symbol: 'pin' | 'diamond';
+  alarms: AlarmMessage[];
+}
+
+function bucketAlarmsByLayer(): AlarmBucket[] {
+  // Layer order from the traffic config drives the legend order.
+  const buckets = new Map<string, AlarmBucket>();
+  props.traffic.forEach((s, idx) => {
+    buckets.set(s.layerKey, {
+      layerKey: s.layerKey,
+      label: s.label,
+      color: PALETTE[idx % PALETTE.length],
+      symbol: 'pin',
+      alarms: [],
+    });
+  });
+  const otherKey = '__other__';
+  for (const a of props.alarms) {
+    const k = a.layerKey ?? '';
+    let target = k && buckets.get(k);
+    if (!target) {
+      target = buckets.get(otherKey);
+      if (!target) {
+        target = {
+          layerKey: otherKey,
+          label: 'Other',
+          color: '#818a9c',
+          symbol: 'diamond',
+          alarms: [],
+        };
+        buckets.set(otherKey, target);
+      }
+    }
+    target.alarms.push(a);
+  }
+  // Drop empty buckets so we don't pollute the legend with layers
+  // that have no alarms in the window.
+  return Array.from(buckets.values()).filter((b) => b.alarms.length > 0);
+}
+
+function alarmSeriesFor(bucket: AlarmBucket) {
+  const seriesName = `${bucket.label} · alarms (${bucket.alarms.length})`;
+  const data = bucket.alarms.map((a) => {
+    const firing = a.recoveryTime === null;
+    const k = keyFor(a);
+    const selected = k === props.selectedAlarmKey;
+    return {
+      value: [a.startTime, 0],
+      // `name` carries the composite (id + startTime) so the click
+      // handler emits the right key — ECharts echoes back data.name
+      // on click. id alone collapses every firing of one rule.
+      name: k,
+      itemStyle: {
+        // Firing alarms keep the layer color full-saturation; recovered
+        // ones drop opacity so the operator can tell active from past
+        // at a glance without losing the layer mapping.
+        color: bucket.color,
+        opacity: firing ? 1 : 0.4,
+        borderColor: selected ? '#fff' : 'transparent',
+        borderWidth: selected ? 1.5 : 0,
+      },
+      alarm: a,
+    };
+  });
+  return {
+    name: seriesName,
+    type: 'scatter',
+    yAxisIndex: 1,
+    symbol: bucket.symbol === 'pin' ? 'pin' : 'diamond',
+    symbolSize: 14,
+    z: 5,
+    data,
+    tooltip: {
+      formatter: (params: unknown): string => {
+        const p = params as { data: { alarm: AlarmMessage } };
+        const a = p.data.alarm;
+        const firing = a.recoveryTime === null;
+        const time = new Date(a.startTime).toLocaleString();
+        return [
+          `<div style="font-weight:600;color:${bucket.color};">${escapeHtml(bucket.label)} · ${firing ? 'firing' : 'recovered'}</div>`,
+          `<div style="margin-top:4px;font-size:11px;color:var(--sw-fg-0);">${escapeHtml(a.message)}</div>`,
+          `<div style="margin-top:2px;font-size:10.5px;color:var(--sw-fg-2);">${escapeHtml(a.name)}</div>`,
+          `<div style="margin-top:2px;font-size:10.5px;color:var(--sw-fg-3);">${time}</div>`,
+        ].join('');
+      },
+    },
+  };
+}
+
+function escapeHtml(s: string): string {
+  return s
+    .replace(/&/g, '&amp;')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;');
+}
+
+function buildOption(): echarts.EChartsCoreOption {
+  const alarmBuckets = bucketAlarmsByLayer();
+  return {
+    animation: false,
+    grid: { left: 48, right: 12, top: 36, bottom: 22 },
+    tooltip: {
+      trigger: 'axis',
+      axisPointer: { type: 'line', lineStyle: { color: 'rgba(255,255,255,0.15)' } },
+      backgroundColor: 'rgba(15,20,29,0.95)',
+      borderColor: 'rgba(255,255,255,0.08)',
+      textStyle: { color: '#e8ecf3', fontSize: 11 },
+      extraCssText: 'box-shadow: 0 4px 16px rgba(0,0,0,0.4);',
+    },
+    legend: {
+      top: 4,
+      left: 8,
+      itemWidth: 10,
+      itemHeight: 8,
+      textStyle: { color: '#b6bdcc', fontSize: 11 },
+      type: 'scroll',
+      data: [
+        // Traffic lines first, then alarm flag series per layer.
+        // Names match the `name` we assign on each series — ECharts
+        // pairs them by string identity.
+        ...props.traffic.map((s) => s.label),
+        ...alarmBuckets.map((b) => `${b.label} · alarms (${b.alarms.length})`),
+      ],
+    },
+    brush: {
+      toolbox: ['lineX', 'clear'],
+      xAxisIndex: 0,
+      brushStyle: { color: 'rgba(249,115,22,0.18)', borderColor: 'rgba(249,115,22,0.6)' },
+      throttleType: 'debounce',
+      throttleDelay: 200,
+    },
+    toolbox: {
+      show: true,
+      top: 2,
+      right: 8,
+      itemSize: 12,
+      feature: {
+        brush: {
+          type: ['lineX', 'clear'],
+          title: { lineX: 'select time range', clear: 'clear' } as Record<string, string>,
+          icon: {
+            // ECharts default icons render fine; just style the text colour.
+          } as Record<string, string>,
+        },
+      },
+      iconStyle: { borderColor: '#818a9c' },
+    },
+    xAxis: {
+      type: 'time',
+      min: props.startTime,
+      max: props.endTime,
+      axisLine: { lineStyle: { color: 'rgba(255,255,255,0.08)' } },
+      axisLabel: { color: '#64748b', fontSize: 10 },
+      splitLine: { show: false },
+    },
+    yAxis: [
+      {
+        // Traffic axis (left).
+        type: 'value',
+        name: 'RPM',
+        nameTextStyle: { color: '#64748b', fontSize: 10, padding: [0, 0, 0, -4] },
+        axisLine: { show: false },
+        axisLabel: { color: '#64748b', fontSize: 10 },
+        splitLine: { lineStyle: { color: 'rgba(255,255,255,0.04)' } },
+      },
+      {
+        // Alarms axis — hidden, just a fixed band so the scatter dots
+        // sit on a horizontal line at the bottom of the plot.
+        type: 'value',
+        show: false,
+        min: -1,
+        max: 1,
+      },
+    ],
+    series: [
+      ...props.traffic.map((s, i) => trafficSeriesOption(s, i, i === 0)),
+      ...alarmBuckets.map((b) => alarmSeriesFor(b)),
+    ],
+  };
+}
+
+function attach(): void {
+  if (!container.value) return;
+  chart = echarts.init(container.value, undefined, { renderer: 'canvas' });
+  chart.setOption(buildOption());
+
+  chart.on('click', (params: unknown) => {
+    // All alarm scatter series have `seriesType: 'scatter'` plus an
+    // `alarm` field on the datum. Discriminate by series type rather
+    // than name so the new per-layer series names (e.g. "Mesh ·
+    // alarms (3)") all match without a regex.
+    const p = params as {
+      seriesType?: string;
+      data?: { name?: string; alarm?: AlarmMessage };
+    };
+    if (p.seriesType === 'scatter' && p.data?.alarm && p.data.name) {
+      emit('select-alarm', p.data.name);
+    }
+  });
+
+  /* Brush emits two events: `brushSelected` while the user drags AND
+   * a final one after release. We listen to `brushEnd` so we only
+   * commit a range when the gesture finishes. */
+  chart.on('brushEnd', (params: unknown) => {
+    const p = params as {
+      areas: Array<{ brushType: string; coordRange: [number, number] }>;
+    };
+    const area = p.areas?.[0];
+    if (!area || area.brushType !== 'lineX') {
+      emit('clear-selection');
+      return;
+    }
+    const [a, b] = area.coordRange;
+    const startTime = Math.min(a, b);
+    const endTime = Math.max(a, b);
+    emit('select-time-range', { startTime, endTime });
+  });
+}
+
+function refresh(): void {
+  if (!chart) return;
+  chart.setOption(buildOption(), { replaceMerge: ['series', 'legend'] });
+}
+
+onMounted(() => {
+  attach();
+  if (typeof window !== 'undefined') window.addEventListener('resize', resize);
+});
+function resize(): void {
+  chart?.resize();
+}
+onBeforeUnmount(() => {
+  if (typeof window !== 'undefined') window.removeEventListener('resize', resize);
+  chart?.dispose();
+  chart = null;
+});
+
+watch(
+  () => [props.traffic, props.alarms, props.startTime, props.endTime, props.selectedAlarmKey],
+  () => refresh(),
+  { deep: true },
+);
+</script>
+
+<template>
+  <div ref="container" class="alarms-timeline" :style="{ height: `${height ?? 200}px` }" />
+</template>
+
+<style scoped>
+.alarms-timeline {
+  width: 100%;
+}
+</style>
diff --git a/apps/ui/src/components/icons/Icon.vue b/apps/ui/src/components/icons/Icon.vue
index 4c905e0..71b6f1f 100644
--- a/apps/ui/src/components/icons/Icon.vue
+++ b/apps/ui/src/components/icons/Icon.vue
@@ -44,7 +44,14 @@ export type IconName =
   | 'star'
   | 'flame'
   | 'sky'
-  | 'user';
+  | 'user'
+  | 'mesh'
+  | 'db'
+  | 'cluster'
+  | 'web'
+  | 'fn'
+  | 'cache'
+  | 'topic';
 </script>
 
 <template>
@@ -173,5 +180,39 @@ export type IconName =
       <circle cx="12" cy="8" r="4" />
       <path d="M4 21c0-4 4-6 8-6s8 2 8 6" />
     </template>
+    <template v-else-if="name === 'mesh'">
+      <circle cx="5" cy="6" r="2" />
+      <circle cx="19" cy="6" r="2" />
+      <circle cx="12" cy="14" r="2" />
+      <circle cx="5" cy="20" r="2" />
+      <circle cx="19" cy="20" r="2" />
+      <path d="M6.5 7.5L10.5 13M17.5 7.5L13.5 13M10.5 15L6.5 19M13.5 15L17.5 19" />
+    </template>
+    <template v-else-if="name === 'db'">
+      <ellipse cx="12" cy="5" rx="8" ry="3" />
+      <path d="M4 5v6c0 1.7 3.6 3 8 3s8-1.3 8-3V5M4 11v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6" />
+    </template>
+    <template v-else-if="name === 'cluster'">
+      <rect x="3" y="3" width="8" height="8" rx="1" />
+      <rect x="13" y="3" width="8" height="8" rx="1" />
+      <rect x="3" y="13" width="8" height="8" rx="1" />
+      <rect x="13" y="13" width="8" height="8" rx="1" />
+    </template>
+    <template v-else-if="name === 'web'">
+      <circle cx="12" cy="12" r="9" />
+      <path d="M3 12h18M12 3a14 14 0 010 18M12 3a14 14 0 000 18" />
+    </template>
+    <template v-else-if="name === 'fn'">
+      <path d="M6 4h8l4 4v12H6z" />
+      <path d="M14 4v4h4" />
+    </template>
+    <template v-else-if="name === 'cache'">
+      <circle cx="12" cy="12" r="9" />
+      <path d="M12 7v5l3 2" />
+    </template>
+    <template v-else-if="name === 'topic'">
+      <circle cx="12" cy="12" r="8" />
+      <path d="M4 12h16M12 4v16" opacity="0.6" />
+    </template>
   </svg>
 </template>
diff --git a/apps/ui/src/components/operate/DestructiveConfirm.vue b/apps/ui/src/components/operate/DestructiveConfirm.vue
new file mode 100644
index 0000000..9941a2d
--- /dev/null
+++ b/apps/ui/src/components/operate/DestructiveConfirm.vue
@@ -0,0 +1,122 @@
+<!--
+  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.
+-->
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue';
+import Modal from './Modal.vue';
+import Btn from '@/components/primitives/Btn.vue';
+
+const props = defineProps<{
+  open: boolean;
+  title: string;
+  /** Verb the operator is about to take ("save with allowStorageChange",
+   *  "revert to bundled", …). Shown verbatim. */
+  intent: string;
+  /** Rule name the user must type to confirm. */
+  ruleName: string;
+  /** Optional warning paragraphs. Each rendered as a `<p>`. */
+  warning: readonly string[];
+  /** Loading flag — disables the button while the action is in flight. */
+  busy?: boolean;
+}>();
+
+const emit = defineEmits<{ confirm: []; close: [] }>();
+
+const typed = ref('');
+
+watch(
+  () => props.open,
+  (isOpen) => {
+    if (!isOpen) typed.value = '';
+  },
+);
+
+const armed = computed<boolean>(() => typed.value === props.ruleName);
+</script>
+
+<template>
+  <Modal :open="open" :title="title" @close="emit('close')">
+    <p class="dc__intent">
+      You are about to <strong>{{ intent }}</strong> <code>{{ ruleName }}</code>.
+    </p>
+    <ul class="dc__warning">
+      <li v-for="(line, i) in warning" :key="i">{{ line }}</li>
+    </ul>
+    <label class="dc__label">
+      Type <code>{{ ruleName }}</code> to confirm:
+      <input
+        v-model="typed"
+        type="text"
+        autocomplete="off"
+        spellcheck="false"
+        :data-testid="'destructive-input'"
+      />
+    </label>
+
+    <template #footer>
+      <Btn @click="emit('close')">cancel</Btn>
+      <Btn
+        kind="danger"
+        :disabled="!armed || busy"
+        :data-testid="'destructive-confirm'"
+        @click="emit('confirm')"
+      >
+        {{ busy ? 'applying…' : 'confirm' }}
+      </Btn>
+    </template>
+  </Modal>
+</template>
+
+<style scoped>
+.dc__intent {
+  margin: 0 0 12px;
+  font-size: 13px;
+  line-height: 1.5;
+  color: var(--rr-ink);
+}
+
+.dc__intent code,
+.dc__label code {
+  font-family: var(--rr-font-mono);
+  color: var(--rr-active);
+  font-size: 12px;
+}
+
+.dc__warning {
+  margin: 0 0 16px;
+  padding-left: 18px;
+  font-size: 13px;
+  line-height: 1.55;
+  color: var(--rr-warn);
+}
+
+.dc__warning li {
+  margin-bottom: 4px;
+}
+
+.dc__label {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  font-size: 12px;
+  color: var(--rr-ink2);
+  font-family: var(--rr-font-mono);
+}
+
+.dc__label input {
+  font-family: var(--rr-font-mono);
+}
+</style>
diff --git a/apps/ui/src/components/operate/Modal.vue b/apps/ui/src/components/operate/Modal.vue
new file mode 100644
index 0000000..acec4cb
--- /dev/null
+++ b/apps/ui/src/components/operate/Modal.vue
@@ -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.
+-->
+<script setup lang="ts">
+import { onMounted, onBeforeUnmount } from 'vue';
+
+const props = defineProps<{
+  open: boolean;
+  title: string;
+}>();
+
+const emit = defineEmits<{ close: [] }>();
+
+function onKey(e: KeyboardEvent): void {
+  if (props.open && e.key === 'Escape') emit('close');
+}
+
+onMounted(() => window.addEventListener('keydown', onKey));
+onBeforeUnmount(() => window.removeEventListener('keydown', onKey));
+</script>
+
+<template>
+  <Teleport to="body">
+    <div v-if="open" class="modal" role="dialog" aria-modal="true" @click.self="emit('close')">
+      <div class="modal__panel">
+        <header class="modal__header">
+          <span class="modal__title">{{ title }}</span>
+          <button
+            type="button"
+            class="modal__close"
+            aria-label="close"
+            @click="emit('close')"
+          >
+            ×
+          </button>
+        </header>
+        <div class="modal__body"><slot /></div>
+        <footer v-if="$slots.footer" class="modal__footer"><slot name="footer" /></footer>
+      </div>
+    </div>
+  </Teleport>
+</template>
+
+<style scoped>
+.modal {
+  position: fixed;
+  inset: 0;
+  background: rgba(0, 0, 0, 0.6);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 1000;
+}
+
+.modal__panel {
+  width: 520px;
+  max-width: calc(100vw - 32px);
+  background: var(--rr-bg2);
+  border: 1px solid var(--rr-border);
+  border-radius: var(--rr-radius-lg);
+  display: flex;
+  flex-direction: column;
+  max-height: calc(100vh - 64px);
+  overflow: hidden;
+}
+
+.modal__header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 12px 16px;
+  background: var(--rr-bg3);
+  border-bottom: 1px solid var(--rr-border);
+}
+
+.modal__title {
+  font-family: var(--rr-font-mono);
+  font-size: 12px;
+  letter-spacing: 0.4px;
+  color: var(--rr-heading);
+}
+
+.modal__close {
+  background: transparent;
+  border: none;
+  color: var(--rr-dim);
+  font-size: 14px;
+  cursor: pointer;
+  padding: 0 4px;
+  line-height: 1;
+}
+.modal__close:hover {
+  color: var(--rr-ink);
+}
+
+.modal__body {
+  padding: 16px;
+  overflow: auto;
+  font-size: 13px;
+  color: var(--rr-ink);
+}
+
+.modal__footer {
+  display: flex;
+  gap: 8px;
+  justify-content: flex-end;
+  padding: 12px 16px;
+  border-top: 1px solid var(--rr-border);
+  background: var(--rr-bg);
+}
+</style>
diff --git a/apps/ui/src/components/operate/MonacoDiff.vue b/apps/ui/src/components/operate/MonacoDiff.vue
new file mode 100644
index 0000000..a86ac4a
--- /dev/null
+++ b/apps/ui/src/components/operate/MonacoDiff.vue
@@ -0,0 +1,87 @@
+<!--
+  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.
+-->
+<script setup lang="ts">
+import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
+import * as monaco from 'monaco-editor';
+import { setupMonaco, RR_THEME_NAME } from '../../monaco/setup.js';
+
+const props = defineProps<{
+  /** "before" — what's currently on the server (or bundled). */
+  original: string;
+  /** "after" — what the user has in the buffer. */
+  modified: string;
+}>();
+
+const host = ref<HTMLDivElement | null>(null);
+let diff: monaco.editor.IStandaloneDiffEditor | null = null;
+let originalModel: monaco.editor.ITextModel | null = null;
+let modifiedModel: monaco.editor.ITextModel | null = null;
+
+onMounted(() => {
+  if (!host.value) return;
+  setupMonaco();
+
+  originalModel = monaco.editor.createModel(props.original, 'yaml');
+  modifiedModel = monaco.editor.createModel(props.modified, 'yaml');
+
+  diff = monaco.editor.createDiffEditor(host.value, {
+    theme: RR_THEME_NAME,
+    automaticLayout: true,
+    fontFamily: "'JetBrains Mono', ui-monospace, monospace",
+    fontSize: 13,
+    minimap: { enabled: false },
+    renderSideBySide: true,
+    readOnly: true,
+    originalEditable: false,
+  });
+  diff.setModel({ original: originalModel, modified: modifiedModel });
+});
+
+watch(
+  () => props.original,
+  (next) => {
+    if (originalModel && originalModel.getValue() !== next) originalModel.setValue(next);
+  },
+);
+watch(
+  () => props.modified,
+  (next) => {
+    if (modifiedModel && modifiedModel.getValue() !== next) modifiedModel.setValue(next);
+  },
+);
+
+onBeforeUnmount(() => {
+  diff?.dispose();
+  originalModel?.dispose();
+  modifiedModel?.dispose();
+  diff = null;
+  originalModel = null;
+  modifiedModel = null;
+});
+</script>
+
+<template>
+  <div ref="host" class="diff" :data-testid="'monaco-diff'" />
+</template>
+
+<style scoped>
+.diff {
+  width: 100%;
+  height: 100%;
+  min-height: 320px;
+}
+</style>
diff --git a/apps/ui/src/components/operate/MonacoYaml.vue b/apps/ui/src/components/operate/MonacoYaml.vue
new file mode 100644
index 0000000..7313526
--- /dev/null
+++ b/apps/ui/src/components/operate/MonacoYaml.vue
@@ -0,0 +1,171 @@
+<!--
+  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.
+-->
+<script setup lang="ts">
+import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
+import * as monaco from 'monaco-editor';
+import type { Catalog } from '@skywalking-horizon-ui/api-client';
+import { setupMonaco, setModelCatalog, RR_THEME_NAME } from '../../monaco/setup.js';
+
+const props = defineProps<{
+  modelValue: string;
+  catalog: Catalog | null;
+  readOnly?: boolean;
+}>();
+
+const emit = defineEmits<{
+  'update:modelValue': [value: string];
+  /** Fired when the operator clicks the green ▶ in the gutter next
+   *  to a `- name: <X>` line. The parent decides how to route — for
+   *  MAL/LAL editor pages, the catalog + file are already in the URL,
+   *  so the parent just needs the rule/metric name. */
+  'debug-click': [ruleName: string];
+}>();
+
+const host = ref<HTMLDivElement | null>(null);
+let editor: monaco.editor.IStandaloneCodeEditor | null = null;
+let model: monaco.editor.ITextModel | null = null;
+let suppressChange = false;
+let decorationCollection: monaco.editor.IEditorDecorationsCollection | null = null;
+/** lineNumber → ruleName, refreshed on every model change so a click
+ *  in the gutter resolves to the rule on that line without a re-scan. */
+const lineToRule = new Map<number, string>();
+
+/** MAL + LAL share the same `- name: <X>` shape inside YAML lists.
+ *  Capture group 1 = metric / rule name. */
+const RULE_NAME_RE = /^[ \t]*-[ \t]+name:[ \t]*([A-Za-z_][A-Za-z0-9_-]*)/gm;
+
+function refreshDecorations(): void {
+  lineToRule.clear();
+  if (!model || !decorationCollection) return;
+  const text = model.getValue();
+  const decorations: monaco.editor.IModelDeltaDecoration[] = [];
+  // Iterate matches and convert byte offsets to (line, column).
+  let m: RegExpExecArray | null;
+  RULE_NAME_RE.lastIndex = 0;
+  while ((m = RULE_NAME_RE.exec(text)) !== null) {
+    // Compute line number from the match offset.
+    let line = 1;
+    for (let i = 0; i < m.index; i++) {
+      if (text.charCodeAt(i) === 10 /* \n */) line += 1;
+    }
+    const ruleName = m[1]!;
+    lineToRule.set(line, ruleName);
+    decorations.push({
+      range: new monaco.Range(line, 1, line, 1),
+      options: {
+        glyphMarginClassName: 'rr-debug-glyph',
+        glyphMarginHoverMessage: {
+          value: `Live debug · **${ruleName}**`,
+        },
+      },
+    });
+  }
+  decorationCollection.set(decorations);
+}
+
+onMounted(() => {
+  if (!host.value) return;
+  setupMonaco();
+
+  model = monaco.editor.createModel(props.modelValue, 'yaml');
+  if (props.catalog) setModelCatalog(model, props.catalog);
+
+  editor = monaco.editor.create(host.value, {
+    model,
+    theme: RR_THEME_NAME,
+    automaticLayout: true,
+    fontFamily: "'JetBrains Mono', ui-monospace, monospace",
+    fontSize: 13,
+    minimap: { enabled: false },
+    scrollBeyondLastLine: false,
+    tabSize: 2,
+    insertSpaces: true,
+    readOnly: props.readOnly ?? false,
+    renderWhitespace: 'selection',
+    smoothScrolling: true,
+    wordWrap: 'off',
+    /* Glyph margin hosts the green ▶ next to each `- name: …`
+     * row so the operator can jump to the live debugger from
+     * the editor without re-typing the metric name. */
+    glyphMargin: true,
+  });
+
+  decorationCollection = editor.createDecorationsCollection([]);
+  refreshDecorations();
+
+  model.onDidChangeContent(() => {
+    if (!suppressChange && model) emit('update:modelValue', model.getValue());
+    refreshDecorations();
+  });
+
+  editor.onMouseDown((e) => {
+    if (e.target.type !== monaco.editor.MouseTargetType.GUTTER_GLYPH_MARGIN) return;
+    const line = e.target.position?.lineNumber;
+    if (typeof line !== 'number') return;
+    const ruleName = lineToRule.get(line);
+    if (ruleName) emit('debug-click', ruleName);
+  });
+});
+
+watch(
+  () => props.modelValue,
+  (next) => {
+    if (model && model.getValue() !== next) {
+      suppressChange = true;
+      try {
+        model.setValue(next);
+      } finally {
+        suppressChange = false;
+      }
+    }
+  },
+);
+
+watch(
+  () => props.catalog,
+  (next) => {
+    if (model && next) setModelCatalog(model, next);
+  },
+);
+
+watch(
+  () => props.readOnly,
+  (ro) => {
+    if (editor) editor.updateOptions({ readOnly: ro ?? false });
+  },
+);
+
+onBeforeUnmount(() => {
+  editor?.dispose();
+  model?.dispose();
+  editor = null;
+  model = null;
+  decorationCollection = null;
+});
+</script>
+
+<template>
+  <div ref="host" class="monaco" :data-testid="'monaco-yaml'" />
+</template>
+
+<style scoped>
+.monaco {
+  width: 100%;
+  height: 100%;
+  min-height: 320px;
+}
+</style>
diff --git a/apps/ui/src/components/operate/RuleCard.vue b/apps/ui/src/components/operate/RuleCard.vue
new file mode 100644
index 0000000..aba41bb
--- /dev/null
+++ b/apps/ui/src/components/operate/RuleCard.vue
@@ -0,0 +1,278 @@
+<!--
+  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.
+-->
+<script setup lang="ts">
+import { computed } from 'vue';
+import { useRouter } from 'vue-router';
+import type { ListRow } from '@skywalking-horizon-ui/api-client';
+import Pill from '@/components/primitives/Pill.vue';
+import StatusDot from '@/components/primitives/StatusDot.vue';
+import { overrideKind, formatRelativeTime } from './grouping.js';
+
+const props = defineProps<{ rule: ListRow }>();
+
+const router = useRouter();
+
+const statusTone = computed(() => {
+  switch (props.rule.status) {
+    case 'ACTIVE':
+      return 'ok' as const;
+    case 'INACTIVE':
+      return 'warn' as const;
+    case 'BUNDLED':
+      return 'dim' as const;
+    case 'n/a':
+      return 'err' as const;
+  }
+  return 'dim' as const;
+});
+
+const isSuspended = computed(() => props.rule.localState === 'SUSPENDED');
+
+const override = computed(() => overrideKind(props.rule));
+
+/** ACTIVE/INACTIVE rows carry updateTime + lastApplyError; bundled /
+ *  orphan rows don't. The discriminated union lets us narrow safely. */
+const updateLabel = computed(() => {
+  if (props.rule.status === 'ACTIVE' || props.rule.status === 'INACTIVE') {
+    return formatRelativeTime(props.rule.updateTime);
+  }
+  return null;
+});
+
+const errorMessage = computed(() => {
+  if (props.rule.status === 'ACTIVE' || props.rule.status === 'INACTIVE') {
+    return props.rule.lastApplyError || null;
+  }
+  return null;
+});
+
+const hashShort = computed(() => props.rule.contentHash.slice(0, 7) || '—');
+
+function open(): void {
+  void router.push({
+    name: 'edit',
+    query: { catalog: props.rule.catalog, name: props.rule.name },
+  });
+}
+
+function onKey(ev: KeyboardEvent): void {
+  if (ev.key === 'Enter' || ev.key === ' ') {
+    ev.preventDefault();
+    open();
+  }
+}
+
+/** Live debugger jump target. MAL goes to `/debug/mal?catalog=&name=`
+ *  — the MAL view then loads the file content, parses
+ *  `metricsRules[].name`, and surfaces a metric picker inside (the
+ *  OAP install needs `(name=<file>, ruleName=<metric>)` granularity).
+ *  LAL goes to `/debug/lal?name=<rule>` — LAL rules are file-grained
+ *  on the catalog side already, so the picker is single-step. OAL
+ *  catalog isn't writable; rule cards aren't shown for it. */
+const debugTarget = computed(() => {
+  if (props.rule.catalog === 'lal') {
+    return { path: '/debug/lal', query: { name: props.rule.name } };
+  }
+  return {
+    path: '/debug/mal',
+    query: { catalog: props.rule.catalog, name: props.rule.name },
+  };
+});
+
+function jumpToDebug(ev: MouseEvent): void {
+  ev.stopPropagation();
+  void router.push(debugTarget.value);
+}
+</script>
+
+<template>
+  <!-- Outer is a div (not <button>) so the inner ▶ button can nest
+       without invalid markup. Keyboard support preserved via
+       role+tabindex+keydown. -->
+  <button
+    type="button"
+    class="card"
+    :class="{ 'card--suspended': isSuspended, 'card--orphan': rule.status === 'n/a' }"
+    :data-testid="`rule-card-${rule.name}`"
+    @click="open"
+    @keydown="onKey"
+  >
+    <div class="card__row">
+      <button
+        type="button"
+        class="card__dbgbtn"
+        :title="`Live debug ${rule.catalog} · ${rule.name}`"
+        :aria-label="`Live debug ${rule.name}`"
+        @click="jumpToDebug"
+      >▶</button>
+      <div class="card__name" :title="rule.name">{{ rule.name }}</div>
+      <Pill :tone="statusTone">{{ rule.status }}</Pill>
+    </div>
+
+    <div class="card__row card__row--badges">
+      <Pill v-if="override === 'modified'" tone="warn">modified</Pill>
+      <Pill v-else-if="override === 'override'" tone="info">override</Pill>
+      <Pill v-else-if="override === 'bundled-only'" tone="dim">bundled</Pill>
+      <span v-if="isSuspended" class="card__suspending">
+        <StatusDot tone="warn" :size="6" />
+        applying…
+      </span>
+    </div>
+
+    <div class="card__meta">
+      <span class="card__hash">{{ hashShort }}</span>
+      <span v-if="updateLabel" class="card__updated">· {{ updateLabel }}</span>
+    </div>
+
+    <p v-if="errorMessage" class="card__error" :title="errorMessage">
+      {{ errorMessage }}
+    </p>
+  </button>
+</template>
+
+<style scoped>
+.card {
+  display: flex;
+  flex-direction: column;
+  align-items: stretch;
+  gap: 6px;
+  width: 240px;
+  text-align: left;
+  background: var(--rr-bg2);
+  border: 1px solid var(--rr-border);
+  border-radius: var(--rr-radius-md);
+  padding: 10px 12px;
+  cursor: pointer;
+  font: inherit;
+  color: inherit;
+  transition: border-color 0.08s ease, background 0.08s ease;
+}
+
+.card:hover {
+  background: var(--rr-bg3);
+  border-color: var(--rr-active);
+}
+
+.card:focus-visible {
+  outline: 1px solid var(--rr-active);
+  outline-offset: -1px;
+  border-color: var(--rr-active);
+}
+
+.card--orphan {
+  border-style: dashed;
+  border-color: color-mix(in oklch, var(--rr-err) 50%, var(--rr-border));
+}
+
+.card--suspended {
+  animation: cardpulse 2s ease-in-out infinite;
+}
+
+@keyframes cardpulse {
+  0%,
+  100% {
+    border-color: var(--rr-warn);
+  }
+  50% {
+    border-color: color-mix(in oklch, var(--rr-warn) 30%, var(--rr-border));
+  }
+}
+
+.card__row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+  min-height: 18px;
+}
+
+.card__row--badges {
+  flex-wrap: wrap;
+  gap: 6px;
+  justify-content: flex-start;
+}
+
+.card__name {
+  font-family: var(--rr-font-mono);
+  font-size: 13px;
+  color: var(--rr-heading);
+  font-weight: 500;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.card__suspending {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  font-family: var(--rr-font-mono);
+  font-size: 11.5px;
+  color: var(--rr-warn);
+  letter-spacing: 0.4px;
+}
+
+.card__meta {
+  display: flex;
+  gap: 6px;
+  font-family: var(--rr-font-mono);
+  font-size: 11.5px;
+  color: var(--rr-dim);
+}
+
+.card__hash {
+  letter-spacing: 0.4px;
+}
+
+.card__error {
+  margin: 0;
+  font-family: var(--rr-font-mono);
+  font-size: 11.5px;
+  color: var(--rr-err);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+/* IDE-style "run" arrow on the left of every rule name. Sits in the
+   gutter so the rule name's left edge stays aligned across cards. */
+.card__dbgbtn {
+  border: 0;
+  padding: 0;
+  margin: 0;
+  background: transparent;
+  color: var(--rr-dim);
+  font-family: inherit;
+  font-size: 11px;
+  line-height: 1;
+  width: 16px;
+  flex-shrink: 0;
+  cursor: pointer;
+  transition: color 80ms;
+  user-select: none;
+}
+
+.card:hover .card__dbgbtn {
+  color: var(--rr-ok);
+}
+
+.card__dbgbtn:hover,
+.card__dbgbtn:focus-visible {
+  color: var(--rr-active);
+  outline: none;
+}
+</style>
diff --git a/apps/ui/src/components/operate/grouping.ts b/apps/ui/src/components/operate/grouping.ts
new file mode 100644
index 0000000..e414f98
--- /dev/null
+++ b/apps/ui/src/components/operate/grouping.ts
@@ -0,0 +1,82 @@
+/*
+ * 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 { ListRow } from '@skywalking-horizon-ui/api-client';
+
+export interface RuleGroup {
+  group: string;
+  rules: ListRow[];
+}
+
+export const TOP_LEVEL = '(top level)';
+
+/** Group rules by the first path segment of `name`. Names without `/`
+ *  go into a `(top level)` group placed last. Within each group rules
+ *  are sorted by name. */
+export function groupRules(rules: readonly ListRow[]): RuleGroup[] {
+  const map = new Map<string, ListRow[]>();
+  for (const r of rules) {
+    const idx = r.name.indexOf('/');
+    const group = idx >= 0 ? r.name.slice(0, idx) : TOP_LEVEL;
+    let bucket = map.get(group);
+    if (!bucket) {
+      bucket = [];
+      map.set(group, bucket);
+    }
+    bucket.push(r);
+  }
+  const entries = [...map.entries()];
+  entries.sort(([a], [b]) => {
+    if (a === TOP_LEVEL) return 1;
+    if (b === TOP_LEVEL) return -1;
+    return a.localeCompare(b);
+  });
+  return entries.map(([group, list]) => ({
+    group,
+    rules: list.slice().sort((x, y) => x.name.localeCompare(y.name)),
+  }));
+}
+
+/** "Modified from bundled" / "Override" / "Bundled-only" / null. */
+export type OverrideKind = 'modified' | 'override' | 'bundled-only' | null;
+
+export function overrideKind(rule: ListRow): OverrideKind {
+  if (rule.status === 'BUNDLED') return 'bundled-only';
+  if (!rule.bundled) return null;
+  if (rule.bundledContentHash && rule.contentHash !== rule.bundledContentHash) {
+    return 'modified';
+  }
+  return 'override';
+}
+
+/** "2m ago", "yesterday", etc. — used by RuleCard for ACTIVE/INACTIVE
+ *  rows. Coarse on purpose: minute-precision is more honest given
+ *  cluster-tick latency. */
+export function formatRelativeTime(updateTime: number, now: number = Date.now()): string {
+  const diffMs = now - updateTime;
+  if (diffMs < 0) return 'just now';
+  const sec = Math.floor(diffMs / 1000);
+  if (sec < 60) return `${sec}s ago`;
+  const min = Math.floor(sec / 60);
+  if (min < 60) return `${min}m ago`;
+  const hr = Math.floor(min / 60);
+  if (hr < 24) return `${hr}h ago`;
+  const days = Math.floor(hr / 24);
+  if (days === 1) return 'yesterday';
+  if (days < 7) return `${days}d ago`;
+  return new Date(updateTime).toISOString().slice(0, 10);
+}
diff --git a/apps/ui/src/components/overview/widgets/AlarmsWidget.vue b/apps/ui/src/components/overview/widgets/AlarmsWidget.vue
new file mode 100644
index 0000000..2ae8e60
--- /dev/null
+++ b/apps/ui/src/components/overview/widgets/AlarmsWidget.vue
@@ -0,0 +1,104 @@
+<!--
+  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.
+-->
+<!--
+  Active-alarms widget for the per-overview dashboards. Renders a
+  placeholder list until the OAP `getAlarm` query is plumbed through
+  (alarms are read-only and recover server-side, so this stays a list,
+  not an action surface). Visual treatment mirrors `AlarmsPanel.vue` so
+  the two read as siblings.
+-->
+<script setup lang="ts">
+import { computed } from 'vue';
+import { RouterLink } from 'vue-router';
+import Icon from '@/components/icons/Icon.vue';
+
+const props = defineProps<{ title: string; tip?: string; limit?: number }>();
+
+const placeholderRows = [
+  { sev: 'err', rule: 'Service SLA below 98%', scope: '— · awaiting OAP getAlarm', since: '—' },
+  { sev: 'warn', rule: 'JVM Old GC > 5s/min', scope: '— · awaiting OAP getAlarm', since: '—' },
+  { sev: 'info', rule: 'Deployment detected', scope: '— · awaiting OAP getAlarm', since: '—' },
+];
+const rows = computed(() =>
+  props.limit && props.limit > 0 ? placeholderRows.slice(0, props.limit) : placeholderRows,
+);
+</script>
+
+<template>
+  <section class="sw-card alarms-widget">
+    <header>
+      <h4>{{ title }}</h4>
+      <span class="sw-badge">Phase 5</span>
+      <RouterLink class="all-link" to="/alarms">
+        <span>View all</span>
+        <Icon name="chev" :size="10" />
+      </RouterLink>
+    </header>
+    <div class="body">
+      <p class="lede">
+        Read-only — alarms recover backend-side. Layer filter is best-effort in v1.
+      </p>
+      <div class="rows">
+        <div v-for="(a, i) in rows" :key="i" class="alarm-row">
+          <span class="sw-badge dot" :class="a.sev">{{ a.sev }}</span>
+          <div class="alarm-text">
+            <div class="rule">{{ a.rule }}</div>
+            <div class="scope">{{ a.scope }}</div>
+          </div>
+          <span class="since">{{ a.since }}</span>
+        </div>
+      </div>
+    </div>
+  </section>
+</template>
+
+<style scoped>
+.alarms-widget { display: flex; flex-direction: column; min-height: 0; height: 100%; }
+header {
+  display: flex; align-items: center; gap: 8px;
+  padding: 10px 12px;
+  border-bottom: 1px solid var(--sw-line);
+}
+h4 { margin: 0; font-size: 12px; font-weight: 600; color: var(--sw-fg-0); }
+.all-link {
+  margin-left: auto; font-size: 11px; color: var(--sw-fg-2);
+  text-decoration: none; display: inline-flex; align-items: center; gap: 4px;
+}
+.all-link:hover { color: var(--sw-accent-2); }
+.body { padding: 10px 12px 12px; display: flex; flex-direction: column; gap: 8px; flex: 1; overflow: hidden; }
+.lede { margin: 0; font-size: 10.5px; color: var(--sw-fg-3); line-height: 1.5; }
+.rows { display: flex; flex-direction: column; gap: 4px; overflow: auto; }
+.alarm-row {
+  display: flex; align-items: flex-start; gap: 8px; padding: 6px 0;
+  border-top: 1px solid var(--sw-line); font-size: 11px;
+}
+.alarm-row:first-child { border-top: none; }
+.sw-badge.dot::before {
+  content: ''; width: 5px; height: 5px; border-radius: 50%;
+  background: currentColor; display: inline-block; margin-right: 3px;
+}
+.alarm-text { flex: 1; min-width: 0; }
+.alarm-text .rule {
+  color: var(--sw-fg-1); font-weight: 500;
+  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+}
+.alarm-text .scope {
+  font-family: var(--sw-mono); color: var(--sw-fg-3); font-size: 10px;
+  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+}
+.since { color: var(--sw-fg-3); font-size: 10px; flex: 0 0 auto; }
+</style>
diff --git a/apps/ui/src/components/overview/widgets/K8sSummaryWidget.vue b/apps/ui/src/components/overview/widgets/K8sSummaryWidget.vue
new file mode 100644
index 0000000..ecdcb21
--- /dev/null
+++ b/apps/ui/src/components/overview/widgets/K8sSummaryWidget.vue
@@ -0,0 +1,115 @@
+<!--
+  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.
+-->
+<!--
+  Kubernetes capacity + utilisation block. Two rows:
+    - Counts: nodes, namespaces, deployments, statefulsets, daemonsets,
+      services, containers (per the layer's k8s_cluster_* family).
+    - Utilisation: CPU / memory / storage % across the cluster.
+  Both sourced from the K8S layer via the standard landing aggregate.
+  Click-through opens the K8S layer's Service tab.
+-->
+<script setup lang="ts">
+import { computed } from 'vue';
+import { RouterLink } from 'vue-router';
+import { K8S_SUMMARY_KPIS } from '@/composables/useOverviewDashboard';
+import { formatValue } from './ValueFormat';
+
+const props = defineProps<{
+  title: string;
+  tip?: string;
+  layer?: string;
+  kpiValues: Record<string, number | null>;
+}>();
+
+const counts = computed(() => K8S_SUMMARY_KPIS.filter((k) => !k.unit));
+const usages = computed(() => K8S_SUMMARY_KPIS.filter((k) => k.unit === '%'));
+const tileTo = computed(() =>
+  props.layer ? `/layer/${props.layer.toLowerCase()}/service` : '',
+);
+
+function pct(v: number | null | undefined): string {
+  if (v === null || v === undefined || !Number.isFinite(v)) return '0%';
+  return `${Math.max(0, Math.min(100, v)).toFixed(1)}%`;
+}
+</script>
+
+<template>
+  <component :is="tileTo ? RouterLink : 'div'" :to="tileTo || undefined" class="tile-link">
+    <section class="sw-card k8s">
+      <header>
+        <h4>{{ title }}</h4>
+        <span v-if="tip" class="tip" :title="tip">?</span>
+      </header>
+      <div class="counts">
+        <div v-for="k in counts" :key="k.label" class="count">
+          <span class="count-value">{{ formatValue(kpiValues[k.label]) }}</span>
+          <span class="count-label">{{ k.label }}</span>
+        </div>
+      </div>
+      <div class="usages">
+        <div v-for="k in usages" :key="k.label" class="usage">
+          <div class="usage-head">
+            <span class="usage-label">{{ k.label }}</span>
+            <span class="usage-value">{{ formatValue(kpiValues[k.label], '%') }}</span>
+          </div>
+          <div class="bar">
+            <span class="fill" :style="{ width: pct(kpiValues[k.label]) }" />
+          </div>
+        </div>
+      </div>
+    </section>
+  </component>
+</template>
+
+<style scoped>
+.tile-link { display: block; text-decoration: none; color: inherit; height: 100%; }
+.tile-link:hover .sw-card { border-color: var(--sw-line-3); }
+.k8s { display: flex; flex-direction: column; padding: 12px 14px; gap: 12px; min-height: 0; height: 100%; }
+header { display: flex; align-items: center; gap: 6px; }
+h4 { margin: 0; font-size: 12px; font-weight: 600; color: var(--sw-fg-0); }
+.tip {
+  font-size: 9px; color: var(--sw-fg-3); border: 1px solid var(--sw-line-2);
+  border-radius: 50%; width: 13px; height: 13px;
+  display: inline-flex; align-items: center; justify-content: center; cursor: help;
+}
+.counts {
+  display: grid; grid-template-columns: repeat(7, 1fr); gap: 8px;
+}
+.count {
+  display: flex; flex-direction: column; gap: 2px;
+  padding: 8px 10px;
+  background: var(--sw-bg-1);
+  border: 1px solid var(--sw-line);
+  border-radius: 6px;
+}
+.count-value {
+  font-size: 18px; font-weight: 600; color: var(--sw-fg-0);
+  font-variant-numeric: tabular-nums;
+}
+.count-label {
+  font-size: 10px; color: var(--sw-fg-3); text-transform: uppercase; letter-spacing: 0.05em;
+}
+.usages { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
+.usage-head { display: flex; justify-content: space-between; margin-bottom: 4px; }
+.usage-label { font-size: 11px; color: var(--sw-fg-2); }
+.usage-value {
+  font-size: 12px; font-weight: 600; color: var(--sw-fg-0);
+  font-variant-numeric: tabular-nums;
+}
+.bar { height: 4px; background: var(--sw-bg-2); border-radius: 2px; overflow: hidden; }
+.fill { display: block; height: 100%; background: var(--sw-accent); transition: width 0.3s; }
+</style>
diff --git a/apps/ui/src/components/overview/widgets/KpiTileWidget.vue b/apps/ui/src/components/overview/widgets/KpiTileWidget.vue
new file mode 100644
index 0000000..1128f97
--- /dev/null
+++ b/apps/ui/src/components/overview/widgets/KpiTileWidget.vue
@@ -0,0 +1,111 @@
+<!--
+  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.
+-->
+<script setup lang="ts">
+import { computed } from 'vue';
+import { RouterLink } from 'vue-router';
+import type { OverviewKpi } from '@skywalking-horizon-ui/api-client';
+import { formatValue } from './ValueFormat';
+
+const props = defineProps<{
+  title: string;
+  tip?: string;
+  /** Layer key — clicking the tile navigates to the layer's Service page. */
+  layer?: string;
+  showCount?: boolean;
+  count: number | null | undefined;
+  kpis: OverviewKpi[];
+  /** Value per kpi.label. */
+  kpiValues: Record<string, number | null>;
+}>();
+
+// BFF /api/menu lowercases layer keys, and that's the casing the sidebar
+// and per-layer routes all use. The overview JSON authors uppercase
+// (matching OAP's enum), so normalise here so the click-through URL
+// matches the rest of the app.
+const tileTo = computed(() =>
+  props.layer ? `/layer/${props.layer.toLowerCase()}/service` : '',
+);
+</script>
+
+<template>
+  <RouterLink v-if="tileTo" :to="tileTo" class="tile-link">
+    <section class="sw-card tile">
+      <header>
+        <h4>{{ title }}</h4>
+        <span v-if="tip" class="tip" :title="tip">?</span>
+      </header>
+      <div v-if="showCount" class="count">
+        <span class="count-label">Services</span>
+        <span class="count-value">{{ formatValue(count) }}</span>
+      </div>
+      <div class="kpis">
+        <div v-for="k in kpis" :key="k.label" class="kpi">
+          <span class="kpi-label">{{ k.label }}</span>
+          <span class="kpi-value">{{ formatValue(kpiValues[k.label], k.unit) }}</span>
+        </div>
+      </div>
+    </section>
+  </RouterLink>
+  <div v-else class="tile-link">
+    <section class="sw-card tile">
+      <header>
+        <h4>{{ title }}</h4>
+        <span v-if="tip" class="tip" :title="tip">?</span>
+      </header>
+      <div v-if="showCount" class="count">
+        <span class="count-label">Services</span>
+        <span class="count-value">{{ formatValue(count) }}</span>
+      </div>
+      <div class="kpis">
+        <div v-for="k in kpis" :key="k.label" class="kpi">
+          <span class="kpi-label">{{ k.label }}</span>
+          <span class="kpi-value">{{ formatValue(kpiValues[k.label], k.unit) }}</span>
+        </div>
+      </div>
+    </section>
+  </div>
+</template>
+
+<style scoped>
+.tile-link { display: block; text-decoration: none; color: inherit; height: 100%; }
+.tile-link:hover .sw-card { border-color: var(--sw-line-3); }
+.tile { display: flex; flex-direction: column; padding: 10px 12px; gap: 8px; min-height: 0; height: 100%; }
+header { display: flex; align-items: center; gap: 6px; }
+h4 { margin: 0; font-size: 11px; font-weight: 600; color: var(--sw-fg-1); }
+.tip {
+  font-size: 9px; color: var(--sw-fg-3); border: 1px solid var(--sw-line-2);
+  border-radius: 50%; width: 13px; height: 13px;
+  display: inline-flex; align-items: center; justify-content: center; cursor: help;
+}
+.count {
+  display: flex; align-items: baseline; gap: 8px;
+  padding: 2px 0 6px;
+  border-bottom: 1px dashed var(--sw-line);
+}
+.count-label { font-size: 10px; color: var(--sw-fg-3); text-transform: uppercase; letter-spacing: 0.08em; }
+.count-value {
+  font-size: 22px; font-weight: 600; color: var(--sw-fg-0);
+  font-variant-numeric: tabular-nums; margin-left: auto;
+}
+.kpis { display: flex; flex-direction: column; gap: 4px; }
+.kpi { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
+.kpi-label { font-size: 11px; color: var(--sw-fg-2); }
+.kpi-value {
+  font-size: 13px; font-weight: 600; color: var(--sw-fg-0);
+  font-variant-numeric: tabular-nums;
+}
+</style>
diff --git a/apps/ui/src/components/overview/widgets/MetricWidget.vue b/apps/ui/src/components/overview/widgets/MetricWidget.vue
new file mode 100644
index 0000000..750bcae
--- /dev/null
+++ b/apps/ui/src/components/overview/widgets/MetricWidget.vue
@@ -0,0 +1,45 @@
+<!--
+  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.
+-->
+<script setup lang="ts">
+import { formatValue } from './ValueFormat';
+defineProps<{ title: string; tip?: string; value: number | null | undefined; unit?: string }>();
+</script>
+
+<template>
+  <section class="sw-card tile">
+    <header>
+      <h4>{{ title }}</h4>
+      <span v-if="tip" class="tip" :title="tip">?</span>
+    </header>
+    <div class="value">{{ formatValue(value, unit) }}</div>
+  </section>
+</template>
+
+<style scoped>
+.tile { display: flex; flex-direction: column; padding: 10px 12px; gap: 4px; min-height: 0; }
+header { display: flex; align-items: center; gap: 6px; }
+h4 { margin: 0; font-size: 11px; font-weight: 600; color: var(--sw-fg-1); }
+.tip {
+  font-size: 9px; color: var(--sw-fg-3); border: 1px solid var(--sw-line-2);
+  border-radius: 50%; width: 13px; height: 13px;
+  display: inline-flex; align-items: center; justify-content: center; cursor: help;
+}
+.value {
+  font-size: 22px; font-weight: 600; color: var(--sw-fg-0); margin-top: auto;
+  font-variant-numeric: tabular-nums;
+}
+</style>
diff --git a/apps/ui/src/components/overview/widgets/PilotSummaryWidget.vue b/apps/ui/src/components/overview/widgets/PilotSummaryWidget.vue
new file mode 100644
index 0000000..0ff0c3a
--- /dev/null
+++ b/apps/ui/src/components/overview/widgets/PilotSummaryWidget.vue
@@ -0,0 +1,87 @@
+<!--
+  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.
+-->
+<!--
+  Istio pilot block. xDS push count, reject errors, write timeouts, and
+  p95 push time — sourced from the MESH_CP layer via the standard
+  landing aggregate. Click-through opens the MESH_CP layer's Instance
+  tab (where per-pilot drill-down lives).
+-->
+<script setup lang="ts">
+import { computed } from 'vue';
+import { RouterLink } from 'vue-router';
+import { PILOT_SUMMARY_KPIS } from '@/composables/useOverviewDashboard';
+import { formatValue } from './ValueFormat';
+
+const props = defineProps<{
+  title: string;
+  tip?: string;
+  layer?: string;
+  kpiValues: Record<string, number | null>;
+}>();
+
+const tileTo = computed(() =>
+  props.layer ? `/layer/${props.layer.toLowerCase()}/instance` : '',
+);
+</script>
+
+<template>
+  <component :is="tileTo ? RouterLink : 'div'" :to="tileTo || undefined" class="tile-link">
+    <section class="sw-card pilot">
+      <header>
+        <h4>{{ title }}</h4>
+        <span v-if="tip" class="tip" :title="tip">?</span>
+      </header>
+      <div class="kpis">
+        <div v-for="k in PILOT_SUMMARY_KPIS" :key="k.label" class="kpi">
+          <span class="kpi-value">{{ formatValue(kpiValues[k.label], k.unit) }}</span>
+          <span class="kpi-label">{{ k.label }}</span>
+        </div>
+      </div>
+    </section>
+  </component>
+</template>
+
+<style scoped>
+.tile-link { display: block; text-decoration: none; color: inherit; height: 100%; }
+.tile-link:hover .sw-card { border-color: var(--sw-line-3); }
+.pilot { display: flex; flex-direction: column; padding: 12px 14px; gap: 12px; min-height: 0; height: 100%; }
+header { display: flex; align-items: center; gap: 6px; }
+h4 { margin: 0; font-size: 12px; font-weight: 600; color: var(--sw-fg-0); }
+.tip {
+  font-size: 9px; color: var(--sw-fg-3); border: 1px solid var(--sw-line-2);
+  border-radius: 50%; width: 13px; height: 13px;
+  display: inline-flex; align-items: center; justify-content: center; cursor: help;
+}
+.kpis {
+  display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; flex: 1;
+}
+.kpi {
+  display: flex; flex-direction: column; gap: 4px;
+  padding: 10px 12px;
+  background: var(--sw-bg-1);
+  border: 1px solid var(--sw-line);
+  border-radius: 6px;
+  justify-content: center;
+}
+.kpi-value {
+  font-size: 20px; font-weight: 600; color: var(--sw-fg-0);
+  font-variant-numeric: tabular-nums;
+}
+.kpi-label {
+  font-size: 10.5px; color: var(--sw-fg-3); text-transform: uppercase; letter-spacing: 0.05em;
+}
+</style>
diff --git a/apps/ui/src/components/shell/AppShell.vue b/apps/ui/src/components/overview/widgets/SectionBreak.vue
similarity index 50%
copy from apps/ui/src/components/shell/AppShell.vue
copy to apps/ui/src/components/overview/widgets/SectionBreak.vue
index 514ae7c..3a15a2f 100644
--- a/apps/ui/src/components/shell/AppShell.vue
+++ b/apps/ui/src/components/overview/widgets/SectionBreak.vue
@@ -15,27 +15,37 @@
   limitations under the License.
 -->
 <script setup lang="ts">
-import { RouterView } from 'vue-router';
-import AppSidebar from './AppSidebar.vue';
-import AppTopbar from './AppTopbar.vue';
-import TracePopout from '@/components/trace/TracePopout.vue';
-import ZipkinTracePopout from '@/components/trace/ZipkinTracePopout.vue';
+defineProps<{ title: string }>();
 </script>
 
 <template>
-  <div class="sw">
-    <AppSidebar />
-    <AppTopbar />
-    <main class="sw-main">
-      <RouterView />
-    </main>
-    <!-- Global trace-id popout: any page can call useTracePopout().openTrace(id)
-         and this modal renders the waterfall + span detail. -->
-    <TracePopout />
-    <!-- Zipkin trace popout — separate URL key (`?openZipkinTraceId=`)
-         so the native + Zipkin popouts can be open in parallel without
-         collision (e.g. an operator drilling into a Zipkin trace from
-         a Logs row → trace link on a mesh layer). -->
-    <ZipkinTracePopout />
+  <div class="section-break">
+    <span class="line" />
+    <h3>{{ title }}</h3>
+    <span class="line" />
   </div>
 </template>
+
+<style scoped>
+.section-break {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 4px 2px;
+}
+.section-break h3 {
+  margin: 0;
+  font-size: 10.5px;
+  font-weight: 700;
+  letter-spacing: 0.1em;
+  text-transform: uppercase;
+  color: var(--sw-fg-2);
+  white-space: nowrap;
+}
+.line {
+  flex: 1;
+  height: 1px;
+  background: var(--sw-line-2);
+}
+.line:first-child { max-width: 16px; }
+</style>
diff --git a/apps/ui/src/components/overview/widgets/ServiceCountWidget.vue b/apps/ui/src/components/overview/widgets/ServiceCountWidget.vue
new file mode 100644
index 0000000..a88e252
--- /dev/null
+++ b/apps/ui/src/components/overview/widgets/ServiceCountWidget.vue
@@ -0,0 +1,45 @@
+<!--
+  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.
+-->
+<script setup lang="ts">
+import { formatValue } from './ValueFormat';
+defineProps<{ title: string; tip?: string; value: number | null | undefined }>();
+</script>
+
+<template>
+  <section class="sw-card tile">
+    <header>
+      <h4>{{ title }}</h4>
+      <span v-if="tip" class="tip" :title="tip">?</span>
+    </header>
+    <div class="value">{{ formatValue(value) }}</div>
+  </section>
+</template>
+
+<style scoped>
+.tile { display: flex; flex-direction: column; padding: 10px 12px; gap: 4px; min-height: 0; }
+header { display: flex; align-items: center; gap: 6px; }
+h4 { margin: 0; font-size: 11px; font-weight: 600; color: var(--sw-fg-1); }
+.tip {
+  font-size: 9px; color: var(--sw-fg-3); border: 1px solid var(--sw-line-2);
+  border-radius: 50%; width: 13px; height: 13px;
+  display: inline-flex; align-items: center; justify-content: center; cursor: help;
+}
+.value {
+  font-size: 22px; font-weight: 600; color: var(--sw-fg-0); margin-top: auto;
+  font-variant-numeric: tabular-nums;
+}
+</style>
diff --git a/apps/ui/src/components/overview/widgets/ValueFormat.ts b/apps/ui/src/components/overview/widgets/ValueFormat.ts
new file mode 100644
index 0000000..1ed0535
--- /dev/null
+++ b/apps/ui/src/components/overview/widgets/ValueFormat.ts
@@ -0,0 +1,35 @@
+/*
+ * 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.
+ */
+
+/** Compact numeric formatter for overview KPI tiles. Values fall back to
+ *  `—` when null/undefined/NaN; integers display without decimals;
+ *  fractional values round to 2 dp; large values get k/M suffixes. */
+export function formatValue(v: number | null | undefined, unit?: string): string {
+  if (v === null || v === undefined || !Number.isFinite(v)) return '—';
+  const abs = Math.abs(v);
+  let body: string;
+  if (abs >= 1_000_000) body = (v / 1_000_000).toFixed(1) + 'M';
+  else if (abs >= 1_000) body = (v / 1_000).toFixed(1) + 'k';
+  else if (Number.isInteger(v)) body = String(v);
+  else if (abs >= 100) body = v.toFixed(0);
+  else if (abs >= 10) body = v.toFixed(1);
+  else body = v.toFixed(2);
+  if (!unit) return body;
+  const u = unit.trim();
+  if (u === '%') return `${body}%`;
+  return `${body} ${u}`;
+}
diff --git a/apps/ui/src/components/primitives/Btn.vue b/apps/ui/src/components/primitives/Btn.vue
new file mode 100644
index 0000000..62d40fc
--- /dev/null
+++ b/apps/ui/src/components/primitives/Btn.vue
@@ -0,0 +1,101 @@
+<!--
+  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.
+-->
+<script setup lang="ts">
+type Kind = 'primary' | 'ghost' | 'danger';
+type Size = 'sm' | 'md';
+
+withDefaults(
+  defineProps<{
+    kind?: Kind;
+    size?: Size;
+    type?: 'button' | 'submit';
+    disabled?: boolean;
+  }>(),
+  { kind: 'ghost', size: 'md', type: 'button', disabled: false },
+);
+</script>
+
+<template>
+  <button
+    :type="type"
+    :disabled="disabled"
+    class="btn"
+    :class="[`btn--${kind}`, `btn--${size}`]"
+  >
+    <slot />
+  </button>
+</template>
+
+<style scoped>
+.btn {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  font-family: var(--rr-font-ui);
+  font-weight: 500;
+  letter-spacing: 0.2px;
+  border: 1px solid transparent;
+  border-radius: var(--rr-radius-md);
+  cursor: pointer;
+  white-space: nowrap;
+  transition: background 0.08s ease;
+}
+
+.btn:disabled {
+  cursor: not-allowed;
+  opacity: 0.5;
+}
+
+.btn--md {
+  padding: 5px 12px;
+  font-size: 12px;
+}
+
+.btn--sm {
+  padding: 3px 8px;
+  font-size: 11.5px;
+}
+
+.btn--ghost {
+  background: transparent;
+  color: var(--rr-ink);
+  border-color: var(--rr-border2);
+}
+.btn--ghost:hover:not(:disabled) {
+  background: var(--rr-bg2);
+  border-color: var(--rr-active);
+  color: var(--rr-heading);
+}
+
+.btn--primary {
+  background: var(--rr-active);
+  color: var(--rr-bg);
+  border-color: var(--rr-active);
+}
+.btn--primary:hover:not(:disabled) {
+  filter: brightness(1.08);
+}
+
+.btn--danger {
+  background: transparent;
+  color: var(--rr-err);
+  border-color: var(--rr-err);
+}
+.btn--danger:hover:not(:disabled) {
+  background: color-mix(in oklch, var(--rr-err) 15%, transparent);
+}
+</style>
diff --git a/apps/ui/src/components/primitives/CatalogNav.vue b/apps/ui/src/components/primitives/CatalogNav.vue
new file mode 100644
index 0000000..f5bf14e
--- /dev/null
+++ b/apps/ui/src/components/primitives/CatalogNav.vue
@@ -0,0 +1,232 @@
+<!--
+  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.
+-->
+<script setup lang="ts">
+import { computed } from 'vue';
+import { useRoute } from 'vue-router';
+
+interface NavLink {
+  label: string;
+  to?: string;
+  active?: (path: string) => boolean;
+  hint?: string;
+  disabled?: boolean;
+}
+
+interface NavSection {
+  kicker: string;
+  links: NavLink[];
+}
+
+const route = useRoute();
+const path = computed(() => route.fullPath);
+
+const sections: NavSection[] = [
+  {
+    kicker: 'cluster',
+    links: [
+      {
+        label: 'Cluster status',
+        to: '/cluster',
+        active: (p) => p.startsWith('/cluster'),
+      },
+    ],
+  },
+  {
+    kicker: 'DSL Management',
+    links: [
+      {
+        label: 'MAL · OTEL',
+        to: '/catalog/otel-rules',
+        active: (p) => p.startsWith('/catalog/otel-rules'),
+      },
+      {
+        label: 'MAL · Telegraf',
+        to: '/catalog/telegraf-rules',
+        active: (p) => p.startsWith('/catalog/telegraf-rules'),
+      },
+      {
+        label: 'LAL',
+        to: '/catalog/lal',
+        active: (p) => p.startsWith('/catalog/lal'),
+      },
+      /* `log-mal-rules` is MAL applied to LAL-derived logs — placed
+       * below LAL so the data-flow direction (LAL → MAL) reads
+       * naturally as the eye scans the sidebar. */
+      {
+        label: 'LAL → MAL',
+        to: '/catalog/log-mal-rules',
+        active: (p) => p.startsWith('/catalog/log-mal-rules'),
+      },
+      {
+        label: 'OAL · read-only',
+        to: '/oal',
+        active: (p) => p.startsWith('/oal'),
+      },
+    ],
+  },
+  {
+    kicker: 'inspect',
+    links: [
+      {
+        label: 'Inspect',
+        to: '/inspect',
+        active: (p) => p.startsWith('/inspect'),
+      },
+    ],
+  },
+  {
+    kicker: 'live debugger',
+    links: [
+      {
+        label: 'Live debugger',
+        to: '/debug',
+        /* Match /debug and /debug/{tab} but NOT /debug/history — the
+         * history page is its own peer entry below. */
+        active: (p) => p === '/debug' || /^\/debug\/(mal|lal|oal)/.test(p),
+      },
+      {
+        label: 'Capture history',
+        to: '/debug/history',
+        active: (p) => p.startsWith('/debug/history'),
+      },
+    ],
+  },
+  {
+    kicker: 'dump',
+    links: [
+      {
+        label: 'Dump & restore',
+        to: '/dump',
+        active: (p) => p.startsWith('/dump'),
+      },
+    ],
+  },
+  {
+    /* "DSL Management" was a misleading label — the page is just a
+     * static read-out of the operator's own RBAC verbs. Renamed
+     * "Permissions" so its purpose is obvious. */
+    kicker: 'account',
+    links: [
+      {
+        label: 'Permissions',
+        to: '/dsl',
+        active: (p) => p.startsWith('/dsl'),
+      },
+    ],
+  },
+];
+
+function isActive(link: NavLink): boolean {
+  if (!link.active) return false;
+  return link.active(path.value);
+}
+</script>
+
+<template>
+  <nav class="catalognav">
+    <section v-for="(section, idx) in sections" :key="idx" class="catalognav__section">
+      <div class="catalognav__kicker">{{ section.kicker }}</div>
+      <ul class="catalognav__links">
+        <li v-for="link in section.links" :key="link.label" class="catalognav__item">
+          <router-link
+            v-if="link.to && !link.disabled"
+            :to="link.to"
+            class="catalognav__link"
+            :class="{ 'catalognav__link--active': isActive(link) }"
+          >
+            <span class="catalognav__label">{{ link.label }}</span>
+          </router-link>
+          <span
+            v-else
+            class="catalognav__link catalognav__link--disabled"
+            :title="link.hint ?? 'coming later'"
+          >
+            <span class="catalognav__label">{{ link.label }}</span>
+            <span v-if="link.hint" class="catalognav__hint">{{ link.hint }}</span>
+          </span>
+        </li>
+      </ul>
+    </section>
+  </nav>
+</template>
+
+<style scoped>
+.catalognav {
+  padding: 14px 0;
+  font-family: var(--rr-font-mono);
+  font-size: 12.5px;
+}
+
+.catalognav__section {
+  margin-bottom: 16px;
+}
+
+.catalognav__kicker {
+  padding: 0 16px 6px;
+  font-size: 11.5px;
+  letter-spacing: 1.2px;
+  text-transform: uppercase;
+  color: var(--rr-dim);
+}
+
+.catalognav__links {
+  list-style: none;
+  margin: 0;
+  padding: 0;
+}
+
+.catalognav__item {
+  margin: 0;
+}
+
+.catalognav__link {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 6px 16px;
+  color: var(--rr-ink2);
+  text-decoration: none;
+  border-left: 2px solid transparent;
+}
+
+.catalognav__link:hover {
+  background: var(--rr-bg2);
+  color: var(--rr-heading);
+  text-decoration: none;
+}
+
+.catalognav__link--active {
+  background: var(--rr-bg3);
+  color: var(--rr-heading);
+  border-left-color: var(--rr-active);
+}
+
+.catalognav__link--disabled {
+  color: var(--rr-dim);
+  cursor: not-allowed;
+}
+.catalognav__link--disabled:hover {
+  background: transparent;
+  color: var(--rr-dim);
+}
+
+.catalognav__hint {
+  font-size: 11.5px;
+  color: var(--rr-dim);
+  font-style: italic;
+}
+</style>
diff --git a/apps/ui/src/components/primitives/Pill.vue b/apps/ui/src/components/primitives/Pill.vue
new file mode 100644
index 0000000..b964499
--- /dev/null
+++ b/apps/ui/src/components/primitives/Pill.vue
@@ -0,0 +1,68 @@
+<!--
+  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.
+-->
+<script setup lang="ts">
+type Tone = 'ok' | 'warn' | 'err' | 'info' | 'dim' | 'active';
+
+withDefaults(
+  defineProps<{
+    tone?: Tone;
+  }>(),
+  { tone: 'dim' },
+);
+</script>
+
+<template>
+  <span class="pill" :class="`pill--${tone}`"><slot /></span>
+</template>
+
+<style scoped>
+.pill {
+  display: inline-flex;
+  align-items: center;
+  font-family: var(--rr-font-mono);
+  font-size: 11.5px;
+  font-weight: 500;
+  letter-spacing: 0.4px;
+  text-transform: uppercase;
+  padding: 2px 7px;
+  border-radius: 999px;
+  border: 1px solid var(--rr-border2);
+  background: var(--rr-bg2);
+  color: var(--rr-ink2);
+}
+
+.pill--ok {
+  border-color: color-mix(in oklch, var(--rr-ok) 50%, var(--rr-border2));
+  color: var(--rr-ok);
+}
+.pill--warn {
+  border-color: color-mix(in oklch, var(--rr-warn) 50%, var(--rr-border2));
+  color: var(--rr-warn);
+}
+.pill--err {
+  border-color: color-mix(in oklch, var(--rr-err) 50%, var(--rr-border2));
+  color: var(--rr-err);
+}
+.pill--info {
+  border-color: color-mix(in oklch, var(--rr-info) 50%, var(--rr-border2));
+  color: var(--rr-info);
+}
+.pill--active {
+  border-color: var(--rr-active);
+  color: var(--rr-active);
+}
+</style>
diff --git a/apps/ui/src/components/primitives/Section.vue b/apps/ui/src/components/primitives/Section.vue
new file mode 100644
index 0000000..aeec4a8
--- /dev/null
+++ b/apps/ui/src/components/primitives/Section.vue
@@ -0,0 +1,80 @@
+<!--
+  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.
+-->
+<script setup lang="ts">
+defineProps<{
+  title: string;
+  kicker?: string;
+}>();
+</script>
+
+<template>
+  <section class="section">
+    <header class="section__header">
+      <span v-if="kicker" class="section__kicker">{{ kicker }}</span>
+      <span class="section__title">{{ title }}</span>
+      <div class="section__actions"><slot name="actions" /></div>
+    </header>
+    <div class="section__body"><slot /></div>
+  </section>
+</template>
+
+<style scoped>
+.section {
+  display: flex;
+  flex-direction: column;
+  background: var(--rr-bg);
+  border: 1px solid var(--rr-border);
+  border-radius: var(--rr-radius-md);
+}
+
+.section__header {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  height: 34px;
+  padding: 0 14px;
+  background: var(--rr-bg2);
+  border-bottom: 1px solid var(--rr-border);
+  flex-shrink: 0;
+}
+
+.section__kicker {
+  font-family: var(--rr-font-mono);
+  font-size: 11.5px;
+  letter-spacing: 1px;
+  text-transform: uppercase;
+  color: var(--rr-dim);
+}
+
+.section__title {
+  font-family: var(--rr-font-ui);
+  font-size: 13px;
+  font-weight: 500;
+  color: var(--rr-heading);
+}
+
+.section__actions {
+  margin-left: auto;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.section__body {
+  padding: 14px;
+}
+</style>
diff --git a/apps/ui/src/components/primitives/StatusDot.vue b/apps/ui/src/components/primitives/StatusDot.vue
new file mode 100644
index 0000000..60f9476
--- /dev/null
+++ b/apps/ui/src/components/primitives/StatusDot.vue
@@ -0,0 +1,57 @@
+<!--
+  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.
+-->
+<script setup lang="ts">
+type Tone = 'ok' | 'warn' | 'err' | 'info' | 'dim';
+
+withDefaults(
+  defineProps<{
+    tone?: Tone;
+    size?: number;
+  }>(),
+  { tone: 'dim', size: 8 },
+);
+</script>
+
+<template>
+  <span
+    class="dot"
+    :class="`dot--${tone}`"
+    :style="{ width: `${size}px`, height: `${size}px` }"
+  />
+</template>
+
+<style scoped>
+.dot {
+  display: inline-block;
+  border-radius: 50%;
+  flex-shrink: 0;
+  background: var(--rr-dim);
+}
+
+.dot--ok {
+  background: var(--rr-ok);
+}
+.dot--warn {
+  background: var(--rr-warn);
+}
+.dot--err {
+  background: var(--rr-err);
+}
+.dot--info {
+  background: var(--rr-info);
+}
+</style>
diff --git a/apps/ui/src/components/profile/ProfileFlameGraph.vue b/apps/ui/src/components/profile/ProfileFlameGraph.vue
index e0a4914..ba4b040 100644
--- a/apps/ui/src/components/profile/ProfileFlameGraph.vue
+++ b/apps/ui/src/components/profile/ProfileFlameGraph.vue
@@ -27,7 +27,7 @@
                                        is `count`.
 -->
 <script setup lang="ts">
-import { onMounted, onBeforeUnmount, ref, watch } from 'vue';
+import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue';
 import * as d3 from 'd3';
 import { flamegraph } from 'd3-flame-graph';
 import type { ProfileAnalyzationElement, ProfileAnalyzationTree } from '@/api/client';
@@ -151,67 +151,70 @@ function draw(): void {
     .title('')
     .selfValue(false)
     .inverted(true)
-    .setColorMapper((d: { highlight: boolean }, original: string) =>
-      d.highlight ? '#6aff8f' : original,
-    );
+    // Dim, frame-friendly palette. d3-flame-graph's default ramp is a
+     // bright yellow→orange→red gradient that overwhelms a dark UI; the
+     // mapper below produces deterministic, lower-saturation colors
+     // keyed off the frame name so each stack cell stays distinguishable
+     // but the overall card reads as part of the dark canvas instead of
+     // glowing through it.
+    .setColorMapper((d: { highlight: boolean; data?: { name?: string } }, _original: string) => {
+      if (d.highlight) return '#6aff8f';
+      const name = d.data?.name ?? '';
+      let hash = 0;
+      for (let i = 0; i < name.length; i++) hash = (hash * 31 + name.charCodeAt(i)) | 0;
+      // 0–360° hue, fixed mid sat + dark lightness for dark-theme calm.
+      const hue = Math.abs(hash) % 360;
+      return `hsl(${hue}, 32%, 38%)`;
+    });
 
-  // Plain in-DOM tooltip (the booster-ui implementation pulled in
-  // d3-tip; we use a single appended <div> instead to avoid the dep).
-  const tip = document.createElement('div');
-  tip.className = 'fg-tip';
-  document.body.appendChild(tip);
   chart.tooltip(false);
   d3.select(root.value).datum(tree).call(chart);
   const svg = root.value.querySelector('svg');
   if (svg) {
-    svg.addEventListener('mousemove', (event) => {
+    // Click a frame → open the popout dialog. This replaces the
+    // earlier cursor-following tooltip, which clipped against the
+    // viewport edge when the cell was near the bottom of the page.
+    // The dialog renders inside Vue's template (see below) — viewport-
+    // centered, with backdrop + ESC + outside-click to dismiss.
+    svg.addEventListener('click', (event) => {
       const target = (event.target as Element).closest('g');
       // eslint-disable-next-line @typescript-eslint/no-explicit-any
       const data: any = target && (d3.select(target as Element).datum() as any);
-      if (!data || !data.data) {
-        tip.style.display = 'none';
-        return;
-      }
-      const d = data.data as FlameNode;
-      const pctRoot = tree.count
-        ? ((d.count / tree.count) * 100).toFixed(2)
-        : '0';
-      tip.innerHTML = `
-        <div class="t-row"><strong>${escapeHtml(d.codeSignature)}</strong></div>
-        <div class="t-row">Dump Count: ${d.count}</div>
-        <div class="t-row">Duration: ${d.duration} ns</div>
-        <div class="t-row">Duration (excl. children): ${d.durationChildExcluded} ns</div>
-        <div class="t-row">% of root: ${pctRoot}%</div>
-      `;
-      tip.style.display = 'block';
-      const me = event as MouseEvent;
-      tip.style.left = me.clientX + 16 + 'px';
-      tip.style.top = me.clientY + 16 + 'px';
+      if (!data || !data.data) return;
+      selectedFrame.value = data.data as FlameNode;
     });
-    svg.addEventListener('mouseleave', () => {
-      tip.style.display = 'none';
-    });
-    // Make absolutely sure the tip leaves with us on unmount.
-    onBeforeUnmount(() => tip.remove());
   }
 }
 
-function escapeHtml(s: string): string {
-  return s.replace(/[&<>"']/g, (c) => {
-    switch (c) {
-      case '&': return '&amp;';
-      case '<': return '&lt;';
-      case '>': return '&gt;';
-      case '"': return '&quot;';
-      case "'": return '&#39;';
-      default: return c;
-    }
-  });
+const selectedFrame = ref<FlameNode | null>(null);
+const rootCountForPct = computed<number>(() => {
+  const tree = buildVirtualRoot();
+  return tree?.count ?? 0;
+});
+const selectedPctRoot = computed<string>(() => {
+  const f = selectedFrame.value;
+  const total = rootCountForPct.value;
+  if (!f || total === 0) return '0';
+  return ((f.count / total) * 100).toFixed(2);
+});
+
+function closeFrameDialog(): void { selectedFrame.value = null; }
+function onKeyDown(ev: KeyboardEvent): void {
+  if (ev.key === 'Escape' && selectedFrame.value) closeFrameDialog();
 }
 
-onMounted(draw);
-watch(() => [props.trees, props.metricKey], draw);
+onMounted(() => {
+  draw();
+  window.addEventListener('keydown', onKeyDown);
+});
+watch(() => [props.trees, props.metricKey], () => {
+  // Closing the dialog on data change keeps a stale frame from being
+  // shown after Analyze re-runs against a different span / segment.
+  selectedFrame.value = null;
+  draw();
+});
 onBeforeUnmount(() => {
+  window.removeEventListener('keydown', onKeyDown);
   if (chart) {
     try {
       chart.destroy();
@@ -226,6 +229,54 @@ onBeforeUnmount(() => {
 
 <template>
   <div ref="root" class="fg-host"></div>
+  <!-- Frame-detail popout. Replaces the cursor-following tooltip that
+       used to clip at the viewport edge — now centered, with a
+       backdrop, ESC + outside-click + close-button to dismiss.
+       Backdrop swallows the click bubble (.self) so clicks inside the
+       dialog don't close it. -->
+  <Teleport to="body">
+    <div
+      v-if="selectedFrame"
+      class="fg-pop-backdrop"
+      @click.self="closeFrameDialog"
+    >
+      <div class="fg-pop sw-card" role="dialog" aria-label="Frame detail">
+        <header class="fg-pop-head">
+          <h4>Stack frame</h4>
+          <button
+            type="button"
+            class="fg-pop-close"
+            aria-label="Close"
+            title="Close (Esc)"
+            @click="closeFrameDialog"
+          >×</button>
+        </header>
+        <div class="fg-pop-body">
+          <div class="fg-pop-sig" :title="selectedFrame.codeSignature">
+            {{ selectedFrame.codeSignature }}
+          </div>
+          <dl class="fg-pop-rows">
+            <div class="fg-pop-row">
+              <dt>Dump count</dt>
+              <dd>{{ selectedFrame.count }}</dd>
+            </div>
+            <div class="fg-pop-row">
+              <dt>Duration</dt>
+              <dd>{{ selectedFrame.duration }} ns</dd>
+            </div>
+            <div class="fg-pop-row">
+              <dt>Duration (excl. children)</dt>
+              <dd>{{ selectedFrame.durationChildExcluded }} ns</dd>
+            </div>
+            <div class="fg-pop-row">
+              <dt>% of root</dt>
+              <dd>{{ selectedPctRoot }}%</dd>
+            </div>
+          </dl>
+        </div>
+      </div>
+    </div>
+  </Teleport>
 </template>
 
 <style scoped>
@@ -241,27 +292,103 @@ onBeforeUnmount(() => {
 </style>
 
 <style>
-.fg-tip {
+/* Modal backdrop + frame-detail card. Lives in <body> via <Teleport>
+ * so it isn't clipped by any ancestor's overflow:hidden. Card width
+ * caps at 560px and uses `overflow-wrap: anywhere` so even the
+ * longest stack frame (`org.jboss.threads.EnhancedQueueExecutor$
+ * ThreadBody.run:1556`) wraps inside the box instead of pushing past
+ * its edge. */
+.fg-pop-backdrop {
   position: fixed;
-  display: none;
-  pointer-events: none;
+  inset: 0;
   z-index: 9999;
+  background: rgba(0, 0, 0, 0.55);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 24px;
+}
+.fg-pop {
+  width: min(560px, 100%);
+  max-height: calc(100vh - 64px);
+  display: flex;
+  flex-direction: column;
   background: var(--sw-bg-1, #1b1d24);
-  color: var(--sw-fg-0, #f5f7fb);
   border: 1px solid var(--sw-line, #2a2d36);
-  border-radius: 4px;
-  padding: 8px 10px;
-  font-family: var(--sw-mono, monospace);
-  font-size: 11px;
-  max-width: 420px;
-  line-height: 1.45;
-  box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4);
+  border-radius: 6px;
+  box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
+  overflow: hidden;
 }
-.fg-tip .t-row + .t-row {
-  margin-top: 3px;
+.fg-pop-head {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 10px 14px;
+  border-bottom: 1px solid var(--sw-line, #2a2d36);
+  background: var(--sw-bg-2, #20232c);
 }
-.fg-tip strong {
+.fg-pop-head h4 {
+  margin: 0;
+  font-size: 12px;
+  font-weight: 600;
+  letter-spacing: 0.04em;
+  text-transform: uppercase;
+  color: var(--sw-fg-1, #d4d6de);
+}
+.fg-pop-close {
+  margin-left: auto;
+  background: transparent;
+  border: none;
+  color: var(--sw-fg-3, #6b6f7a);
+  font-size: 18px;
+  line-height: 1;
+  cursor: pointer;
+  padding: 0 6px;
+  border-radius: 3px;
+}
+.fg-pop-close:hover {
+  background: var(--sw-bg-3, #2a2d36);
+  color: var(--sw-fg-0, #f5f7fb);
+}
+.fg-pop-body {
+  padding: 12px 14px;
+  overflow: auto;
+  font-family: var(--sw-mono, monospace);
+  font-size: 11.5px;
+  color: var(--sw-fg-1, #d4d6de);
+  line-height: 1.5;
+}
+.fg-pop-sig {
   color: var(--sw-fg-0, #f5f7fb);
   font-weight: 600;
+  margin-bottom: 10px;
+  padding-bottom: 8px;
+  border-bottom: 1px dashed var(--sw-line, #2a2d36);
+  overflow-wrap: anywhere;
+  word-break: break-all;
+}
+.fg-pop-rows {
+  margin: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+}
+.fg-pop-row {
+  display: flex;
+  align-items: baseline;
+  justify-content: space-between;
+  gap: 12px;
+}
+.fg-pop-row dt {
+  margin: 0;
+  color: var(--sw-fg-3, #6b6f7a);
+  flex: 0 0 auto;
+}
+.fg-pop-row dd {
+  margin: 0;
+  color: var(--sw-fg-0, #f5f7fb);
+  font-variant-numeric: tabular-nums;
+  text-align: right;
+  overflow-wrap: anywhere;
 }
 </style>
diff --git a/apps/ui/src/components/profile/ProfileStackTable.vue b/apps/ui/src/components/profile/ProfileStackTable.vue
index d67c9bd..1e6985d 100644
--- a/apps/ui/src/components/profile/ProfileStackTable.vue
+++ b/apps/ui/src/components/profile/ProfileStackTable.vue
@@ -100,30 +100,36 @@ onMounted(() => {
 </script>
 
 <template>
+  <!-- Header + body share ONE scroll container so the column titles
+       horizontally scroll alongside the rows when the Thread Stack
+       column overflows. `position: sticky; top: 0` keeps the header
+       vertically pinned during a body scroll. -->
   <div class="stack-table">
-    <div class="stack-header">
-      <div class="cell sig" :style="{ width: thread + 'px' }">
-        Thread Stack
-        <span ref="dragger" class="dragger" title="Drag to resize">⇿</span>
+    <div class="stack-scroll">
+      <div class="stack-header">
+        <div class="cell sig" :style="{ width: thread + 'px' }">
+          Thread Stack
+          <span ref="dragger" class="dragger" title="Drag to resize">⇿</span>
+        </div>
+        <div class="cell num">Duration (ms)</div>
+        <div class="cell num">
+          Self Duration (ms)
+          <button
+            type="button"
+            class="top-slow"
+            :class="{ 'is-on': highlightTop }"
+            @click="emit('toggle-highlight')"
+            title="Highlight top 10 slow methods"
+          >
+            top slow
+          </button>
+        </div>
+        <div class="cell num">Dump Count</div>
       </div>
-      <div class="cell num">Duration (ms)</div>
-      <div class="cell num">
-        Self Duration (ms)
-        <button
-          type="button"
-          class="top-slow"
-          :class="{ 'is-on': highlightTop }"
-          @click="emit('toggle-highlight')"
-          title="Highlight top 10 slow methods"
-        >
-          top slow
-        </button>
+      <div class="stack-body">
+        <div v-if="!tableData.length" class="empty">No analyze data yet — pick a profiled span and click Analyze.</div>
+        <ProfileStackRow v-for="(n, i) in tableData" :key="'r' + i" :node="n" :thread="thread" :depth="0" />
       </div>
-      <div class="cell num">Dump Count</div>
-    </div>
-    <div class="stack-body">
-      <div v-if="!tableData.length" class="empty">No analyze data yet — pick a profiled span and click Analyze.</div>
-      <ProfileStackRow v-for="(n, i) in tableData" :key="'r' + i" :node="n" :thread="thread" :depth="0" />
     </div>
   </div>
 </template>
@@ -136,6 +142,16 @@ onMounted(() => {
   font-size: 11.5px;
   color: var(--sw-fg-1);
   font-family: var(--sw-mono);
+  overflow: hidden;
+}
+/* Single scroll container — header lives inside so it scrolls
+ * horizontally with rows. `width: max-content` lets the inner row
+ * width drive horizontal scrollbar instead of being capped by the
+ * viewport. */
+.stack-scroll {
+  flex: 1 1 0;
+  min-height: 0;
+  overflow: auto;
 }
 .stack-header {
   display: flex;
@@ -143,6 +159,15 @@ onMounted(() => {
   background: var(--sw-bg-2);
   border-bottom: 1px solid var(--sw-line);
   flex: 0 0 auto;
+  position: sticky;
+  top: 0;
+  z-index: 2;
+  width: max-content;
+  min-width: 100%;
+}
+.stack-body {
+  width: max-content;
+  min-width: 100%;
 }
 .cell {
   height: 28px;
@@ -186,10 +211,6 @@ onMounted(() => {
   color: var(--sw-accent);
   border-color: var(--sw-accent);
 }
-.stack-body {
-  flex: 1 1 0;
-  overflow: auto;
-}
 .empty {
   padding: 16px;
   text-align: center;
diff --git a/apps/ui/src/components/shell/AdminFeatureWarning.vue b/apps/ui/src/components/shell/AdminFeatureWarning.vue
new file mode 100644
index 0000000..977ca7b
--- /dev/null
+++ b/apps/ui/src/components/shell/AdminFeatureWarning.vue
@@ -0,0 +1,225 @@
+<!--
+  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.
+-->
+<script setup lang="ts">
+/**
+ * Per-page warning header for admin-host routes (DSL Management,
+ * Live Debugger, Dump, OAL viewer, Inspect).
+ *
+ * Renders when the admin host (`:17128`) is unreachable, OR when the
+ * caller-named module's SWIP-13 selector is off on OAP. The page
+ * body still mounts below — most of the admin-host UIs degrade
+ * cleanly to an empty / read-only state when the API is missing.
+ *
+ * Two failure shapes:
+ *   - admin host unreachable → "fix network / port / SW_ADMIN_SERVER"
+ *   - admin reachable but specific module off → "set SW_<MODULE>=default"
+ *
+ * The component picks the right copy automatically based on
+ * `useAdminFeatures()` state and the `module` prop.
+ */
+import { computed } from 'vue';
+import { useAdminFeatures } from '@/composables/useAdminFeatures';
+import Icon from '@/components/icons/Icon.vue';
+
+const props = defineProps<{
+  /** OAP module name this page depends on. One of:
+   *  `receiver-runtime-rule` · `dsl-debugging` · `inspect`. The
+   *  `admin-server` module is implicit — every page that uses this
+   *  banner needs it. */
+  module: 'receiver-runtime-rule' | 'dsl-debugging' | 'inspect';
+  /** Short human label of the feature this page provides. Used in
+   *  the warning copy ("DSL Management requires …"). */
+  featureLabel: string;
+}>();
+
+const { result, adminReachable, adminError, adminUrl, moduleByName, refetch } =
+  useAdminFeatures();
+
+const mod = moduleByName(props.module);
+const moduleEnabled = computed<boolean>(() => mod.value?.enabled ?? false);
+
+/** Hide when everything is fine — admin reachable AND target module
+ *  on. Loading state also hides (page renders normally; banner pops
+ *  in if/when preflight returns a failure). */
+const visible = computed<boolean>(() => {
+  if (!result.value) return false;
+  if (!adminReachable.value) return true;
+  return !moduleEnabled.value;
+});
+
+const kind = computed<'host' | 'module'>(() =>
+  !adminReachable.value ? 'host' : 'module',
+);
+
+const moduleEnvVar = computed<string | undefined>(() => mod.value?.envVar);
+</script>
+
+<template>
+  <div v-if="visible" class="warn" role="alert">
+    <span class="icon"><Icon name="alert" :size="14" /></span>
+
+    <div class="body">
+      <!-- Admin host unreachable: full-stop, network / port issue -->
+      <template v-if="kind === 'host'">
+        <h3>Admin host unreachable</h3>
+        <p>
+          <strong>{{ featureLabel }}</strong> talks to OAP on the admin port
+          (<code>{{ adminUrl ?? ':17128' }}</code>). The page below is rendered
+          read-only and will fail any save / debug action.
+        </p>
+        <p v-if="adminError" class="err">
+          Last error: <code>{{ adminError }}</code>
+        </p>
+        <ul class="hints">
+          <li>
+            Confirm OAP's <code>admin-server</code> module is on
+            (<code>SW_ADMIN_SERVER=default</code>) and the OAP pod has been restarted.
+          </li>
+          <li>
+            Confirm the admin port (default <code>17128</code>) is exposed on the
+            network / k8s Service / ingress and reachable from this BFF.
+          </li>
+          <li>
+            See <RouterLink to="/operate/cluster">Cluster status</RouterLink>
+            for the full module preflight.
+          </li>
+        </ul>
+      </template>
+
+      <!-- Admin host fine, but this feature's specific module is off -->
+      <template v-else>
+        <h3>Module <code>{{ module }}</code> is off on OAP</h3>
+        <p>
+          <strong>{{ featureLabel }}</strong> requires the
+          <code>{{ module }}</code> module. Enable it on OAP and restart:
+        </p>
+        <pre class="env">{{ moduleEnvVar }}=default</pre>
+        <p class="hint">
+          Until then this page renders read-only — every API call to
+          <code>{{ adminUrl }}</code> for this feature returns 404.
+          See <RouterLink to="/operate/cluster">Cluster status</RouterLink>
+          for the full module preflight.
+        </p>
+      </template>
+    </div>
+
+    <button type="button" class="recheck" @click="() => refetch()">re-check</button>
+  </div>
+</template>
+
+<style scoped>
+.warn {
+  display: flex;
+  align-items: flex-start;
+  gap: 12px;
+  margin: 14px 20px 18px;
+  padding: 14px 16px;
+  background: var(--sw-warn-soft);
+  border: 1px solid rgba(234, 179, 8, 0.4);
+  border-radius: 8px;
+  color: var(--sw-fg-1);
+}
+.icon {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 24px;
+  height: 24px;
+  border-radius: 50%;
+  background: rgba(234, 179, 8, 0.22);
+  color: var(--sw-warn);
+  flex-shrink: 0;
+  margin-top: 1px;
+}
+.body {
+  flex: 1;
+  min-width: 0;
+}
+.body h3 {
+  font-size: 12.5px;
+  font-weight: 600;
+  color: var(--sw-warn);
+  margin: 0 0 6px;
+  letter-spacing: -0.01em;
+}
+.body p {
+  font-size: 12px;
+  margin: 0 0 6px;
+  line-height: 1.55;
+  color: var(--sw-fg-1);
+}
+.body p.err {
+  color: var(--sw-fg-2);
+  font-size: 11.5px;
+}
+.body p.hint {
+  color: var(--sw-fg-2);
+  font-size: 11.5px;
+  margin-top: 6px;
+}
+.body code {
+  font-family: var(--sw-mono);
+  background: rgba(0, 0, 0, 0.25);
+  padding: 1px 5px;
+  border-radius: 3px;
+  font-size: 11.5px;
+  color: var(--sw-fg-0);
+}
+.body strong {
+  color: var(--sw-fg-0);
+  font-weight: 600;
+}
+.body .env {
+  display: inline-block;
+  margin: 4px 0 8px;
+  font-family: var(--sw-mono);
+  font-size: 12px;
+  background: var(--sw-bg-2);
+  border: 1px solid var(--sw-line);
+  border-radius: 5px;
+  padding: 6px 10px;
+  color: var(--sw-fg-0);
+}
+.hints {
+  margin: 6px 0 0;
+  padding-left: 18px;
+  color: var(--sw-fg-1);
+  font-size: 11.5px;
+  line-height: 1.7;
+}
+.hints a {
+  color: var(--sw-accent);
+  text-decoration: none;
+}
+.hints a:hover {
+  text-decoration: underline;
+}
+.recheck {
+  background: rgba(0, 0, 0, 0.25);
+  border: 1px solid rgba(234, 179, 8, 0.4);
+  color: var(--sw-fg-0);
+  font: inherit;
+  font-size: 11.5px;
+  padding: 5px 10px;
+  border-radius: 5px;
+  cursor: pointer;
+  flex-shrink: 0;
+}
+.recheck:hover {
+  background: rgba(0, 0, 0, 0.4);
+}
+</style>
diff --git a/apps/ui/src/components/shell/AppShell.vue b/apps/ui/src/components/shell/AppShell.vue
index 514ae7c..67551f7 100644
--- a/apps/ui/src/components/shell/AppShell.vue
+++ b/apps/ui/src/components/shell/AppShell.vue
@@ -18,6 +18,7 @@
 import { RouterView } from 'vue-router';
 import AppSidebar from './AppSidebar.vue';
 import AppTopbar from './AppTopbar.vue';
+import GlobalConnectivityBanner from './GlobalConnectivityBanner.vue';
 import TracePopout from '@/components/trace/TracePopout.vue';
 import ZipkinTracePopout from '@/components/trace/ZipkinTracePopout.vue';
 </script>
@@ -27,6 +28,10 @@ import ZipkinTracePopout from '@/components/trace/ZipkinTracePopout.vue';
     <AppSidebar />
     <AppTopbar />
     <main class="sw-main">
+      <!-- Sticky strip under the topbar; only renders when the graphql
+           (`:12800`) poll reports unreachable. Admin-port (`:17128`)
+           failures render per-page via AdminFeatureWarning, not here. -->
+      <GlobalConnectivityBanner />
       <RouterView />
     </main>
     <!-- Global trace-id popout: any page can call useTracePopout().openTrace(id)
diff --git a/apps/ui/src/components/shell/AppSidebar.vue b/apps/ui/src/components/shell/AppSidebar.vue
index 76b8358..380bfdc 100644
--- a/apps/ui/src/components/shell/AppSidebar.vue
+++ b/apps/ui/src/components/shell/AppSidebar.vue
@@ -22,6 +22,7 @@ import logoSw from '@/assets/icons/logo-sw.svg?raw';
 import { useAuthStore } from '@/stores/auth';
 import { useLayers, firstLayerTab } from '@/composables/useLayers';
 import { useLandingOrder } from '@/composables/useLandingOrder';
+import { useOverviewDashboards } from '@/composables/useOverviewDashboards';
 
 const auth = useAuthStore();
 const router = useRouter();
@@ -31,16 +32,57 @@ async function signOut(): Promise<void> {
 }
 
 const { availableLayers, oapReachable, oapError, hasTopology } = useLayers();
-// Sidebar shares the landing's priority order so the two views stay in sync.
 const orderedLayers = useLandingOrder(availableLayers);
+const { publicOverviews } = useOverviewDashboards();
+
+function sectionIcon(label: string): IconName {
+  const k = label.toLowerCase();
+  if (k === 'overviews' || k === 'overview') return 'dash';
+  if (k === 'layers') return 'svc';
+  if (k === 'platform monitoring') return 'flame';
+  if (k === 'operate') return 'set';
+  if (k === 'dashboard setup') return 'metric';
+  if (k === 'admin') return 'user';
+  if (k.startsWith('istio') || k.includes('mesh') || k.includes('envoy') || k.includes('cilium')) {
+    return 'mesh';
+  }
+  if (k.includes('kubernetes') || k.includes('k8s') || k.includes('eks')) return 'cluster';
+  if (k.includes('browser') || k.includes('rum') || k.includes('mini')) return 'web';
+  if (k.includes('database') || k.includes('sql') || k.includes('mongo')) return 'db';
+  if (k.includes('cache') || k.includes('redis')) return 'cache';
+  if (k.includes('mq') || k.includes('kafka') || k.includes('queue')) return 'topic';
+  if (k.includes('faas') || k.includes('function')) return 'fn';
+  if (k.includes('aws') || k.includes('cloud')) return 'cluster';
+  if (k.includes('agent') || k.includes('so11y') || k.includes('satellite')) return 'flame';
+  return 'dash';
+}
+
+function layerIcon(L: SidebarLayer): IconName {
+  const k = L.key.toLowerCase();
+  if (k === 'general' || k === 'general_service') return 'sky';
+  if (k.startsWith('mesh') || k.startsWith('istio') || k.startsWith('envoy') || k.startsWith('cilium')) {
+    return 'mesh';
+  }
+  if (k.startsWith('k8s') || k === 'aws_eks') return 'cluster';
+  if (k === 'browser' || k === 'ios' || k.includes('mini_program')) return 'web';
+  if (k === 'faas') return 'fn';
+  if (
+    k === 'virtual_mq' || k === 'kafka' || k === 'rocketmq' || k === 'rabbitmq' ||
+    k === 'pulsar' || k === 'activemq' || k === 'bookkeeper'
+  ) {
+    return 'topic';
+  }
+  if (
+    k === 'virtual_database' || k === 'mysql' || k === 'postgresql' || k === 'mongodb' ||
+    k === 'clickhouse' || k === 'elasticsearch' || k === 'aws_dynamodb'
+  ) {
+    return 'db';
+  }
+  if (k === 'virtual_cache' || k === 'redis') return 'cache';
+  if (k.startsWith('so11y') || k.includes('agent') || k.includes('satellite')) return 'flame';
+  return 'svc';
+}
 
-/* A layer counts as "single-feature" when its caps + slots only support
- * the basic services view + maybe a dashboards tab — no traces, logs,
- * topology, profiling, events, instances, or endpoints. For these,
- * expanding the row would reveal at most one or two sibling links, so
- * we skip the expander and make the row a direct link to the services
- * page (which IS the dashboard for virtual / cache / database / MQ
- * scopes). */
 type SidebarLayer = (typeof orderedLayers.value)[number];
 function hasInstances(L: SidebarLayer): boolean {
   return L.caps.instances ?? Boolean(L.slots.instances);
@@ -48,6 +90,8 @@ function hasInstances(L: SidebarLayer): boolean {
 function hasEndpoints(L: SidebarLayer): boolean {
   return L.caps.endpoints ?? Boolean(L.slots.endpoints);
 }
+/** A layer whose only worthwhile screen is the services list — no
+ *  tabs to expand into. Rendered as a direct link, not an accordion. */
 function isSingleFeatureLayer(L: SidebarLayer): boolean {
   if (hasInstances(L) || hasEndpoints(L)) return false;
   if (hasTopology(L)) return false;
@@ -57,23 +101,10 @@ function isSingleFeatureLayer(L: SidebarLayer): boolean {
   return true;
 }
 
-// Which layer's row is expanded — the tab strip (Service / Instance /
-// Endpoint / Topology / …) renders only beneath the expanded layer.
-// Auto-driven by the URL: whatever layer the route is on stays
-// expanded so a cold reload of `/layer/<key>/...` shows the tabs +
-// the active tab highlighted. The initial-load watcher only fires
-// when the route ISN'T on a layer page (the top-level / overview) —
-// in that case we pick the first available layer so the sidebar
-// doesn't look closed on first visit.
 const expandedLayer = ref<string | null>(null);
 function toggleLayer(key: string): void {
   const wasExpanded = expandedLayer.value === key;
   expandedLayer.value = wasExpanded ? null : key;
-  // Opening a layer (transition closed → open) also navigates to its
-  // first available sub-tab so the operator lands on actionable
-  // content. Collapsing is purely a section close (no nav). Skip when
-  // the route is already on this layer — we'd be navigating to the
-  // same place. This matches the group toggle's behaviour.
   if (!wasExpanded) {
     const L = orderedLayers.value.find((l) => l.key === key);
     if (!L) return;
@@ -84,20 +115,16 @@ function toggleLayer(key: string): void {
   }
 }
 
-// Bucket the ordered layer list by the template's `group` field so the
-// sidebar renders a single section per group (Istio / Kubernetes / AWS
-// / …) instead of dumping every layer flat. Ungrouped layers (general,
-// browser) are surfaced first at the top. Group sections are
-// independently collapsible — clicking one toggles its open state.
 interface LayerGroup { kind: 'group'; label: string; layers: SidebarLayer[] }
 interface LayerSingle { kind: 'single'; layer: SidebarLayer }
 type SidebarEntry = LayerGroup | LayerSingle;
-const sidebarEntries = computed<SidebarEntry[]>(() => {
+/** Group layers by their template's `group` field, preserving the
+ *  first-seen position so the section lands where the first member
+ *  appears in source order. Ungrouped layers fall through as singles. */
+function bucket(rows: SidebarLayer[]): SidebarEntry[] {
   const out: SidebarEntry[] = [];
   const groupBuckets = new Map<string, SidebarLayer[]>();
-  // Preserve the source order — first time we see a group, that's
-  // where its section lands in the sidebar.
-  for (const L of orderedLayers.value) {
+  for (const L of rows) {
     if (L.group) {
       if (!groupBuckets.has(L.group)) {
         groupBuckets.set(L.group, []);
@@ -109,36 +136,26 @@ const sidebarEntries = computed<SidebarEntry[]>(() => {
     }
   }
   return out;
-});
-const openGroups = ref<Set<string>>(new Set());
-function isGroupOpen(label: string): boolean { return openGroups.value.has(label); }
-// Plain toggle — click open if closed, click close if open. Opening
-// also navigates to the first layer's Service page so the operator
-// lands on actionable content; closing is purely a section collapse
-// (no nav, so they don't get yanked away from their current page).
-function toggleGroup(label: string, layers: SidebarLayer[]): void {
-  const wasOpen = openGroups.value.has(label);
-  const next = new Set(openGroups.value);
-  if (wasOpen) next.delete(label);
-  else next.add(label);
-  openGroups.value = next;
-  if (!wasOpen && layers[0]) {
-    void router.push(`/layer/${layers[0].key}/${firstLayerTab(layers[0])}`);
-  }
 }
+const publicLayers = computed(() =>
+  orderedLayers.value.filter((L) => L.visibility !== 'operate'),
+);
+const operateLayers = computed(() =>
+  orderedLayers.value.filter((L) => L.visibility === 'operate'),
+);
+const sidebarEntries = computed<SidebarEntry[]>(() => bucket(publicLayers.value));
 
 const route = useRoute();
 function isActive(path: string): boolean {
   return route.path === path || route.path.startsWith(path + '/');
 }
+/** Use this — not {@link isActive} — for sibling routes where one is
+ *  a prefix of another (e.g. `/operate/live-debug` vs
+ *  `/operate/live-debug/history`); prefix-match would light both up. */
+function isActiveExact(path: string): boolean {
+  return route.path === path;
+}
 
-// URL-driven sidebar focus. When the route is on a layer page, the
-// containing group expands AND the layer's row expands so the tabs +
-// active-tab highlight are visible. Fires on every route change so
-// deep-linking from outside (bookmark, paste-in) lands the operator
-// with the right rails open. When the route ISN'T on a layer, fall
-// back to the first available layer so the sidebar isn't empty on
-// the overview / setup pages.
 watch(
   [() => route.path, orderedLayers],
   ([path, rows]) => {
@@ -146,19 +163,9 @@ watch(
     if (m) {
       const key = m[1];
       const L = rows.find((l) => l.key === key);
-      if (L) {
-        // Expand this layer's tab strip.
-        expandedLayer.value = key;
-        // Open its parent group if grouped.
-        if (L.group && !openGroups.value.has(L.group)) {
-          openGroups.value = new Set([...openGroups.value, L.group]);
-        }
-      }
+      if (L) expandedLayer.value = key;
       return;
     }
-    // No layer in URL — only seed the default expansion if nothing is
-    // currently expanded (don't yank a layer the operator was looking
-    // at on the previous route).
     if (!expandedLayer.value && rows.length > 0) {
       expandedLayer.value = rows[0].key;
     }
@@ -171,6 +178,10 @@ interface NavRow {
   label: string;
   to: string;
   badge?: { text: string; kind?: 'ok' | 'warn' | 'err' | 'info' };
+  /** Custom active-match; defaults to exact `path === to`. */
+  activeWhen?: (path: string) => boolean;
+  /** Present ⇒ row renders as an L1 expandable with these as L2. */
+  children?: NavRow[];
 }
 
 interface NavSection {
@@ -178,62 +189,86 @@ interface NavSection {
   links: NavRow[];
 }
 
-// One leading row before the Layers block — the cross-layer landing.
-const overview: NavRow = { icon: 'dash', label: 'Overview', to: '/' };
-
-// Vantage-style flat kickers for the Operate / Admin half of the sidebar.
-// Alarms is user-facing so it sits before the Operate block (between user
-// observability concerns and OAP operator concerns).
 const sections: NavSection[] = [
   {
-    kicker: 'Alerts',
-    links: [{ icon: 'alert', label: 'Alarms', to: '/alarms', badge: { text: '7', kind: 'err' } }],
-  },
-  {
-    kicker: 'Marketplace',
-    links: [{ icon: 'metric', label: 'All dashboards', to: '/operate/marketplace' }],
-  },
-  {
-    kicker: 'Cluster',
-    links: [{ icon: 'svc', label: 'Cluster status', to: '/operate/cluster' }],
-  },
-  {
-    kicker: 'DSL Management',
+    kicker: 'Operate',
     links: [
-      { icon: 'set', label: 'MAL · OTEL', to: '/operate/dsl/otel-rules' },
-      { icon: 'set', label: 'MAL · Telegraf', to: '/operate/dsl/telegraf-rules' },
-      { icon: 'set', label: 'LAL', to: '/operate/dsl/lal' },
-      // log-mal-rules = MAL applied to LAL-derived logs; the data flow reads
-      // LAL → MAL so the label says so.
-      { icon: 'set', label: 'LAL → MAL', to: '/operate/dsl/log-mal-rules' },
-      { icon: 'trace', label: 'OAL · read-only', to: '/operate/oal' },
+      { icon: 'svc', label: 'Cluster status', to: '/operate/cluster' },
+      {
+        icon: 'flame',
+        label: 'Live debugger',
+        to: '/operate/live-debug',
+        // Match the tab variants only; the history sibling at
+        // /operate/live-debug/history must NOT highlight this row.
+        activeWhen: (p) => p === '/operate/live-debug' || /^\/operate\/live-debug\/(mal|lal|oal)(\/|$)/.test(p),
+        children: [
+          { icon: 'event', label: 'Capture history', to: '/operate/live-debug/history' },
+        ],
+      },
+      { icon: 'metric', label: 'Metrics Inspect', to: '/operate/inspect' },
+      {
+        icon: 'set',
+        label: 'DSL Management',
+        // No standalone landing — `to` jumps to the first rule page so
+        // the L1 itself is clickable; activeWhen covers all DSL routes.
+        to: '/operate/dsl/otel-rules',
+        activeWhen: (p) => p === '/operate/oal' || /^\/operate\/dsl(\/|$)/.test(p),
+        children: [
+          { icon: 'set', label: 'MAL · OTEL', to: '/operate/dsl/otel-rules' },
+          { icon: 'set', label: 'MAL · Telegraf', to: '/operate/dsl/telegraf-rules' },
+          { icon: 'set', label: 'LAL', to: '/operate/dsl/lal' },
+          { icon: 'set', label: 'LAL → MAL', to: '/operate/dsl/log-mal-rules' },
+          { icon: 'trace', label: 'OAL · read-only', to: '/operate/oal' },
+          { icon: 'download', label: 'Dump & restore', to: '/operate/dsl/dump' },
+        ],
+      },
     ],
   },
   {
-    kicker: 'Inspect',
-    links: [{ icon: 'metric', label: 'Inspect', to: '/operate/inspect' }],
-  },
-  {
-    kicker: 'Live debugger',
+    kicker: 'Dashboard setup',
     links: [
-      { icon: 'flame', label: 'Live debugger', to: '/operate/live-debug' },
-      { icon: 'event', label: 'Capture history', to: '/operate/live-debug/history' },
+      { icon: 'set', label: 'Overview dashboards', to: '/setup' },
+      { icon: 'metric', label: 'Layer dashboards', to: '/admin/layer-dashboards' },
+      { icon: 'alert', label: 'Alert page', to: '/admin/alert-page-setup' },
     ],
   },
-  {
-    kicker: 'Dump',
-    links: [{ icon: 'download', label: 'Dump & restore', to: '/operate/dump' }],
-  },
   {
     kicker: 'Admin',
     links: [
-      { icon: 'set', label: 'Overview setup', to: '/setup' },
-      { icon: 'metric', label: 'Layer dashboards', to: '/admin/layer-dashboards' },
       { icon: 'user', label: 'Users', to: '/admin/users' },
       { icon: 'set', label: 'Roles', to: '/admin/roles' },
     ],
   },
 ];
+
+const openNavL1 = ref<Set<string>>(new Set());
+function isNavL1Open(to: string): boolean { return openNavL1.value.has(to); }
+function toggleNavL1(row: NavRow): void {
+  if (!row.children) return;
+  const next = new Set(openNavL1.value);
+  if (next.has(row.to)) next.delete(row.to);
+  else next.add(row.to);
+  openNavL1.value = next;
+}
+
+watch(
+  () => route.path,
+  (path) => {
+    for (const sec of sections) {
+      for (const row of sec.links) {
+        if (!row.children) continue;
+        const childActive = row.children.some((c) =>
+          c.activeWhen ? c.activeWhen(path) : path === c.to,
+        );
+        const parentActive = row.activeWhen ? row.activeWhen(path) : path === row.to;
+        if ((childActive || parentActive) && !openNavL1.value.has(row.to)) {
+          openNavL1.value = new Set([...openNavL1.value, row.to]);
+        }
+      }
+    }
+  },
+  { immediate: true },
+);
 </script>
 
 <template>
@@ -244,19 +279,32 @@ const sections: NavSection[] = [
     </RouterLink>
 
     <nav class="sw-nav">
+      <div class="sw-nav-section sw-nav-section--icon">
+        <Icon :name="sectionIcon('Overviews')" />
+        <span>Overviews</span>
+      </div>
       <RouterLink
-        :to="overview.to"
-        class="sw-nav-item lead"
-        :class="{ 'is-active': route.path === overview.to }"
+        v-for="ov in publicOverviews"
+        :key="`pub:${ov.id}`"
+        :to="`/overview/${ov.id}`"
+        class="sw-nav-item"
+        :class="{ 'is-active': isActive(`/overview/${ov.id}`) }"
       >
-        <Icon :name="overview.icon" /><span>{{ overview.label }}</span>
+        <Icon :name="(ov.icon as IconName) || 'dash'" /><span>{{ ov.title }}</span>
+      </RouterLink>
+      <RouterLink
+        to="/alarms"
+        class="sw-nav-item"
+        :class="{ 'is-active': isActive('/alarms') }"
+      >
+        <Icon name="alert" /><span>Alarms</span>
+        <span class="sw-badge err" style="margin-left: auto">7</span>
       </RouterLink>
 
-      <div class="sw-nav-section sw-row" style="justify-content: space-between">
-        <span>Layers</span>
-        <span style="color: var(--sw-fg-3); font-weight: 400">
-          {{ availableLayers.length }} with services
-        </span>
+      <div class="sw-nav-section sw-nav-section--icon" style="justify-content: space-between">
+        <Icon :name="sectionIcon('Layers')" />
+        <span style="flex: 1">Layers</span>
+        <span class="sw-nav-section-count">{{ publicLayers.length }} with services</span>
       </div>
       <div v-if="!oapReachable && oapError" class="oap-banner" :title="oapError">
         OAP unreachable
@@ -268,49 +316,36 @@ const sections: NavSection[] = [
         </RouterLink>
       </div>
       <template v-for="(E, ei) in sidebarEntries" :key="E.kind === 'group' ? `g:${E.label}` : `s:${E.layer.key}:${ei}`">
-        <!-- Grouped layers: collapsible section header + nested layer
-             rows. Group is identified by the layer template's `group`
-             field; expanding shows the same row UI as ungrouped layers. -->
         <template v-if="E.kind === 'group'">
-          <div
-            class="layer-group"
-            :class="{ 'is-open': isGroupOpen(E.label) }"
-            @click="toggleGroup(E.label, E.layers)"
-          >
+          <div class="sw-nav-section sw-nav-section--icon">
+            <Icon :name="sectionIcon(E.label)" />
             <span class="layer-group-name">{{ E.label }}</span>
-            <span class="caret" :class="{ open: isGroupOpen(E.label) }">
-              <Icon name="caret" :size="10" />
-            </span>
           </div>
-          <template v-if="isGroupOpen(E.label)">
-            <template v-for="L in E.layers" :key="`${E.label}::${L.key}`">
-              <!-- Same per-layer markup as the ungrouped path. -->
+          <template v-for="L in E.layers" :key="`${E.label}::${L.key}`">
               <RouterLink
                 v-if="isSingleFeatureLayer(L)"
                 :to="`/layer/${L.key}/${firstLayerTab(L)}`"
                 class="layer-row direct in-group"
                 :class="{ 'is-active': isActive(`/layer/${L.key}`) }"
               >
-                <span class="layer-dot" :style="{ background: L.color }" />
+                <Icon :name="layerIcon(L)" />
                 <span class="layer-name">{{ L.name }}</span>
               </RouterLink>
               <div
                 v-else
                 class="layer-row in-group"
-                :class="{ 'is-active': expandedLayer === L.key }"
+                :class="{
+                  'is-expanded': expandedLayer === L.key,
+                  'is-active': isActiveExact(`/layer/${L.key}`),
+                }"
                 @click="toggleLayer(L.key)"
               >
-                <span class="layer-dot" :style="{ background: L.color }" />
-                <span class="layer-name" :style="{ fontWeight: expandedLayer === L.key ? 600 : 500 }">
-                  {{ L.name }}
-                </span>
+                <Icon :name="layerIcon(L)" />
+                <span class="layer-name">{{ L.name }}</span>
                 <span class="caret" :class="{ open: expandedLayer === L.key }">
                   <Icon name="caret" :size="10" />
                 </span>
               </div>
-              <!-- Children of a multi-feature grouped layer slot in below
-                   the row, indented one level deeper than ungrouped layers'
-                   children so the visual hierarchy stays legible. -->
               <div
                 v-if="!isSingleFeatureLayer(L) && expandedLayer === L.key"
                 class="layer-children in-group"
@@ -414,7 +449,6 @@ const sections: NavSection[] = [
                 </RouterLink>
               </div>
             </template>
-          </template>
         </template>
 
         <!-- Ungrouped single-feature layer: direct link. -->
@@ -424,30 +458,25 @@ const sections: NavSection[] = [
           class="layer-row direct"
           :class="{ 'is-active': isActive(`/layer/${E.layer.key}`) }"
         >
-          <span class="layer-dot" :style="{ background: E.layer.color }" />
+          <Icon :name="layerIcon(E.layer)" />
           <span class="layer-name">{{ E.layer.name }}</span>
         </RouterLink>
 
-        <!-- Ungrouped multi-feature layer: rendered as its own implicit
-             section header so the visual hierarchy stays consistent
-             with grouped sections. The "row" sits at the same level as
-             group headers (ISTIO / DATABASES / …); the tabs slot in
-             beneath it as the section's children. -->
         <div
           v-else
-          class="layer-group standalone"
-          :class="{ 'is-open': expandedLayer === E.layer.key }"
+          class="layer-row expandable"
+          :class="{
+            'is-expanded': expandedLayer === E.layer.key,
+            'is-active': isActiveExact(`/layer/${E.layer.key}`),
+          }"
           @click="toggleLayer(E.layer.key)"
         >
-          <span class="layer-dot" :style="{ background: E.layer.color }" />
-          <span class="layer-group-name">{{ E.layer.name }}</span>
+          <Icon :name="layerIcon(E.layer)" />
+          <span class="layer-name">{{ E.layer.name }}</span>
           <span class="caret" :class="{ open: expandedLayer === E.layer.key }">
             <Icon name="caret" :size="10" />
           </span>
         </div>
-        <!-- Children for the ungrouped multi-feature path. The grouped
-             path renders its own children block above, indented one
-             level deeper. -->
         <div
           v-if="E.kind === 'single' && !isSingleFeatureLayer(E.layer) && expandedLayer === E.layer.key"
           class="layer-children"
@@ -552,20 +581,118 @@ const sections: NavSection[] = [
         </div>
       </template>
 
-      <template v-for="sec in sections" :key="sec.kicker">
-        <div class="sw-nav-section">{{ sec.kicker }}</div>
-        <RouterLink
-          v-for="row in sec.links"
-          :key="row.to"
-          :to="row.to"
-          class="sw-nav-item"
-          :class="{ 'is-active': isActive(row.to) }"
-        >
-          <Icon :name="row.icon" /><span>{{ row.label }}</span>
-          <span v-if="row.badge" class="sw-badge" :class="row.badge.kind" style="margin-left: auto">
-            {{ row.badge.text }}
-          </span>
-        </RouterLink>
+      <template v-if="operateLayers.length > 0">
+        <div class="sw-nav-section sw-nav-section--icon">
+          <Icon :name="sectionIcon('Platform monitoring')" />
+          <span>Platform monitoring</span>
+        </div>
+        <template v-for="L in operateLayers" :key="`op:${L.key}`">
+          <RouterLink
+            v-if="isSingleFeatureLayer(L)"
+            :to="`/layer/${L.key}/${firstLayerTab(L)}`"
+            class="layer-row direct"
+            :class="{ 'is-active': isActive(`/layer/${L.key}`) }"
+          >
+            <Icon :name="layerIcon(L)" />
+            <span class="layer-name">{{ L.name }}</span>
+            <span class="sw-badge" style="margin-left: auto">{{ L.serviceCount }}</span>
+          </RouterLink>
+          <div
+            v-else
+            class="layer-row expandable"
+            :class="{
+              'is-expanded': expandedLayer === L.key,
+              'is-active': isActiveExact(`/layer/${L.key}`),
+            }"
+            @click="toggleLayer(L.key)"
+          >
+            <Icon :name="layerIcon(L)" />
+            <span class="layer-name">{{ L.name }}</span>
+            <span class="caret" :class="{ open: expandedLayer === L.key }">
+              <Icon name="caret" :size="10" />
+            </span>
+          </div>
+          <div
+            v-if="!isSingleFeatureLayer(L) && expandedLayer === L.key"
+            class="layer-children"
+          >
+            <RouterLink
+              v-if="L.caps.dashboards"
+              :to="`/layer/${L.key}/${firstLayerTab(L)}`"
+              class="sw-nav-item"
+              :class="{ 'is-active': isActive(`/layer/${L.key}/${firstLayerTab(L)}`) || route.path === `/layer/${L.key}` }"
+            >
+              <Icon name="svc" /><span>Service</span>
+              <span class="sw-badge" style="margin-left: auto">{{ L.serviceCount }}</span>
+            </RouterLink>
+            <RouterLink
+              v-if="hasInstances(L)"
+              :to="`/layer/${L.key}/instance`"
+              class="sw-nav-item"
+              :class="{ 'is-active': isActive(`/layer/${L.key}/instance`) }"
+            >
+              <Icon name="prof" /><span>{{ L.slots.instances ?? 'Instance' }}</span>
+            </RouterLink>
+            <RouterLink
+              v-if="hasEndpoints(L)"
+              :to="`/layer/${L.key}/endpoint`"
+              class="sw-nav-item"
+              :class="{ 'is-active': isActive(`/layer/${L.key}/endpoint`) }"
+            >
+              <Icon name="ep" /><span>{{ L.slots.endpoints ?? 'Endpoint' }}</span>
+            </RouterLink>
+          </div>
+        </template>
+      </template>
+
+      <template v-for="entry in sections" :key="`m:${entry.kicker}`">
+        <div class="sw-nav-section sw-nav-section--icon">
+          <Icon :name="sectionIcon(entry.kicker)" />
+          <span>{{ entry.kicker }}</span>
+        </div>
+        <template v-for="row in entry.links" :key="row.to">
+          <RouterLink
+            v-if="!row.children"
+            :to="row.to"
+            class="sw-nav-item"
+            :class="{ 'is-active': row.activeWhen ? row.activeWhen(route.path) : isActiveExact(row.to) }"
+          >
+            <Icon :name="row.icon" /><span>{{ row.label }}</span>
+            <span v-if="row.badge" class="sw-badge" :class="row.badge.kind" style="margin-left: auto">
+              {{ row.badge.text }}
+            </span>
+          </RouterLink>
+          <template v-else>
+            <div
+              class="layer-row expandable"
+              :class="{
+                'is-expanded': isNavL1Open(row.to),
+                'is-active': row.activeWhen ? row.activeWhen(route.path) : isActiveExact(row.to),
+              }"
+              @click="toggleNavL1(row)"
+            >
+              <Icon :name="row.icon" />
+              <span class="layer-name">{{ row.label }}</span>
+              <span class="caret" :class="{ open: isNavL1Open(row.to) }">
+                <Icon name="caret" :size="10" />
+              </span>
+            </div>
+            <div v-if="isNavL1Open(row.to)" class="layer-children">
+              <RouterLink
+                v-for="c in row.children"
+                :key="c.to"
+                :to="c.to"
+                class="sw-nav-item"
+                :class="{ 'is-active': c.activeWhen ? c.activeWhen(route.path) : isActiveExact(c.to) }"
+              >
+                <Icon :name="c.icon" /><span>{{ c.label }}</span>
+                <span v-if="c.badge" class="sw-badge" :class="c.badge.kind" style="margin-left: auto">
+                  {{ c.badge.text }}
+                </span>
+              </RouterLink>
+            </div>
+          </template>
+        </template>
       </template>
     </nav>
 
@@ -616,27 +743,51 @@ const sections: NavSection[] = [
   margin-left: 2px;
   letter-spacing: 0.02em;
 }
+/* L1 row — unified menu spec: 28px tall, 16px icon, 12/600 fg-1.
+ * Active = accent inset + 10% accent fill; expanded-only = white-fade. */
 .layer-row {
   display: flex;
   align-items: center;
   gap: 9px;
-  padding: 7px 10px;
+  margin: 1px 8px;
+  padding: 6px 10px;
   border-radius: 6px;
   color: var(--sw-fg-1);
   font-size: 12px;
+  font-weight: 600;
   cursor: pointer;
   user-select: none;
   position: relative;
 }
+.layer-row :deep(svg) {
+  width: 16px;
+  height: 16px;
+  flex: 0 0 16px;
+  color: var(--sw-fg-2);
+  opacity: 1;
+}
+.layer-row .caret :deep(svg) {
+  width: 10px;
+  height: 10px;
+  flex: 0 0 10px;
+  color: var(--sw-fg-3);
+}
 .layer-row:hover {
-  background: var(--sw-bg-2);
+  background: rgba(255, 255, 255, 0.04);
+  color: var(--sw-fg-0);
+}
+.layer-row.is-expanded {
+  background: rgba(255, 255, 255, 0.025);
   color: var(--sw-fg-0);
 }
 .layer-row.is-active {
-  background: var(--sw-bg-3);
+  background: var(--sw-accent-soft);
   color: var(--sw-fg-0);
   box-shadow: inset 2px 0 0 var(--sw-accent);
 }
+.layer-row.is-active :deep(svg):not(.caret svg) {
+  color: var(--sw-accent);
+}
 .layer-row .layer-name {
   flex: 1;
   min-width: 0;
@@ -647,6 +798,10 @@ const sections: NavSection[] = [
 .layer-row.direct {
   text-decoration: none;
 }
+.layer-row.expandable {
+  cursor: pointer;
+  user-select: none;
+}
 .empty-layers {
   margin: 4px 10px 8px;
   padding: 6px 8px;
@@ -654,14 +809,8 @@ const sections: NavSection[] = [
   color: var(--sw-fg-3);
   font-style: italic;
 }
-.layer-dot {
-  width: 7px;
-  height: 7px;
-  border-radius: 50%;
-  flex: 0 0 7px;
-}
+/* Base glyph points down; -90° collapses to right-arrow. */
 .caret {
-  color: var(--sw-fg-3);
   margin-left: 4px;
   transition: transform 0.15s;
   display: inline-flex;
@@ -671,83 +820,146 @@ const sections: NavSection[] = [
 .caret.open {
   transform: rotate(0);
 }
+/* L2 — children of an expanded layer. Vertical rail at left:22 with
+ * a per-row horizontal tick; the last child masks the rail's tail
+ * with --sw-bg-1 so it reads as a half-line. */
 .layer-children {
-  padding-left: 12px;
-  margin-left: 18px;
+  position: relative;
+  padding: 2px 0 4px;
   margin-bottom: 4px;
-  border-left: 1px dashed var(--sw-line-2);
+}
+.layer-children::before {
+  content: '';
+  position: absolute;
+  left: 22px;
+  top: 0;
+  bottom: 0;
+  width: 1px;
+  background: var(--sw-line-2);
 }
 .layer-children .sw-nav-item {
+  position: relative;
+  margin: 1px 8px 1px 28px;
+  padding: 5px 9px;
+  border-radius: 5px;
+  font-size: 11.5px;
+  font-weight: 500;
   text-decoration: none;
-}
-/* Sidebar group header (e.g. "Istio", "Databases", "AWS"). Collapsible
-   section that buckets layer rows sharing the template's `group` field.
-   Visually quieter than a layer row — the layer rows beneath it stay
-   the primary affordance. */
-.layer-group {
-  display: flex;
-  align-items: center;
   gap: 8px;
-  padding: 9px 12px 5px;
-  margin-top: 8px;
-  font-size: 11px;
-  font-weight: 700;
-  letter-spacing: 0.08em;
-  text-transform: uppercase;
-  /* The section header IS the navigational anchor — operators rely on
-   * it to find the right layer family — so it needs to be legible at
-   * a glance. We previously dimmed it to `--sw-fg-3` which made the
-   * grouped sections (ISTIO / DATABASES / …) read like throwaway
-   * dividers; now it stays in `--sw-fg-2` baseline and brightens to
-   * `--sw-fg-0` on hover/open with a 2-px accent rule on the left so
-   * the section boundary is unambiguous. */
+  color: var(--sw-fg-1);
+}
+.layer-children .sw-nav-item::before {
+  content: '';
+  position: absolute;
+  left: -6px;
+  top: 50%;
+  width: 8px;
+  height: 1px;
+  background: var(--sw-line-2);
+}
+.layer-children .sw-nav-item:last-child::after {
+  content: '';
+  position: absolute;
+  left: -7px;
+  top: calc(50% + 1px);
+  bottom: -4px;
+  width: 2px;
+  background: var(--sw-bg-1);
+}
+.layer-children .sw-nav-item :deep(svg) {
+  width: 14px;
+  height: 14px;
+  flex: 0 0 14px;
   color: var(--sw-fg-2);
-  cursor: pointer;
-  user-select: none;
-  border-left: 2px solid transparent;
-  border-radius: 0 4px 4px 0;
+  opacity: 1;
 }
-.layer-group:hover { color: var(--sw-fg-0); border-left-color: var(--sw-line-2); }
-.layer-group.is-open {
+.layer-children .sw-nav-item:hover {
+  background: rgba(255, 255, 255, 0.04);
   color: var(--sw-fg-0);
-  border-left-color: var(--sw-accent);
 }
-.layer-group-name { flex: 1; min-width: 0; }
-/* Standalone variant — ungrouped multi-feature layers (General /
-   Browser today) render as their own implicit section header so they
-   read as siblings of ISTIO / DATABASES / etc. The colored layer dot
-   sits before the name; styling stays in the group-header voice. */
-.layer-group.standalone {
-  padding: 7px 12px 5px;
-}
-.layer-group.standalone .layer-dot {
-  /* Slightly bigger so the dot reads at the section-header type size. */
-  width: 8px;
-  height: 8px;
-  flex: 0 0 8px;
+.layer-children .sw-nav-item.is-active {
+  background: rgba(249, 115, 22, 0.12);
+  color: var(--sw-fg-0);
+  font-weight: 600;
+  box-shadow: inset 2px 0 0 var(--sw-accent);
 }
-/* Layer rows nested inside a group section get a left indent + dashed
-   guide rule so the visual hierarchy reads at a glance. */
-.layer-row.in-group {
-  margin-left: 10px;
-  padding-left: 10px;
-  border-left: 1px dashed var(--sw-line-2);
+.layer-children .sw-nav-item.is-active :deep(svg) {
+  color: var(--sw-accent);
+}
+/* L0 — every section header lives at the same rhythm regardless of
+ * whether it's the top-of-sidebar Overviews/Layers/Manage kicker or
+ * an in-Layers sub-group bucket (Istio / Kubernetes / …). All wear
+ * the spec voice (10/700/0.1em uppercase fg-3, 14px top padding) and
+ * carry an icon on the left so the icon column stays aligned with
+ * the L1 rows below — the eye doesn't have to jump positions when
+ * scanning. No caret, no click — L0s are presentational. */
+.sw-nav-section,
+.sw-nav-section--icon {
+  font-size: 10px;
+  font-weight: 700;
+  letter-spacing: 0.1em;
+  text-transform: uppercase;
+  color: var(--sw-fg-3);
 }
-.layer-children.in-group {
-  margin-left: 28px;
+.sw-nav-section--icon {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 14px 14px 4px;
+}
+.sw-nav-section--icon :deep(svg) {
+  /* Slightly larger than the 10px text so the glyph reads as a label
+   * mark, not a punctuation dot. Inherits text colour (fg-3) so it
+   * stays in the L0 voice. */
+  width: 12px;
+  height: 12px;
+  flex: 0 0 12px;
+  color: var(--sw-fg-3);
+  opacity: 1;
 }
+.layer-group-name { flex: 1; min-width: 0; }
+/* Grouped and ungrouped layer rows sit at the same indent — the group
+ * header already delineates the section, so no extra tree-style nest. */
+.layer-row.in-group { }
+.layer-children.in-group { }
 .sw-nav-item {
   text-decoration: none;
 }
-.sw-nav-item.lead {
-  margin-top: 4px;
-}
-.sw-nav-item.is-inactive .layer-dot {
-  opacity: 0.4;
+.sw-nav-section-count {
+  color: var(--sw-fg-3);
+  font-weight: 500;
+  font-size: 10.5px;
+  text-transform: none;
+  letter-spacing: 0;
 }
-.sw-nav-item.is-inactive > span:nth-child(2) {
+.sw-nav-item.is-inactive > span {
   color: var(--sw-fg-3);
 }
+/* L1 overrides for `.sw-nav-item` at the top level of the sidebar.
+ * Direct-child selector keeps it scoped so the global tokens-default
+ * `.sw-nav-item.is-active` (bg-3) still applies on other surfaces. */
+.sw-nav > .sw-nav-item {
+  margin: 1px 8px;
+  font-weight: 600;
+}
+.sw-nav > .sw-nav-item :deep(svg) {
+  width: 16px;
+  height: 16px;
+  flex: 0 0 16px;
+  color: var(--sw-fg-2);
+  opacity: 1;
+}
+.sw-nav > .sw-nav-item:hover {
+  background: rgba(255, 255, 255, 0.04);
+}
+.sw-nav > .sw-nav-item.is-active {
+  background: var(--sw-accent-soft);
+  color: var(--sw-fg-0);
+  box-shadow: inset 2px 0 0 var(--sw-accent);
+}
+.sw-nav > .sw-nav-item.is-active :deep(svg) {
+  color: var(--sw-accent);
+}
 .oap-banner {
   margin: 4px 10px 8px;
   padding: 6px 8px;
diff --git a/apps/ui/src/components/shell/AppTopbar.vue b/apps/ui/src/components/shell/AppTopbar.vue
index e2f4b4d..0f9b640 100644
--- a/apps/ui/src/components/shell/AppTopbar.vue
+++ b/apps/ui/src/components/shell/AppTopbar.vue
@@ -21,6 +21,7 @@ import Icon from '@/components/icons/Icon.vue';
 import { useOapInfo } from '@/composables/useOapInfo';
 import { useLayers } from '@/composables/useLayers';
 import { useAutoRefreshStore } from '@/stores/autoRefresh';
+import { useTimeRangeStore, TIME_PRESETS, STEP_LIMITS, isValidRange, type TimeStep } from '@/stores/timeRange';
 
 const route = useRoute();
 const { layers } = useLayers();
@@ -140,8 +141,31 @@ const TIME_RANGE_OPT_OUT = [
   // operator is mid-investigation. Block the global picker + pause the
   // auto-refresher whenever the operator is on a Logs tab.
   /^\/layer\/[^/]+\/logs$/,
+  // Alarms is a triage view — auto-refresh shifts the visible window
+  // out from under any selection / brush the operator is making, and
+  // we already chunk the traffic backfill ourselves with explicit
+  // delta refresh. Pause the global ticker while on /alarms.
+  /^\/alarms$/,
+  // Profiling tabs (trace / eBPF / network / async / pprof) bind to a
+  // *task* and the operator drills into a single segment + analyze
+  // result. Auto-refresh would yank the task list, re-pick the first
+  // task, and blow away the analyze pane mid-investigation. The pages
+  // expose their own "refresh tasks" affordance instead. Same regex
+  // shape as the trace/logs opt-outs above.
+  /^\/layer\/[^/]+\/trace-profiling$/,
+  /^\/layer\/[^/]+\/ebpf-profiling$/,
+  /^\/layer\/[^/]+\/network-profiling$/,
+  /^\/layer\/[^/]+\/async-profiling$/,
+  /^\/layer\/[^/]+\/pprof$/,
 ];
 const ownsTimeRange = computed<boolean>(() => TIME_RANGE_OPT_OUT.some((r) => r.test(route.path)));
+/** Auto-refresh is paused whenever the operator has frozen the window
+ *  to a custom range — polling a fixed snapshot just re-fetches the
+ *  same bytes. The button still works for a one-shot manual refresh. */
+const hasFrozenRange = computed<boolean>(
+  () => useTimeRangeStore().presetId === 'custom',
+);
+const autoSuspended = computed<boolean>(() => ownsTimeRange.value || hasFrozenRange.value);
 const globalTimeTooltip = computed<string>(() => {
   if (ownsTimeRange.value) {
     return 'This page uses its own time range — disable the page picker to use the global one.';
@@ -158,7 +182,7 @@ const globalTimeTooltip = computed<string>(() => {
  */
 const auto = useAutoRefreshStore();
 watch(
-  ownsTimeRange,
+  autoSuspended,
   (now) => {
     if (now) auto.suspend();
     else auto.resume();
@@ -191,16 +215,173 @@ if (typeof window !== 'undefined') {
   window.addEventListener('click', onWindowClickClose);
 }
 const refreshLabel = computed<string>(() => {
-  if (ownsTimeRange.value) return 'Paused';
+  if (autoSuspended.value) return 'Paused';
   if (auto.intervalSec === null) return 'Off';
   if (auto.secondsUntilNext === null) return '—';
   return `${auto.secondsUntilNext}s`;
 });
 const refreshTooltip = computed<string>(() => {
   if (ownsTimeRange.value) return 'Auto-refresh paused on this page';
+  if (hasFrozenRange.value) return 'Auto-refresh paused while a custom time range is selected';
   if (auto.intervalSec === null) return 'Auto-refresh off · click to refresh now';
   return `Auto-refresh every ${auto.intervalSec}s · ${auto.secondsUntilNext ?? '—'}s remaining · click to refresh now`;
 });
+
+// ── Global time-range picker ──────────────────────────────────────
+const timeRange = useTimeRangeStore();
+const timeMenuOpen = ref(false);
+const timeClusterEl = ref<HTMLElement | null>(null);
+/** Active tab in the dropdown — one tab per precision. Seeded from
+ *  the currently-selected preset's step so reopening lands on the
+ *  same precision the operator last used. */
+const activeStepTab = ref<TimeStep>(timeRange.step);
+watch(() => timeRange.step, (s) => (activeStepTab.value = s));
+const TAB_DEFS: Array<{ step: TimeStep; tab: string; cap: string }> = [
+  { step: 'MINUTE', tab: 'Minute', cap: '≤ 4 h' },
+  { step: 'HOUR',   tab: 'Hour',   cap: '≤ 14 d' },
+  { step: 'DAY',    tab: 'Day',    cap: '≤ 3 mo' },
+];
+const presetsForActiveTab = computed(() =>
+  TIME_PRESETS.filter((p) => p.step === activeStepTab.value),
+);
+function pickTimePreset(id: string): void {
+  timeRange.selectPreset(id);
+  timeMenuOpen.value = false;
+  // Fire one auto-refresh tick so subscribers re-query with the new
+  // window immediately rather than waiting for the next interval.
+  if (!ownsTimeRange.value) auto.refreshNow();
+}
+function onTimeMenuClickClose(ev: MouseEvent): void {
+  if (!timeMenuOpen.value) return;
+  const el = timeClusterEl.value;
+  if (el && !el.contains(ev.target as Node)) {
+    timeMenuOpen.value = false;
+    customOpenStep.value = null;
+  }
+}
+if (typeof window !== 'undefined') {
+  window.addEventListener('click', onTimeMenuClickClose);
+}
+const timeChipLabel = computed<string>(() => {
+  if (ownsTimeRange.value) return 'Page time range';
+  if (timeRange.presetId === 'custom') {
+    const r = timeRange.range;
+    return `${formatRangeStamp(r.startMs, timeRange.step)} → ${formatRangeStamp(r.endMs, timeRange.step)}`;
+  }
+  return timeRange.label;
+});
+
+// ── Custom range, per precision ───────────────────────────────────
+// One "Custom…" expander per precision group. Inputs are scoped to
+// the step so the operator can't pick at finer resolution than the
+// data will support:
+//   MINUTE → datetime-local (date + HH:MM)
+//   HOUR   → date + hour-select (00…23), no minute field at all
+//   DAY    → date-only
+// Submit clamps against STEP_LIMITS so an overlong window is
+// rejected at the boundary instead of silently truncated.
+const customOpenStep = ref<TimeStep | null>(null);
+/** Per-bound, per-step form state. For HOUR we keep date and hour
+ *  in separate keys so the UI can render two distinct controls. */
+const customDraft = ref<Record<string, string>>({});
+const customError = ref<string | null>(null);
+
+function z2(n: number): string {
+  return String(n).padStart(2, '0');
+}
+const HOURS_OF_DAY: readonly number[] = Array.from({ length: 24 }, (_, i) => i);
+
+/** Seed form state for one bound. The shape depends on the step. */
+function setDraftFromMs(step: TimeStep, side: 'start' | 'end', ms: number): void {
+  const d = new Date(ms);
+  const base = `${d.getFullYear()}-${z2(d.getMonth() + 1)}-${z2(d.getDate())}`;
+  if (step === 'DAY') {
+    customDraft.value[`${step}-${side}`] = base;
+    return;
+  }
+  if (step === 'HOUR') {
+    customDraft.value[`${step}-${side}-date`] = base;
+    customDraft.value[`${step}-${side}-hour`] = z2(d.getHours());
+    return;
+  }
+  // MINUTE
+  customDraft.value[`${step}-${side}`] = `${base}T${z2(d.getHours())}:${z2(d.getMinutes())}`;
+}
+/** Compose form state for one bound back into an ms timestamp. */
+function draftToMs(step: TimeStep, side: 'start' | 'end'): number | null {
+  if (step === 'DAY') {
+    const v = customDraft.value[`${step}-${side}`] ?? '';
+    if (!v) return null;
+    const [y, mo, da] = v.split('-').map(Number);
+    if (!y || !mo || !da) return null;
+    return new Date(y, mo - 1, da).getTime();
+  }
+  if (step === 'HOUR') {
+    const dateV = customDraft.value[`${step}-${side}-date`] ?? '';
+    const hourV = customDraft.value[`${step}-${side}-hour`] ?? '';
+    if (!dateV || hourV === '') return null;
+    const [y, mo, da] = dateV.split('-').map(Number);
+    const h = Number(hourV);
+    if (!y || !mo || !da || Number.isNaN(h)) return null;
+    return new Date(y, mo - 1, da, h, 0, 0, 0).getTime();
+  }
+  // MINUTE
+  const v = customDraft.value[`${step}-${side}`] ?? '';
+  if (!v) return null;
+  const dt = new Date(v);
+  return Number.isNaN(dt.getTime()) ? null : dt.getTime();
+}
+function openCustom(step: TimeStep): void {
+  customError.value = null;
+  if (customOpenStep.value === step) {
+    customOpenStep.value = null;
+    return;
+  }
+  customOpenStep.value = step;
+  // Seed the form with a sensible default range for the precision —
+  // half the max, ending now. Operators then nudge whichever bound
+  // they actually care about.
+  const lim = STEP_LIMITS[step];
+  const endMs = Date.now();
+  const startMs = endMs - Math.floor(lim.maxMs / 2);
+  setDraftFromMs(step, 'start', startMs);
+  setDraftFromMs(step, 'end', endMs);
+}
+function submitCustom(step: TimeStep): void {
+  const startMs = draftToMs(step, 'start');
+  const endMs = draftToMs(step, 'end');
+  if (startMs === null || endMs === null) {
+    customError.value = 'Pick both a start and an end.';
+    return;
+  }
+  if (endMs <= startMs) {
+    customError.value = 'End must be after start.';
+    return;
+  }
+  if (!isValidRange(step, endMs - startMs)) {
+    const lim = STEP_LIMITS[step];
+    customError.value = `Range exceeds ${step.toLowerCase()}-precision cap of ${humanDuration(lim.maxMs)}.`;
+    return;
+  }
+  customError.value = null;
+  timeRange.selectCustom(startMs, endMs, step);
+  timeMenuOpen.value = false;
+  customOpenStep.value = null;
+  if (!ownsTimeRange.value) auto.refreshNow();
+}
+function humanDuration(ms: number): string {
+  const h = Math.floor(ms / 3_600_000);
+  if (h < 24) return `${h} h`;
+  const d = Math.floor(h / 24);
+  return `${d} d`;
+}
+function formatRangeStamp(ms: number, step: TimeStep): string {
+  const d = new Date(ms);
+  const z = (n: number) => String(n).padStart(2, '0');
+  if (step === 'DAY') return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())}`;
+  if (step === 'HOUR') return `${z(d.getMonth() + 1)}-${z(d.getDate())} ${z(d.getHours())}h`;
+  return `${z(d.getMonth() + 1)}-${z(d.getDate())} ${z(d.getHours())}:${z(d.getMinutes())}`;
+}
 </script>
 
 <template>
@@ -227,10 +408,108 @@ const refreshTooltip = computed<string>(() => {
           {{ tzOffsetLabel }}
         </span>
       </RouterLink>
-      <div class="sw-btn" :class="{ 'is-disabled': ownsTimeRange }" :title="globalTimeTooltip">
-        <Icon name="clock" :size="12" />
-        <span>{{ ownsTimeRange ? 'Page time range' : 'Last 60 minutes' }}</span>
-        <Icon name="caret" :size="10" />
+      <div ref="timeClusterEl" class="time-cluster">
+        <button
+          type="button"
+          class="sw-btn time-trigger"
+          :class="{ 'is-disabled': ownsTimeRange }"
+          :title="globalTimeTooltip"
+          :disabled="ownsTimeRange"
+          @click="timeMenuOpen = !timeMenuOpen"
+        >
+          <Icon name="clock" :size="12" />
+          <span>{{ timeChipLabel }}</span>
+          <Icon name="caret" :size="10" />
+        </button>
+        <transition name="rf-menu">
+          <div v-if="timeMenuOpen && !ownsTimeRange" class="tr-menu">
+            <!-- Tab strip — one tab per precision; switching tabs
+                 doesn't apply anything, it just swaps which preset
+                 list + custom form is shown. -->
+            <div class="tr-tabs" role="tablist">
+              <button
+                v-for="t in TAB_DEFS"
+                :key="t.step"
+                type="button"
+                class="tr-tab"
+                :class="{ 'is-on': activeStepTab === t.step }"
+                role="tab"
+                :aria-selected="activeStepTab === t.step"
+                @click="activeStepTab = t.step; customError = null"
+              >
+                <span class="tr-tab-name">{{ t.tab }}</span>
+                <span class="tr-tab-cap">{{ t.cap }}</span>
+              </button>
+            </div>
+
+            <div class="tr-tab-body">
+              <button
+                v-for="p in presetsForActiveTab"
+                :key="p.id"
+                type="button"
+                class="tr-item"
+                :class="{ 'is-on': timeRange.presetId === p.id }"
+                @click="pickTimePreset(p.id)"
+              >
+                <span>{{ p.label }}</span>
+                <span v-if="timeRange.presetId === p.id" class="tr-tick">✓</span>
+              </button>
+              <!-- Custom expander, scoped to the active precision. -->
+              <button
+                type="button"
+                class="tr-item tr-custom-trigger"
+                :class="{ 'is-on': customOpenStep === activeStepTab }"
+                @click="openCustom(activeStepTab)"
+              >
+                <span>Custom range…</span>
+                <span class="tr-tick">{{ customOpenStep === activeStepTab ? '▾' : '▸' }}</span>
+              </button>
+              <div v-if="customOpenStep === activeStepTab" class="tr-custom">
+                <template v-for="side in (['start', 'end'] as const)" :key="side">
+                  <label class="tr-custom-field">
+                    <span>{{ side === 'start' ? 'Start' : 'End' }}</span>
+                    <!-- MINUTE precision — full date + HH:MM. -->
+                    <input
+                      v-if="activeStepTab === 'MINUTE'"
+                      type="datetime-local"
+                      step="60"
+                      class="tr-custom-input"
+                      v-model="customDraft[`${activeStepTab}-${side}`]"
+                    />
+                    <!-- HOUR precision — date + hour-select. -->
+                    <div v-else-if="activeStepTab === 'HOUR'" class="tr-custom-split">
+                      <input
+                        type="date"
+                        class="tr-custom-input"
+                        v-model="customDraft[`${activeStepTab}-${side}-date`]"
+                      />
+                      <select
+                        class="tr-custom-input tr-custom-hour"
+                        v-model="customDraft[`${activeStepTab}-${side}-hour`]"
+                      >
+                        <option v-for="h in HOURS_OF_DAY" :key="h" :value="String(h).padStart(2, '0')">
+                          {{ String(h).padStart(2, '0') }}:00
+                        </option>
+                      </select>
+                    </div>
+                    <!-- DAY precision — date only. -->
+                    <input
+                      v-else
+                      type="date"
+                      class="tr-custom-input"
+                      v-model="customDraft[`${activeStepTab}-${side}`]"
+                    />
+                  </label>
+                </template>
+                <div v-if="customError" class="tr-custom-err">{{ customError }}</div>
+                <div class="tr-custom-foot">
+                  <button type="button" class="tr-cust-btn ghost" @click="customOpenStep = null">Cancel</button>
+                  <button type="button" class="tr-cust-btn primary" @click="submitCustom(activeStepTab)">Apply</button>
+                </div>
+              </div>
+            </div>
+          </div>
+        </transition>
       </div>
       <!-- Auto-refresh cluster: countdown + spinning button on the
            left, dropdown caret on the right. Click the icon to
@@ -342,6 +621,183 @@ const refreshTooltip = computed<string>(() => {
 .rf-menu-enter-active, .rf-menu-leave-active {
   transition: opacity 0.15s ease, transform 0.15s ease;
 }
+
+/* ── Global time-range picker ─────────────────────────────────── */
+.time-cluster {
+  position: relative;
+  display: flex;
+}
+.time-trigger {
+  cursor: pointer;
+}
+.tr-menu {
+  position: absolute;
+  top: calc(100% + 4px);
+  right: 0;
+  z-index: 60;
+  width: 240px;
+  background: var(--sw-bg-1);
+  border: 1px solid var(--sw-line);
+  border-radius: 6px;
+  box-shadow: 0 10px 24px rgba(0, 0, 0, 0.45);
+  padding: 4px 0;
+  font-size: 11.5px;
+  max-height: 70vh;
+  overflow-y: auto;
+}
+/* Tab strip + body for the 3-precision dropdown. The tab list sits
+ * flush with the menu top and slides the body content under it.
+ * Active tab gets an underline + accent text so the precision is
+ * obvious at a glance. */
+.tr-tabs {
+  display: flex;
+  border-bottom: 1px solid var(--sw-line);
+  background: var(--sw-bg-2);
+}
+.tr-tab {
+  flex: 1 1 0;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 2px;
+  padding: 8px 4px 6px;
+  background: transparent;
+  border: 0;
+  border-bottom: 2px solid transparent;
+  color: var(--sw-fg-2);
+  font: inherit;
+  cursor: pointer;
+  transition: color 0.1s ease, border-color 0.1s ease;
+}
+.tr-tab:hover {
+  color: var(--sw-fg-0);
+}
+.tr-tab.is-on {
+  color: var(--sw-accent);
+  border-bottom-color: var(--sw-accent);
+}
+.tr-tab-name {
+  font-size: 11.5px;
+  font-weight: 600;
+}
+.tr-tab-cap {
+  font-size: 9.5px;
+  color: var(--sw-fg-3);
+  letter-spacing: 0.04em;
+}
+.tr-tab.is-on .tr-tab-cap {
+  color: var(--sw-accent-2);
+}
+.tr-tab-body {
+  padding: 4px 0;
+}
+.tr-item {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  width: 100%;
+  padding: 5px 12px;
+  border: 0;
+  background: transparent;
+  color: var(--sw-fg-1);
+  font: inherit;
+  text-align: left;
+  cursor: pointer;
+}
+.tr-item:hover {
+  background: var(--sw-bg-2);
+  color: var(--sw-fg-0);
+}
+.tr-item.is-on {
+  background: var(--sw-accent-soft);
+  color: var(--sw-accent-2);
+  font-weight: 600;
+}
+.tr-tick {
+  font-size: 11px;
+  color: var(--sw-accent);
+}
+.tr-menu {
+  width: 280px;
+}
+.tr-custom-trigger {
+  color: var(--sw-fg-2);
+}
+.tr-custom-trigger.is-on {
+  background: var(--sw-bg-2);
+  color: var(--sw-fg-0);
+  font-weight: 500;
+}
+.tr-custom {
+  padding: 6px 12px 10px;
+  background: var(--sw-bg-2);
+  border-top: 1px dotted var(--sw-line);
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+}
+.tr-custom-field {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+  font-size: 10px;
+  text-transform: uppercase;
+  letter-spacing: 0.06em;
+  color: var(--sw-fg-3);
+}
+.tr-custom-input {
+  background: var(--sw-bg-1);
+  color: var(--sw-fg-0);
+  border: 1px solid var(--sw-line);
+  border-radius: 3px;
+  padding: 4px 6px;
+  font-family: var(--sw-mono);
+  font-size: 11.5px;
+  outline: none;
+}
+.tr-custom-input:focus {
+  border-color: var(--sw-accent);
+}
+.tr-custom-split {
+  display: flex;
+  gap: 6px;
+}
+.tr-custom-split .tr-custom-input {
+  flex: 1 1 0;
+  min-width: 0;
+}
+.tr-custom-hour {
+  flex: 0 0 78px;
+}
+.tr-custom-err {
+  font-size: 10.5px;
+  color: var(--sw-err);
+}
+.tr-custom-foot {
+  display: flex;
+  justify-content: flex-end;
+  gap: 6px;
+  margin-top: 2px;
+}
+.tr-cust-btn {
+  font-size: 11px;
+  padding: 3px 10px;
+  border-radius: 3px;
+  border: 1px solid var(--sw-line-2);
+  background: transparent;
+  color: var(--sw-fg-1);
+  cursor: pointer;
+}
+.tr-cust-btn.primary {
+  background: var(--sw-accent);
+  border-color: var(--sw-accent);
+  color: #0a0d12;
+  font-weight: 600;
+}
+.tr-cust-btn:hover:not(:disabled) {
+  filter: brightness(1.08);
+}
+
 .oap-chip {
   text-decoration: none;
   font-family: var(--sw-mono);
diff --git a/apps/ui/src/components/shell/GlobalConnectivityBanner.vue b/apps/ui/src/components/shell/GlobalConnectivityBanner.vue
new file mode 100644
index 0000000..6a6ce8e
--- /dev/null
+++ b/apps/ui/src/components/shell/GlobalConnectivityBanner.vue
@@ -0,0 +1,274 @@
+<!--
+  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.
+-->
+<script setup lang="ts">
+/**
+ * Global "OAP query port unreachable" strip.
+ *
+ * Mounts under the topbar on every shell route. Surfaces a verbose
+ * warning the moment the `/api/oap/info` poll starts returning
+ * `reachable: false`, with the last error reason inline. While the
+ * banner is showing, a faster retry poll (operator-selectable
+ * off/5/15/60s, persisted to localStorage) takes over from the
+ * default 30s `useOapInfo` cadence — so recovery shows up in the UI
+ * without an operator-triggered refresh.
+ *
+ * Only the query port (`:12800`) drives this strip. Admin-port
+ * (`:17128`) failures render in-page via `AdminFeatureWarning`
+ * inside each admin-host route — they don't blanket-warn because a
+ * dead admin host is normal in deploys that opted out of those
+ * modules.
+ */
+import { computed, onBeforeUnmount, ref, watch } from 'vue';
+import { useRoute } from 'vue-router';
+import { useOapInfo } from '@/composables/useOapInfo';
+import Icon from '@/components/icons/Icon.vue';
+
+type RetryChoice = 'off' | '5' | '15' | '60';
+interface RetryOption {
+  value: RetryChoice;
+  label: string;
+}
+const RETRY_OPTIONS: RetryOption[] = [
+  { value: 'off', label: 'off' },
+  { value: '5', label: '5s' },
+  { value: '15', label: '15s' },
+  { value: '60', label: '60s' },
+];
+const RETRY_KEY = 'horizon:banner:retry-poll:v1';
+
+function loadRetry(): RetryChoice {
+  try {
+    const raw = localStorage.getItem(RETRY_KEY);
+    if (raw && RETRY_OPTIONS.some((o) => o.value === raw)) return raw as RetryChoice;
+  } catch {
+    /* private-browsing / quota — ignore */
+  }
+  return '5';
+}
+const retryChoice = ref<RetryChoice>(loadRetry());
+watch(retryChoice, (next) => {
+  try {
+    localStorage.setItem(RETRY_KEY, next);
+  } catch {
+    /* ignore */
+  }
+});
+
+const { info, reachable, refetch } = useOapInfo();
+const route = useRoute();
+
+/** True only after the first response has arrived AND it was a
+ *  failure. Suppresses the banner during boot / pre-auth so we don't
+ *  flash it on routes that haven't finished loading. */
+const unreachable = computed<boolean>(() => info.value !== null && !reachable.value);
+
+/** Auth-gated routes only — `/login` etc. carry `meta.public` and
+ *  don't have an OAP session yet. */
+const isPublicRoute = computed<boolean>(() => route.meta.public === true);
+
+/** ms since the first failed poll. Reset on every successful one.
+ *  The strip shows "Xs ago" so the operator can tell stale failures
+ *  from transient blips. */
+const firstFailureAt = ref<number | null>(null);
+const nowMs = ref(Date.now());
+watch(unreachable, (down) => {
+  if (down) {
+    if (firstFailureAt.value === null) firstFailureAt.value = Date.now();
+  } else {
+    firstFailureAt.value = null;
+  }
+});
+const tickerId = setInterval(() => {
+  nowMs.value = Date.now();
+}, 1_000);
+onBeforeUnmount(() => clearInterval(tickerId));
+
+const downForLabel = computed<string>(() => {
+  if (firstFailureAt.value === null) return '';
+  const sec = Math.max(1, Math.floor((nowMs.value - firstFailureAt.value) / 1000));
+  if (sec < 60) return `${sec}s ago`;
+  const min = Math.floor(sec / 60);
+  if (min < 60) return `${min}m ago`;
+  const hr = Math.floor(min / 60);
+  return `${hr}h ${min % 60}m ago`;
+});
+
+/** Retry-while-down poller. Separate from `useOapInfo`'s 30s loop so
+ *  flipping the dropdown to `off` keeps the standard 30s cadence
+ *  running underneath. Cleared whenever the connection recovers, the
+ *  cadence changes, or the banner unmounts. */
+let retryId: ReturnType<typeof setInterval> | null = null;
+function clearRetry(): void {
+  if (retryId) {
+    clearInterval(retryId);
+    retryId = null;
+  }
+}
+function startRetry(): void {
+  clearRetry();
+  if (!unreachable.value) return;
+  if (retryChoice.value === 'off') return;
+  const ms = Number(retryChoice.value) * 1000;
+  retryId = setInterval(() => {
+    void refetch();
+  }, ms);
+}
+watch([unreachable, retryChoice], () => startRetry(), { immediate: true });
+onBeforeUnmount(() => clearRetry());
+
+const errorText = computed<string>(() => info.value?.error ?? 'no response');
+const statusUrl = computed<string | undefined>(() => info.value?.statusUrl);
+</script>
+
+<template>
+  <div v-if="unreachable && !isPublicRoute" class="banner" role="alert">
+    <span class="icon">
+      <Icon name="alert" :size="14" />
+    </span>
+    <div class="msg">
+      <strong>OAP query port unreachable</strong>
+      <span class="detail">
+        Last seen <span class="when">{{ downForLabel }}</span>
+        <span v-if="statusUrl">
+          · <code>{{ statusUrl }}</code>
+        </span>
+        · <span class="err">{{ errorText }}</span>
+      </span>
+    </div>
+
+    <label class="retry" title="Auto-retry cadence while OAP is unreachable">
+      <span class="retry-label">retry</span>
+      <select v-model="retryChoice">
+        <option v-for="o in RETRY_OPTIONS" :key="o.value" :value="o.value">
+          {{ o.label }}
+        </option>
+      </select>
+    </label>
+
+    <button type="button" class="now" @click="() => refetch()">retry now</button>
+    <RouterLink to="/operate/cluster" class="link">View cluster status →</RouterLink>
+  </div>
+</template>
+
+<style scoped>
+.banner {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  padding: 8px 16px;
+  background: linear-gradient(90deg, rgba(239, 68, 68, 0.18), rgba(239, 68, 68, 0.10));
+  border-bottom: 1px solid rgba(239, 68, 68, 0.45);
+  color: var(--sw-fg-0);
+  font-size: 12px;
+  position: sticky;
+  top: 0;
+  z-index: 50;
+}
+.icon {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 22px;
+  height: 22px;
+  border-radius: 50%;
+  background: rgba(239, 68, 68, 0.25);
+  color: var(--sw-err);
+  flex-shrink: 0;
+}
+.msg {
+  display: flex;
+  align-items: baseline;
+  gap: 10px;
+  flex: 1;
+  min-width: 0;
+}
+.msg strong {
+  color: var(--sw-err);
+  font-weight: 600;
+  white-space: nowrap;
+}
+.detail {
+  color: var(--sw-fg-1);
+  font-size: 11.5px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.when {
+  color: var(--sw-fg-0);
+  font-variant-numeric: tabular-nums;
+}
+.detail code {
+  font-family: var(--sw-mono);
+  background: rgba(0, 0, 0, 0.25);
+  padding: 1px 5px;
+  border-radius: 3px;
+  font-size: 11px;
+  color: var(--sw-fg-1);
+}
+.detail .err {
+  color: var(--sw-fg-2);
+  font-style: italic;
+}
+
+.retry {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  flex-shrink: 0;
+}
+.retry-label {
+  font-size: 10.5px;
+  text-transform: uppercase;
+  letter-spacing: 0.06em;
+  color: var(--sw-fg-2);
+}
+.retry select {
+  background: var(--sw-bg-2);
+  border: 1px solid var(--sw-line-2);
+  color: var(--sw-fg-0);
+  font: inherit;
+  font-size: 11.5px;
+  padding: 3px 6px;
+  border-radius: 4px;
+  cursor: pointer;
+}
+.now {
+  background: rgba(0, 0, 0, 0.25);
+  border: 1px solid rgba(239, 68, 68, 0.4);
+  color: var(--sw-fg-0);
+  padding: 4px 10px;
+  font: inherit;
+  font-size: 11.5px;
+  border-radius: 4px;
+  cursor: pointer;
+}
+.now:hover {
+  background: rgba(0, 0, 0, 0.4);
+}
+.link {
+  color: var(--sw-err);
+  font-size: 11.5px;
+  text-decoration: none;
+  white-space: nowrap;
+  flex-shrink: 0;
+}
+.link:hover {
+  color: var(--sw-fg-0);
+  text-decoration: underline;
+}
+</style>
diff --git a/apps/ui/src/components/trace/NativeTraceWaterfall.vue b/apps/ui/src/components/trace/NativeTraceWaterfall.vue
new file mode 100644
index 0000000..c49d41b
--- /dev/null
+++ b/apps/ui/src/components/trace/NativeTraceWaterfall.vue
@@ -0,0 +1,410 @@
+<!--
+  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.
+-->
+<!--
+  Native-trace waterfall row list. Shared between the Trace tab
+  (`/layer/:key/trace`) and the Trace Profiling tab — both display a
+  segment's spans as an indented timeline with service-coloured bars,
+  kind glyphs, component icons, and a per-row duration suffix.
+
+  Inputs:
+    spans          — flat NativeSpan-like array (parentSpanId + refs)
+    selectedSpan   — the currently highlighted span (for trace-profiling)
+    markProfiled   — when true, surfaces a "profiled" chip per row
+                     based on `span.profiled`; off by default for the
+                     trace explorer where the chip is meaningless
+
+  Emits:
+    select-span    — row clicked
+-->
+<script setup lang="ts">
+import { computed } from 'vue';
+import { componentIconOrNull } from '@/composables/useTopologyIcons';
+import { fmtMetric } from '@/utils/formatters';
+
+/** Structural type covering both NativeSpan (from /trace) and
+ *  ProfileSpan (from /trace-profiling). Only the fields the row
+ *  renderer touches are required; the rest stay opaque so callers
+ *  can pass either without ceremony. */
+export interface WaterfallSpan {
+  segmentId: string;
+  spanId: number;
+  parentSpanId: number;
+  refs?: Array<{ parentSegmentId: string; parentSpanId: number }>;
+  serviceCode: string;
+  serviceInstanceName?: string;
+  startTime: number;
+  endTime: number;
+  endpointName: string;
+  type?: string | null;
+  peer?: string;
+  component?: string;
+  isError?: boolean;
+  attachedEvents?: Array<unknown>;
+  profiled?: boolean;
+}
+
+const props = withDefaults(
+  defineProps<{
+    spans: WaterfallSpan[];
+    selectedSpan?: WaterfallSpan | null;
+    markProfiled?: boolean;
+  }>(),
+  { selectedSpan: null, markProfiled: false },
+);
+const emit = defineEmits<{ (e: 'select-span', span: WaterfallSpan): void }>();
+
+const SERVICE_PALETTE = [
+  'var(--sw-accent)', 'var(--sw-info)', 'var(--sw-cyan)', 'var(--sw-purple)',
+  'var(--sw-ok)', 'var(--sw-warn)', 'var(--sw-pink)',
+  '#a78bfa', '#fb7185', '#34d399', '#fbbf24', '#60a5fa',
+];
+function hashString(s: string): number {
+  let h = 2166136261;
+  for (let i = 0; i < s.length; i++) {
+    h ^= s.charCodeAt(i);
+    h = Math.imul(h, 16777619);
+  }
+  return h >>> 0;
+}
+const serviceColors = computed<Map<string, string>>(() => {
+  const m = new Map<string, string>();
+  for (const s of props.spans) {
+    if (!m.has(s.serviceCode)) {
+      m.set(s.serviceCode, SERVICE_PALETTE[hashString(s.serviceCode) % SERVICE_PALETTE.length]);
+    }
+  }
+  return m;
+});
+function serviceColor(c: string): string {
+  return serviceColors.value.get(c) ?? 'var(--sw-fg-2)';
+}
+
+type KindFamily = 'entry' | 'local' | 'exit' | 'producer' | 'consumer' | 'other';
+function kindFamily(type: string | null | undefined): KindFamily {
+  const t = (type ?? '').toUpperCase();
+  if (t === 'ENTRY' || t.includes('SERVER')) return 'entry';
+  if (t === 'EXIT' || t.includes('CLIENT')) return 'exit';
+  if (t === 'LOCAL') return 'local';
+  if (t.includes('PRODUCER')) return 'producer';
+  if (t.includes('CONSUMER')) return 'consumer';
+  return 'other';
+}
+function kindColor(type: string | null | undefined): string {
+  const t = (type ?? '').toUpperCase();
+  if (t.includes('SERVER') || t === 'ENTRY') return 'var(--sw-accent)';
+  if (t.includes('CLIENT') || t === 'EXIT') return 'var(--sw-info)';
+  if (t === 'LOCAL') return 'var(--sw-purple)';
+  if (t.includes('PRODUCER') || t.includes('CONSUMER')) return 'var(--sw-purple)';
+  if (t.includes('DATABASE') || t.includes('SQL') || t.includes('CACHE')) return 'var(--sw-cyan)';
+  return 'var(--sw-fg-2)';
+}
+
+interface WaterfallRow {
+  span: WaterfallSpan;
+  depth: number;
+  startOffset: number;
+  duration: number;
+}
+const waterfall = computed<WaterfallRow[]>(() => {
+  const spans = props.spans;
+  if (spans.length === 0) return [];
+  const minStart = Math.min(...spans.map((s) => s.startTime));
+  const key = (segId: string, spanId: number) => `${segId}/${spanId}`;
+  const byKey = new Map(spans.map((s) => [key(s.segmentId, s.spanId), s]));
+  const childMap = new Map<string, WaterfallSpan[]>();
+  const roots: WaterfallSpan[] = [];
+  function resolveParentKey(s: WaterfallSpan): string | null {
+    if (s.parentSpanId !== -1) {
+      const k = key(s.segmentId, s.parentSpanId);
+      return byKey.has(k) ? k : null;
+    }
+    for (const r of s.refs ?? []) {
+      const k = key(r.parentSegmentId, r.parentSpanId);
+      if (byKey.has(k)) return k;
+    }
+    return null;
+  }
+  for (const s of spans) {
+    const parentKey = resolveParentKey(s);
+    if (parentKey) {
+      const arr = childMap.get(parentKey) ?? [];
+      arr.push(s);
+      childMap.set(parentKey, arr);
+    } else {
+      roots.push(s);
+    }
+  }
+  const out: WaterfallRow[] = [];
+  function walk(s: WaterfallSpan, depth: number): void {
+    out.push({
+      span: s,
+      depth,
+      startOffset: s.startTime - minStart,
+      duration: Math.max(0, s.endTime - s.startTime),
+    });
+    const children = childMap.get(key(s.segmentId, s.spanId)) ?? [];
+    for (const c of children) walk(c, depth + 1);
+  }
+  for (const r of roots) walk(r, 0);
+  return out;
+});
+const totalDuration = computed(() => {
+  const rows = waterfall.value;
+  if (rows.length === 0) return 0;
+  return Math.max(...rows.map((r) => r.startOffset + r.duration));
+});
+
+function fmtMs(v: number | null | undefined): string {
+  if (v === null || v === undefined) return '—';
+  return `${fmtMetric(v)} ms`;
+}
+function rowKey(span: WaterfallSpan): string {
+  return `${span.segmentId}/${span.spanId}`;
+}
+function onPick(span: WaterfallSpan): void {
+  emit('select-span', span);
+}
+</script>
+
+<template>
+  <div>
+    <div v-if="waterfall.length === 0" class="tr-empty">no span data</div>
+    <div v-else class="tr-default-list">
+      <div
+        v-for="row in waterfall"
+        :key="rowKey(row.span)"
+        class="tr-default-row"
+        :class="{
+          err: row.span.isError,
+          'is-active': selectedSpan && rowKey(selectedSpan) === rowKey(row.span),
+        }"
+        @click="onPick(row.span)"
+      >
+        <span class="svc-stripe" :style="{ background: serviceColor(row.span.serviceCode) }" />
+        <span
+          class="status-flag sm"
+          :class="row.span.isError ? 'flag-err' : 'flag-ok'"
+          :title="row.span.isError ? 'Span errored' : 'Span OK'"
+        ><span class="flag-dot" /></span>
+        <svg
+          class="kind-glyph"
+          viewBox="0 0 14 14"
+          :style="{ color: kindColor(row.span.type) }"
+          :aria-label="row.span.type || 'span'"
+          :title="row.span.type || ''"
+        >
+          <template v-if="kindFamily(row.span.type) === 'entry'">
+            <path d="M1 7 L9 7 M6 4 L9 7 L6 10" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
+            <line x1="11.5" y1="3" x2="11.5" y2="11" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
+          </template>
+          <template v-else-if="kindFamily(row.span.type) === 'exit'">
+            <line x1="2.5" y1="3" x2="2.5" y2="11" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
+            <path d="M5 7 L13 7 M10 4 L13 7 L10 10" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
+          </template>
+          <template v-else-if="kindFamily(row.span.type) === 'local'">
+            <circle cx="7" cy="7" r="4" fill="none" stroke="currentColor" stroke-width="1.6" />
+            <circle cx="7" cy="7" r="1.4" fill="currentColor" />
+          </template>
+          <template v-else-if="kindFamily(row.span.type) === 'producer'">
+            <path d="M2 4 L11 4 L11 9 L7 9 L4 11.5 L4 9 L2 9 Z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" />
+            <path d="M9 7 L12.5 7 M11 5.5 L12.5 7 L11 8.5" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
+          </template>
+          <template v-else-if="kindFamily(row.span.type) === 'consumer'">
+            <path d="M12 4 L3 4 L3 9 L7 9 L10 11.5 L10 9 L12 9 Z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" />
+            <path d="M5 7 L1.5 7 M3 5.5 L1.5 7 L3 8.5" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
+          </template>
+          <template v-else>
+            <rect x="2.5" y="2.5" width="9" height="9" rx="1.6" fill="none" stroke="currentColor" stroke-width="1.4" />
+            <circle cx="7" cy="7" r="1.6" fill="currentColor" />
+          </template>
+        </svg>
+        <img
+          v-if="componentIconOrNull(row.span.component ?? '')"
+          class="comp-icon"
+          :src="componentIconOrNull(row.span.component ?? '') ?? ''"
+          :alt="row.span.component || ''"
+          :title="row.span.component || ''"
+        />
+        <svg
+          v-else
+          class="comp-icon comp-icon-generic"
+          viewBox="0 0 18 18"
+          :aria-label="row.span.component || 'generic span'"
+        >
+          <rect x="3" y="4.5" width="12" height="3" rx="1.5" fill="currentColor" opacity="0.45" />
+          <rect x="5" y="10.5" width="10" height="3" rx="1.5" fill="currentColor" opacity="0.85" />
+        </svg>
+        <div class="tr-name-band">
+          <span
+            class="tr-name-band-bg"
+            :style="{
+              left: totalDuration > 0 ? (row.startOffset / totalDuration * 100) + '%' : '0%',
+              width: totalDuration > 0 ? Math.max(0.8, row.duration / totalDuration * 100) + '%' : '0%',
+              background: serviceColor(row.span.serviceCode),
+              outlineColor: row.span.isError ? 'var(--sw-err)' : 'transparent',
+            }"
+          />
+          <span
+            v-for="g in row.depth"
+            :key="g"
+            class="tr-depth-guide"
+            :style="{ left: (g - 1) * 22 + 10 + 'px' }"
+          />
+          <span class="tr-name-indent" :style="{ width: row.depth * 22 + 'px' }" />
+          <span
+            class="tr-name mono"
+            :title="row.span.endpointName || row.span.component || row.span.peer || '—'"
+          >{{ row.span.endpointName || row.span.component || row.span.peer || '—' }}</span>
+          <span
+            v-if="row.span.peer"
+            class="tr-peer mono"
+            :title="`peer · ${row.span.peer}`"
+          >→ {{ row.span.peer }}</span>
+          <!-- Profile-only marker. Trace explorer hides it (the field is
+               undefined on NativeSpan); trace-profiling sets
+               `mark-profiled` and renders the chip from `span.profiled`. -->
+          <span
+            v-if="markProfiled"
+            class="prof-chip"
+            :class="row.span.profiled ? 'on' : 'off'"
+            :title="row.span.profiled ? 'Span was profiled' : 'Span not profiled'"
+          >{{ row.span.profiled ? 'profiled' : '—' }}</span>
+          <span
+            v-if="!markProfiled && row.span.attachedEvents && row.span.attachedEvents.length > 0"
+            class="evt-badge"
+            :title="`${row.span.attachedEvents.length} attached event${row.span.attachedEvents.length === 1 ? '' : 's'}`"
+          >
+            <span class="evt-flag">⚑</span>
+            <span class="evt-count">{{ row.span.attachedEvents.length }}</span>
+          </span>
+        </div>
+        <span class="tr-row-dur mono">{{ fmtMs(row.duration) }}</span>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.tr-empty { padding: 24px; text-align: center; color: var(--sw-fg-3); font-size: 11.5px; }
+.tr-default-list { padding: 6px 0; }
+.tr-default-row {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 4px 8px 4px 0;
+  border-bottom: 1px solid var(--sw-line);
+  cursor: pointer;
+  position: relative;
+}
+.tr-default-row:hover { background: var(--sw-bg-2); }
+.tr-default-row.err { background: rgba(239, 68, 68, 0.06); }
+.tr-default-row.is-active {
+  background: var(--sw-bg-3, var(--sw-bg-2));
+  box-shadow: inset 3px 0 0 var(--sw-accent);
+}
+.svc-stripe { width: 4px; height: 18px; flex: 0 0 auto; }
+.kind-glyph {
+  width: 14px; height: 14px; flex: 0 0 auto;
+}
+.status-flag {
+  display: inline-flex; align-items: center; justify-content: center;
+  flex: 0 0 auto;
+}
+.status-flag.sm {
+  width: 12px; height: 12px; border-radius: 50%;
+}
+.status-flag.sm .flag-dot { margin: 0; }
+.flag-dot {
+  width: 6px; height: 6px; border-radius: 50%;
+  background: currentColor;
+}
+.status-flag.flag-ok { background: rgba(34, 197, 94, 0.14); color: var(--sw-ok); }
+.status-flag.flag-err { background: rgba(239, 68, 68, 0.18); color: var(--sw-err); }
+.comp-icon { width: 16px; height: 16px; flex: 0 0 auto; opacity: 0.9; }
+.comp-icon-generic { color: var(--sw-fg-2); }
+
+.tr-name-band {
+  position: relative;
+  flex: 1 1 auto;
+  min-width: 0;
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  height: 24px;
+  padding: 0 6px;
+}
+.tr-name-band-bg {
+  position: absolute;
+  top: 4px; bottom: 4px;
+  border-radius: 3px;
+  opacity: 0.22;
+  outline: 1px solid transparent;
+}
+.tr-default-row.err .tr-name-band-bg { opacity: 0.45; }
+.tr-depth-guide {
+  position: absolute;
+  top: 0; bottom: 0;
+  width: 1px;
+  background: var(--sw-line-2);
+  opacity: 0.5;
+}
+.tr-name-indent { flex: 0 0 auto; }
+.tr-name {
+  font-size: 11.5px;
+  color: var(--sw-fg-0);
+  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+  position: relative; z-index: 1;
+  min-width: 0; flex: 0 1 auto;
+}
+.tr-peer {
+  font-size: 10.5px;
+  color: var(--sw-fg-3);
+  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+  position: relative; z-index: 1;
+  min-width: 0; flex: 0 1 auto;
+}
+.tr-row-dur {
+  font-size: 10.5px;
+  color: var(--sw-fg-2);
+  flex: 0 0 auto;
+  padding-right: 4px;
+}
+.evt-badge {
+  display: inline-flex; align-items: center; gap: 2px;
+  background: rgba(234, 179, 8, 0.15);
+  color: var(--sw-warn);
+  font-size: 10px;
+  padding: 1px 5px;
+  border-radius: 8px;
+  position: relative; z-index: 1;
+  flex: 0 0 auto;
+}
+.evt-flag { font-size: 10px; line-height: 1; }
+.evt-count { font-family: var(--sw-mono); }
+.prof-chip {
+  display: inline-flex;
+  align-items: center;
+  font-size: 10px;
+  padding: 1px 6px;
+  border-radius: 8px;
+  font-family: var(--sw-mono);
+  position: relative; z-index: 1;
+  flex: 0 0 auto;
+}
+.prof-chip.on { background: var(--sw-accent-soft, rgba(249, 115, 22, 0.18)); color: var(--sw-accent-2, var(--sw-accent)); }
+.prof-chip.off { color: var(--sw-fg-3); }
+</style>
diff --git a/apps/ui/src/composables/useAdminFeatures.ts b/apps/ui/src/composables/useAdminFeatures.ts
new file mode 100644
index 0000000..90b0ee8
--- /dev/null
+++ b/apps/ui/src/composables/useAdminFeatures.ts
@@ -0,0 +1,91 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { computed } from 'vue';
+import { useQuery } from '@tanstack/vue-query';
+import type { PreflightResult } from '@skywalking-horizon-ui/api-client';
+import { bffClient } from '@/api/client';
+import { useAutoRefreshSubscribe } from './useAutoRefreshSubscribe';
+
+/**
+ * Admin-port preflight — interrogates OAP's `/debugging/config/dump`
+ * (port 17128 by default) and exposes per-module enablement plus a
+ * shorthand `adminReachable` flag. Drives:
+ *   - the admin section of `/operate/cluster`
+ *   - the per-page warning header on admin-host routes (DSL Mgmt,
+ *     Live Debugger, Dump, OAL viewer, Inspect)
+ *
+ * The graphql-port (`:12800`) reachability lives in `useOapInfo` —
+ * keeping the two checks split lets the cluster page show "graphql
+ * fine, admin down" without false negatives on either side.
+ *
+ * Polled every 60s; the cluster page can call `refetch()` to poke
+ * it on demand after the operator fixes the network / selectors.
+ */
+export function useAdminFeatures() {
+  const q = useQuery({
+    queryKey: ['oap-preflight'],
+    queryFn: () => bffClient.preflight(),
+    staleTime: 30_000,
+    refetchInterval: 60_000,
+    refetchOnWindowFocus: true,
+  });
+
+  useAutoRefreshSubscribe(() => q.refetch());
+
+  const result = computed<PreflightResult | null>(() => q.data.value ?? null);
+  const adminReachable = computed<boolean>(() => result.value?.adminReachable ?? false);
+  const adminUrl = computed<string | undefined>(() => result.value?.adminUrl);
+  const adminError = computed<string | undefined>(() => result.value?.adminError);
+
+  /** Look up a single module by OAP name (e.g. `receiver-runtime-rule`). */
+  function moduleByName(name: string) {
+    return computed(() => result.value?.modules.find((m) => m.name === name));
+  }
+
+  const runtimeRuleEnabled = computed<boolean>(
+    () => moduleByName('receiver-runtime-rule').value?.enabled ?? false,
+  );
+  const dslDebuggingEnabled = computed<boolean>(
+    () => moduleByName('dsl-debugging').value?.enabled ?? false,
+  );
+  const inspectEnabled = computed<boolean>(
+    () => moduleByName('inspect').value?.enabled ?? false,
+  );
+
+  /** Convenience for the per-page warning header — true when nothing
+   *  on the admin port works (port itself down OR admin-server module
+   *  off). Page UIs render the warning + still try to mount so the
+   *  empty-state messaging stays consistent. */
+  const adminUnavailable = computed<boolean>(
+    () => !result.value || !adminReachable.value,
+  );
+
+  return {
+    isLoading: q.isLoading,
+    result,
+    adminUrl,
+    adminReachable,
+    adminUnavailable,
+    adminError,
+    runtimeRuleEnabled,
+    dslDebuggingEnabled,
+    inspectEnabled,
+    moduleByName,
+    refetch: q.refetch,
+  };
+}
diff --git a/apps/ui/src/composables/useClientId.ts b/apps/ui/src/composables/useClientId.ts
new file mode 100644
index 0000000..b733a67
--- /dev/null
+++ b/apps/ui/src/composables/useClientId.ts
@@ -0,0 +1,70 @@
+/*
+ * 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.
+ */
+
+/**
+ * `clientId` mint for the live debugger.
+ *
+ * SWIP-13 §5 says the UI should mint a stable per-debug-context UUID
+ * stored in `sessionStorage` (per-tab) and reuse it across polls.
+ * The server enforces "one clientId → at most one active session";
+ * a new POST under the same clientId triggers the cluster-scope
+ * StopByClientId broadcast that terminates the prior session
+ * wherever it lived.
+ *
+ * One clientId per widget keeps each debugger view (MAL / LAL /
+ * OAL) on its own session slot — the operator can have all three
+ * open simultaneously.
+ */
+
+const STORAGE_PREFIX = 'vantage-studio:debug-clientid:';
+
+function makeUuid(): string {
+  if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
+    return crypto.randomUUID();
+  }
+  // Fallback for older browsers — adequate for an opaque identifier;
+  // not a security context.
+  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
+    const r = (Math.random() * 16) | 0;
+    const v = c === 'x' ? r : (r & 0x3) | 0x8;
+    return v.toString(16);
+  });
+}
+
+/**
+ * Returns a stable clientId for the given widget key. Persists in
+ * sessionStorage (tab-scoped); a new tab gets a fresh id, which is
+ * the correct semantics per SWIP-13 (one debug context per tab).
+ *
+ * If sessionStorage is unavailable (privacy-mode browser, SSR),
+ * returns a fresh UUID per call — degraded but functional. A
+ * forgetful operator who clicks Start sampling repeatedly under
+ * that fallback may consume multiple session slots; the
+ * server-side retention timeout reaps them eventually.
+ */
+export function getClientId(widget: string): string {
+  const key = STORAGE_PREFIX + widget;
+  try {
+    const existing = sessionStorage.getItem(key);
+    if (existing) return existing;
+    const fresh = makeUuid();
+    sessionStorage.setItem(key, fresh);
+    return fresh;
+  } catch {
+    return makeUuid();
+  }
+}
diff --git a/apps/ui/src/composables/useDebugHistory.ts b/apps/ui/src/composables/useDebugHistory.ts
new file mode 100644
index 0000000..425b7b1
--- /dev/null
+++ b/apps/ui/src/composables/useDebugHistory.ts
@@ -0,0 +1,198 @@
+/*
+ * 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.
+ */
+
+/**
+ * Browser-local history of past debug captures, shared across the
+ * three live-debugger views (MAL / LAL / OAL).
+ *
+ * Each session that finishes capture is auto-saved here so the
+ * operator can re-open it later without re-running OAP. The store
+ * lives entirely in `localStorage` under one key — no BFF round-trip,
+ * survives reloads, capped at MAX_ENTRIES (oldest dropped on overflow
+ * or quota error).
+ *
+ * The composable returns a `widget`-filtered reactive list plus
+ * mutators. The underlying cache is a singleton ref so multiple
+ * mounts share state without hitting localStorage repeatedly.
+ */
+
+import { computed, ref, type ComputedRef, type Ref } from 'vue';
+import type { SessionResponse } from '@skywalking-horizon-ui/api-client';
+
+export type DebugWidget = 'mal' | 'lal' | 'oal';
+
+export interface HistoryEntry {
+  /** Stable id; survives serialization. */
+  id: string;
+  /** Unix-ms when the capture was archived. With upsert-by-sessionId
+   *  this stays pinned to the FIRST save (i.e. session start time)
+   *  even as later polls update the session payload — so sorting by
+   *  this value gives natural newest-first ordering by start time. */
+  savedAt: number;
+  widget: DebugWidget;
+  catalog: string;
+  name: string;
+  ruleName: string;
+  granularity?: string;
+  recordCap?: number;
+  retentionMillis?: number;
+  /** Unix-ms when OAP will reap the live session. When `Date.now() <
+   *  retentionDeadline`, the capture is still active and a new tab
+   *  can resume polling against it via `useDebugSession.resume()`. */
+  retentionDeadline?: number;
+  /** Counts surfaced in the dropdown without re-walking the session. */
+  recordCount: number;
+  nodeCount: number;
+  /** Frozen SessionResponse at the moment of save. */
+  session: SessionResponse;
+}
+
+/** True when this entry's live session hasn't yet been reaped by OAP
+ *  (deadline in the future). Active entries are eligible for resume. */
+export function isActive(entry: HistoryEntry, nowMs: number = Date.now()): boolean {
+  return entry.retentionDeadline !== undefined && entry.retentionDeadline > nowMs;
+}
+
+const STORAGE_KEY = 'vs:debug-history:v1';
+const MAX_ENTRIES = 20;
+
+let cache: Ref<HistoryEntry[]> | null = null;
+
+function readAll(): HistoryEntry[] {
+  if (typeof localStorage === 'undefined') return [];
+  try {
+    const raw = localStorage.getItem(STORAGE_KEY);
+    if (!raw) return [];
+    const parsed = JSON.parse(raw);
+    return Array.isArray(parsed) ? (parsed as HistoryEntry[]) : [];
+  } catch {
+    return [];
+  }
+}
+
+function writeAll(entries: HistoryEntry[]): void {
+  if (typeof localStorage === 'undefined') return;
+  let candidate = entries;
+  // On quota errors, peel oldest entries off the tail until the write
+  // succeeds — beats losing the new capture entirely.
+  for (;;) {
+    try {
+      localStorage.setItem(STORAGE_KEY, JSON.stringify(candidate));
+      return;
+    } catch {
+      if (candidate.length === 0) return;
+      candidate = candidate.slice(0, -1);
+    }
+  }
+}
+
+function getCache(): Ref<HistoryEntry[]> {
+  if (cache === null) cache = ref(readAll());
+  return cache;
+}
+
+export interface DebugHistoryHandle {
+  /** Entries for the bound widget, newest first. */
+  entries: ComputedRef<HistoryEntry[]>;
+  /** All entries across widgets — for a future global-history view. */
+  all: ComputedRef<HistoryEntry[]>;
+  save(input: Omit<HistoryEntry, 'id' | 'savedAt'>): HistoryEntry;
+  remove(id: string): void;
+  /** Wipe just this widget's entries; other widgets' remain. */
+  clear(): void;
+}
+
+export function useDebugHistory(widget: DebugWidget): DebugHistoryHandle {
+  const store = getCache();
+  const entries = computed(() =>
+    store.value
+      .filter((e) => e.widget === widget)
+      .slice()
+      .sort((a, b) => b.savedAt - a.savedAt),
+  );
+  const all = computed(() => store.value.slice().sort((a, b) => b.savedAt - a.savedAt));
+
+  /** Upsert by (widget, session.sessionId). When an entry for the
+   *  same live session already exists, refresh its session snapshot
+   *  + counts in place; the original `id` and `savedAt` (capture-start
+   *  time) are preserved so deep-links stay stable across polls. When
+   *  nothing meaningful changed (same recordCount/nodeCount), the
+   *  write is skipped — capture polls 1×/s and we don't want to
+   *  serialize 100s of KB to localStorage every tick. */
+  function save(input: Omit<HistoryEntry, 'id' | 'savedAt'>): HistoryEntry {
+    const idx = store.value.findIndex(
+      (e) => e.widget === input.widget && e.session.sessionId === input.session.sessionId,
+    );
+    if (idx >= 0) {
+      const existing = store.value[idx]!;
+      // Skip the localStorage write when nothing meaningful moved;
+      // BUT honour a `retentionDeadline` newly-arrived from a start
+      // response (the very first save right after dbg.start may not
+      // have it yet because the watch fires before start() completes
+      // — once start() resolves and the next watch tick brings the
+      // deadline, we want to record it).
+      const sameCounts =
+        existing.recordCount === input.recordCount && existing.nodeCount === input.nodeCount;
+      const sameDeadline = existing.retentionDeadline === input.retentionDeadline;
+      if (sameCounts && sameDeadline) {
+        return existing;
+      }
+      const updated: HistoryEntry = {
+        ...existing,
+        ...input,
+        id: existing.id,
+        savedAt: existing.savedAt,
+      };
+      const next = store.value.slice();
+      next[idx] = updated;
+      store.value = next;
+      writeAll(next);
+      return updated;
+    }
+    const entry: HistoryEntry = {
+      ...input,
+      id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`,
+      savedAt: Date.now(),
+    };
+    let next = [entry, ...store.value];
+    // Cap PER widget so a busy MAL session never evicts saved LAL /
+    // OAL captures. Walk the same-widget slice newest-first; drop any
+    // that fall past MAX_ENTRIES.
+    const sameWidget = next.filter((e) => e.widget === entry.widget);
+    if (sameWidget.length > MAX_ENTRIES) {
+      const dropIds = new Set(sameWidget.slice(MAX_ENTRIES).map((e) => e.id));
+      next = next.filter((e) => !dropIds.has(e.id));
+    }
+    store.value = next;
+    writeAll(next);
+    return entry;
+  }
+
+  function remove(id: string): void {
+    const next = store.value.filter((e) => e.id !== id);
+    store.value = next;
+    writeAll(next);
+  }
+
+  function clear(): void {
+    const next = store.value.filter((e) => e.widget !== widget);
+    store.value = next;
+    writeAll(next);
+  }
+
+  return { entries, all, save, remove, clear };
+}
diff --git a/apps/ui/src/composables/useDebugSession.ts b/apps/ui/src/composables/useDebugSession.ts
new file mode 100644
index 0000000..4a58d8f
--- /dev/null
+++ b/apps/ui/src/composables/useDebugSession.ts
@@ -0,0 +1,313 @@
+/*
+ * 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.
+ */
+
+/**
+ * Reactive lifecycle wrapper around `/api/debug/*`.
+ *
+ * The actual wire (see `reference_swip13_actual_wire.md`) doesn't have a
+ * top-level session status — `nodes[].status` is per-node and the
+ * recorder's `captured` boolean signals "session frozen". This composable
+ * computes a single UI-facing state by combining: install ack, every
+ * node's status, and the per-node `captured` flag.
+ *
+ * UI states:
+ *   - idle     — never started
+ *   - starting — POST /session in flight
+ *   - capturing — at least one node is `status: ok` and not captured
+ *   - captured  — every reachable node is captured
+ *   - stopped   — operator hit Stop (response received, polling halted)
+ *   - error     — start failed
+ *
+ * Polling cadence: 1 s while `capturing`, then halts on terminal.
+ */
+
+import { computed, onScopeDispose, ref, shallowRef, type Ref } from 'vue';
+import type {
+  InstallSummary,
+  PeerInstallAck,
+  PriorCleanup,
+  SessionResponse,
+  StartSessionArgs,
+} from '@skywalking-horizon-ui/api-client';
+import { bff, describeApiError } from '@/api/client';
+import { getClientId } from './useClientId.js';
+
+export type DebugWidgetKey = 'mal' | 'lal' | 'oal';
+
+export type DebugState = 'idle' | 'starting' | 'capturing' | 'captured' | 'stopped' | 'error';
+
+export interface UseDebugSessionResult {
+  clientId: string;
+  state: Ref<DebugState>;
+  session: Ref<SessionResponse | null>;
+  sessionId: Ref<string | null>;
+  /** Set when state === 'error'. */
+  error: Ref<string | null>;
+  /** Per-peer install acks from the most recent start call. */
+  peerAcks: Ref<PeerInstallAck[]>;
+  /** Cluster-wide install rollup `{created, total}` from the most
+   *  recent start call. Null while idle / starting. */
+  installed: Ref<InstallSummary | null>;
+  /** Cluster cleanup report from the StopByClientId broadcast that
+   *  ran before the session was allocated. The local node's slice is
+   *  always present; `peers[]` is one entry per known peer. */
+  priorCleanup: Ref<PriorCleanup | null>;
+  /** Convenience flat list of every prior session id that was
+   *  terminated by the cleanup, derived from `priorCleanup`. */
+  replacedPriorIds: Ref<string[]>;
+  /** Unix-ms when OAP will reap the live session. Null between
+   *  captures or when an entry was resumed without a known deadline. */
+  retentionDeadline: Ref<number | null>;
+  /** Allocate a new session. Discards any prior local state. */
+  start(args: Omit<StartSessionArgs, 'clientId'>): Promise<void>;
+  /** Attach to an already-allocated session id (typically because the
+   *  operator returned to a tab after navigating away — the session
+   *  is still live on OAP, we just need to resume polling). Skips the
+   *  start API call entirely. */
+  resume(id: string, retentionDeadline?: number | null): void;
+  /** Stop the current session. No-op when idle. */
+  stop(): Promise<void>;
+}
+
+const POLL_INTERVAL_MS = 1000;
+/** Pause polling when the tab is hidden — operator can't see captures
+ *  anyway, no reason to keep load on the BFF + OAP. The first
+ *  visibilitychange on resume snaps an immediate poll. */
+const PAUSE_ON_HIDDEN = true;
+/** Tolerate brief OAP / network blips before declaring an error.
+ *  Each retry waits POLL_INTERVAL_MS × (attempt + 1) ms. */
+const POLL_RETRY_BUDGET = 2;
+
+/** Decide the UI-facing state from a polled SessionResponse. */
+function deriveState(resp: SessionResponse): 'capturing' | 'captured' {
+  // The wire doesn't have a top-level status; combine per-node flags.
+  // If every reachable node is captured (or unreachable / not_local),
+  // there's nothing more coming — treat as captured.
+  const reachable = resp.nodes.filter((n) => n.status === 'ok' || n.status === 'captured');
+  if (reachable.length === 0) {
+    // No-one's contributing data; nothing to wait for.
+    return 'captured';
+  }
+  const allCaptured = reachable.every((n) => n.status === 'captured' || n.captured === true);
+  return allCaptured ? 'captured' : 'capturing';
+}
+
+export function useDebugSession(widget: DebugWidgetKey): UseDebugSessionResult {
+  const clientId = getClientId(widget);
+  const state = ref<DebugState>('idle');
+  // shallowRef: the session response is replaced wholesale per poll
+  // and never mutated in place. Deep reactivity over a 1000-record
+  // payload would walk every leaf on every poll for no benefit —
+  // shallowRef notifies dependents on assignment only.
+  const session = shallowRef<SessionResponse | null>(null);
+  const sessionId = ref<string | null>(null);
+  const error = ref<string | null>(null);
+  const peerAcks = ref<PeerInstallAck[]>([]);
+  const installed = ref<InstallSummary | null>(null);
+  const priorCleanup = ref<PriorCleanup | null>(null);
+  const retentionDeadline = ref<number | null>(null);
+
+  const replacedPriorIds = computed<string[]>(() => {
+    const c = priorCleanup.value;
+    if (!c) return [];
+    const out: string[] = [...(c.local.stoppedSessionIds ?? [])];
+    for (const p of c.peers) {
+      if (p.stoppedSessionIds) out.push(...p.stoppedSessionIds);
+    }
+    return out;
+  });
+
+  let pollTimer: ReturnType<typeof setTimeout> | null = null;
+  let pollGeneration = 0;
+  /** Consecutive failures since the last successful poll. Resets on
+   *  every successful response. */
+  let pollFailures = 0;
+  /** True when polling is on hold because `document.hidden` is true.
+   *  An immediate poll fires when the tab becomes visible again. */
+  let pausedForHidden = false;
+
+  function clearTimer(): void {
+    if (pollTimer !== null) {
+      clearTimeout(pollTimer);
+      pollTimer = null;
+    }
+  }
+
+  function schedulePoll(delay: number = POLL_INTERVAL_MS): void {
+    clearTimer();
+    if (PAUSE_ON_HIDDEN && typeof document !== 'undefined' && document.hidden) {
+      pausedForHidden = true;
+      return;
+    }
+    const gen = pollGeneration;
+    pollTimer = setTimeout(() => {
+      void poll(gen);
+    }, delay);
+  }
+
+  async function poll(gen: number): Promise<void> {
+    if (gen !== pollGeneration) return;
+    const id = sessionId.value;
+    if (!id) return;
+    try {
+      const got = await bff.debugSession(id);
+      if (gen !== pollGeneration) return;
+      pollFailures = 0;
+      if (got === null) {
+        // Session went past retention; freeze whatever we last saw and
+        // surface as captured (we kept the last poll's records).
+        state.value = 'captured';
+        clearTimer();
+        return;
+      }
+      session.value = got;
+      const next = deriveState(got);
+      state.value = next;
+      if (next === 'capturing') schedulePoll();
+      else clearTimer();
+    } catch (err) {
+      if (gen !== pollGeneration) return;
+      pollFailures += 1;
+      if (pollFailures <= POLL_RETRY_BUDGET) {
+        // Exponential-ish backoff while the blip clears. Don't surface
+        // the error in the UI — operator sees uninterrupted capturing.
+        schedulePoll(POLL_INTERVAL_MS * (pollFailures + 1));
+        return;
+      }
+      error.value = describeApiError(err);
+      state.value = 'error';
+      clearTimer();
+    }
+  }
+
+  async function start(args: Omit<StartSessionArgs, 'clientId'>): Promise<void> {
+    pollGeneration += 1;
+    clearTimer();
+    pollFailures = 0;
+    pausedForHidden = false;
+    state.value = 'starting';
+    error.value = null;
+    session.value = null;
+    sessionId.value = null;
+    peerAcks.value = [];
+    installed.value = null;
+    priorCleanup.value = null;
+
+    try {
+      const r = await bff.debugStart({ clientId, ...args });
+      sessionId.value = r.sessionId;
+      peerAcks.value = r.peers ?? [];
+      installed.value = r.installed ?? null;
+      priorCleanup.value = r.priorCleanup ?? null;
+      retentionDeadline.value = r.retentionDeadline ?? null;
+      state.value = 'capturing';
+      // First poll immediately so the operator sees something fast.
+      void poll(pollGeneration);
+    } catch (err) {
+      error.value = describeApiError(err);
+      state.value = 'error';
+    }
+  }
+
+  /** Attach to an existing live session without re-allocating one on
+   *  OAP. Used when reopening a tab that was previously running a
+   *  capture — the session id was persisted in the history store with
+   *  its retention deadline, and we just need to resume polling. The
+   *  first poll fires immediately so the operator sees the latest
+   *  records without waiting a full tick. */
+  function resume(id: string, deadline: number | null = null): void {
+    pollGeneration += 1;
+    clearTimer();
+    pollFailures = 0;
+    pausedForHidden = false;
+    state.value = 'capturing';
+    error.value = null;
+    session.value = null;
+    sessionId.value = id;
+    peerAcks.value = [];
+    installed.value = null;
+    priorCleanup.value = null;
+    retentionDeadline.value = deadline;
+    void poll(pollGeneration);
+  }
+
+  async function stop(): Promise<void> {
+    pollGeneration += 1;
+    clearTimer();
+    const id = sessionId.value;
+    if (!id) {
+      state.value = 'idle';
+      return;
+    }
+    try {
+      await bff.debugStop(id);
+      state.value = 'stopped';
+      // Refresh once so the post-stop "not_local" status surfaces in
+      // the UI.
+      try {
+        const after = await bff.debugSession(id);
+        if (after !== null) session.value = after;
+      } catch {
+        // ignore — stop already succeeded.
+      }
+    } catch (err) {
+      error.value = describeApiError(err);
+      state.value = 'error';
+    }
+  }
+
+  function onVisibilityChange(): void {
+    if (typeof document === 'undefined') return;
+    if (document.hidden) {
+      // Going hidden — clear the timer; the next schedulePoll will
+      // park itself in pausedForHidden until we come back.
+      clearTimer();
+    } else if (pausedForHidden && state.value === 'capturing') {
+      // Resume: snap an immediate poll so the operator sees fresh
+      // data on tab focus.
+      pausedForHidden = false;
+      void poll(pollGeneration);
+    }
+  }
+  if (typeof document !== 'undefined') {
+    document.addEventListener('visibilitychange', onVisibilityChange);
+  }
+
+  onScopeDispose(() => {
+    pollGeneration += 1;
+    clearTimer();
+    if (typeof document !== 'undefined') {
+      document.removeEventListener('visibilitychange', onVisibilityChange);
+    }
+  });
+
+  return {
+    clientId,
+    state,
+    session,
+    sessionId: computed(() => sessionId.value) as Ref<string | null>,
+    error,
+    peerAcks,
+    installed,
+    priorCleanup,
+    replacedPriorIds,
+    retentionDeadline,
+    start,
+    resume,
+    stop,
+  };
+}
diff --git a/apps/ui/src/composables/useOverviewDashboard.ts b/apps/ui/src/composables/useOverviewDashboard.ts
new file mode 100644
index 0000000..3c4cc8b
--- /dev/null
+++ b/apps/ui/src/composables/useOverviewDashboard.ts
@@ -0,0 +1,295 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { computed, type Ref } from 'vue';
+import { useQueries, useQuery } from '@tanstack/vue-query';
+import type { LandingConfig, OverviewDashboard, OverviewWidget } from '@skywalking-horizon-ui/api-client';
+import { bffClient } from '@/api/client';
+
+/**
+ * Resolved value for one overview widget. The renderer reads
+ * `values[widget.id]` (or for `kpi-tile`, `kpiValues[widget.id][label]`).
+ *
+ * Values come from the existing per-layer `landing` route, which already
+ * computes layer-wide sum/avg aggregates. We batch one landing call per
+ * referenced layer with all that layer's widget MQEs folded into a
+ * single `columns` request — N layers => N round-trips, not N*M.
+ */
+export interface OverviewWidgetValues {
+  /** Aggregate value for `metric` / `service-count` widgets. */
+  values: Record<string, number | null>;
+  /** Per-KPI values for `kpi-tile` widgets, keyed by widget id then label. */
+  kpiValues: Record<string, Record<string, number | null>>;
+}
+
+interface MqeRequest {
+  widgetId: string;
+  kpiLabel?: string;
+  mqe: string;
+  aggregation: 'sum' | 'avg';
+  unit?: string;
+}
+
+/**
+ * Group every data-bound widget by its `layer`. Section-breaks, alarms,
+ * and topology widgets are skipped (no aggregate to evaluate).
+ */
+function groupByLayer(widgets: OverviewWidget[]): Map<string, MqeRequest[]> {
+  const out = new Map<string, MqeRequest[]>();
+  for (const w of widgets) {
+    const layer = w.layer;
+    if (!layer) continue;
+    if (w.type === 'section-break' || w.type === 'alarms' || w.type === 'topology') continue;
+    if (w.type === 'service-count') {
+      // Service count lives in the landing aggregates as `serviceCount`,
+      // no MQE column needed — but we still tag the layer so the call
+      // happens.
+      if (!out.has(layer)) out.set(layer, []);
+      continue;
+    }
+    if (w.type === 'metric' && w.mqe) {
+      const reqs = out.get(layer) ?? [];
+      reqs.push({
+        widgetId: w.id,
+        mqe: w.mqe,
+        aggregation: w.aggregation ?? 'avg',
+        unit: w.unit,
+      });
+      out.set(layer, reqs);
+      continue;
+    }
+    if (w.type === 'kpi-tile' && w.kpis) {
+      const reqs = out.get(layer) ?? [];
+      for (const k of w.kpis) {
+        reqs.push({
+          widgetId: w.id,
+          kpiLabel: k.label,
+          mqe: k.mqe,
+          aggregation: k.aggregation ?? 'avg',
+          unit: k.unit,
+        });
+      }
+      out.set(layer, reqs);
+      continue;
+    }
+    if (w.type === 'k8s-summary') {
... 16724 lines suppressed ...