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, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"');
+}
+
+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 '&';
- case '<': return '<';
- case '>': return '>';
- case '"': return '"';
- case "'": return ''';
- 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 ...