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/08/03 02:26:50 UTC
(skywalking-horizon-ui) branch main updated: fix: permissions that say what they do, no unbuilt promises, per-version changelog (#107)
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 091fb6d fix: permissions that say what they do, no unbuilt promises, per-version changelog (#107)
091fb6d is described below
commit 091fb6d26c8b1f40fa811f51f20f6051ea7e4d8d
Author: 吴晟 Wu Sheng <wu...@foxmail.com>
AuthorDate: Mon Aug 3 10:26:44 2026 +0800
fix: permissions that say what they do, no unbuilt promises, per-version changelog (#107)
The Roles board offered capabilities nothing checks, and two more that
contradicted themselves. "Edit alarm rules", "Add / remove local users",
"Change role grants" and "Read the audit log" read as things you could
delegate: alarm rules are read-only upstream, users and roles are edited
in horizon.yaml, and the audit trail is a file you ship elsewhere. All
four are now marked Reserved, declared by the server so the page cannot
drift, and the built-in operator role no longer grants alarm-rule:write.
Six other permissions absent from the route table turned out to be
enforced in a handler or as a nav gate; calling those reserved would
have been a fresh falsehood.
A route's permission is checked before its handler runs, so when the two
disagreed the hook won and the handler's — the one the page and the docs
are written against — never decided. Downloading a runtime-rule dump
asked for a debug permission while its button, handler and docs all said
read, so a read-only role saw an enabled button and got a 403; exporting
rules is a read and is gated as one. The Live Debugger is now
live-debug:read to watch and live-debug:write to start and stop, and
nothing else: rule:debug was demanded on top of every one of those
calls, which broke the documented read-only role, and it never permitted
or denied a request on its own. It is gone from the vocabulary, the
operator role, the board and the docs. That loosens a hand-written role
which granted live-debug:write while withholding rule:debug — the
changelog says so plainly.
Two structural tests hold this: one derives enforcement from the real
sources and fails when the board and the code disagree, the other fails
when a route is gated on a permission its handler never mentions. The
first was fooled once by a permission surviving only as an audit-log
field, so detection now requires something that can actually deny a
request.
The runtime-rule page advertised a restore panel tagged "later release"
and leaked an internal working name into shipped strings. There is no
restore — reverting a rule to its bundled version is the reset Horizon
offers — so the panel is gone rather than reworded, and a lint gate now
fails the build on any future-promise affordance. That string had also
drifted off its source literal, so every locale silently fell back to
English; rekeying it removes the name and restores the translation.
Changelog notes move out of one 810-line file: every version owns
docs/changelog/<version>.md, listed in the docs menu, with no root file.
Release reads that version's file and seeds the next cycle's empty one,
reusing the version it already computes; both tarballs still carry a
root CHANGELOG.md copied at packaging time. The gate now requires the
title to be the version being cut, since the extractor drops the first
heading whatever it says.
Also corrected: the 3D map admin promised bundled defaults as a fallback
live mode deliberately removed, in eight languages; the local-user doc
described a per-request user lookup that does not exist, so removal now
says a held session survives until sign-out, TTL or restart; two paging
summaries still described per-request record caps; and the README
counted instrumentation layers, which goes stale. The release script
keeps version derivation in one expression so nothing pre-confirmation
survives an operator correcting it at the prompt, and the UI i18n gate
now reports keys in en.json that no source mentions.
---
.claude/skills/pull-request/SKILL.md | 27 +-
CHANGELOG.md | 806 ---------------------
CLAUDE.md | 14 +-
README.md | 8 +-
apps/bff/src/config/schema.ts | 6 +-
apps/bff/src/http/admin/auth-status.ts | 8 +-
apps/bff/src/http/admin/live-debug.ts | 42 +-
apps/bff/src/rbac/route-policy.test.ts | 126 +++-
apps/bff/src/rbac/route-policy.ts | 47 +-
apps/bff/src/rbac/verb-enforcement.test.ts | 506 +++++++++++++
apps/bff/src/rbac/verbs.ts | 23 +-
apps/ui/src/api/scopes/admin-auth.ts | 3 +
.../features/admin/infra-3d/Infra3dAdminView.vue | 2 +-
apps/ui/src/features/admin/roles/RolesView.test.ts | 110 +++
apps/ui/src/features/admin/roles/RolesView.vue | 163 ++++-
.../features/admin/roles/menuVisibility.test.ts | 233 ++++++
apps/ui/src/features/operate/dsl/DslDumpView.vue | 16 +-
apps/ui/src/i18n/locales/de.json | 31 +-
apps/ui/src/i18n/locales/en.json | 31 +-
apps/ui/src/i18n/locales/es.json | 31 +-
apps/ui/src/i18n/locales/fr.json | 31 +-
apps/ui/src/i18n/locales/ja.json | 31 +-
apps/ui/src/i18n/locales/ko.json | 31 +-
apps/ui/src/i18n/locales/pt.json | 31 +-
apps/ui/src/i18n/locales/zh-CN.json | 31 +-
apps/ui/src/shell/AppSidebar.vue | 2 +-
apps/ui/src/shell/InfraMapPill.vue | 8 +
apps/ui/src/shell/useSidebarMenu.ts | 15 +-
docs/README.md | 1 +
docs/access-control/admin-pages.md | 7 +-
docs/access-control/local-backend.md | 2 +-
docs/access-control/rbac.md | 68 +-
docs/changelog/0.1.0.md | 8 +
docs/changelog/0.2.0.md | 35 +
docs/changelog/0.3.0.md | 39 +
docs/changelog/0.4.0.md | 45 ++
docs/changelog/0.5.0.md | 46 ++
docs/changelog/0.6.0.md | 192 +++++
docs/changelog/0.7.0.md | 169 +++++
docs/changelog/1.0.0.md | 273 +++++++
docs/menu.yml | 19 +
docs/operate/infra-3d-map.md | 3 +-
docs/operate/live-debugger.md | 7 +-
docs/operate/runtime-rules.md | 4 +-
docs/setup/horizon-yaml.md | 2 +-
docs/setup/rbac.md | 6 +-
package.json | 3 +-
scripts/changelog-release-notes.mjs | 57 +-
scripts/changelog-version.mjs | 209 ++++++
scripts/check-ui-i18n.mjs | 22 +
scripts/check-ui-promises.mjs | 118 +++
scripts/release-finalize.sh | 30 +-
scripts/release.sh | 79 +-
53 files changed, 2684 insertions(+), 1173 deletions(-)
diff --git a/.claude/skills/pull-request/SKILL.md b/.claude/skills/pull-request/SKILL.md
index 50e0429..d92031b 100644
--- a/.claude/skills/pull-request/SKILL.md
+++ b/.claude/skills/pull-request/SKILL.md
@@ -1,6 +1,6 @@
---
name: pull-request
-description: Open a pull request for the current Horizon UI branch the right way — run the full preflight gate first (CHANGELOG + docs updated, type-check + build "compiling passed", new UI strings translated across all 8 locales, license headers, lint + unit tests, live-OAP validation), commit with NO AI trailers, push, and open the PR against apache/skywalking-horizon-ui. After it merges, return to main, fast-forward to latest, and delete the merged local branch.
+description: Open a pull request for the current Horizon UI branch the right way — run the full preflight gate first (changelog + docs updated, type-check + build "compiling passed", new UI strings translated across all 8 locales, license headers, lint + unit tests, live-OAP validation), commit with NO AI trailers, push, and open the PR against apache/skywalking-horizon-ui. After it merges, return to main, fast-forward to latest, and delete the merged local branch.
user-invocable: true
---
@@ -28,15 +28,17 @@ Walk the list in order. Each item maps to a thing CI enforces or a thing a
reviewer will bounce the PR for. If you genuinely can't run one, say so in the
PR body rather than skipping silently.
-### A. CHANGELOG updated
+### A. Changelog updated
If the change is **operator-visible** — a new page / tab / widget, a new
component flag, a bundled-template change (a layer gaining a capability, new
-dashboards / widgets / metrics), a new admin surface — add an entry under the
-current **unreleased** (`*-dev` in `package.json`) section of `CHANGELOG.md`,
-written from the operator's point of view. **One line per paragraph / bullet —
-no hard wrap** (house style; see `CLAUDE.md`). Released version sections are
-frozen (bug-fix entries only). A pure refactor / tooling / internal-docs change
-gets **no** changelog entry.
+dashboards / widgets / metrics), a new admin surface — add an entry to the
+changelog file of the version in development: `docs/changelog/<version>.md`,
+where `<version>` is what `package.json` carries as `*-dev` (`1.0.0-dev` →
+`docs/changelog/1.0.0.md`). Written from the operator's point of view. **One
+line per paragraph / bullet — no hard wrap** (house style; see `CLAUDE.md`).
+Every released version keeps its own file and those are frozen — never edit
+them. A pure refactor / tooling / internal-docs change gets **no** changelog
+entry.
### B. docs/ updated
If observable behavior or configuration changed, update the public docs under
@@ -178,10 +180,11 @@ When you're updating an already-open PR's body after another review pass:
section starts to read like a changelog of the review itself, that's the
signal to fold it back into the feature description and delete the seams.
-Operator-visible fixes still belong in `CHANGELOG.md` per the usual house
-style (see the preflight gate above) — that file *is* allowed to say what
-changed, because it's read chronologically across releases. The PR body is
-not; it's read once, describing one final state.
+Operator-visible fixes still belong in the in-development changelog file
+(`docs/changelog/<version>.md`) per the usual house style (see the preflight
+gate above) — that file *is* allowed to say what changed, because it's read
+chronologically across releases. The PR body is not; it's read once, describing
+one final state.
## 4 — After the PR is MERGED: return to main, update, delete the branch
diff --git a/CHANGELOG.md b/CHANGELOG.md
deleted file mode 100644
index ab519bb..0000000
--- a/CHANGELOG.md
+++ /dev/null
@@ -1,806 +0,0 @@
-# Changelog
-
-Notable changes to Apache SkyWalking Horizon UI, written from the operator's point of view — what's new on screen and what's now possible, not the file-by-file implementation. For per-commit detail, see the git log.
-
-The version line is shared by every package in the monorepo (apps + shared packages) plus the BFF's `HORIZON_VERSION` default.
-
-## 1.0.0
-
-### AI assistant (new)
-
-- **Ask about your system in plain language and get answers built from the real dashboard widgets — not just text.** A new floating **AI Assistant** launcher (right edge, after login) opens a chat: describe what you want to know ("what's unhealthy right now?", "investigate latency for a service") and the assistant reads **live OAP data** through the same query path the dashboards use, then streams back an ordered narrative with **inline charts, top-N lists and tables** drawn by the same [...]
-- **Read-only, and it inherits your permissions.** The assistant can list layers/services, read active **alarms** (the "what's unhealthy" signal), browse the per-layer **metric catalog** (the curated MQE for each metric), drill a service down to its instances/endpoints, and render those metrics — every data tool checks the same read verb you already hold, so the assistant never sees more than you can, and it never changes configuration, rules, or dashboards.
-- **It renders the real feature views inline — same components, focused on the service.** Rather than a link you open, the assistant mounts the *actual* product views inside the chat, read-only and scoped to the service: ask for **topology** and it embeds the real service map focused one hop (hex nodes, edges, RPM/latency, zoom controls); ask how a service maps across layers and it shows the real **Smartscape hierarchy** fan; ask for **traces** and it embeds the Traces view — the trace l [...]
-- **Read live Kubernetes pod logs, right in the chat.** For a k8s workload the assistant pulls a pod container's on-demand logs (the error stack) and shows the fetched lines inline as a read-only result — the same on-demand-log path as the Pod Logs tab, so nothing is stored and it inherits your `logs:read` permission. It's a result, not a console: no tail or refresh controls (operate a live tail in the Pod Logs tab); when a content filter was applied the block shows it, so an empty resul [...]
-- **It can propose profiling — the right kind for the target — and only you start it.** When metrics and traces can't localise a cause, the assistant presents a **decision card** explaining what it found, why profiling would help, and what it expects to reveal; nothing runs until you **approve it in the popout**, and only if you hold the profiling permission. It picks the profiling flavour that fits the target — **trace** sampling, **async-profiler** for a JVM service, **pprof** for a Go [...]
-- **Your backend decides what profiling is allowed — the assistant no longer refuses on its own guesses.** Previously the assistant could decline to even show a profiling card: because the layer's template didn't list that profiling type, because the instances reported a runtime the profiler doesn't match, because no process had advertised eBPF support recently, or because no process had reported in the last 30 minutes — and it phrased those as facts about your deployment ("the GENERAL l [...]
-- **Off-CPU eBPF profiles are ranked by time blocked, not by how often a thread yielded.** An OFF_CPU profile is what you reach for when the question is "what is this service waiting on", but its frames were ranked by scheduler switch *count* — so a method that yields thousands of times for microseconds outranked the one that blocks for a second, inverting the answer. Off-CPU results now aggregate by duration. Relatedly, the assistant now names each profiler's real unit when it reads a f [...]
-- **An approved profiling task starts immediately, timed by your backend rather than your laptop.** The task's start time was sent as the browser's wall clock, but OAP treats it as "begin after this moment" and only dispatches tasks starting within ±5 minutes of *its own* clock — so on a workstation whose clock had drifted (a suspended VM, a container host without NTP) the task was accepted, returned an id, the card said "started", and nothing ever ran. Both trace and eBPF tasks now use [...]
-- **An event the assistant doesn't recognise is no longer silently swapped for CPU.** Asking for a profiling event outside its known list produced a card that read as requested but fired a CPU profile instead. The event is now passed through for OAP to accept or reject by name, and the assistant flags that it didn't recognise it — a clear rejection beats a wrong profile. Relatedly, when a **HEAP** or **ALLOCS** pprof profile is read back, the assistant now states that the ranking is a co [...]
-- **Long eBPF profiles say when only part of the range was analyzed.** A wide eBPF task could exceed what OAP fetches in a single analysis, and the excess was dropped server-side with nothing on screen to indicate it — a partial flame graph presented as complete. The assistant now bounds the request to the most recent profiling schedules and tells you when it did.
-- **An empty network process graph is no longer reported as a missing agent.** Because a network task runs for a fixed 10 minutes, analyzing shortly after approval legitimately finds nothing yet; that now reads as "still collecting", and a missing Rover agent is offered as the likely cause only once the window has actually elapsed.
-- **A profiling task that is still collecting is no longer reported as one that found nothing.** Asking the assistant to analyze a task shortly after you approved it — the obvious thing to do — could come back with "the task ran but produced no analyzable stacks; do not retry", because *any* progress log was read as "it finished". The first log a task gets means only that it was handed to the agent. The assistant now distinguishes the three real states: **still collecting** (wait and ask [...]
-- **Allocation and multi-event profiles are read whole.** An async-profiler **ALLOC** profile was showing only half its data: OAP splits allocations into in-TLAB and outside-TLAB trees, and only the first was read — so the large-object allocation paths, usually the reason for opening an allocation profile, were missing. A task started with several events (say CPU + ALLOC + LOCK) likewise rendered only the first. Both now render every tree the task produced.
-- **Network profiling results no longer come back empty on a wide time range.** The process-conversation graph is built from metrics OAP only keeps at minute granularity, so reading it against an hour- or day-scale chat window returned nothing — which the assistant then reported as "no Rover agent / network profiling unavailable". The read is now always minute-granular.
-- **Trace profile analysis stopped asking OAP the same question many times over.** One analysis query was issued per profiled *span*, though OAP marks profiling per *segment* — so a segment with 20 spans cost 20 near-identical queries, exhausted the analysis budget 20× faster, and could trip OAP's "only part of the snapshots were analyzed" warning, which was then relayed to you as a truncated profile that was never truncated. One query per segment now.
-- **A point-in-time pprof profile (HEAP, GOROUTINE, ALLOCS, THREADCREATE) is labelled as one** instead of showing a "0 min" collection window, which read as a task that collected for no time. Relatedly, the process list used to confirm an instance can be network-profiled no longer counts virtual processes, which OAP's own check excludes — it was possible to be offered an instance OAP would then reject.
-- **pprof collection windows are validated in the unit OAP actually uses.** The pprof task duration is measured in **minutes** and capped at 15 by OAP, but Horizon was bounding it as if it were 600 seconds — so an over-long request sailed through and came back as an opaque backend error after you approved it. It is now bounded at OAP's real limit. Likewise, an async or pprof task targeting more instances than a single task allows is now **rejected with a clear reason** instead of quietly [...]
-- **Guided root-cause analysis.** Ask "what's the root cause?" and the assistant follows built-in investigation playbooks — a master method (locate the root service → calling chain → error stack; walk the dependency topology upstream and fix a sick upstream first; a remote / Virtual_* dependency exposes only its client-side edge metric) plus latency, error-rate/SLA, saturation, middleware, **Kubernetes-workload**, and service-mesh specializations. It can also follow the **cross-layer hie [...]
-- **Bring your own LLM — vendor-neutral, and off by default.** Enable it with the new `ai:` config block (`HORIZON_AI_*`). The default transport is **OpenAI-compatible** (any OpenAI-shaped endpoint — a hosted model, a local model, or an AI gateway; set model + base URL + API key); **Amazon Bedrock** is also supported (`provider: bedrock`). The API key is a secret, env-only, redacted from logs and excluded from the audit trail. The launcher shows for every signed-in user so the AI-powered [...]
-- **Both prompts are yours.** The assistant's system prompt (`ai.systemPrompt`) and the starter example chips shown in an empty chat (`ai.starters`) ship with sensible defaults and can be replaced entirely in `horizon.yaml`.
-- **Starter chips can ask for a service first.** A starter that names a `<service>` (e.g. "Investigate latency for <service>") opens a one-field fill-in on click: type the service free-form — a partial name or a description is fine — and it's dropped into the prompt before sending. You don't have to know the exact name or its layer; the assistant matches what you typed to a real service through its own cross-layer search. Plain starters without a placeholder still send in one click [...]
-- **Your question stays in view while the answer streams.** The message you just sent pins to the top of the chat and stays there as a sticky header — while its answer streams in and as you scroll through it — so you never lose sight of what you asked; opening or switching to a past conversation pins that chat's last question the same way. Each turn now carries a timestamp: when you sent the question, and when its answer finished.
-- **The assistant orients on a layer's template before reading it — so it answers in the layer's own terms.** A new capability lookup surfaces, per layer: what a Service / Instance / Endpoint is CALLED there (a K8S_SERVICE instance is a Pod, a mesh instance a Sidecar, a K8S service a Cluster), which components the layer carries, whether its traces are native or Zipkin (so the assistant picks the right trace tool instead of guessing from the layer name), and each relationship's metric leg [...]
-- **Embedded maps are captured evidence now — they replay exactly on reload, offline, with their edge sparklines.** When the assistant draws a **topology**, **deployment**, **instance map**, **API-dependency chain** or **cross-layer hierarchy**, it now captures the whole graph the moment it reads it — every node and edge WITH its metric values and the per-edge trend series — and the chat block carries that snapshot. So reopening the conversation later re-renders the identical point-in-ti [...]
-- **Embedded traces, logs and browser errors are captured evidence too — the list AND its detail replay offline on reload.** When the assistant shows a service's **traces**, **Zipkin traces**, **logs** or **browser errors**, it now freezes the list the moment it reads it and the chat block carries that snapshot — so reopening the conversation replays the exact same rows (each stamped **"captured <time>"**) with no fresh query, and clicking a row opens its detail (the span **waterfa [...]
-- **Every captured block now wears a replay badge.** Each frozen point-in-time block — figures, maps, the profiling flame, traces/logs/browser errors, and pod logs — carries a small **replay icon** next to its "captured <time>" stamp, so it's unmistakable at a glance that you're looking at a snapshot the assistant took, not live data. Because the assistant captures every answer's data server-side and feeds the UI that snapshot, these blocks are *always* replaying — the badge shows [...]
-- **A charted metric is the real dashboard widget — with its explanation, exact MQE and capture time.** When the assistant shows a metric from a layer's catalog it now renders that dashboard widget directly, carrying its full configuration (unit, number formatting, value thresholds and legends) instead of a rebuilt approximation — so the chart in the chat matches the dashboard exactly. Each figure documents what it is: the metric's plain-language explanation, the precise MQE expression t [...]
-- **Conversation history now persists per user in your browser, with controls.** Past chats move to the browser's IndexedDB — far larger than before, so long conversations with embedded charts survive — and are scoped to your username, so a shared browser keeps each person's history separate. A **Save history** toggle (on by default) turns persistence off entirely; a usage meter shows how much of the client budget (default 500 MB, `HORIZON_AI_HISTORY_MAX_MB`) is in use; **Clear all** (wi [...]
-- **Two tabs can't quietly overwrite each other's chats.** History is read once when the assistant opens, and each conversation is stored under its own id, so saving one never touches another. Before every save the assistant checks whether that same conversation was continued somewhere else — in a second tab — and if the two have diverged **nothing is overwritten**: the conversation is marked **Not saved** in the History sidebar and the chat asks which version you want to keep, this one [...]
-
-### Profiling
-
-- **Continuous-profiling saves are now consistent under a failed confirmation, a mid-save edit, or a mid-save service switch.** A save that succeeded but whose confirmation read failed no longer reseeds the editor from stale, pre-save data — the applied badge now stays honest rather than showing an old policy as current. An edit made while Apply is still in flight is no longer discarded by that same reseed, and neither is switching to a different service mid-save: the confirmation now al [...]
-
-- **A profiling request that cannot be honoured is refused, not repaired.** pprof's `dumpPeriod` is a sampling *rate* — lower means more samples — and an invalid value used to be replaced with `1`, turning a bad request into the most expensive profile the agent can produce. Invalid values are now rejected with the reason.
-
-- **Async profiling accepts the full 15 minutes it offers.** The New Task dialog has always listed a 15-minute option, but the task was quietly shortened to 10. The cap now matches what the dialog offers, and a request above any cap is refused with the limit named rather than silently trimmed — the same for target instances, where asking for more than a task accepts is an error, not a silent slice of the fleet.
-
-- **Network profiling no longer refuses a task OAP would accept.** Create was disabled whenever no process had reported on the instance in the last 30 minutes, but OAP's own check has no time limit — so an idle-but-healthy instance was blocked for a task the backend would have run. The process list is now advice: it warns, and lets you create.
-
-- **Profiling tasks that a continuous policy started are now visible.** The eBPF and Network Profiling tabs — and the assistant's profile reads — asked OAP only for tasks somebody started by hand, so a policy could fire and its profile appeared nowhere. Both kinds are now listed together, newest first.
-
-- **Continuous profiling now has a home: a per-layer Continuous Profiling tab.** Every profiling surface until now started a task *on demand*. Continuous profiling is the opposite: arm a policy once and the task starts **itself** when a process crosses a threshold, with nobody present — which is how you catch the problem that only shows up at 3 a.m. It is **eBPF profiling only** and needs a Rover agent, which both evaluates the thresholds and runs the task; trace, async-profiler and ppro [...]
-
-- **Profiling task creation is consistent and tells you upfront what it needs.** Across all five task types (Trace / eBPF / Network / pprof / Async) the **New Task** button enables as soon as the basic entity is chosen and always carries a tooltip, and inside the create box a missing target is explained next to **Create** rather than by a silently greyed-out button — a hard requirement (no instances on the service) disables Create with the reason, while an advisory one (no rover-reported [...]
-
-- **Network profiling picks its target instance in the create box and checks it has processes before you submit.** The create modal selects the instance inline and lists that instance's rover-monitored processes; if it has none, a warning explains the task will likely collect nothing — **Create** stays available, since OAP itself accepts a task on an idle-but-healthy instance.
-
-- **pprof and async-profiling tasks open a detail modal with their captured logs.**
-
-- **K8S_SERVICE gains a Network Profiling tab.** Kubernetes services, already observed by SkyWalking Rover's eBPF probes, now expose Network Profiling — pick a pod and capture the process-to-process network conversations as a topology, the same capability the Mesh layer offers.
-
-- **Profiling create dialogs are clearer and harder to misuse.** After a create the hint counts down to its single list refresh (`refreshing in Ns`), Escape closes the Async and pprof create dialogs, and Analyze stays disabled until at least one instance is selected.
-
-- **The Async result's event-type picker shows only what the task captured.** It lists just the JFR trees the selected task's events produce (EXECUTION_SAMPLE, LOCK, OBJECT_ALLOCATION_*), dropping options like PROFILER_LIVE_OBJECT that no Horizon-created task can produce — so you can't pick a type that renders an empty graph.
-
-- **A profiling task list that can't scope to its service now says so, instead of looking empty.** The Trace, eBPF, Network, async-profiler and pprof task reads returned an empty list when they couldn't establish which service to ask about, which reads as "this service has never been profiled". Each now reports the reason in the tab's error line. The Network task read went further and dropped the service from the request entirely, answering with every service's network-profiling tasks — [...]
-
-### Traces & logs
-
-- **Custom time ranges on the Traces and Logs tabs now return results when your browser and the OAP server sit in different timezones.** A custom range (and the metric→trace drill's centered window) was sent as a browser-local wall-clock string that the server re-read in its own timezone — so on a UTC-container deployment the window shifted by your UTC offset and came back empty, while the rolling presets kept working. Both paths now send absolute timestamps and the server applies the OA [...]
-- **Opening a Zipkin trace's spans is now instant — no second query to OAP.** On a Zipkin-tracing layer (mesh / Kubernetes), clicking a trace in the results list rendered its span waterfall by re-fetching the trace by id — even though Zipkin's list response already contained every span. The list now carries those spans through, so the waterfall opens immediately from what's already on screen, one round-trip lighter. Opening a trace by a pasted id or a shared deep link still fetches it directly.
-
-- **A one-service query can no longer widen into a whole-layer one.** Traces, Logs and Browser errors are scoped to the service you picked, and when that scoping couldn't be established the service filter was simply left out of the query. OAP reads a missing service as *all* services, so the tab filled with other services' traces / log lines / JS errors under a page that still named yours. Those reads now come back empty, stating that the service identity didn't arrive intact, and the Lo [...]
-
-- **A service-scoped tab no longer queries the whole layer in the moment before it knows which service you picked.** The service is resolved after the tab opens — from the layer's sampled rollup, or from the full service roster when the pick sits outside that sample (a deep link, a service in the long tail) — and a query started inside that window carried no service, which OAP answers with every service in the layer. Traces, Logs (its level facet counts included) and Browser errors now w [...]
-
-- **Switching service on the Traces tab clears the previous service's results.** The committed query survived the switch, so the trace list, the duration distribution and an open span waterfall stayed on screen under the new service's name — read as its data — and a distribution pick left behind could filter the next query's results down to nothing. A switch now resets the tab to **Pick your conditions, then click Run query.**, dropping what the previous service returned, the way the Log [...]
-
-- **The Traces tab prints why a read failed, in the page.** The reason — an unreachable OAP, or the refusal above naming the service and the layer — was only reachable by hovering the small **unreachable** marker on the results header. It now appears above the results, the way the Logs tab already reports a failed read.
-
-- **The service you picked is now queried by its exact identity, not by its display name.** The pickers select a service by SkyWalking's own service id, but several reads then threw that id away and sent the *name* they were showing, leaving the backend to look it up again. That round-trip can land somewhere else: SkyWalking holds an agent-detected service and a conjectured (virtual) one of the same name as two different entities, and a service name can equally read like another service' [...]
-
-- **A service whose name looks like an internal id — `api.1`, `orders.2026` — is queried correctly again.** Service names ending in a dot and digits happen to match the shape of SkyWalking's own service ids, and several reads guessed by that shape: the name was forwarded as if it were an id, matched nothing, and the tab came back empty for a service sitting right there in the picker. Traces, Logs, Browser errors, every profiling task list, and the Instance and Endpoint pickers now carry [...]
-
-### Result counts and paging
-
-- **A last page that happens to be exactly full no longer offers a Next that lands on an empty screen.** The Logs and Browser Logs pagers decided whether another page existed by looking at how many rows came back: a page that filled the page size was assumed to have more behind it. When the window's row count happened to be a multiple of the page size — a window holding 24 rows read 12 at a time — the final page was full, **Next** stayed enabled, and clicking it showed nothing with no ex [...]
-
-- **The Logs pager stopped claiming a total it never had.** The footer read `page 3 · showing 50 of 50 total` — where "total" was the count of rows on the page you were looking at, so it always equalled what was shown and told you nothing. OAP's log query does not report a cross-page total (no list query in the query protocol does), so the footer now states the page and what is on it, and the honest "there is more" signal lives on the **Next** button.
-
-- **Changing the Logs page size restarts at page 1.** Page size feeds the query's offset, so sitting on page 5 at 20 rows and switching to 100 jumped from row 80 to row 400 — usually past the end of the window, leaving a blank page with nothing to explain it. The Browser Logs tab already did this; the Logs tab now matches.
-
-- **"Truncated" and "capped" notices are now facts instead of guesses at the boundary.** A result set whose size landed exactly on the cap was reported as truncated when it was complete: the topbar alarm badge showed `200+` for exactly 200 events, the alarms page warned that the window might be clipped when it wasn't, the events popout offered "more available — narrow the range" for a window that held nothing more, and the Logs level counts couldn't say whether their sample covered the w [...]
-
-- **Trace, log and Explore result lists say when they were capped — a signal they never had.** A Traces, Zipkin-traces or Explore/Inspect result that exactly filled the **Limit** you chose was indistinguishable from a complete one: there was no total, no indicator, nothing. Those lists now carry a **capped at N — narrow the window** note when the backend held more than the limit returned. Same for the endpoint search behind the Endpoint / Page pickers, which now says **more matches — nar [...]
-
-- **A next-page read no longer risks skipping rows.** OAP derives a query's offset from its page size, so asking for "one row more" past the first page shifts the offset too and silently pages in the wrong stride. Horizon asks for the extra row only where that is exact, and everywhere else reads the next page's first row as a separate one-row probe carried in the same request against the same time window — so the page you see and the answer about what follows it always describe the same window.
-
-### Deployment & configuration
-- **Serving Horizon under a path prefix is a first-class build option now, and the docs say how.** Set `HORIZON_UI_BASE=/horizon/` when building from source and the prefix is baked into the artifact — page URLs, static assets and every API call resolve under it — for a reverse proxy that strips the same prefix before forwarding. Server Listener documents both halves and what each one looks like when only one of them is in place. Previously this needed a hand-passed build flag and the set [...]
-- **The Dump & restore download works under a path prefix.** Downloading a runtime-rule dump (all catalogs or one) was the single link in the app that ignored the deploy prefix: it aimed at the site root, so behind a gateway serving Horizon at `/horizon/` it hit whatever else owns `/api` there — usually a 404 instead of the archive. It now resolves against the same base as the rest of the UI.
-- **Running against an OAP whose template store Horizon cannot read? `templates.mode: readonly` is required.** In the default `live` mode Horizon renders only what is published to OAP, and an unreachable template store is a deliberate block: layer-driven pages stay empty behind the connectivity banner instead of quietly rendering a bundled copy that may not be what you published. The most visible casualty is **Traces**, which reads its query configuration from the layer template. That is [...]
-- **Run on the bundled templates, read-only — no OAP ui_template API needed.** A new `templates.mode` setting (`HORIZON_TEMPLATES_MODE`) adds a `readonly` mode: Horizon renders every dashboard / overview / alert-page / 3D-map / translation from the **local bundle** and never calls OAP's ui_template admin API. The whole config surface goes **read-only** — the admin pages still open and show the bundled config, but editing and publishing are disabled (and the BFF rejects a write even if it [...]
-- **The container image runs with environment variables only — no mounted config file.** There is now **one committed, env-driven `horizon.yaml`** (the former `horizon.example.yaml` / local-copy split is gone): every field is a `${HORIZON_…:default}` token, and the image bakes that same file. So `docker run -e HORIZON_OAP_QUERY_URL=… -e HORIZON_AUTH_LOCAL_USERS='[…]' …` is enough — no `-v` mount, no repackaging. Previously `oap.*`, `auth.*`, users, LDAP, RBAC, and performance tuning were [...]
-- **Per-layer and alarm-page setup live only in OAP now — two local state files retired.** Horizon no longer writes `horizon-setup.json` or `horizon-alarms.json`: the `setup.file` / `HORIZON_SETUP_FILE` config is gone (the per-layer override it backed was unused — per-layer config is authored in the layer-dashboards template), and the **alarm-page setup** (pinned layers, default window, overview fetch cap) now reads and writes only OAP's `horizon.alert.page-setup` template, exactly like [...]
-- **Audit logging can be turned off.** `audit.enabled` (env `HORIZON_AUDIT_ENABLED`, default `true`) — the audit trail stays on by default (it is a security record) but can be disabled entirely, mirroring `debugLog`'s enabled + file shape.
-- **Cluster Status now reports admin-feature reachability, not just config-presence.** The admin-host pane fires a safe GET at the real REST path each feature calls on OAP — dashboard templates → `/ui-management/templates`, DSL management → `/runtime/rule/list`, live debugger → `/dsl-debugging/status`, Inspect → `/inspect/metrics` — and colors each row by whether that path actually responds. A feature whose module is loaded but whose endpoint 404s (a renamed or forked module, a selector [...]
-- **An invalid `horizon.yaml` edit at runtime is rejected out loud, not silently ignored.** `horizon.yaml` is hot-reloaded, but a reload that failed validation used to be swallowed with no trace — the file said one thing while the server kept running another. A rejected reload is now logged with the exact field path(s) that failed (the same per-field shape a boot failure prints), and the previous valid config keeps serving until the file is fixed.
-- **The role editor no longer offers two permissions that did nothing.** `setup:write` and `alarm-setup:write` appeared as *Edit per-layer setup* and *Edit alarm-page setup* in the role editor, but no request ever checked them — publishing a Global-defaults or Alert-page-setup change goes through the same template-write path as every other template and is gated by `overview:write`. Withholding them fenced nothing off, so they are gone; the two read verbs stay (they decide whether the sid [...]
-
-- **The release process now gates the exact commit it tags, and stable images wait for the vote.** The release script pushed the tag before it installed, packaged or license-checked anything, ran no tests / type-check / lint / i18n at all, and validated the caller's working tree while tagging a freshly cloned one — so it could green-light one tree and release another. Repository CI never covered the gap either: it runs on pull requests and pushes to `main`, never on a tag. The full batte [...]
-
-- **A release is verified before it is published, and a half-finished finalize can be re-run.** Finalizing a passed vote moved the candidate into the official `dist/release` location FIRST and only downloaded it afterwards, and it never checked the `.asc` detached signatures at all — so a candidate that was corrupt, truncated or signed by the wrong key became the official Apache release before anything looked at it. The candidate is now fetched and verified before a single byte is promot [...]
-
-- **The key a release is signed with is the key the script showed you.** On a workstation holding more than one GPG secret key the release script could report one key, validate a *different* key's `@apache.org` address, and then hand the signing over to whichever key GPG picks by default — three keys, no guarantee any two were the same. It now resolves exactly one signing key (`HORIZON_RELEASE_GPG_KEY`, else `git config user.signingkey`, else the sole key in the keyring) and refuses to g [...]
-
-- **The official release directory holds exactly what was voted on — nothing rides along.** Promotion verifies six artifacts but moves the whole candidate directory in one step, so anything else left in it (a stale tarball from an earlier candidate, a hand-uploaded file) would have landed in the official Apache release location without ever being verified or voted on. The candidate's contents are now compared against the six expected artifacts before anything is published; an unexpected [...]
-
-- **Cleaning up the superseded release can no longer delete a newer one.** After promoting, finalize offers to remove the release this one supersedes — ASF keeps only the current release live and older ones fall to the archive — but it picked the highest OTHER version on the release path, so finalizing an older maintenance line (1.0.1 while 1.2.0 was already out) proposed deleting the NEWER release. Only versions below the one being finalized are considered now; any newer release is repo [...]
-
-- **The CHANGELOG is validated on the commit being tagged, and the published notes are read from the tag.** The release section check runs inside the release clone with the rest of the pre-tag battery, so a section that exists only in the release manager's local checkout — or one still carrying the `(In development …)` stub on the branch being released — fails the release instead of passing it while a different tree gets tagged. The GitHub release body finalize publishes is likewise extr [...]
-
-- **Finalize expects the moving `latest` image tag only when the release really is the newest.** The promotion run deliberately leaves `apache/skywalking-ui:latest` where it is when the promoted tag is not the highest, so that publishing a patch on a superseded line never drags `latest` backwards — but finalize announced `latest` as expected for every release. It now applies the same rule: `latest` is checked only when the release is the highest tag, and then it is also confirmed to poin [...]
-
-- **Dependencies refreshed — the shipped image and tarball now carry no known-vulnerable package.** The bundled runtime tree was upgraded (notably the static-file server the BFF serves the UI with, and the charting library), clearing every advisory `pnpm audit` reported against the production dependency tree, and the binary LICENSE/NOTICE were regenerated to match.
-
-- **The production default log level is now `warn` (was `error`).** The misconfiguration and security warnings Horizon emits — break-glass logins, LDAP failures, rejected config reloads — were discarded by the old default, so none of them reached stdout unless the operator had already raised `LOG_LEVEL`. They now show by default; set `LOG_LEVEL=error` to restore the old quietness.
-- **The `debugLog` wire log is implemented — enabling it now actually captures OAP traffic.** The config block was documented (see the [wire debug log docs](docs/setup/debug-log.md)) but nothing wrote the file; with `debugLog.enabled: true` every outbound OAP request/response pair (GraphQL, admin REST, Zipkin) is now appended to `debugLog.file` as one JSON line — `Authorization` headers redacted by default, bodies truncated at `maxBodyChars` — and every setting, including on/off and the [...]
-- **A duplicated template row on OAP is now reported, never resolved behind your back.** When the same dashboard / overview / alert-page template ends up on two OAP rows (two row ids for one template name — residue of an OAP release that minted a random id per create), earlier versions quietly disabled one of them at every BFF start, and picked the untouched bundled copy over the one you had edited. Retiring a row does not bring its content back — OAP has no delete, only disable, and Rea [...]
-- **A duplicated template is now impossible to miss — and impossible to open by accident.** A layer **or overview dashboard** whose template name sits on more than one enabled OAP record, where the copies carry **different** content, is **hidden from the sidebar** rather than rendered from whichever copy happened to win, so nobody navigates to a dashboard whose definition is ambiguous (the server log names every duplicate it finds, and the hidden layers specifically). Opening such a laye [...]
-- **Publishing a layer dashboard now refuses malformed content before anything reaches OAP.** Overview and 3D-map templates were already checked on the way out; layer dashboards were not, so a hand-edited or imported template could be published and break that layer for everyone until someone pushed a good copy over it. Publishing — **Check diff & push**, **Reset to bundled**, or a bulk sync — now names the offending field (an unknown key, a misspelled component flag or dashboard scope, a [...]
-
-
-### General Service — PHP runtime (PHM)
-
-- **Six instance dashboard line widgets for PHP Health Metrics** — process CPU utilization, memory used/peak, virtual memory, thread count, and open file descriptors (`meter_instance_php_*`). Each line widget uses `visibleWhen` so widgets render only when the PHP agent reports PHM data (Linux `/proc` sampling of the parent PHP process via `getppid()`).
-
-### General Service — Node.js runtime
-
-- **Six instance dashboard line widgets for Node.js runtime metrics** — process CPU, V8 heap used/total/limit, RSS, and external memory (`meter_instance_nodejs_*`). Each line widget uses `visibleWhen` so widgets render only when the Node.js agent reports runtime data.
-
-### General Service — metric-to-trace drill-down
-
-- **Removed the deprecated "Layer-scoped" widget toggle from the dashboard editor.** It emitted OAP's `scope: All` entity — deprecated in the query-protocol since OAP 9.4.0 — and no bundled dashboard used it. Any legacy stored dashboard that still carries the flag now renders at normal service scope (the unknown key is ignored). Layer-wide rollups belong on the Overview dashboards.
-
-- **Click a latency or error point on a General-service chart to open the matching traces.** Drill-capable line widgets — Avg Response Time, Response Time Percentile, Error Rate, Apdex, Success Rate, and MQ consume latency, across the Service, Instance, and Endpoint scopes — carry a **traces** flag in their header, and their datapoints are clickable. Clicking a point opens the native Traces list in a **new browser tab**, pre-filtered to that service (and the selected instance / endpoint) [...]
-
-### Access control & permissions
-
-- **What one operator's session read no longer reaches the next person to sign in on that browser tab.** Horizon caches fetched responses so a page you return to paints instantly — but that cache outlived the session: after a sign-out and a fresh sign-in in the same tab, the new user could be served the previous user's already-fetched data (service lists, alarms, traces, dashboards), and the reads Horizon deliberately holds for the life of a page (alarms, events, the layer service list) [...]
-
-- **Shortening the session timeout now actually shortens the sessions.** `session.ttlMinutes` hot-reloads, but only half of it was applying: the cookie handed to the browser carried the new timeout while the server went on honouring session ids for whatever timeout was in force when the BFF started. Tightening the window — the thing you do right after an incident — was the damaging direction, because it looked like it had worked: the browser was told to forget the cookie sooner, while an [...]
-
-- **A custom role no longer loses the org theme, the default time window and the alarm badge on sign-in.** The three org-wide settings an admin publishes on **Global defaults** and **Alert page setup** — the default theme, the default time window for dashboards and overviews, and the alert page setup that also sizes the topbar alarm badge and the overview "Active alarms" widget — were read through the template-administration endpoint, which requires `overview:read` and returns every dash [...]
-
-- **When OAP's template store can't be read, org settings fall back to Horizon's built-in defaults instead of the files shipped in the image.** In the default `live` template mode the copy stored on OAP is the only source of truth for configuration, but if the template store was unreachable — or a setting's row was missing or had been disabled — Horizon quietly rendered the theme, time window and alert page setup that ship inside the image as though they were your organization's publishe [...]
-
-### Alarms
-
-- **The alarm timeline reads more clearly** — a clearer selection band and legend, and the detail sidebar reflows cleanly on narrow windows. Hovering the timeline now hints both affordances — click a minute to filter, or drag across the timeline to select a range — so range-selection is no longer hidden.
-
-- **Filtering alarms by a virtual service now finds them.** The databases, caches, message queues and gateways SkyWalking infers from an agent's calls (`VIRTUAL_DATABASE`, `VIRTUAL_CACHE`, `VIRTUAL_MQ`, `VIRTUAL_GATEWAY`, …) are conjectural services, and OAP distinguishes them from agent-reporting ones by a flag that is part of the service's identity. The alarms filter dropped that flag and always asked as if the service were agent-reporting, so picking a virtual service — on its own or [...]
-
-### Events
-
-- **A per-service events popout on the service banner.** Every layer drill-down's service banner gains an Events button that opens a modal for that one service's lifecycle events — agent restarts, Kubernetes events, and other point-in-time records from OAP `queryEvents` — without leaving the page you're on. The service is fixed, so the view is a swimlane of **instance × time**: one row per service instance in its own color, each event a bar on a time axis (an event with no end time is an [...]
-
-- **Built for scale and honest about limits.** A rolling restart of a large service is one bar per instance stacked at the same moment — the granularity is the point, and a search box filters the instance rows by name for services that run hundreds of them. Scrolling is fully internal (sticky time-axis header + sticky instance column, horizontal scroll for long ranges, opened scrolled to the newest events). The newest events are fetched up to a configurable cap (200 by default); the popo [...]
-
-### User experience
-
-- **The whole console now speaks your language.** Large surfaces that previously rendered English in every locale — the per-layer sidebar sub-navigation, the Logs tab filter bar, all five profiling tabs and their task dialogs, the admin template editors (layer dashboards, overview, topology, deployment, 3D map, global defaults), the 3D Infrastructure Map, the service-map detail panels, and the remaining topbar chips — are now fully wired into the translation catalogs and ship translated [...]
-
-- **The page's `<html lang>` attribute now follows the active locale** — screen readers, spell checkers, and translation tooling see the language you actually picked instead of a hardcoded `en`.
-
-- **Escape closes any dismissible panel** — modals, row popouts, and the topology focus / node-filter dropdowns all dismiss on Esc.
-
-- **Switching service clears the dependent filters** (log level / tags, browser-error category) back to a clean state, so a stale filter never silently hides the new service's data.
-
-- **Denser Kubernetes dashboard tables** — the K8s layer's table widgets show more rows without scrolling.
-
-- **Live debugger reads cleanly on tall and wide results.** The LAL pipeline matrix's frozen first column now stays pinned when you scroll the grid sideways (it used to drift off with the rest of the matrix), clicking a source line flashes the whole matching step row — not just its label — and the MAL / LAL / OAL debugger pages now scroll as one page for tall captures instead of trapping the result in a fixed-height inner box.
-
-- **The LAL pipeline matrix renders Envoy access-log (ALS) and any non-generic log format.** Each cell now shows whatever fields OAP serialized for the record rather than a fixed `LogData` subset — so an `EnvoyAccessLogBuilder` snapshot displays its service / endpoint / response data where it used to render blank, a record whose raw proto input OAP couldn't serialize surfaces the reason (`jsonformat-failed …`) instead of an empty cell, and each cell names its payload class. The free-text [...]
-
-- **The LAL matrix gains per-row filtering and is correct across cluster nodes.** A filter on each step row — shown only when that row has gaps — narrows the grid to the records that actually produced data for that step (e.g. just the records that emitted output), and the row counts now reflect the whole capture rather than the visible page. Each OAP node's matrix filters and column-pins independently, so acting on one node's grid no longer changes another's. Oversized cells are height-c [...]
-
-- **A LAL step that dropped a record now says why.** When a pipeline step stops a record, the matrix cell and its popout show OAP's human-readable drop reason — a parse exception, a non-matching regexp, an input-type mismatch — instead of leaving you to reconstruct the cause from the payload. An OAP version that predates the reason field simply shows no reason, as before.
-
-- **Inspect a LAL cell's full data and diff pipeline stages.** Every cell carries a persistent button — `VIEW` on the input row, `DIFF` on the builder rows — that opens the complete payload in a syntax-highlighted JSON viewer with the nested log `content` inlined as real JSON. For the builder snapshots a compare picker renders the captured DSL itself, with per-statement steps marked on their line and the `extractor` / `sink` block snapshots drawn as selectable ranges; picking one shows a [...]
-
-- **A failed live-debug or OAL request now names OAP's own error code.** Those endpoints report a failure as a code plus a message — `rule_not_found` when no node has the rule loaded, `too_many_sessions` when every node is at its capture ceiling, `injection_disabled` when debug capture is switched off — but Horizon read the field the rule-apply endpoints use instead, so the code was dropped and the failure rendered as `undefined: <message>` in the server log and in the per-node live-debu [...]
-
-### Dashboards
-
-- **A dashboard can no longer be published under a name that isn't what it holds.** Pushing a layer dashboard to a name the runtime never reads — `horizon.layer.general` instead of `horizon.layer.GENERAL`, or an OAP legacy alias like `horizon.layer.CACHE` where the layer is read as `VIRTUAL_CACHE` — used to succeed and then change nothing on screen, with no explanation anywhere: the record existed but no page ever asked for it. Publishing an overview dashboard under one id while its cont [...]
-
-- **Overview dashboards are checked against the same bar as everything else when you publish them.** The push previously ran a weaker check than the one the shipped dashboards pass in CI, so a misspelled field, a widget type the renderer has no case for, or an out-of-range width was stored as dead config and simply never rendered. Half-authored work still publishes — a blank MQE, an empty KPI list, a cleared title, a brand-new dashboard with no widgets yet are all normal states of an unf [...]
-
-- **A refused push now tells you why on the layer and overview editors.** The reason (which field, or the name to publish under) was already in the response but the pages showed only "failed (400)".
-
-- **Cards can render values as colored status chips.** A card widget with `format: enum` now takes an optional chip color per value-map entry — `ok` (green), `warn` (amber), `err` (red), `info` (blue), `neutral` (grey) — and renders each matched value, or metric label, as a colored chip instead of a bare number. Set it in the layer-dashboard admin's value-map editor, next to the existing value → label mapping.
-
-- **The Kubernetes Node Status card now reads as a status, not a number.** Instead of a raw `1`, it shows the node's active conditions as colored chips — `Ready` in green, the `*Pressure` / `NetworkUnavailable` conditions in amber/red — so node health is legible at a glance.
-
-- **The Kubernetes Node dashboard gains a Pod Total card.** A compact card now sits directly under Node Status showing the current count of pods scheduled on the selected node (all phases) — the latest value of the same metric the "Pods on Node" trend already charts — so the space beside the status card is no longer blank.
-
-- **Satellite event and queue widgets break out per pipeline.** The SO11Y_SATELLITE Receive Events, Fetch Events, Queue Input / Output, and Queue Used widgets now label each series by its Satellite pipeline (`tracingpipe`, `jvmpipe`, `logpipe`, …) instead of collapsing every line onto a single `all`, so you can see which collection pipeline drives the rate.
-
-- **HOUR-step line charts no longer collide their x-axis labels.** An hourly label carries the date (`07-02 05:00`), so on a narrow widget several wide labels overlapped into an unreadable smear; overlapping labels are now hidden (the label density target is unchanged — only the colliding ones drop), so any dashboard viewed at HOUR step keeps a legible axis.
-
-### Overview dashboards
-
-- **The per-layer KPI tiles now report the whole layer, not a single service.** A tile such as "General services · RPM / Latency / SLA" was aggregating just one service (an internal top-1 cap), so a busy layer read far too low. Each tile now rolls up **every** service in its layer — throughput summed, latency and SLA averaged — for General, Mesh, and the Virtual Database / Cache / MQ / GenAI layers. Mesh's second tile is now **Latency** (average response time) in place of the old P95.
-- **New per-widget aggregation control for Overview composites.** An Overview data widget defaults to a self-aggregating expression that OAP rolls up server-side; a widget can instead pick **page-side aggregation** (an *Aggregation* mode choice in the Overview templates admin, with a top-`limit` services window) for metrics that can't be expressed that way — the Kubernetes cluster-capacity and Istio pilot composites use it (pilot over up to 5 control-plane services). A page-side widget a [...]
-
-### Performance & behavior tuning
-
-- **New `performance` section in `horizon.yaml`.** Tune how hard the BFF fans metric queries out to OAP — per-route bulk (request) sizes and concurrency for the topology, 3D-map, landing, and dashboard fan-outs — plus protective caps: the service-map render valve (`topologyMaxNodes` / `topologyMaxEdges`) and per-request record caps for traces / logs / browser logs. Operational, hot-reloaded, per-deployment; defaults match the previous built-in values, so the whole block is optional. Rais [...]
-- **3D-map fan-out tuning moved out of the dashboard template into `horizon.yaml`** (`performance.bulk.infra3d`). These metric concurrency / batch knobs were operational settings misplaced in a published-to-OAP dashboard template (not even surfaced in the admin editor); a stale template still carrying the old `pipeline` block is accepted and stripped on save, so a 3D config that was synced before the move converges back to `synced` after one re-push (instead of showing `diverged` forever).
-- **Unified page-size pickers across the event lists.** Traces, Logs, and Browser Logs share a `20 / 30 / 50 / 100` page-size dropdown — and Browser Logs gains a picker it never had (it had a fixed 100). Each picker's max matches the server-side fetch cap in `performance.limits.maxPageSize`.
-- **Node memory sizing guidance.** The container image now sets a default `NODE_OPTIONS=--max-old-space-size`, and the docs cover sizing the Node heap to your container memory limit and the in-memory source-map budget.
-
-### Trace explorer
-
-- **Zipkin traces now render with the full native trace experience.** The Zipkin trace detail and popout gained the KPI strip, service legend, the duration-distribution scatter (drag to filter, click to open), time-positioned waterfall bars (service · operation inside the bar, kind/status affordances), and a centered span-detail modal — matching the SkyWalking-native trace view. Zipkin annotation codes (`cr` / `cs` / `sr` / `ss` …) show an inline plain-language hint.
-- **Shareable trace links are unified.** Native and Zipkin traces both open from a single `?traceId=` link under the layer's trace tab; the viewer auto-selects native vs Zipkin by the trace-ID shape, so `/layer/<layer>/trace?traceId=…` always opens the right one.
-- **Trace filters are searchable, on-theme dropdowns.** The native Service / Instance / Endpoint pickers and the Zipkin Service / Remote service / Span name pickers use a dark type-to-filter dropdown that reopens correctly after a pick.
-
-### Logs
-
-- **Log and browser-error lists query on demand, not on every edit.** The per-layer Logs tab, cross-layer Log inspect, and the Browser Errors tab now stage condition changes and fetch only when you press **Run query** — a fresh tab shows a "Pick your conditions, then click Run query" prompt, and switching service resets to that prompt (clearing the level / tag / category filters), so the previous service's data never lingers under the new one.
-- **Log inspect uses the full width.** The cross-layer Log inspect form (Target + Tags / Trace ID / Time / Limit conditions) now spans the whole page instead of sharing a two-column strip with empty space.
-- **Clicking a log row opens a centered popout.** Both the cross-layer Log inspect and the per-layer Logs tab now open the same full-payload popout on row click — format-aware pretty-print (JSON pretty-printed by content type), the tags table, service / instance / endpoint / time meta, a copy button, and the trace link. Escape or the close button dismisses it.
-- **Log inspect can now query Browser errors across the page.** A new **Browser** source on Log inspect (beside Raw) queries the BROWSER layer's JS error logs from anywhere — pick a browser service or type a service name (or leave it blank for all services), then narrow by category (AJAX / RESOURCE / VUE / PROMISE / JS / UNKNOWN), version, page, and time window, and read the error list (message, category, page path, app version, time, minified `line:col`). Upload and manage source maps i [...]
-- **Tag fields autocomplete on theme.** The Tags filter on Trace inspect, Log inspect, and the per-layer Traces / Logs tabs now suggests tag keys (before `=`) and per-key values (after) in a dark, dense dropdown anchored under the field, replacing the browser's native `<datalist>` popup. On Trace / Log inspect, pressing Enter commits the current tag and starts the next, mirroring the per-layer chip tabs.
-- **Log inspect can now read Kubernetes Pod logs across the page.** A new **Kubernetes Pod logs** source on Log inspect (beside Raw and Browser) tails a specific pod's container logs on demand from the K8s API through OAP — pick a Kubernetes layer, then **pick or type** a service, choose a pod and container, set a trailing window (30s … 30m) and optional keyword filters, and read the dense, read-only log lines (timestamp + content). The Layer field lists only Kubernetes-deployed layers ( [...]
-
-### Metrics Inspect
-
-- **Inspect can now chart a "foreign" metric — one the connected OAP doesn't define.** When a metric is written into shared storage by another OAP (an older version, or a different distribution, that the OAP behind Horizon doesn't carry the analysis rule for), it never shows up in the catalog drawer. The + add metric drawer now has a **Foreign metric** tab (beside the catalog browse): type the metric name, pick its scope (Service / ServiceInstance / Endpoint / the three relations), and g [...]
-
-### Bundled layer dashboards
-
-- **Single-value metrics now render as cards, not flat lines, on several layer dashboards.** Widgets whose expression collapses the window to one number (a `latest(...)` total) had been mis-ported as line charts — drawn as a lone dot that misreads as a time series and shares one axis with an unrelated average trend. Each is now split into a proper single-value **card** (the total) plus a trend **line** (the average), matching the metric's shape, the way booster-ui rendered them. Affects [...]
-- **The Kubernetes Service dashboard tiles without a gap.** The Service and Pod views left an empty half-row where a run of same-width widgets didn't divide evenly into the grid, and the layout back-filled the hole by pulling a later chart up out of order. The CPU / Memory Resources and Pod HTTP RPM / Response Time widgets are re-sized so every row fills the grid in order — no gap, and the charts stay in reading order.
-
-- **The Kafka layer's cluster metrics are back.** Its **Max Lag** column asked for a roll-up the service-list query does not offer (`max`), and one unusable column rejects the whole request — so the Kafka page came back with no cluster measured at all: every metric column blank, an empty KPI strip, and no cluster selected for you (the clusters themselves stayed listed and selectable). Max Lag now rolls up as the **average across the listed clusters**, matching how the same metric is char [...]
-
-- **The AWS DynamoDB account list sorts by throttling again.** Its default sort named a `throttled` column that does not exist — the columns are Read / Write Throttled and Read / Write Sys Err — so accounts came back in name order instead of worst-first, and on a deployment with more accounts than the metric-probe cap the accounts that got measured were not the throttled ones. It now sorts by **Read Throttled**.
-
-- **Every bundled layer and overview template is checked before release.** The templates Horizon ships are validated on each build against what the pages actually accept — roll-up values, widget kinds, required fields, and every internal reference (a default sort naming a real column, an overview widget naming a real layer) — so a template defect like the two above fails the build instead of reaching your screen as an empty page.
-
-### Layer dashboard editor
-
-- **A layer template that would break the layer is refused at publish instead of being stored.** Publishing a layer template — from the editor, or from a file you imported or hand-edited — is now structurally checked before anything reaches OAP, the same way overview dashboards and the 3D-map config already were. A template carrying an unknown field, an unknown component flag or dashboard scope, a widget the dashboard grid cannot run (unknown kind, or no expression at all), or a service- [...]
-
-- **New `tab` widget — a sized slot with named tab panels, each holding its own widgets, edited inline.** A layer dashboard widget can now be a `tab` container: a grid slot you size with span / row span, holding any number of **named tabs**, where each tab is its own little dashboard — its own set of widgets (card / line / top / record / table) in a sub-grid. Switching a tab swaps the whole set, and only the active tab's widgets are queried (lazy) — an unopened tab costs nothing, and a p [...]
-- **`+ Add widget` now lets you pick the kind.** The add button opens a grouped menu — `Tab group` first, then the five widget kinds (card, line, top, record, table) — each with a one-line description, instead of always dropping in a card you then have to retype. The editor header stays pinned so the button is always reachable, and selecting a widget reserves a right-hand column for the edit drawer (the canvas re-shrinks to fit) with the drawer pinned in view as you scroll.
-- **The widget editor pins in place beside the canvas and always opens complete.** On the Layer dashboards admin, clicking a widget — anywhere on the board, including the bottom rows — now opens the per-widget editor pinned next to the canvas and fully visible, without scrolling the page (a sticky panel used to get clipped past the bottom of a tall board, hiding the editor's top or its `Up` / `Down` / `Delete` row). The move / delete controls sit in a pinned footer, and the editor tucks [...]
-
-### Compare on a layer dashboard
-
-- **One-click exit from comparison.** The compare bar gains a **Clear all** button that drops every locked entity — including the current one, whose chip has no per-entity × — and returns the page to the single-entity view. Previously, when your only lock was the current entity, there was no way to leave compare from the bar.
-
-## 0.7.0
-
-### Browser errors & source maps
-
-- **New "Browser Logs" tab on the BROWSER layer** — lists the JS error logs the browser agent reports (message, category, page, app version, time, and the minified `line:col`), filterable by category and time window. Expanding a row shows the raw stack alongside a de-obfuscated view.
-- **Source-map de-obfuscation (issue [#6784](https://github.com/apache/skywalking/issues/6784)).** Upload a `.map` file from the tab and resolve any error's minified stack back to the original source — file, line, column, symbol name, and a source snippet — by picking which map to apply. Maps are held in the BFF's **memory only** (no backend storage): they're surfaced as *temporary*, evicted least-recently-used when the configured budget is hit, and lost on restart. For durable provision [...]
-
-### Layers
-
-- **Split a layer's menu by service group.** A new per-layer **Split menu by service group** toggle (Layer dashboards admin, right after **Alias**; default off) fans the layer out into one **level-0 sidebar entry per OAP `Service.group`** — the `<group>::` prefix. The entry's display name leads with the group so it reads everywhere (sidebar, page header, KPI tile) and survives narrow-sidebar truncation — e.g. `agent · General Service`. Each entry is scoped to its group: the service heade [...]
-- **The navigation sidebar is now resizable** — drag the divider between the sidebar and the page to widen or narrow it (double-click the divider to reset to the default width); the chosen width persists per browser. Useful when long entries — like group-split names (`agent · General Service`) or deep namespaces — would otherwise truncate.
-- **The per-layer service picker shows each service's group.** When a layer has no topology-cluster naming rule, the service-list rows now surface the OAP `<group>::` prefix (e.g. `agent`) as the group chip — so the group is visible there as it already is on the topology map.
-- **Every layer OAP reports now appears in the sidebar**, including ones with no Horizon template (they render with default capabilities — a plain Service page). The previous hard-coded hidden-layer list (which dropped `BanyanDB`) is gone; a layer is hidden only when an admin explicitly disables its template, or when it is listed in the new config-driven `layers.excluded` block in `horizon.yaml` (defaults: `FAAS` and `VIRTUAL_GATEWAY`; clear the list to surface every reported layer).
-- **The admin Layer dashboards page is now layer-list-oriented.** It lists every available layer — not just the ones shipping a bundled JSON or living on OAP. A layer with no template yet opens on a blank default you can configure (components, metric columns, widgets, topology) and **Save**, which publishes the template to OAP on first save. No per-layer JSON has to be shipped for a layer to be configurable. The picker gains a **Not configured** filter (beside Diverged / Local) and the s [...]
-- **Removed the legacy per-layer `overview` block** from every bundled layer template (and its translation overlays). It no longer rendered anything — the standalone **Overview Dashboards** replaced the old per-layer Overview tile — so it was dead config; the per-layer KPI strip is driven by the layer-header columns.
-
-### Airflow monitoring layer (SWIP-7)
-
-- New **Airflow** layer under **Workflow Scheduler** — service dashboard (scheduler / executor / pool KPIs and trends), Components dashboard (per-host scheduler and triggerer metrics for **Airflow 3.x** native OTel), and a 3D Infra Map load ring for **Tasks Executable**. Pairs with OAP backend SWIP-7 (`meter_airflow_*` / `meter_airflow_instance_*`).
-
-### BanyanDB self-observability layer (SWIP-15)
-
-- New **BanyanDB** layer under **Self-Observability**, modeling a clustered, role- and tier-aware BanyanDB deployment scraped through its FODC proxy. The cluster is one **Cluster** (service), each container is one **Container** (instance, carrying its `container_name` role and `node_type` tier as attributes), and each storage **Group** is an endpoint:
- - **Cluster** dashboard — write / query / error-rate KPIs, CPU / memory / disk capacity, a throughput + errors trend, and a **Containers by Role** table.
- - **Container** dashboard adapts to the selected container's role: every container shows CPU / memory / Go-runtime resources (and, where the system collector runs, uptime / disk / network); a **liaison** adds ingestion, query, gRPC errors, the tier-2 publish pipeline and write-queue depth; a **data** node adds storage totals, merge/compaction, inverted index, subscribe queue and retention; a **lifecycle** sidecar shows migration cycles and last-run time / status. Liaison and data panel [...]
- - **Group** dashboard — metrics split **per data-model** (measure / stream / trace / property): each model gets write rate, query latency, stored data, merge rate / latency / partitions, series write + term-search and total series, plus the type-agnostic subscribe / publish queue (throughput, p99, batch + message rate, publish bytes). Because a BanyanDB group stores one catalog, only the matching model's panels render for a given group — gated by the model's series-count flag — so a `m [...]
- - A **Deployment** tab renders the cluster's container **inventory** — every container grouped into its node's role/tier box (liaison, data hot/warm/cold) and its pod, with per-role health metrics (liaison query rate + gRPC errors, data ingest rate + disk usage, lifecycle migration cycles + last-run status). The node health-ring legend names **each role's own** ring metric **and its colour-band thresholds** (driven by the layer template) instead of a single shared, hard-coded one. Cont [...]
- - The whole deployment model — clustering / grouping rules, per-role node metrics, and role-pair edge metrics — is editable from the **Layer dashboards** admin → **Deployment** scope.
-- Pairs with OAP backend SWIP-15 (`meter_banyandb_*` cluster / `meter_banyandb_instance_*` container / `meter_banyandb_endpoint_*` group). Queue-batch and lifecycle last-run panels appear once the cluster runs a BanyanDB build that emits those metrics.
-
-### Dashboard widget value formatting
-
-- Card widgets gain a **`enum`** format with a **value→label map**: a coded metric (e.g. a 1/0 success gauge) renders a readable label (`1 → OK`, `0 → Failed`) instead of the raw number. Labels are **translatable per locale** (BFF-side template i18n overlay) and the map is editable in the Layer dashboards admin. BanyanDB's lifecycle **Last Sync** card uses it.
-- New **`duration`** format renders a SECONDS metric as a human time-ago (`5m 20s ago`; compact `5m` / `2h` on axes) — used by BanyanDB's **Time Since Last Sync** card.
-
-### Record widgets — jump to trace & copy
-
-- **Record widgets now drill into the originating trace.** Each sampled row gets a **jump-to-trace icon** at the row head — shown only when the sample actually carries a trace id (these are sampled, so it can be absent) — that opens the trace waterfall in the global popout. It resolves the trace **by id, not by layer**, so it works even though the trace belongs to the calling service on a different layer (a virtual-target layer has no traces tab of its own). The statement text itself is [...]
-
-### Instance-list badge
-
-- The badge on each row of the **instance list** (Containers / Pods / Nodes / …) is now **configurable per layer** (`instances.badge` on the layer template) — it can show any instance **attribute** instead of the fixed agent `language`. BanyanDB shows `container_name` (**liaison / data / lifecycle**), the role that actually distinguishes a container; agent-traced layers keep `language` (Java / Go / …). The badge is now **hidden when the value is empty or `UNKNOWN`**, so OpenTelemetry-scr [...]
-
-### Dashboard widget visibility
-
-- Layer-dashboard widgets gain a structured **Visible when** gate (Layer dashboards admin → widget drawer) so a widget only renders when it's relevant to the selected entity. Two kinds:
- - **MQE metric** — show the widget only when an expression *has value*, or when any value is **>** / **<** a threshold. Naming the widget's *own* metric self-gates it (the JVM widgets appear only on JVM instances, the MQ widgets only on MQ producers, …); naming a *different* metric gates a whole group on one shared signal — that metric is checked once and the entire group's queries are **skipped** when it's empty, so e.g. a non-JVM instance no longer runs the JVM widget queries at all.
- - **Entity attribute** — on the Instance scope, gate on the selected instance's attributes, e.g. *language equals JAVA* (case-insensitive) or an attribute simply being present. Service / Endpoint entities carry no attributes, so entity gates are ignored on those scopes.
-- Gates are evaluated server-side; gated-out widgets just don't appear in the grid. **Note:** a layer dashboard saved before this release that used the old free-text predicate loses its gate (the widget renders ungated) until you re-set the gate in the new editor and save the dashboard.
-
-### Topology node filter & component icons
-
-- The per-layer **Topology** map (and the embedded topology widget on the Services / Mesh overview dashboards) gains a **Filter** control to hide the conjectured peers that clutter a dense map. One auto-derived facet — by **layer**, presented exactly as the sidebar shows it: each row carries the layer's own icon and its localized display name (*General Service*, *Virtual Database*, *Java Agent*, …), plus an **Others** bucket for nodes OAP couldn't resolve, alongside a standalone **User** [...]
-- **Technology component icons on the nodes.** Service-map nodes now render the icon for their detected component — the same icon set the trace waterfall uses, so a PostgreSQL node looks like PostgreSQL — falling back to the generic service / external / user glyph when the component ships no icon or couldn't be resolved.
-- The topology's **service selector** (the "All services" picker) now **groups its list by service group** — OAP's `Service.group` (the `<group>::` prefix, e.g. `agent`) shown under a value-first `<name> [GROUP]` header — so a layer whose services share a group reads grouped instead of as one flat list. This is a per-service attribute and needs no per-layer naming-rule setup; services with no group stay in a single header-less section. Clicking a group header **batch-selects or unselects [...]
-
-### Instance topology
-
-- The per-layer **Topology** map gains an **instance map** drill-down on layers that enable instance topology. Click a call between two services and then **Instance map →** to open it: the instances of each service as two columns (left = client, right = server) with the instance-level calls between them — pan/zoom, animated client→server flow, the same node health-ring + per-call client/server metric sidebar the service map uses, and a node popover with **Open instance dashboard**. A bac [...]
-- **Configurable like the service map.** The Layer-dashboards admin → **Topology** scope now has an **Enable instance topology** toggle and its own node / server-edge / client-edge metric editors, kept visually separate from the service-topology metrics so the two are never confused. Enabled out of the box on **General**, **Service Mesh**, **Kubernetes Service**, and **Cilium Service**; the config rides each layer's topology template (so it travels with template export/import).
-- When OAP's template store is unreachable, the instance map now shows the same empty + connectivity-banner state as the service map, rather than a misleading "not supported" — block and unsupported are no longer conflated.
-- **Localized across all eight UI languages.** The instance-map UI, the template-store-unreachable banner, and the remaining alarm / live-debugger strings are now translated in zh-CN, ja, ko, es, pt, de and fr (English stays the source) — no feature renders English-only for non-English operators.
-
-### Lock & compare entities on a layer dashboard
-
-- **Lock several services, instances, or endpoints — including ones from different services — and compare them in place.** Compare is standard on every service / instance / endpoint layer dashboard — no flag, nothing to enable. Pin entities from the service picker or the instance / endpoint list; instance and endpoint pins are cross-service, so instances belonging to different services can be compared side by side. A persistent, scope-aware **comparison bar** shows the cohort regardless [...]
-- **The entity you're viewing is always part of the comparison** — it appears first, tagged `CURRENT` in the accent color (and still drives the header KPIs); pinned entities add to it, each in its own stable hue (up to six pins). The comparison-bar chips are display-only: clicking a chip never changes what you're viewing (no disruptive reload) and `×` unpins — switch the focused entity from the top selector / list as usual.
-- **Each widget compares inline in its own tile** — line widgets overlay one hued series per entity; card widgets show one row per entity; top-N and record widgets get per-entity tabs plus a merged "All" tab; table widgets gain an **Entity** column that groups rows by entity and folds each entity's long tail into one `(others)` row (summed for counts, count-only for latencies / percentiles where a sum would mislead). With nothing locked, every page renders exactly as before.
-- **Labeled series lead with the meaningful dimension** — a multi-label series reads `<label> · <service> · <instance>` so the label (e.g. a JVM thread state) survives when a long instance / endpoint id has to truncate; entities stay distinguishable by color. A widget that only some compared entities expose (e.g. JVM widgets when a Java service is pinned alongside a non-JVM one) shows whenever *any* compared entity has it.
-- **Progressive, per-entity loading** — each entity loads as its own request; tiles fill in as entities arrive and one slow or failed entity never blanks the others.
-- The Topology, Deployment, trace, and log pages are unaffected — comparison applies to the service / instance / endpoint dashboards only.
-- **Widget tooltips and legends show the widget title, never the raw MQE expression**, for un-labeled single-series line widgets; the multi-series tooltip is a fixed, aligned table — the entity name truncates and the values form one clean right-aligned column with the unit in the header.
-
-### Charts & bundled metrics
-
-- **Large numbers on chart axes and tooltips now use compact SI suffixes** (`45.1k`, `1.34M`, `2.5G`) instead of scientific notation (`4.51e4`), which operators found hard to read.
-- **Go runtime "Metadata Mspan" and "Metadata Mcache" widgets now report KB**, fixing values that were displayed as a mislabeled "MB" of raw bytes (≈1000× too large) — they now read in the same KB scale as the other Go metadata-size widgets.
-
-### Deployment
-
-- New per-layer **Deployment** tab — the **deployment topology of all of a service's instances**: the instance-to-instance call graph **within a single service**. Where the instance map drills into the instances *between* two services, this shows how one service's own instances are deployed and talk to each other (e.g. a clustered store's nodes calling each other). Pick a service from the layer's Service header and the tab draws its instances as health-ring nodes with the intra-service c [...]
-- **Node clustering.** Instances can group into labelled boxes by a single **instance attribute** (e.g. role / tier), by **several attributes** combined into one key (e.g. `node_role` + `node_type`, where an attribute absent on a node drops out — so a BanyanDB cluster splits its data nodes into hot / warm / cold boxes while the liaison nodes, which carry no tier, stay one box), or by a **name regex** run on the instance name — so a fleet of mixed-role nodes reads as one box per role inst [...]
-- **Optional + configurable.** Off by default for every layer; a layer opts in from the Layer-dashboards admin → **Deployment** scope, which has its own node / server-edge / client-edge metric editors (instance scope) plus the clustering-rule picker. The config is a self-contained block on the layer template, so it travels with template export/import and is independent of the service-map topology config.
-- **Pod / sibling model.** Instances render as **hexagons** and can bundle into pods: a pod's **main** container is a full hex with its **sibling** containers attached as smaller hexes around its edges. Three independent rules drive it — **cluster** (the dashed boxes), **sibling** (which containers form one pod), and **role** (per-container-type metrics + which container is the main). Edges resolve to the exact container, so cross-pod sidecar links (e.g. a lifecycle agent calling its pee [...]
-- **Tiered layout + draggable pods.** Each cluster box lays its pods out by call depth — sources on the left, the pods they call to the right — so a hot → warm → cold lifecycle chain reads as left-to-right tiers. Pods stack vertically within a tier, and a tier with more than four pods wraps into additional stacked columns of four. Drag any pod to rearrange; its cluster box re-flows to keep every node enclosed.
-- **Role-to-role edge metrics + a Flows view.** Deployment edge metrics are keyed by the **(source-role → target-role)** pair, so each kind of link shows its own metrics rather than one flat set — a `liaison → data` edge can surface write / query / part-sync throughput while a `lifecycle → data` edge surfaces migration volume. Pairs match most-specific-first with a `*` wildcard fallback. Each pair names a **primary** metric that prints **inline on the edge** in the map, so the headline n [...]
-
-### API dependency
-
-- The per-layer **API dependency** tab renders an endpoint's caller → callee chain as a graph. Pick an endpoint and it lays out in columns by direction — callers on the left, the focus endpoint in the centre, callees on the right — with the same node health-ring border, SLA-coloured RPM, and latency you read on the service map; edges animate the call direction and label the heaviest by RPM.
-- **Expand to walk the chain.** A selected endpoint shows a single **+** handle that pulls in *its* own callers and callees in one click (new callers land left, callees right). The handle spins while the dependency query is in flight; when an endpoint is a leaf with nothing further to load it fades and a brief banner says so — a silent "nothing happened" never reads as a bug.
-- **Rearrange freely.** Drag any node box to pull a dense graph apart — edges follow live. Pan, wheel-zoom, and a fit button act on the whole canvas, and a node holds a steady on-screen size whether or not the detail sidebar is open.
-- **Drill straight out, in a new tab.** The node detail's **Open endpoint** and **Service →**, and the service-map node/edge jumps (**Open service**, **API map →**, **Instance map →**), now open in a new browser tab — so you keep the graph you're exploring while the drill-down opens alongside it.
-- Nodes share the service-map's visual vocabulary (SLA-band border, an agent badge on instrumented endpoints, the focus star), and the tab is localized across all eight UI languages.
-
-### Dashboard template portability
-
-- Every template admin page — Overview templates, Layer dashboards, and the 3D-map config — now has **Export** and **Import** actions. Export downloads the *in-use* version (what end users render: the version live on OAP, or the bundled default when OAP has none) as a JSON file, for backup, sharing, or moving a dashboard to another OAP. Import reads a JSON file, validates it, and loads it as a local draft in this browser — preview it, then publish with “Check diff & push” as usual. Impor [...]
-- The **Translations** page has matching **Export** / **Import**, scoped to the current language: export the in-use translation for a template + locale as a JSON file, or import one as a local draft to review and push. (Source templates and their translations are edited on separate pages, so their import/export are separate too — each on its own page.)
-
-### Template store reliability
-
-- **Runtime config is strictly what's on OAP.** Layer dashboards, overviews, and topology now render only the version published to OAP's UI-template store (or the in-code minimal default for a layer that has none). The disk-bundled templates reach a running UI **only** by being synced to OAP (first boot / admin reset) or through the admin **Preview** button — they are never a silent live fallback. So an operator always sees the live published config, not a stale bundled copy masquerading [...]
-- **Unreachable template store is a visible block, not a quiet fallback.** When OAP's UI-template host can't be reached, a banner (same red treatment as the OAP-query-unreachable strip) reports it, and the dashboard / overview / topology surfaces stay empty rather than back-filling bundled defaults that could be read as real. The sidebar still navigates so the rest of the app is reachable.
-- The admin **Preview** button now drives **every** template-rendered page — the **overview detail** view and the per-layer **topology** (incl. the **instance map**), **API dependency**, **traces**, and **network-profiling** pages — not just the layer dashboards. Previewing renders the draft's metrics/config against live OAP, so an edit to topology or dependency metrics is visible before you publish. Preview and the absent-remote path stay strictly separate: a draft renders only in `?mod [...]
-- **Editors no longer silently fall back to the bundled default.** When a layer / overview / translation has no version published to OAP, the editor shows a *"No published version on OAP"* panel instead of quietly loading the shipped bundled copy as if it were live. Bundled now reaches the editor only when you click **Reset to bundled** — matching the runtime, which renders the published version or blocks, never the bundle.
-
-### Layer landing & service list
-
-- **The layer landing now shows your services, not just an arbitrary 25.** It used to cap the metric fan-out at the first 25 services *by list order* — so larger layers hid the rest, and the "top" services weren't even the true top (the cap happened before the ranking). Now it probes **all** services up to a configurable cap and, when a layer exceeds it, runs a cheap single- metric ranking pass to pick the **true top-N** by the landing's order-by column. The service picker surfaces **"to [...]
-- New `query.landingServiceCap` in `horizon.yaml` (default **100**) tunes how many services a landing probes per request — raise it if your OAP + storage can take the larger fan-out, lower it to protect a modest deployment.
-- **The service picker now lists the *whole* layer, not only the metric-probed top-N.** Services that ranked below the metric cap on the order-by column now appear as their own rows with **`low`** in that column (and `—` for the others, which were never probed) instead of being hidden — every service stays browsable, searchable, and selectable regardless of the cap. The header chip reads **"metrics: top N"** to make the metric trim explicit.
-- **Removed the stale "Landing KPI tile" controls** (Headline / Trend line) from the Layer-dashboards admin. They no longer matched the rendered layer header — which shows every configured metric column as its own KPI with its own trend line — so editing them changed nothing on screen. The header is driven entirely by the service-list columns + default sort; the preview now reflects that.
-- **Selecting a low-traffic (below-cap) service now works on *every* tab**, not just the dashboard. Logs, traces, and endpoint-dependency resolved the picked service's name from the landing sample only — so a tail service queried as blank (and Logs even snapped the pick back to the top service). All per-layer tabs now resolve the name from the full roster, so a `low` service drills in everywhere.
-- **Profiling scopes no longer show an editor grid that goes nowhere.** Trace / eBPF / async profiling are built-in runtime views with nothing to author, so the admin now shows a "configured at runtime" note for them instead of a widget grid whose widgets never rendered.
-
-### Access control & permissions
-
-- The **Roles & Permissions** board now lists `infra-3d:read` — the permission to view the **3D Infrastructure Map** — under the data-catalog group, with a matching "3D infrastructure map" row in the menu-visibility matrix. It was already enforced and granted to every built-in role (viewer and up), but it never appeared on the board, so an admin couldn't see who held it.
-- Editing a **layer dashboard template** is now gated on the `dashboard:write` permission the editor already advertises; publishing overview, alert, and 3D-map configs stays on `overview:write`. The required permission is resolved per template kind at save time. Built-in roles are unaffected (`operator` and `admin` hold both), but a custom role granted only `dashboard:write` can now save layer dashboards.
-- The **Cluster Status debug view** (`/api/debug/status`) now requires only `live-debug:read`. It previously also demanded `cluster:read`, so a role granted live-debug access but not cluster-read was wrongly blocked.
-- Saving a **local draft** of a template (the "Save local" action) now enforces the same per-kind permission as publishing — a layer draft needs `dashboard:write`, other kinds `overview:write` — instead of a blanket `overview:write`.
-
-### Performance hardening
-
-- **Layer dashboards skip a redundant service lookup on every load.** The dashboard route used to issue its own `listServices` to auto-pick the service and carry its entity-scope flags; it now reads the shared per-layer service catalog the sidebar already keeps warm, so a dashboard's first paint costs one fewer OAP round-trip in the common case. It still falls back to a live lookup for a just-registered service, a cold snapshot, or to surface an OAP outage (the "OAP unreachable" state no [...]
-- **The alarms list and count fire their two startup probes in parallel.** The server-time offset and backend-capability probes that precede every alarms query now run concurrently instead of one-after-the-other.
-- **The 3D Infra Map loads its metrics in parallel.** Per-node metric values used to fetch one batch at a time; they now load in bounded-concurrency batches, so the load rings and traffic values fill in sooner on large layers. A new `metricConcurrency` setting in the Infra Map config (default 4) caps how many metric batches run at once.
-- **Oversized layer topologies fail with a clear message instead of an unreadable map.** When a layer's service graph exceeds the render ceiling (5,000 services or 15,000 calls), the service map shows a "Topology too large to render" notice with the live counts and a hint to pick a specific service or lower the depth, rather than attempting to lay out a graph too dense to read.
-- **Partial metric-load failures are now surfaced on every topology map.** If some metric batches fail to load (a transient OAP error) on the service map, instance topology, deployment, or endpoint-dependency map, a banner now explains that blank values may be unavailable rather than zero — and on the endpoint-dependency map, that some endpoints or links may be missing — so a backend hiccup isn't misread as real "no traffic" data.
-
-### Fixes
-
-- **Metrics Inspect** — the crosshair value tooltip is no longer clipped behind the navigation sidebar when you hover near a widget's left edge; it now renders above the page chrome.
-- **3D Infrastructure Map config** — "Check diff & push" now requires saving a local draft first (it was selectable while edits were still unsaved), and the push dialog renders the side-by-side before/after JSON diff instead of an empty panel.
-- **The API-dependency tab now honors the topbar time picker.** It was pinned to the last hour regardless of the selected range; changing the range (and expanding a node) now re-queries the chosen window, like the service map and instance map already did.
-- **A dashboard no longer blanks entirely when one metric group fails.** A transient backend error (timeout / 5xx / query-complexity limit) on a single batch of widgets now marks only those widgets as failed; the rest of the dashboard renders normally instead of every cell going blank.
-- **Trace list rows pick the correct root span on BanyanDB.** A multi-service trace could surface a downstream span's endpoint / duration / start time in the list; the row now reliably reflects the trace's true entry span.
-- **Correct timestamps right after repointing OAP.** The server-timezone offset is now cached per OAP URL, so a configuration reload that switches to a different-timezone OAP re-probes immediately instead of serving the previous server's offset for up to a minute.
-- **Baseline security response headers** (`X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer`) are now sent on every response.
-- Removed an internal `?mockTop=` debug query parameter that padded top-N widgets with synthetic rows; it no longer ships in release builds.
-- **The profiling pages now use more of the page height.** The Trace / eBPF / Async / pprof / network profiling layouts were sized off a viewport offset that over-counted the chrome above them, leaving dead space at the bottom on taller screens; they now extend closer to the bottom of the view.
-- **Overview dashboard templates are validated before save.** A malformed overview (missing a required field, an unknown widget type) is now rejected with a clear field-level error instead of being written to OAP — restoring a guard that was lost when overview editing moved to the OAP-backed save path.
-
-### Documentation & release tooling
-
-- A further accuracy pass corrected the Cluster Status page (three panes — Query, Admin, Zipkin/OTLP — and no per-node member list), the Kubernetes readiness-probe guidance (point it at the public `/api/health`, not the authenticated `/api/oap/info`), the layer-template `components` default (only the service dashboard is on when a key is omitted) and the `aliases` authoring key, the removed `visibleWhen` free-text and embedded-i18n template shapes, and the data-retention cold-stage controls.
-- The website docs were brought current with the 0.6.0 build and the configuration pages restructured around the admin UI — the JSON shape is now a reference appendix, not an authoring surface (these admin pages are structured editors, not raw-JSON editors). Accuracy fixes span the RBAC verbs (incl. `infra-3d:read`), the audit-log action set, the Metrics Inspect API paths, the layer-template component flags, and the redesigned 3D-map config + loading stages. A new `docs/CLAUDE.md` record [...]
-- The container image is published to Docker Hub by CI on every `v*` tag; the post-vote finalize script now only verifies the published tags (the manual local-push fallback and Docker Hub login preflight were removed).
-
-### Layer drill-down fixes
-
-- The per-layer **Instance** and **Endpoint** pages now honor the layer's configured aliases in their section headers and in the service-picker's name column — e.g. ActiveMQ reads **Brokers** / **Destinations** and Virtual MQ reads **Topics** / **MQ clusters**, matching the sidebar — instead of the generic "Instance" / "Endpoint" / "Service" labels. Layers that define no alias still read the generic words.
-- A layer's Instance or Endpoint page no longer hangs on a perpetual "Reading data…" when the selected service reports no instances or endpoints (or a search matches nothing). It now shows the empty picker and renders the metric widgets in their normal "no data" state, so the layout stays visible and ready for services that do report them.
-- **Clearer cluster boundaries on every topology view.** The dashed grouping boxes — namespaces on the service map, per-service boxes on the instance map, role/tier clusters on the Deployment tab — now draw with a bolder, brighter dashed border and a fully transparent background, so the boundary reads clearly on every theme (light themes included) instead of fading into the canvas. The Deployment tab also packs its cluster boxes evenly: boxes sit at a uniform spacing with no dead corrido [...]
-
-### Live debugger
-
-- **MAL sample groups.** A captured step that fans out to many samples no longer dumps every label set on screen: the samples are grouped by metric name into a one-line summary — `<metric> · N samples · values=…` — and you expand only the groups you care about to see each sample's full labels. Groups are collapsed by default.
-- **Diff is the default when a group is expanded.** A multi-sample group opens straight into diff view: the labels shared by every sample collapse into a dimmed "common" block and only the labels that differ are highlighted per sample — so it is immediate which label distinguishes each one (e.g. `node_role` / `pod_name`) and what value it maps to. A **diff** toggle beside the group's header switches back to the full per-sample label list. The "common" set is computed across the whole gro [...]
-- **Multiple output entities collapse the same way.** When a record materialises one metric for several entities (e.g. a per-endpoint write rate over `sw_metricsMinute` / `sw_metricsHour` / `sw_metricsDay`), the repeated meter cards fold into one block: a shared header (metric / function / time bucket), a `N outputs · values=…` summary, and a diff that surfaces only the entity fields that actually differ — whichever they are, not a fixed field — with each output's value beside it.
-- **Readable sample values.** Long fractional values from `rate()` / `avg()` (e.g. `57.0333333333…`) are trimmed to a few significant digits for display so they stop overflowing the value column; integer counters still render exact, and the precise value stays available on hover.
-
-### DSL management — live apply progress & recovery
-
-- **A structural rule change now shows live apply progress.** Saving an edit that moves a metric's storage shape (scope, downsampling, or the metric set) no longer just flashes "submitted" — the editor tracks the apply across the cluster through a phase stepper (Compiled → Confirming across the cluster → Committing → Done) and reports success only once OAP confirms the change is durable. **Revert to bundled** (also a schema change) goes through the same stepper. Body- and filter-only edi [...]
-- **"Applied — cluster propagation unconfirmed" is a warning, not an error.** When a structural change is committed and durable but one or more nodes hadn't confirmed the new schema within OAP's fence budget, the editor names the lagging nodes and explains they self-converge on their next scan — the rule is applied, not rolled back. Reloading the editor reads it back as applied (from the stored rule).
-- **A failed apply is called out as rolled back** — the cluster stays on the previous rule, the failure reason is shown inline, and your edit is kept in the editor so you can fix and save again. A compile error now surfaces as an inline diagnostic under the editor instead of a transient toast.
-- **Force re-apply to recover.** A degraded or transiently-failed apply offers a one-click **Force re-apply (recover)** that re-runs the apply across the cluster to re-confirm the schema and un-stick any waiting node — gated behind a confirm that spells out it briefly pauses collection for that rule's metrics, even when the content is unchanged. This subsumes the old Advanced `force` toggle for the recovery case.
-
-## 0.6.0
-
-This release is the production-readiness pass for Horizon UI: every page now renders correctly across the eight supported languages on non-UTC OAP deployments, with deliberate caps and validation on the load surfaces that operators reach. The pillars below describe the operator-visible result.
-
-### Eight-locale internationalization
-
-Horizon now ships with eight first-class UI languages — English (source) plus zh-CN, ja, ko, es, pt, de, fr — selectable from the top-bar locale chip on every page (including the pre-auth login). The choice persists per device.
-
-- **UI chrome.** Every routed page and every shared sub-component renders through `vue-i18n`; non-English locales now cover every admin page (Roles, Users, Auth status, Alert page setup, Global defaults, 3D-map config), every operate page (Alerting rules, DSL catalog / editor / dump, OAL catalog, Live debugger + MAL / LAL / OAL, Capture history, Metrics inspect, OAP config, TTL), the alarms surface, and the shared modals. Long lede paragraphs that previously rendered as `English | one tr [...]
-- **BFF-shipped templates.** All 42 layer dashboards and both overview dashboards carry per-locale overlay catalogs alongside the source template. Coverage is ~2,300 translatable leaves per non-English locale across the layer set. The BFF picks the locale from the request's `X-Horizon-Locale` header (auto-set by the SPA), merges the overlay onto the source, and serves the localised template to the renderer — translation resolves once on the BFF, never on every chart mount.
-- **Operator-runnable Translations page.** A new admin surface (Dashboard setup → Translations) edits the per-locale overlays through the live preview: pick a target language, click any widget in the rendered dashboard, type the translation. Per-locale status chips on the template picker show at a glance which dashboards have drafts, which are synced, which diverge from disk, and which are empty for a given locale. Push writes the sibling overlay row on OAP; pushing zh-CN never touches ja.
-- **Tech-term policy.** Product, project, and protocol names (SkyWalking, Kubernetes, OAP, MQE, eBPF, Zipkin, OpenTelemetry, Istio, GraphQL, etc.), OAP scope enums (Service, ServiceInstance, Endpoint, Process), layer keys, MQE function names, env vars, HTTP status codes, and per-language runtimes (JVM, Go, Python, …) stay verbatim in every locale per CLAUDE.md. Phrases containing tech terms are translated around the term (`HTTP Connections` → `HTTP 连接` / `HTTP 接続`), not transliterated.
-- **OAP-supplied data is never translated.** Service names, alarm rule names, trace span operation names, log messages — anything arriving over the OAP wire — render verbatim regardless of locale.
-- **Validator gate.** `i18n:validate` is stricter: every source template must have a sibling overlay file per advertised locale, and empty `{}` overlays are now a finding (used to pass silently — surfaced as "structurally complete" while every translatable string still rendered in English).
-
-### Typography + self-hosted fonts
-
-- **Inter + JetBrains Mono are now self-hosted.** The Google Fonts CDN dependency is gone — air-gapped or firewalled deployments render the intended typography instead of silently falling back to system fonts.
-- **One typescale across every page.** Older admin pages that drifted to a mixed pixel palette (9.5 / 10 / 10.5 / 11 / 11.5 / 12 / 14 / 18 / 20 / 22) now share the same six-step scale + uppercase-label vocabulary as the newer dashboards. Sidebar, kpi labels, table headers, kickers all line up.
-
-### Wire-correctness on non-UTC OAP
-
-Every BFF query route now spells `Duration.start` / `end` in the OAP server's timezone (probed once per minute, cached). Previously only the alarms route did this; dashboards / landing / topology / endpoint / endpoint-dependency / instance / eBPF / traces / logs / trace-tag all emitted UTC, which silently shifted every query on non-UTC OAP installs by the server's offset.
-
-Traces and logs additionally query at SECOND precision now (records, not metric buckets) — a trace that just finished falls inside the window instead of getting rounded off the MINUTE boundary.
-
-### Performance hardening
-
-- **Landing batches no longer 5xx on wide layers.** The per-layer landing route used to build one GraphQL with up to 250 aliased fragments (25 services × 10 metric columns) and trip OAP's per-request complexity ceiling, blanking every cell. Chunks at 6 services per round-trip and fires them in parallel — same pattern the dashboard route already uses.
-- **Trace waterfall opens fast on huge traces.** Rows render lazily via the browser's `content-visibility` window — a 5000-span trace no longer freezes the main thread on open.
-- **Backgrounded tabs stop polling.** The shared auto-refresh ticker pauses when the tab is hidden and resumes (with one immediate tick) on return. An unattended browser no longer streams queries at the topbar interval × every subscribed widget.
-
-### RBAC + input-validation hardening
-
-- **`/api/health` no longer leaks the active session count** to unauthenticated callers — the public liveness probe returns only status + version. The authenticated `/api/auth/health` surface still carries detail.
-- **`pageSize` capped server-side** on every trace / log route (trace 200, log 100). OAP forwards `paging.pageSize` straight to the storage `LIMIT`, so a client posting `pageSize: 50000` previously cascaded the load to OAP. The UI picker's matching cap is now defended at the BFF boundary too.
-- **Profiling task bodies validated.** Async-profiler, pprof, eBPF fixed-task, and network-profiling create routes now sanitize and bound their bodies — duration caps, target-instance and event-list caps, payload-size clamps. Closes a DoS vector where a user with `profile:enable` could submit a multi-hour profile that pegs the target instance's CPU.
-
-### Diff modal console error fixed
-
-The four admin "Check diff & push" modals (Layer dashboards, Overview templates, Translations, the shared TemplateDiffModal) used to log `Uncaught (in promise) Error: Missing requestHandler or method: resetSchema` (two per modal — one per diff pane) the moment the operator opened them. Monaco's JSON language service was sending the message to a worker that didn't know how to handle it. Now wired correctly — the diff itself rendered all along, but the console is quiet again.
-
-### DSL / OAL catalog renames + admin editor seeding
-
-- **Catalog pages spell out the language name** in the header: `Metrics Analysis Language - OpenTelemetry Rules` (and `… - Telegraf Rules` / `… - Log MAL Rules`); LAL renders as `Log Analysis Language`; the OAL browse as `Observability Analysis Language`. The sidebar keeps the abbreviated MAL · OTEL form for space.
-- **Layer Dashboards + Overview Templates editor opens REMOTE on diverged rows.** The runtime menu already renders remote content when the row is diverged, but the editor used to seed from bundled — so operators were editing a copy that silently disagreed with what end users saw. Priority is now local → remote (diverged / remote-only) → bundled; the source pill on both editors reads `from local / from remote / from bundled` consistently.
-- **Rule card arrow stays next to the rule name.** Short names like `default` / `mesh-dp` / `vm` used to float in the middle of the card with a big gap from the ▶ run arrow — the arrow + name are now grouped on the left, with the status pill alone pushed right.
-
-### Sync-status counters don't include translation rows
-
-Per-locale overlay rows on OAP share their parent template's kind, so they were inflating the `remote-only` and `diverged` counts on the Overview Templates and Layer Dashboards admin pages (the banners would read `14 remote-only` / `294 remote-only` when most of those entries were translation rows that belong on the Translations page only). Filtered at bundle assembly so the sync-status banners count source rows only.
-
-### Cluster Status + admin polish
-
-- **Cluster Status — Pane B + Pane C fully translatable.** Module column headers, gate descriptions (the SWIP-13 affects strings), per-row enabled/missing badges, the admin-host status badge (`loading…` / `unreachable` / `all selectors on` / `{n} selectors off`), the admin-host-unreachable hint, the Zipkin / OTLP pane lede, the Endpoint card heading, the Zipkin badge and the Zipkin-unreachable hint all now render in the active locale.
-- **Hide redundant BUNDLED chrome when synced.** On the Overview templates and Layer dashboards admin pages, when the row's sync badge is `synced` the BUNDLED and REMOTE versions are byte-equal, so the `from {source}` pill and the `Reset to / Preview Bundled` dropdown items add no information and are hidden. LOCAL drafts always show; BUNDLED resurfaces the moment the row diverges.
-- **Translations picker prefers the English bundle.** REMOTE-only rows with non-English titles (legacy duplicates from prior import cycles) no longer appear as separate dashboards in the picker — the picker lists the canonical English bundled dashboards once each, and the preview renders the English source as the baseline.
-
-### Alerting rules — running entities show their OAP node
-
-The **Operate › Alerting rules** detail pane's **Currently watching** list now spans the whole cluster and tags each entity with the OAP node evaluating it. Each OAP instance evaluates a rule independently over the slice of entities it holds, so the watched set is the union across nodes — the page previously showed only the first responding node's entities, which misread as "these are all the entities the rule watches." The list now aggregates every instance's entities and labels each ro [...]
-
-Clicking a watched entity now opens a **running-context popup** — the live evaluation window the rule is computing for that entity, per OAP node. It shows the current state (`FIRING` / `SILENCED_FIRING` / `RECOVERY_OBSERVATION`), the window size and silence / recovery countdowns, the window end, the last-alarm time and message, and the per-metric snapshot the expression was evaluated against — rendered as a sparkline plus per-bucket values so an operator can see exactly why a rule is (or [...]
-
-### Live debugger fixes
-
-A clutch of small but visible bugs were caught while exercising the i18n surfaces:
-
-- **History → debug deep-link rendered blank.** MAL and LAL views crashed silently on `?historyId=…` because `loadHistorical` reset refs (`selectedRow`, `expandedEntities`, `foldedRecords`; `selectedCell` for LAL) that the file declared further down. The `watch(historyId, …, { immediate: true })` fires during setup, so the TDZ ReferenceError aborted setup before the page rendered, with no console trace. Resettable refs are now hoisted above their consumer.
-- **Captured records wiped on stop.** After `stop()`, the per-DSL view did one final `session()` refresh against an already-cleaned- up OAP, which returned nodes-only-with-empty-records and overwrote the rich live snapshot in localStorage. `save()` now refuses to shrink an existing entry's `recordCount` for the same `sessionId` — only metadata (`retentionDeadline`, `retentionMillis`) updates.
-- **Stable node ordering.** MAL / LAL / OAL node cards now sort by `nodeId ?? peer` so they don't reshuffle between polls.
-- **Tab buttons jumped nowhere.** `LiveDebuggerView.selectTab` pushed `/debug/<tab>` (a path that doesn't exist); fixed to `/operate/live-debug/<tab>`. Same correction in `DebugHistoryView.loadEntry` deep-links and surrounding doc-comments in `RuleCard.vue` / `DslEditorView.vue`.
-- **Empty-capture placeholder.** When a saved session has zero populated nodes, `DebugView` now shows an explicit "This capture has no records" rather than rendering blank, so an honest empty capture is visibly different from a bug.
-- **Per-locale lookup widened.** Per-DSL views look up the historyId via `history.all` (not the widget-filtered `history.entries`); the route already pinned us to the right widget, and double-filtering by `widget` was a silent way for a stale field to drop the entry.
-
-### Other small fixes
-
-- **MAL / LAL editor gutter glyph.** The green ▶ live-debug entrance on every `- name:` row was referencing an unstyled CSS class — Monaco reserved the gutter and wired clicks, but the icon was invisible. Restored.
-- **Rule catalog cards.** Duplicate BUNDLED pill removed; the header status pill already conveyed it.
-- **K8s instance Node Status.** Switched from a single-scalar card (rendering just `1`) to a table of currently-true Kubernetes conditions, matching the cluster-scope sibling and aligning widget heights.
-
-### Upstream `ui-management` compatibility
-
-Aligned with upstream [skywalking#13884](https://github.com/apache/skywalking/pull/13884) ("remove auto generate id for UI templates"): Horizon now sends `id = <envelope name>` on `POST /ui-management/templates`. Current OAP requires it (POSTs without `id` are rejected); legacy OAP releases ignored the field and auto-generated UUIDs, so the same payload works against both. Horizon already treated `r.id` as opaque; mixed-id deployments self-heal via `reconcileDuplicates` on the next boot.
-
-### Smartscape service hierarchy
-
-OAP 10's cross-layer service hierarchy is now reachable from any layer's service map — a logical service projected across observation layers (GENERAL agent ↔ MESH sidecar ↔ MESH_DP data-plane ↔ K8S_SERVICE pod) is one click away on every selected hex.
-
-- **Lazy-probed chip on the selected hex.** Picking a node fires one `getServiceHierarchy` call; if the service has cross-layer peers, a small chevron-stack chip clips to the hex's right edge. No probe, no chip on services with no peers.
-- **Focus + context + suggestions overlay.** Click the chip and the topology dims under a transparent canvas; the focused hex re-renders bright at the exact same screen position and scale as the underlying hex (the topology's d3 zoom transform is mirrored onto the overlay). Peers fan vertically from the focus column using OAP's `listLayerLevels` order — higher-level (request-near) layers above, lower-level (infra-near) layers below, matching booster-ui's hierarchy rendering rule.
-- **Auto-refresh pauses while the overlay is open** so the background topology and KPI panels don't shift under the operator. Closing the overlay (`×` button, ESC, or click-on-dim) resumes the ticker and fires one immediate tick so the page snaps back to live data.
-- **Two-step peer open.** First click on a peer hex arms it (selection halo + side `↗ Open in <Layer>` action chip); second click on the chip opens the destination layer in a new browser tab, pre-selecting the peer service. Peers in layers Horizon has no template for render dimmed with a `cursor: not-allowed`; clicking them logs *"No layer template configured for <Layer>"* to the event log instead.
-- **URL-pinned service validator on the destination tab.** Every per-layer page now validates the URL-hydrated `?service=<id>` against the layer's real service roster (the new `GET /api/layer/:key/services`, served from the BFF's 60s catalog cache so it adds no extra OAP traffic). A genuinely missing id pops a `Service not found in this layer` modal with a one-click fallback to the first available service; a valid id is trusted even when landing's top-N rollup doesn't sample it (the caus [...]
-- **Service-name resolution** on the layer dashboard now consults the roster after landing's top-N, so deep links to low-traffic services no longer sit on *"Resolving service…"* forever waiting for a row that won't arrive.
-
-### On-demand pod logs (live tail)
-
-A new per-layer **Pod Logs** tab live-tails a Kubernetes pod's container logs, pulled on demand from the K8s API through OAP and never persisted.
-
-- **Instance-pinned tail.** Pick a pod, pick one of its containers, press Start; the trailing window (30s / 1m / 5m / 15m / 30m) streams into a read-only log pane and refreshes on a chosen interval (2s / 5s / 10s / 30s) until paused. A header strip shows the container, line count, a live dot, and "updated Ns ago".
-- **Include / Exclude filtering** forwards to OAP's content keyword filters — full-line regex, so a substring match reads `.*error.*`.
-- **Enabled on the Kubernetes-deployed layers** — Kubernetes Services (`K8S_SERVICE`), Istio Managed Services (`MESH`), and Istio Data Plane (`MESH_DP`) — whose service instances resolve to a pod. The tab is gated by a new `podLogs` component flag added to those bundled layer templates; an existing OAP whose stored template predates the flag still gets the tab, because the flag is back-filled from the bundled default (no re-push needed).
-- The page **owns its own refresh** — the global auto-refresh ticker and the topbar time picker are paused while on it, the same as Traces / Logs.
-- When the selected instance carries no pod metadata (or the pod has rotated away), OAP's reason is shown verbatim with a hint to pick a currently-running pod or enable the feature on OAP.
-
-### BanyanDB cold-stage query
-
-The cold lifecycle stage is now reachable from the UI on BanyanDB deployments — operators can query data that has aged past the hot + warm window without leaving the page.
-
-- **Topbar `Cold` pill** appears only when the connected OAP is BanyanDB. Toggling it switches every page to read from the cold stage instead of hot + warm — it replaces the read, it does NOT union the two stages, so the pill label flips to `Cold only` while on. The choice is sticky per browser and re-runs every visible query so what you see matches the new mode immediately.
-- **Cold-trap banner.** When the pill is on AND the current time range is within the hot + warm window, a yellow strip appears under the topbar: *"Cold-only read is active — your time range is within the last N d (hot + warm), where the cold stage returns nothing."* A one-click *Turn Cold off* sits on the right.
-- **Trace lookup from a log row** now passes the row's timestamp through the popout so the trace lookup spans a window around that timestamp instead of OAP's default last-1-day search — paired with the Cold pill, a trace that lives in cold resolves from a cold-era log row instead of silently failing to load.
-
-### Data retention page
-
-- **Per-data-class data lifecycle bar.** One row per data class (Normal / Trace / Zipkin trace / Log / Browser error log / Metadata / Minute metric / Hour metric / Day metric) with proportional Hot+Warm and (when configured) Cold segments. Widths are proportional to total retention across all rows, so a class retained longer visibly stretches further than its peers.
-- When every class in a category shares the same TTL pair, the rows collapse to `All records (5)` / `All metrics (4)` — the page never renders nine identical bars.
-- The page **branches sharply by backend.** BanyanDB shows the full lifecycle bar + stage vocabulary; on any other backend, the page renders a single `Retention` pane with per-class values and skips the stage vocabulary entirely.
-- A footer note names the wire-level truth: OAP's TTL response collapses hot + warm into one number per class, BanyanDB migrates between stages in segments so records near a boundary may briefly exist in both, and property data is omitted (forever-retained, no TTL reported).
-
-### Time picker
-
-- **Custom range seeds from the last applied range** when you re-open the picker, instead of resetting to "half the max ending now". Reopening also auto-expands the Custom form on the matching precision tab when the current range is custom.
-- Locale-bleed fix on the alarms page custom-range stamp and the log row date column (was rendering `5月08日` on zh-CN browsers; now uniform `MM-DD`).
-
-### Overview widgets follow the global time picker
-
-- The **Services Dashboard** (and any overview using metric / KPI / table widgets) now honors the topbar time picker. Previously the per-layer landing and topology routes were hardcoded to the last 60 minutes, so picking 12 days back kept showing recent numbers; now picker + Cold pill flow end-to-end. The layer dashboard's header KPIs follow the picker too (was showing live numbers while the body honored the picker).
-- The **Active alarms** widget title now shows the actual window (e.g. `· last 10m`) and the empty-state copy uses the same value instead of a hardcoded "last 60m".
-
-### Polish
-
-- Sentence-case fixes on a couple of leftover Title-Case labels (`DSL management`, `Metrics inspect`) so the menu and roles tables read consistently; acronyms (DSL / OAP / MAL / LAL / OAL) stay uppercase.
-- Public-demo (`demo.skywalking.apache.org`) references removed from setup docs — the demo doesn't accept anonymous traffic.
-
-### Dashboard authoring
-
-The Layer dashboards and Overview templates admin pages now share one editing model where your work-in-progress lives in your browser, and the live, shared version is whatever OAP serves.
-
-- **Edits are local to your browser.** "Save (local)" stores your draft in this browser only — it is never written to the server and nobody else sees it. Everyone (including you, in normal viewing) keeps seeing the published remote dashboard until you publish.
-- **Load any source into the editor.** A single **Reset to ▾** control loads the **Bundled** (shipped default) or **Remote** (OAP live) version into the editor; editing from there becomes your local draft.
-- **Preview any source on the real page.** A **Preview ▾** control opens the actual layer / overview page in a new tab rendering your **Local** draft, the **Bundled** default, or **Remote** — via `?mode=preview&source=…`, which stays in the URL and propagates as you navigate the menu. A banner names what you're previewing (dismiss with × or Esc).
-- **Publish with a diff.** **Check diff & push** shows a side-by-side local→remote diff and publishes to OAP; it's enabled only when your local draft actually differs from remote. Bundled can also be pushed straight to OAP. Resetting to remote clears the local draft. **Reset to bundled** then publishes correctly when the bundled default differs from remote — Save (local) and Check diff & push now compare the editor against remote, not just against what was first loaded, so a bundled-vs-r [...]
-- Preview faithfully reflects your draft's **enabled components / menu labels** — disabled tabs disappear and renamed nouns ("Nodes", "Topics") show through — without pushing anything to the server. Preview works even for layers OAP currently reports no services for.
-- An editors-only reminder lists any unpublished local drafts with quick links to the relevant edit page (no more "use local vs remote" prompt — remote is always the live source).
-- **Create mirrors edit.** "+ New dashboard" writes a local draft (the id is the template name, checked unique) — edit and preview it, then **Check diff & push** publishes it. A pushed dashboard with no bundled default is **remote-only** and now renders everywhere (live page + sidebar), not just in the editor.
-- **Delete = soft-disable** (OAP has no hard delete). A local-only draft is removed from the browser; a dashboard on OAP is disabled — dropped from the picker's live state, the sidebar, and the live page. A **disabled** status chip shows it. Confirmations are styled in-app dialogs, not the browser's native box.
-
-### Layer dashboards editor
-
-- The layer picker is a single filterable dropdown showing alias + key + sync status. A live **menu preview** sits beside the Alias / Components / **Menu labels** (per-layer slot aliases) editor; clicking a menu item jumps to that component's config. Scope tabs and section headings read in the layer's own vocabulary.
-- The service-list metrics editor gains a **sample-data preview** (plus a faithful **landing KPI tile** preview that reuses the real header components), the column remove-button alignment is fixed, and the landing KPI tile config makes clear it just picks which existing column feeds the headline + sparkline.
-- The picker's Diverged / Local filters now sit **inside the dropdown** (next to the search), and the editor header reads on one line — `Layer: <name> <key> <status>` — showing the same sync-status chip the picker does.
-- **Disable / Reactivate a layer.** Disabling soft-disables the layer on OAP and drops it from the sidebar (the menu honors disabled templates); a disabled layer offers **Reactivate**, which re-enables it from the bundled default (the OAP update path clears the disabled flag).
-
-### Overview templates editor
-
-- Rebuilt as a **layer-style canvas**: a 12-column grid you drag to reorder and corner-drag to resize, click-to-edit in a right drawer that appears on selection (Esc / deselect hides it), with section-breaks and the dashboard title selectable and unselected widgets hinted by a dashed outline. The canvas mirrors the live grid (fixed row height), so the layout matches the real page — including side-by-side widgets like topology + alarms.
-- The **composite-metrics** KPI editor stacks each row as a card (label / source / MQE / unit / aggr / style / max) so nothing truncates in the narrow drawer.
-
-### Navigation & shell
-
-- The **main sidebar folds** to a narrow rail to reclaim width; the logo moves into the topbar while folded (the original wordmark is unchanged).
-- The active menu item now reliably expands, highlights, and scrolls into view on entering a page (route matching is case-insensitive). Fixed a regression where the sidebar scrolled to the very bottom on every navigation (the "Debug events" toggle's active state was being treated as the scroll target).
-- **Overview dashboards appear in the sidebar only when their layers are reporting services.** Visibility is derived from each dashboard's widgets (their `layer` field) ∪ the explicit `layers[]` list, gated against the live `availableLayers`. A dashboard you create via "+ New" inherits this automatically — no need to maintain the `layers[]` field by hand. Polls on the 60s menu cadence + window focus, so entries appear / disappear as services start and stop reporting.
-- **Smarter landing.** Root `/` cascades through a sensible chain so the user never sees a blank page: first available public overview → first layer with services → the **empty landing** (`/landing-empty`). The cascade only lands on destinations that are also in the sidebar — a bundled-but-inactive layer (no services yet) is deliberately not a fallback, since it would put the user on a page they can't navigate back to via the menu. The empty page is also a real bookmarkable route, with t [...]
-- **Debug events panel now defaults OFF on every host** (was on for localhost). Same baseline for operators and developers so reproductions match what operators see.
-- **Zipkin trace mode** drops the per-layer service-KPI header — the Zipkin explorer is a self-contained, cross-service view.
-
-### 3D Infrastructure Map
-
-A standalone, bird's-eye view of the deployment at `/3d/map`: services render as cubes on stacked tier-planes (apps · service mesh · middleware · infra), each tier subdivided into per-layer zones with the layer's brand mark stamped on its colored swatch. Drag to rotate, scroll to zoom, arrow keys / WASD to pan; click a cube for its detail card and a link into that layer's dashboard.
-
-- **Live data windows.** The map auto-refreshes every minute — per-cube traffic rolls up the last 2h of metrics (HOUR step) and alarmed services light up from the last 20m of alarms. A toolbar chip shows the active scopes (`metrics 2h · alarms 20m · ↻ 1m`). An alarmed cube burns red with a radiating ripple, matched to its service by (layer, name) so only the firing service in the right tier is flagged.
-- **Live topology.** The deployment structure is read live from OAP rather than a bundled snapshot: each layer's service roster and service map are fetched one at a time (low concurrency) and assembled into the scene, so the map is correct on any deployment. It refreshes on the same one-minute cycle — an unchanged structure updates metrics/alarms in place without disturbing the camera, while a service appearing or disappearing rebuilds the affected tier. The load progresses stage by stag [...]
-- **Beacon mode.** A toolbar toggle dims every healthy cube to a wireframe ghost and lets only alarming cubes glow, so the services that are firing jump out instantly during an incident.
-- **Logic groups.** Related layers can be clustered into a single labelled block on a tier — the bundled config ships a **Self-Observability** group (OAP, Satellite, BanyanDB, and the Java / Go agents) on the middleware tier. Members keep their own cube colors but read as one block on the map.
-- **Configurable tiers + layers.** Tier order, per-layer plane mapping, cube colors, the traffic MQE per layer, and the logic groups are all driven by the 3D map config, edited on a structured admin page at `/admin/3d-map`. Pin each layer to a tier (with a single global layer filter as the top-level gate), edit each layer's color + traffic metric, manage logic groups (members, color, icon, tier), and choose the single failover tier for anything unpinned. The config is published to OAP an [...]
-- **Topology clustering.** Within layers that carry a service map, services group into named clusters drawn as a wireframe frame with the cluster name baked into the frame's lower-left corner — service-mesh services cluster by their showcase group, Kubernetes services by namespace. Clustering follows each layer's naming rule, so layers without one keep rendering flat.
-- **Navigate by tier.** The right-side **Tiers** panel is a two-level tree (tier → layer / logic-group). Clicking any entry resets the view, glides the camera to face that region, zooms in, and flashes the region for a few seconds so it is easy to spot. A **Reset** button in the panel header restores the initial framing.
-- **Hover = preview.** Hovering a cube shows the same detail card as a click — tier, layer, service, and (when present) group and cluster — anchored at the cube, minus the open-in-dashboard link.
-- **Call-direction flow.** Call relationships animate directional particles so the direction of traffic reads at a glance.
-- **Focus one layer.** The per-layer service map gains a **View in 3D** link that opens the map focused on just that layer.
-- **Refresh countdown.** The load status strip shows a live countdown to the next refresh, anchored to the stage that will run next.
-
-### Reliability
-
-- When the BFF is unreachable the UI now shows a clear "Cannot reach the server" message instead of the cryptic "body stream already read" — the API client reads each error response body once and surfaces the real status/text (or a wrapped network error).
-- **Server-global service-by-layer catalog.** One singleton on the BFF (60s TTL + single-flight) now owns the `listLayers` + aliased `listServices(layer)` fan-out. The sidebar menu's per-layer counts and the alarms layer-tagger share this one cache instead of each running their own poll, so OAP sees at most one fan-out per minute regardless of how many routes are polling — and the two views can no longer drift by 60s relative to each other.
-- The brand link and the post-login redirect no longer resolve to an empty address — both now land on the operator's actual landing route.
-- Trace span detail now labels the span direction as **Kind** (the noun) rather than the mistranslated verb form, and long tag keys wrap inside the panel instead of overflowing their column.
-
-## 0.5.0
-
-First Apache-style release cut from this repo: source + binary tarballs, GPG-signed and SHA-512 checksummed, with a self-contained binary that boots via `node server.js` and no `pnpm install` step. Binary distribution ships a regenerated `LICENSE` + `NOTICE` that enumerate every bundled third-party package — produced by `scripts/collect-dist-licenses.mjs` during packaging and validated against a deny-list before signing.
-
-### Profiling
-
-- **pprof (Go) profiling** is fully wired: pick one event per task (CPU / HEAP / BLOCK / MUTEX / GOROUTINE / ALLOCS / THREADCREATE), with duration shown for CPU/BLOCK/MUTEX and a sampling-rate field for BLOCK/MUTEX. Create and analyze both match OAP's single-event pprof schema.
-- **eBPF profiling** gets a reworked process picker — click a row to expand its full attributes, selection lives on the checkbox, anchored pop-out — a refresh button on every task list, Intl-formatted times, and a hover-info frame on the flame graph. Flame-graph thrash on re-analyze is gone.
-- The shared flame graph fixes "% of root" (it read a never-aggregated count), highlights the selected frame across all four profilers, and shows a single hover card (the library's duplicate native tooltip is suppressed).
-- After creating any profiling task (trace / async / eBPF / network / pprof) the list now polls up to 4× at 10s until the new task shows up, instead of leaving a stale pre-create list.
-
-### Network profiling & process topology
-
-- A booster-style honeycomb process topology: pods as hexagons, peers hugging the boundary, animated protocol-coloured edges (HTTP/TCP/TLS), a node pop-over, and a wide client | server edge-metric dashboard. Network task creation and the task-list query now use OAP's schema field names.
-
-### Platform monitoring (operate)
-
-- Two new read-only operate pages: **Data retention** (TTL — `getRecordsTTL` / `getMetricsTTL`) and **OAP configuration** (the admin-port config dump, with OAP-masked secrets). Gated on new `ttl:read` / `config:read` verbs granted to maintainer and above. Data retention now loads on non-BanyanDB backends too (the `metadata` TTL field is optional).
-- The operate sidebar now leads with a single **Platform monitoring** group (cluster status, data retention, OAP configuration) above the per-layer self-observability dashboards.
-
-### Dashboards & templates
-
-- **The global time picker now drives dashboards.** Layer dashboards query OAP at the picker's window and precision (MINUTE / HOUR / DAY) instead of a fixed last-hour minute window, and line charts label the x-axis with real times per step (e.g. `MM-DD` for a 30-day view) rather than `-Nm`.
-- **New `table` widget** for label-dimensioned metrics — pod phase per service, node condition, deployment replicas, etc. — rendered as one column per label (e.g. `Condition | Node`) instead of a scalar card or a misleading flat line. The K8S dashboards (and kong / mongodb / elasticsearch) now use it where upstream booster-ui does; widgets that were charting a single `latest(…)` value as a line are now cards. The K8S Cluster view is realigned to the upstream layout (totals cards · resour [...]
-- **Edit locally, publish on your terms.** Saving a dashboard/overview template now writes the local bundled copy (so the edit renders immediately for preview) and marks it diverged — nothing reaches OAP until you press **Sync all to OAP**, which pushes only the templates that differ, behind a confirmation listing exactly what will be written. A post-save tip spells out that the change is local-only until published.
-- **Local-vs-remote, made explicit.** When local edits diverge from OAP, a per-session prompt (by menu name, not file name) asks which to render — **keep my local edits** (preview) or **use live** (which overwrites the local copy with the remote version, confirmed). The layer-templates admin page carries the same Local/Remote display toggle next to Sync all, and a **Diverged only** filter; each diverged layer shows a yellow warning icon in the sidebar.
-
-### Traces
-
-- The native trace view auto-selects OAP's trace-query API — `queryTraces` (whole trace inline, BanyanDB) vs `queryBasicTraces` (segment list + a per-trace fetch on click, every other backend) — and a banner states which is in use; in segment-list mode the list reads "Segments" and a click loads the full trace.
-- Span kind (Entry / Exit / Local) renders as a colored word, not a filled pill.
-
-### Auth, RBAC & resilience
-
-- Every OAP call — GraphQL, admin REST, and Zipkin — now carries the configured basic-auth credentials, so a secured OAP no longer 401s pages.
-- The sidebar is RBAC-gated by read verb, the Roles page shows a per-role menu-visibility matrix, and the Users page labels per-node "Active (24h)" / "Last seen" honestly (these are tracked per BFF replica, not cluster-wide).
-- **Routes are verb-gated, not just menus.** A user without the required read verb is bounced from a restricted page (e.g. a viewer can no longer reach Cluster Status via the topbar OAP chip or a direct URL); the chip only links there for `cluster:read`. This sits on top of the existing per-route BFF verb enforcement.
-- **LDAP** resolves group membership with the service account, not the logging-in user — directories that hide the group subtree from ordinary users no longer collapse every login to the fallback role.
-- When OAP is unreachable the menu and admin loaders fall back to bundled templates, and non-JSON OAP responses surface a clear diagnostic.
-
-### Smaller touches
-
-- Top-N widgets get hover tooltips for long names and a title-bar pop-out to the full ranked list; redundant single-service name prefixes are dropped.
-- The admin template-diff modal is a wide side-by-side view with labelled bundled-vs-OAP columns and an explanation of what the template drives; the layer-dashboards admin rail gains an in-page search.
-- Per-layer alarm filtering uses the singular `queryAlarms` layer condition.
-- Dependency hygiene for the release: `dompurify` ≥ 3.3.2 and `@fastify/static` ≥ 9.1.1 (clears the known advisories); the `general` layer drops `networkProfiling`, which is instance-scoped to k8s / mesh.
-
-## 0.4.0
-
-OAP becomes the runtime source of truth for UI templates, the 5-theme system lands, and the app supports being served behind a gateway prefix.
-
-### Templates synced to OAP
-
-- Five reserved template families now live on OAP's UI-template REST surface (`/ui-management/templates*` on the admin port): overview dashboards, per-layer dashboards, alert page setup, theme selection, time-defaults. Bundled JSON ships as the seed + read-only fallback.
-- One-shot seed on BFF boot pushes any missing bundled template to OAP; runtime sync is read-only with a 30-second single-flight cache.
-- New admin endpoints: `GET /api/admin/templates/sync-status`, `POST /api/admin/templates/save`, `POST /api/admin/templates/resync`, `POST /api/admin/templates/:name/push-bundled`.
-- When the admin port is unreachable, every admin page goes read-only with a red banner; Save / Create / Delete are disabled; render falls back to bundled.
-- Diverged rows surface a "Show diff & reset" Monaco modal with a destructive-confirm (type the template key to arm reset).
-
-### Themes
-
-- Five bundled themes — **Horizon** (default), **Meridian**, **Obsidian**, **Daybreak**, **Aurora** — each shipping a complete token set (bg, fg, accent, info/ok/warn/err, font, radius, density).
-- New `/admin/global-defaults` admin page replaces the old "Setup" link. Theme picker uses preview cards lifted from the design (hero strip, mini-app mockup with Primary/Tonal/Ghost buttons, KPI tiles, sparkline, density/font/radius badges).
-- Per-user theme override via a labelled topbar chip — three-tier resolution `localStorage user → OAP org default → bundled`, written to `<html data-theme>` / `<html data-appearance>` synchronously on boot so the pre-auth login page already respects the local override.
-- Sidebar SkyWalking logo swaps to the official brand blue (`#1368B3`) on light-appearance themes.
-- Widget series colors (Zipkin trace palette, AlarmSnapshotChart, AlarmsTimeline) track the active theme's `--sw-accent` via a shared `readAccent()` util.
-- Sign-in button gradient derives both stops from the theme accent.
-
-### Time defaults
-
-- `/admin/global-defaults` also owns the global picker's default window (60 minutes shipped). OAP `step` precision is derived from window size — ≤ 4 h MINUTE, 6 h–14 d HOUR, ≥ 30 d DAY — and surfaced inline on the page.
-- Per-user override in the topbar time picker: "Save as my default" / "Reset to org default".
-
-### Reliability + diagnostics
-
-- Topology cluster boundary now grows to encompass dragged nodes; the chip moved inside the cluster header so it stays visible at any drag position.
-- Alarms page gains an **Other** KPI tile that surfaces the residual count between `Active` and the sum of pinned-layer chips — `Active = General + Mesh + Other` reconciles even when alarms land in unmapped layers.
-- Overview "Active alarms" widget now reads the admin's configured `defaultWindowMs` from `/admin/alert-page-setup`; all three alarm surfaces (overview widget, alarms page, topbar badge) share one window.
-- Every backend call failure (network throw or non-2xx) writes a `pushEvent('api', 'err', …)` into the debug event log with the BFF's `code` / `message` envelope inlined when present.
-- Dashboards with more than 40 widgets (e.g. the General/instance page, 56 widgets) now succeed: the UI splits oversize requests into ≤40-widget chunks fired in parallel, then merges results.
-
-### Deployment
-
-- Gateway-prefix support: `BffClient.request()` prepends `import.meta.env.BASE_URL` to every API path. Build with `vite build --base=/horizon/` and a gateway that strips the prefix and the SPA + every API call resolves cleanly under the sub-path.
-- Cluster Status route corrected from `/admin/cluster` → `/operate/cluster` (the prior default 404'd because no route by that name existed).
-
-### Cleanup
-
-- Documentation rewritten as an orientation map; the left-side menu is the canonical navigation now. All `SWIP-*` references removed from user-visible text and docs.
-- "Coming in Phase 6 / 7" placeholder strip on Cluster Status removed.
-- Dead code dropped — `LandingView.vue`, `LayerTabPlaceholder.vue`, the orphaned disk-write template routes (`POST /api/admin/overview-templates/:id` + `POST /api/admin/layer-templates/:key`), and stale `Phase X` markers across BFF + UI + docs.
-- The OAP UTC-offset chip is gone from the topbar; the health dot stays.
-
-## 0.3.0
-
-The shell unifies, the operate stack lands, and the first round of public documentation ships.
-
-### Operate stack
-
-- **Alarms page** — incident-merged active-alarms view, severity tabs, alarm list with right-side detail (trigger expression, channel routing), inline Live Debug card (Run / Step / Pause / Copy as MQE, execution-trace ladder with per-step output + latency, matched entities, eval-window chart, raw OAP response).
-- **Inspect** — metric catalog + entity enumerator with search, type filter, scope (Service / Instance / Endpoint / Process / All), and source attribution.
-- **Live Debugger** — MAL / LAL / OAL session start, poll, stop. Per-node status fan-out, sample payloads, capture history with replay-ready recordings.
-- **Profiling** — flame graph + stack table over five profilers: trace-driven thread profiling, eBPF CPU/off-CPU, JVM async-profiler, network profiling (process conversation graph), Go pprof.
-- **Zipkin trace explorer** — service / span search, waterfall popout with per-service color bands, sticky time-axis.
-- **Overview dashboards** — cross-layer war-room views (Services, Mesh) with per-layer KPI tiles, alarm rails, and the existing chart widgets.
-
-### Auth + access control
-
-- Local + LDAP authentication backends. Break-glass admin honored only when `backend: ldap` AND the LDAP probe is failing.
-- Three admin pages — Users, Auth status, Roles & permissions.
-- 4 built-in roles (viewer / maintainer / operator / admin) and a 28-verb permission model. Every BFF route gated by a single policy table.
-- Login view redesigned (canyon hero, status pill, configured-backend banner).
-
-### Reliability + UX
-
-- Cascade-clear, then load — every dependent area visibly resets and shows "Reading data…" between an upstream control change (service / instance / endpoint pick, time-range change, layer / scope nav) and the new data landing. No silent freezes; no stale value sitting under a spinner.
-- Global time picker in the topbar wired into the landing + widget query keys; the picker only applies to dashboards / overviews (triage pages keep their own per-page time).
-- Single-shot bundle preload: layer dashboards + overview list arrive in one round-trip, cached in localStorage with ETag revalidation.
-- Framework event ticker in the topbar replaces breadcrumb+search; Admin-toggled debug panel surfaces a 200-event buffer with operator click capture.
-- Auto-pick first instance / endpoint when a scope needs one and the list is non-empty.
-- Topology + dashboard fixes, multi-layer service attribution, sticky service selection across navigations.
-
-### Documentation
-
-- First public docs tree (`docs/`) — Setup, Compatibility, Access Control, Customization, Components, Operate. Lives in-repo and publishes to skywalking.apache.org.
-
-### Container + CI
-
-- Real `packages/*` builds + self-contained `dist/` + copy-in image (no compile in the container).
-- Zero-config boot: image defaults `HORIZON_SERVER_HOST=0.0.0.0`.
-- Multi-arch publish-image — native amd64 + arm64 builds, OCI manifest list.
-- Unit-test job in CI; 107 UTs covering entity-scope construction + routing decisions.
-
-## 0.2.0
-
-Per-layer dashboards become real, the layer-template editor ships, and topology gets its booster-ui port.
-
-### Per-layer dashboards
-
-- Real widget grid per layer driven by JSON templates. 43 layer dashboards migrated from booster-ui.
-- Per-scope widget sets: each layer template defines its own `service`, `instance`, `endpoint`, `topology`, `traces`, `logs`, profiling variants.
-- Visibility predicates per widget (`visibleWhen`) so MQ / DB widgets only render when the relevant metrics are reporting.
-
-### Layer admin
-
-- Read-only template browser, then full edit UI: components editor (toggle which per-layer views exist), metrics editor (header columns), separate Overview tile card, scope-aware visibleWhen hints.
-
-### Service deep-dive
-
-- APIs widget (formerly Services), MQ widgets gated by visibleWhen, TopList multi-expression switcher with MQE preview in tooltip, smaller widget height, per-metric color alignment, dual-axis MQ.
-
-### Topology
-
-- Polished linear-chain variant, dual-panel detail, per-side line charts. Drag-to-move + barycentric layout for smaller graphs. RPM-only chip variant. Istio renamed.
-
-### Logs
-
-- Legend at top of table (drop service facet duplication), workflow notes.
-
-### Charting
-
-- TimeChart: legend formatting fix for dual-axis widgets, value dots, tooltip escape for clipped charts, no more legend / axis-name crowding at chart top.
-
-### Sidebar + chrome
-
-- Group toggle + group click cascades to first layer's first tab.
-- Topbar 60m widget format hints (int / decimal / compact).
-- Per-layer image pipeline (icons) shipped.
-
-## 0.1.0
-
-Foundational scaffolding. The shell renders, auth works, OAP is reachable, and CI is green. No operator-facing data surfaces yet.
-
-- pnpm monorepo: `apps/ui` (Vue 3 + Vite), `apps/bff` (Fastify), shared `packages/api-client` (typed REST + GraphQL clients), shared `packages/design-tokens` (CSS custom properties).
-- BFF — Fastify skeleton with `horizon.yaml` config + hot reload, local auth (argon2 + cookie sessions), RBAC verb gating + JSONL audit log, OAP proxy with cluster fan-out + preflight.
-- UI — AppShell (sidebar, topbar) with design tokens, Pinia auth store with on-401 redirect, login view with route guard + sign-out, stub admin / operate pages.
-- CI — monorepo workspace build + dependency license check via `skywalking-eyes`.
diff --git a/CLAUDE.md b/CLAUDE.md
index 6ec88f8..517b7bd 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -115,13 +115,17 @@ English is the source of truth. Every UI string and every translatable template
**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.
-## Changelog (`CHANGELOG.md`)
+## Changelog (`docs/changelog/`)
-Keep `CHANGELOG.md` current as part of the change, not as an afterthought. Written from the operator's point of view — what's new on screen and what's now possible — never file-by-file implementation (that's the git log).
+Keep the changelog current as part of the change, not as an afterthought. Written from the operator's point of view — what's new on screen and what's now possible — never file-by-file implementation (that's the git log).
-- **New features go in the changelog.** Any operator-visible capability — a new page / tab / widget, a new component flag, **bundled template changes** (a layer gaining a capability, new dashboards / widgets / metrics), a new admin surface — must be recorded under the current **unreleased** version section. If a change alters what an operator sees or can do, it belongs here.
-- **Released version sections are frozen.** A version that's been tagged/released only ever receives **bug-fix** entries afterward (and only for fixes shipped to that line). Never add a new feature to an already-released version's section — features always land under the current/next unreleased version. The current version is `*-dev` in `package.json`; the newest released line is the latest `v*` git tag.
-- **One line per paragraph and per list item — do not hard-wrap prose.** A GitHub **Release** body (rendered by the same engine as issue / PR / discussion comments) turns every single newline into a literal `<br>` (GFM hard line breaks), so a paragraph hard-wrapped at ~80 cols renders as a ragged column of short lines with a sea of right-hand whitespace. The repo file view collapses those same newlines to spaces, so the damage is invisible there — you only see it on the release page. Kee [...]
+**The layout: one file per version, kept forever.** `docs/changelog/<version>.md` — every shipped version has one, and so does the version in flight. There is **no root `CHANGELOG.md`**. Each file is a website page: an H1 title carrying the version, then the notes, listed in `docs/menu.yml` under **Release Notes**.
+
+- **Add your entry to the file whose version matches `package.json`.** The in-development version is the `*-dev` one there, so `1.0.0-dev` means `docs/changelog/1.0.0.md`. Any operator-visible capability — a new page / tab / widget, a new component flag, **bundled template changes** (a layer gaining a capability, new dashboards / widgets / metrics), a new admin surface — must be recorded in it. If a change alters what an operator sees or can do, it belongs in the changelog.
+- **Released files are frozen, and the file boundary is what enforces it.** A shipped version's prose sits in its own file that no release ever rewrites, so there is no released section to accidentally append a feature to. Never edit a released version's file: it is the historical record of what shipped. A bug fix that ships in the next release from main goes in the in-development file like anything else; a bug fix released as a **patch** off an already-released tag is written on that pa [...]
+- **A release moves nothing.** `scripts/release.sh` READS the release version's file (vote email, both tarballs, the GitHub release body) and SEEDS the next cycle — an empty `docs/changelog/<next>.md` plus its `docs/menu.yml` entry, written by `scripts/changelog-version.mjs seed`. Run that command by hand when no release seeds the file for you (opening a patch line: `node scripts/changelog-version.mjs seed 1.0.1`). The seeded file carries a stub line the pre-tag gate rejects, so a releas [...]
+- **The `CHANGELOG.md` at the root of a source tarball is made during packaging.** ASF convention puts one there for release reviewers, so `scripts/release.sh` copies `docs/changelog/<version>.md` to the tarball root as it assembles it. It is a packaging artifact — do not re-create it in git.
+- **One line per paragraph and per list item — do not hard-wrap prose.** A GitHub **Release** body (rendered by the same engine as issue / PR / discussion comments) turns every single newline into a literal `<br>` (GFM hard line breaks), so a paragraph hard-wrapped at ~80 cols renders as a ragged column of short lines with a sea of right-hand whitespace. The repo file view collapses those same newlines to spaces, so the damage is invisible there — you only see it on the release page. Kee [...]
## Common AI failure modes to avoid
diff --git a/README.md b/README.md
index ac0947e..d8a809a 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
[](https://www.apache.org/licenses/LICENSE-2.0)
-Horizon UI is the next-generation web UI for [Apache SkyWalking](https://github.com/apache/skywalking) — a config-driven, dark-dense, multi-layer observability front end built for feature parity with the legacy [skywalking-booster-ui](https://github.com/apache/skywalking-booster-ui) on the same OAP GraphQL query-protocol and MQE. It renders services, instances, endpoints, topology, traces, logs, alarms, and profiling across 44 instrumentation layers, ships an in-browser admin suite for r [...]
+Horizon UI is the next-generation web UI for [Apache SkyWalking](https://github.com/apache/skywalking) — a config-driven, dark-dense, multi-layer observability front end built for feature parity with the legacy [skywalking-booster-ui](https://github.com/apache/skywalking-booster-ui) on the same OAP GraphQL query-protocol and MQE. It renders services, instances, endpoints, topology, traces, logs, alarms, and profiling across every layer SkyWalking observes — language agents, service mesh, [...]
## Features
@@ -19,7 +19,7 @@ Horizon UI is the next-generation web UI for [Apache SkyWalking](https://github.
### Layers & dashboards
-- 44 bundled instrumentation layers across four tiers — Apps (GENERAL, BROWSER, mobile, VIRTUAL_DATABASE/CACHE/MQ/GENAI), Service Mesh (MESH, MESH_DP, MESH_CP, CILIUM_SERVICE), Middleware (databases, queues, gateways, Flink, Airflow), and Infrastructure (K8S, OS_LINUX/WINDOWS, AWS) — plus a self-observability group (SO11Y_OAP, SO11Y_SATELLITE, BANYANDB).
+- Bundled instrumentation layers across four tiers — Apps (GENERAL, BROWSER, mobile, VIRTUAL_DATABASE/CACHE/MQ/GENAI), Service Mesh (MESH, MESH_DP, MESH_CP, CILIUM_SERVICE), Middleware (databases, queues, gateways, Flink, Airflow), and Infrastructure (K8S, OS_LINUX/WINDOWS, AWS) — plus a self-observability group (SO11Y_OAP, SO11Y_SATELLITE, BANYANDB).
- Config-driven layer dashboards — multi-scope (Service / Instance / Endpoint) JSON templates edited in the UI admin and, in OAP 11 live mode, published to OAP with no code changes; per-layer sync badges show synced / diverged / local state.
- Rich widget vocabulary — scalar KPI cards, time-series line charts with dual-axis, top-N rankings, sampled record lists, label-dimensioned tables, KPI composites, embedded topology, and alarm tiles.
- Server-side widget visibility gates (`visibleWhen`) — render a widget only when an MQE metric has a value or an entity attribute matches (e.g. `language = JAVA`, `container_name = lifecycle`).
@@ -144,7 +144,7 @@ Horizon UI is configured by a single `horizon.yaml` (hot-reloaded, with `${VAR}`
- `rbac` — four built-in roles (viewer / maintainer / operator / admin) over fine-grained, verb-namespaced permissions (e.g. `dashboard:write`, `rule:write:structural`, `source-map:write`).
- `templates` — `live` (default: bundled templates seed through OAP 11's REST API and stay editable) or `readonly` (render from the local bundle; required for OAP 10 because Horizon does not consume its legacy GraphQL template API).
- `ai` — the AI assistant: `enabled` (off by default), provider (`openai-compatible` or `bedrock`), model, base URL, and an env-only API key.
-- `performance` — how hard the BFF fans metric queries out to OAP (per-route bulk sizes and concurrency) plus protective caps (topology render valve, per-request record limits).
+- `performance` — how hard the BFF fans metric queries out to OAP (per-route bulk sizes and concurrency) plus protective caps (topology render valve, the largest page a list may display).
- `query` — load caps: `landingServiceCap` (how many top services a layer landing fetches metrics for) and `overviewTopN` (the Overview KPI rollup window).
- `session`, `audit` (audit-log toggle + file), `debugLog` (the outbound OAP wire log), `sourceMaps` (browser-error source-map cache), and `layers.excluded` — the audit and wire-log files default under `/data/*` in the container.
@@ -156,7 +156,7 @@ Operator-focused documentation (setup, OAP compatibility, access control, custom
## Contributing
-Contributions are welcome. Horizon UI is a greenfield rewrite that tracks the OAP GraphQL query-protocol and MQE — backend contracts are fixed and owned by [apache/skywalking](https://github.com/apache/skywalking). Read `CLAUDE.md` for the project's working principles (correctness first, validate against a live OAP, TypeScript strict, charts wrapped, density beats whitespace), keep `CHANGELOG.md` current, and run the type-check / lint / unit-test / license-header gates before opening a PR.
+Contributions are welcome. Horizon UI is a greenfield rewrite that tracks the OAP GraphQL query-protocol and MQE — backend contracts are fixed and owned by [apache/skywalking](https://github.com/apache/skywalking). Read `CLAUDE.md` for the project's working principles (correctness first, validate against a live OAP, TypeScript strict, charts wrapped, density beats whitespace), record operator-visible changes in the changelog file for the version in development (`docs/changelog/<version>. [...]
## License
diff --git a/apps/bff/src/config/schema.ts b/apps/bff/src/config/schema.ts
index 2c134db..b0a2064 100644
--- a/apps/bff/src/config/schema.ts
+++ b/apps/bff/src/config/schema.ts
@@ -205,7 +205,9 @@ const rbacSchema = z
],
// Configures observability: dashboards, alarm rules, DSL/OAL,
// diagnostics. Inherits viewer + platform reads so operators
- // can verify their changes against live data.
+ // can verify their changes against live data. No reserved verb is
+ // granted here (see RESERVED_VERBS): granting one promises a
+ // capability now and silently confers it the day something enforces it.
operator: [
'metrics:read',
'alarms:read',
@@ -227,13 +229,11 @@ const rbacSchema = z
'dashboard:write',
'alarm-setup:read',
'alarm-rule:read',
- 'alarm-rule:write',
'infra-3d:read',
'rule:read',
'rule:write',
'rule:write:structural',
'rule:delete',
- 'rule:debug',
'live-debug:read',
'live-debug:write',
'profile:enable',
diff --git a/apps/bff/src/http/admin/auth-status.ts b/apps/bff/src/http/admin/auth-status.ts
index 769606d..7af766a 100644
--- a/apps/bff/src/http/admin/auth-status.ts
+++ b/apps/bff/src/http/admin/auth-status.ts
@@ -37,7 +37,7 @@ import type { ConfigSource } from '../../config/loader.js';
import { probeLdap, resolveLdapUser } from '../../user/ldap.js';
import type { LdapHealth } from '../../user/ldap-health.js';
import type { SessionStore } from '../../user/sessions.js';
-import { VERBS } from '../../rbac/verbs.js';
+import { RESERVED_VERBS, VERBS } from '../../rbac/verbs.js';
export interface AuthStatusRouteDeps {
config: ConfigSource;
@@ -89,12 +89,15 @@ export interface AuthStatusBody {
/** RBAC policy snapshot — drives the read-only Roles & permissions
* page. The roles + grants come straight from `rbac.roles` in
* horizon.yaml; `knownVerbs` is the closed namespace declared in
- * the BFF's verbs module. */
+ * the BFF's verbs module, and `reservedVerbs` is the subset of it
+ * that nothing enforces — the page marks those rather than showing
+ * them as capabilities. */
rbac: {
enabled: boolean;
roles: Record<string, string[]>;
landingByRole: Record<string, string>;
knownVerbs: string[];
+ reservedVerbs: string[];
};
}
@@ -177,6 +180,7 @@ export function registerAuthStatusRoutes(app: FastifyInstance, deps: AuthStatusR
roles: cfg.rbac.roles,
landingByRole: cfg.rbac.landingByRole,
knownVerbs: Object.values(VERBS).sort(),
+ reservedVerbs: [...RESERVED_VERBS].sort(),
},
};
return body;
diff --git a/apps/bff/src/http/admin/live-debug.ts b/apps/bff/src/http/admin/live-debug.ts
index 1cecbbd..2b39b93 100644
--- a/apps/bff/src/http/admin/live-debug.ts
+++ b/apps/bff/src/http/admin/live-debug.ts
@@ -28,7 +28,11 @@
*
* 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`.
+ * upstream's query-param shape on the wire.
+ *
+ * RBAC lives entirely in `ROUTE_POLICY`: the reads take `live-debug:read`,
+ * start / stop take `live-debug:write`. The `verb` field in the audit records
+ * below names the capability being exercised — it is a log field, not a check.
*/
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
@@ -45,8 +49,6 @@ import {
import type { ConfigSource } from '../../config/loader.js';
import type { AuditLogger } from '../../audit/logger.js';
import { requireAuth } from '../../user/middleware.js';
-import { sessionHasVerb } from '../../rbac/policy.js';
-import type { Session } from '../../user/sessions.js';
import type { SessionStore } from '../../user/sessions.js';
import { resolveTargets } from '../../util/dns-fanout.js';
import { buildOapClients, type OapClients } from '../../client/index.js';
@@ -76,7 +78,6 @@ export function registerDebugRoutes(app: FastifyInstance, deps: DebugRouteDeps):
'/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 {
@@ -88,7 +89,7 @@ export function registerDebugRoutes(app: FastifyInstance, deps: DebugRouteDeps):
peers.reduce((n, p) => n + (p.stoppedCount ?? p.stoppedSessionIds?.length ?? 0), 0);
deps.audit.record({
action: 'debug.start',
- verb: 'rule:debug',
+ verb: 'live-debug:write',
actor: req.session?.username ?? null,
outcome: 'ok',
details: {
@@ -112,7 +113,7 @@ export function registerDebugRoutes(app: FastifyInstance, deps: DebugRouteDeps):
} catch (err) {
deps.audit.record({
action: 'debug.start',
- verb: 'rule:debug',
+ verb: 'live-debug:write',
actor: req.session?.username ?? null,
outcome: outcomeOf(err),
details: {
@@ -133,7 +134,6 @@ export function registerDebugRoutes(app: FastifyInstance, deps: DebugRouteDeps):
'/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 {
@@ -150,14 +150,13 @@ export function registerDebugRoutes(app: FastifyInstance, deps: DebugRouteDeps):
'/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',
+ verb: 'live-debug:write',
actor: req.session?.username ?? null,
outcome: 'ok',
details: { sessionId: params.id, localStopped: result.localStopped },
@@ -168,7 +167,7 @@ export function registerDebugRoutes(app: FastifyInstance, deps: DebugRouteDeps):
} catch (err) {
deps.audit.record({
action: 'debug.stop',
- verb: 'rule:debug',
+ verb: 'live-debug:write',
actor: req.session?.username ?? null,
outcome: outcomeOf(err),
details: { sessionId: params.id },
@@ -183,8 +182,7 @@ export function registerDebugRoutes(app: FastifyInstance, deps: DebugRouteDeps):
app.get(
'/api/debug/sessions',
{ preHandler: auth },
- async (req: FastifyRequest, reply: FastifyReply) => {
- if (!ensureVerb(req, reply, deps, 'rule:debug')) return;
+ async (_req: FastifyRequest, reply: FastifyReply) => {
try {
const list = await clients().debug().listSessions();
return reply.send(list);
@@ -203,8 +201,6 @@ export function registerDebugRoutes(app: FastifyInstance, deps: DebugRouteDeps):
'/api/debug/status',
{ preHandler: auth },
async (_req: FastifyRequest, reply: FastifyReply) => {
- // Gated by `live-debug:read` in ROUTE_POLICY (the single source of truth);
- // no extra in-handler verb so a caller doesn't need two grants.
const c = clients();
const targets = await resolveTargets(c.adminUrl());
const nodes = await Promise.all(
@@ -227,24 +223,6 @@ export function registerDebugRoutes(app: FastifyInstance, deps: DebugRouteDeps):
);
}
-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 {
diff --git a/apps/bff/src/rbac/route-policy.test.ts b/apps/bff/src/rbac/route-policy.test.ts
index 7997356..bb0bd8d 100644
--- a/apps/bff/src/rbac/route-policy.test.ts
+++ b/apps/bff/src/rbac/route-policy.test.ts
@@ -16,9 +16,17 @@
*/
import { describe, it, expect, afterEach, vi } from 'vitest';
-import type { FastifyReply, FastifyRequest } from 'fastify';
-import { isTemplateWriteRoute, denyTemplateWriteWhenReadOnly } from './route-policy.js';
+import type { FastifyReply, FastifyRequest, RouteOptions } from 'fastify';
+import {
+ ROUTE_POLICY,
+ checkVerb,
+ makeRouteAuthHook,
+ isTemplateWriteRoute,
+ denyTemplateWriteWhenReadOnly,
+} from './route-policy.js';
import { setTemplateReadOnly } from '../logic/templates/sync.js';
+import { configSchema } from '../config/schema.js';
+import type { AuthDeps } from '../user/middleware.js';
describe('isTemplateWriteRoute — which routes the readonly backstop covers', () => {
it('matches non-GET config-template write routes', () => {
@@ -36,6 +44,120 @@ describe('isTemplateWriteRoute — which routes the readonly backstop covers', (
});
});
+/**
+ * `live-debug:read` / `live-debug:write` is the whole of the Live Debugger's
+ * access control: watching a capture takes the read, running one takes the
+ * write, and no rule verb participates. The `rule-only` role below is the
+ * guard on that last clause — every rule grant there is, including the `rule:*`
+ * wildcard, must neither open nor close a live-debug route.
+ */
+describe('the live-debug policy is read to watch, write to run', () => {
+ const config = configSchema.parse({
+ rbac: {
+ roles: {
+ watcher: ['live-debug:read'],
+ debugger: ['live-debug:read', 'live-debug:write'],
+ 'rule-only': ['rule:*'],
+ },
+ },
+ });
+
+ const deps = { config: { current: config } } as unknown as AuthDeps;
+ type PreHandler = (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
+
+ /** Runs one pre-handler as `role` and reports what it replied, if anything. */
+ async function run(gate: PreHandler, role: string): Promise<{ code?: number; body?: unknown }> {
+ const out: { code?: number; body?: unknown } = {};
+ const reply = {
+ code(c: number) {
+ out.code = c;
+ return { send: (b: unknown) => void (out.body = b) };
+ },
+ } as unknown as FastifyReply;
+ await gate({ session: { roles: [role] } } as FastifyRequest, reply);
+ return out;
+ }
+
+ /** Runs the route's own policy as the hook would apply it. */
+ async function decide(route: string, role: string): Promise<{ code?: number; body?: unknown }> {
+ const policy = ROUTE_POLICY[route];
+ if (policy === undefined) throw new Error(`no ROUTE_POLICY entry for ${route}`);
+ return run(checkVerb(deps, policy), role);
+ }
+
+ it('lets a read-only live-debug role read sessions and cluster status', async () => {
+ for (const route of [
+ 'GET /api/debug/session/:id',
+ 'GET /api/debug/sessions',
+ 'GET /api/debug/status',
+ ]) {
+ expect(await decide(route, 'watcher'), route).toEqual({});
+ }
+ });
+
+ it('refuses a start and a stop to a watcher, naming the missing verb', async () => {
+ for (const route of ['POST /api/debug/session', 'POST /api/debug/session/:id/stop']) {
+ expect(await decide(route, 'watcher'), route).toEqual({
+ code: 403,
+ body: { error: 'permission_denied', verb: 'live-debug:write' },
+ });
+ }
+ });
+
+ it('lets a role holding live-debug:write start and stop', async () => {
+ expect(await decide('POST /api/debug/session', 'debugger')).toEqual({});
+ expect(await decide('POST /api/debug/session/:id/stop', 'debugger')).toEqual({});
+ });
+
+ it('gives a holder of every rule verb no live-debug access at all', async () => {
+ for (const route of [
+ 'GET /api/debug/session/:id',
+ 'GET /api/debug/sessions',
+ 'GET /api/debug/status',
+ ]) {
+ expect(await decide(route, 'rule-only'), route).toEqual({
+ code: 403,
+ body: { error: 'permission_denied', verb: 'live-debug:read' },
+ });
+ }
+ for (const route of ['POST /api/debug/session', 'POST /api/debug/session/:id/stop']) {
+ expect(await decide(route, 'rule-only'), route).toEqual({
+ code: 403,
+ body: { error: 'permission_denied', verb: 'live-debug:write' },
+ });
+ }
+ });
+
+ // The table only decides anything if the registration hook carries the entry
+ // through to a pre-handler — a route that fell into the 'auth' branch would
+ // gate nothing while the table still read strict.
+ it('the registration hook attaches the verb gate to the route', async () => {
+ const route = { method: 'POST', url: '/api/debug/session' } as RouteOptions;
+ makeRouteAuthHook(deps)(route);
+ const gates = (Array.isArray(route.preHandler) ? route.preHandler : []) as PreHandler[];
+ const verbGate = gates.find((h) => h.name === 'verbOnlyPreHandler');
+ expect(verbGate, 'no verb pre-handler was attached').toBeDefined();
+ expect(await run(verbGate!, 'watcher')).toEqual({
+ code: 403,
+ body: { error: 'permission_denied', verb: 'live-debug:write' },
+ });
+ });
+
+ // No route needs a conjunction today. The form stays for the next route that
+ // genuinely takes two grants, so it is exercised directly rather than through
+ // the table: every listed verb is required, and the FIRST one missing is the
+ // one reported — a policy that stopped at the last would name the wrong verb.
+ it('a multi-verb policy requires all of them and reports the first missing', async () => {
+ expect(await run(checkVerb(deps, ['live-debug:read', 'live-debug:write']), 'watcher')).toEqual({
+ code: 403,
+ body: { error: 'permission_denied', verb: 'live-debug:write' },
+ });
+ expect(await run(checkVerb(deps, ['live-debug:read', 'live-debug:write']), 'debugger')).toEqual(
+ {},
+ );
+ });
+});
+
describe('denyTemplateWriteWhenReadOnly — the BFF backstop', () => {
afterEach(() => setTemplateReadOnly(false));
diff --git a/apps/bff/src/rbac/route-policy.ts b/apps/bff/src/rbac/route-policy.ts
index ed83399..bc834e3 100644
--- a/apps/bff/src/rbac/route-policy.ts
+++ b/apps/bff/src/rbac/route-policy.ts
@@ -17,9 +17,13 @@
/**
* Single source of truth for the RBAC verb gating each route.
- * - 'public' → no auth (login / logout / health).
- * - 'auth' → signed-in session, no verb (shell bootstrap routes).
- * - '<verb>' → signed-in session + that verb grant.
+ * - 'public' → no auth (login / logout / health).
+ * - 'auth' → signed-in session, no verb (shell bootstrap routes).
+ * - '<verb>' → signed-in session + that verb grant.
+ * - ['<verb>', …] → signed-in session + EVERY listed verb. A conjunction
+ * belongs here rather than in a handler check: the hook
+ * runs first, so a handler-side second verb is invisible
+ * to this table and to anything derived from it.
* The `onRoute` hook (server.ts) attaches the matching pre-handler. A
* missing `/api/*` entry hard-throws at boot — never silently open.
*/
@@ -31,7 +35,7 @@ import { requireAuth } from '../user/middleware.js';
import { isTemplateReadOnly } from '../logic/templates/sync.js';
import { logger } from '../logger.js';
-export type RoutePolicy = 'public' | 'auth' | string;
+export type RoutePolicy = 'public' | 'auth' | string | string[];
/** A config-surface write. The template routes push to OAP's ui_template store
* (the alert page-setup rides this path too, as the `horizon.alert.page-setup`
@@ -60,20 +64,33 @@ export async function denyTemplateWriteWhenReadOnly(
/**
* Verb-only check. Assumes a prior pre-handler (`requireAuth`) has
- * already populated `req.session`; sends 401 if not.
+ * already populated `req.session`; sends 401 if not. Several verbs are a
+ * conjunction — all are required, and the first one the session lacks is the
+ * one reported back.
*/
-export function checkVerb(deps: AuthDeps, verb: string) {
+export function checkVerb(deps: AuthDeps, verb: string | readonly string[]) {
+ const required = typeof verb === 'string' ? [verb] : verb;
return async function verbOnlyPreHandler(req: FastifyRequest, reply: FastifyReply): Promise<void> {
const session = req.session;
if (!session) {
return void reply.code(401).send({ error: 'unauthenticated' });
}
- if (!sessionHasVerb(deps.config.current, session.roles, verb)) {
- return void reply.code(403).send({ error: 'permission_denied', verb });
+ for (const v of required) {
+ if (!sessionHasVerb(deps.config.current, session.roles, v)) {
+ return void reply.code(403).send({ error: 'permission_denied', verb: v });
+ }
}
};
}
+/** Two policy entries requiring the same thing. Conjunctions are distinct
+ * array objects per key, so identity would read equal lists as a conflict. */
+function samePolicy(a: RoutePolicy, b: RoutePolicy): boolean {
+ const x = Array.isArray(a) ? a : [a];
+ const y = Array.isArray(b) ? b : [b];
+ return x.length === y.length && x.every((v, i) => v === y[i]);
+}
+
/**
* Keyed by `${METHOD} ${url}` (url as registered, with `:param` placeholders).
* Grouped by the lowest built-in role that can reach each route:
@@ -226,9 +243,15 @@ export const ROUTE_POLICY: Record<string, RoutePolicy> = {
'POST /api/rule': 'rule:write',
'POST /api/rule/inactivate': 'rule:write',
'POST /api/rule/delete': 'rule:delete',
- 'GET /api/dump': 'rule:debug',
- 'GET /api/dump/:catalog': 'rule:debug',
-
+ // Exporting rules is a READ — anything stricter here 403s a `rule:read` role
+ // from a button the DSL page renders as enabled, since this hook decides
+ // before the handler the page and the docs are written against.
+ 'GET /api/dump': 'rule:read',
+ 'GET /api/dump/:catalog': 'rule:read',
+
+ // The live debugger is the whole of `live-debug:*`: watching a capture is
+ // `live-debug:read`, running one is `live-debug:write`. No rule verb is
+ // involved — a capture installs a debug hook, it does not edit a rule.
'GET /api/debug/session/:id': 'live-debug:read',
'GET /api/debug/sessions': 'live-debug:read',
'GET /api/debug/status': 'live-debug:read',
@@ -284,7 +307,7 @@ export function makeRouteAuthHook(deps: AuthDeps) {
// explicitly. The policy table only carries GET entries.
const p = ROUTE_POLICY[key] ?? (M === 'HEAD' ? ROUTE_POLICY[`GET ${route.url}`] : undefined);
if (p === undefined) continue;
- if (chosen !== null && chosen !== p) {
+ if (chosen !== null && !samePolicy(chosen, p)) {
logger.warn(
{ url: route.url, methods, conflicting: [chosenKey, key] },
'rbac: route registered for multiple methods with divergent policies; using the first',
diff --git a/apps/bff/src/rbac/verb-enforcement.test.ts b/apps/bff/src/rbac/verb-enforcement.test.ts
new file mode 100644
index 0000000..92cc80d
--- /dev/null
+++ b/apps/bff/src/rbac/verb-enforcement.test.ts
@@ -0,0 +1,506 @@
+/*
+ * 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.
+ */
+
+/**
+ * Drift gate between four lists that have no compiler link:
+ * the verb namespace, the places that enforce a verb, the Roles board that
+ * presents verbs to an admin as capabilities, and the sidebar menu whose
+ * per-role visibility that same board tabulates.
+ *
+ * The failure this exists to stop: a verb shown on the board (and granted by
+ * a stock role) that nothing anywhere checks — an admin reads it as a working
+ * capability and hands it out. The inverse is just as wrong: a verb marked
+ * RESERVED that has since been wired up would be documented as inert while it
+ * really gates something. The menu matrix fails the same way in both
+ * directions — a row naming a verb nothing gates promises a control that does
+ * not exist, and a sidebar entry with no row makes the matrix an incomplete
+ * answer to "what will this role see" while the page says it is the whole one.
+ *
+ * Enforcement is read out of the sources rather than declared, so adding a
+ * route / handler check / nav gate is enough to move a verb — nothing here
+ * needs a parallel edit except when a verb genuinely changes status.
+ */
+
+import { describe, expect, it } from 'vitest';
+import { readdirSync, readFileSync, statSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { join, relative } from 'node:path';
+import { RESERVED_VERBS, VERBS, hasVerb } from './verbs.js';
+import { ROUTE_POLICY } from './route-policy.js';
+import { configSchema } from '../config/schema.js';
+
+const REPO = fileURLToPath(new URL('../../../../', import.meta.url));
+const BFF_SRC = join(REPO, 'apps/bff/src');
+const UI_SRC = join(REPO, 'apps/ui/src');
+const ROLES_VIEW = join(UI_SRC, 'features/admin/roles/RolesView.vue');
+/** The static (non-layer) sidebar registry the menu matrix tabulates. */
+const SIDEBAR_MENU = join(UI_SRC, 'shell/useSidebarMenu.ts');
+/** The nav entries the shell builds itself instead of from that registry. */
+const APP_SIDEBAR = join(UI_SRC, 'shell/AppSidebar.vue');
+
+/** `admin` is a grant sentinel, not a required verb: no request asks for it,
+ * it is what `matchOne` honors to mean "everything". Asserted separately. */
+const GRANT_SENTINEL = 'admin';
+
+const ALL_VERBS = Object.values(VERBS).filter((v) => v !== GRANT_SENTINEL);
+
+function walk(dir: string, out: string[] = []): string[] {
+ for (const entry of readdirSync(dir)) {
+ const p = join(dir, entry);
+ if (statSync(p).isDirectory()) walk(p, out);
+ else if (/\.(ts|vue)$/.test(p) && !p.endsWith('.test.ts')) out.push(p);
+ }
+ return out;
+}
+
+/** Comments are prose, not enforcement — a verb named only in a comment must
+ * not count as a gate. */
+function code(src: string): string {
+ return src.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:])\/\/[^\n]*/g, '$1 ');
+}
+
+const KNOWN_VERBS = new Set<string>(Object.values(VERBS));
+
+/** Every verb any route requires, conjunctions flattened. */
+const POLICY_VERBS = new Set(
+ Object.values(ROUTE_POLICY).flatMap((p) => (Array.isArray(p) ? p : [p])),
+);
+
+/** A call that can deny the request. `hasVerb` covers the UI's
+ * `auth.hasVerb(...)` and the AI tools' `ctx.hasVerb(...)` alike. */
+const CHECK_CALL = /(?:ensureVerb|sessionHasVerb|checkVerb|hasVerb)\s*\(([^()]*(?:\([^()]*\)[^()]*)*)\)/g;
+const QUOTED = /'([^'\n]+)'|"([^"\n]+)"/g;
+const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
+
+/**
+ * The verbs a source file can DENY a request on — never the ones it merely
+ * names. A verb reaches a check three ways:
+ * - as a literal argument to a check call;
+ * - through a local `const` a check call is handed (the rule routes pick
+ * between two verbs by a ternary, the template save between two kinds);
+ * - in a UI file, as a `verb:` field — a sidebar row (the menu filters rows
+ * through `auth.hasVerb`) or a route's `meta.verb` (the router guard reads
+ * it). BFF files carry `verb:` fields too, but there they are audit-log
+ * metadata, which is the whole point of this distinction: a verb that
+ * survives only in an audit record passed this whole file green while
+ * nothing checked it.
+ * A construction this cannot resolve statically (a verb built at runtime, a
+ * check behind an indirection) is left OUT: under-claiming reports a verb as
+ * unenforced and gets looked at; over-claiming is how the miss happened.
+ */
+function checkedVerbs(src: string, opts: { ui: boolean }): Set<string> {
+ const source = code(src);
+ const found = new Set<string>();
+ const viaIdentifier = new Set<string>();
+ const keep = (s: string | undefined): void => {
+ if (s !== undefined && KNOWN_VERBS.has(s)) found.add(s);
+ };
+ for (const call of source.matchAll(CHECK_CALL)) {
+ const args = call[1] ?? '';
+ for (const q of args.matchAll(QUOTED)) keep(q[1] ?? q[2]);
+ for (const arg of args.split(',')) {
+ const a = arg.trim();
+ if (IDENTIFIER.test(a)) viaIdentifier.add(a);
+ }
+ }
+ for (const id of viaIdentifier) {
+ for (const decl of source.matchAll(new RegExp(`\\b(?:const|let|var)\\s+${id}\\s*=([^;\\n]*)`, 'g'))) {
+ for (const q of decl[1].matchAll(QUOTED)) keep(q[1] ?? q[2]);
+ }
+ }
+ if (opts.ui) for (const m of source.matchAll(/\bverb:\s*'([^'\n]+)'/g)) keep(m[1]);
+ return found;
+}
+
+/**
+ * Where each verb is actually checked:
+ * - the route table (`ROUTE_POLICY`),
+ * - an in-handler check under `http/` or an AI-tool check under `ai/`,
+ * - a UI gate: a sidebar row's `verb`, a route's `meta.verb`, or
+ * `auth.hasVerb(...)` in a view. The Roles board itself is excluded —
+ * it *displays* every verb, which is exactly what is being checked.
+ *
+ * `policyVerbs` is injectable so a test can ask what the detector would say
+ * with the route-table gate removed.
+ */
+function enforcementSites(verb: string, policyVerbs: ReadonlySet<string> = POLICY_VERBS): string[] {
+ const sites: string[] = [];
+ if (policyVerbs.has(verb)) sites.push('ROUTE_POLICY');
+ for (const dir of ['http', 'ai']) {
+ for (const file of walk(join(BFF_SRC, dir))) {
+ if (checkedVerbs(readFileSync(file, 'utf8'), { ui: false }).has(verb)) {
+ sites.push(file.slice(REPO.length));
+ }
+ }
+ }
+ for (const file of walk(UI_SRC)) {
+ if (file === ROLES_VIEW) continue;
+ if (checkedVerbs(readFileSync(file, 'utf8'), { ui: true }).has(verb)) {
+ sites.push(file.slice(REPO.length));
+ }
+ }
+ return sites;
+}
+
+const rolesView = readFileSync(ROLES_VIEW, 'utf8');
+/** Every verb listed in a `verbs: [...]` group of the capability matrix. */
+const boardVerbs = new Set(
+ [...rolesView.matchAll(/verbs: \[([^\]]*)\]/g)].flatMap((m) =>
+ [...m[1].matchAll(/'([^']+)'/g)].map((q) => q[1]),
+ ),
+);
+/** Verbs the board gives a plain-English label — the `verb` fallback label
+ * would print the raw identifier the page deliberately keeps off screen. */
+const labeledVerbs = new Set(
+ [...rolesView.matchAll(/'([a-z0-9:*-]+)':\s*\{\s*label:/g)].map((m) => m[1]),
+);
+
+/** The `[…]` a `const <name> = …(() => [` registry is initialised to. Both
+ * registries the menu gate compares are plain literals, which is why it can
+ * read them here instead of pulling Vue into a BFF test run. */
+function registryLiteral(src: string, name: string): string {
+ const decl = src.indexOf(`const ${name}`);
+ const arrow = decl < 0 ? -1 : src.indexOf('=> [', decl);
+ // Must belong to THIS declaration: latching onto the next one's literal
+ // would compare a registry nobody asked about and call the result agreement.
+ const next = /\n\s*const /.exec(src.slice(decl + 1));
+ const nextDecl = next?.index === undefined ? -1 : decl + 1 + next.index;
+ if (arrow < 0 || (nextDecl >= 0 && arrow > nextDecl)) {
+ throw new Error(`${name}: no array-literal registry found on the declaration`);
+ }
+ const open = arrow + 3;
+ let depth = 0;
+ for (let i = open; i < src.length; i++) {
+ if (src[i] === '[') depth++;
+ else if (src[i] === ']' && --depth === 0) return src.slice(open, i + 1);
+ }
+ throw new Error(`${name}: unbalanced array literal`);
+}
+
+const at = (m: RegExpMatchArray): number => m.index ?? 0;
+
+interface SidebarEntry {
+ path: string;
+ /** `null` when the row's gate could not be resolved — see below. */
+ verb: string | null;
+}
+
+/**
+ * Every destination the static sidebar menu renders, with the verb its row is
+ * hidden by: the L1 rows and the L2 rows under an expandable one alike (an
+ * expandable L1 repeats its first child's path, which is harmless — the gate
+ * compares sets).
+ *
+ * A row's verb is the first `verb:` between its `to:` and the next row's,
+ * which is the shape every entry in the registry has. A row that carries none
+ * there comes back `null` rather than borrowing the following row's: the
+ * assertions report it instead of pairing an entry with a gate it does not
+ * have. Under-claiming is the point — a wrong pairing reads as agreement.
+ */
+function sidebarEntries(): SidebarEntry[] {
+ const block = registryLiteral(readFileSync(SIDEBAR_MENU, 'utf8'), 'sections');
+ const tos = [...block.matchAll(/\bto:\s*'([^']+)'/g)];
+ return tos.map((m, i) => {
+ const from = at(m) + m[0].length;
+ const until = i + 1 < tos.length ? at(tos[i + 1]!) : block.length;
+ const verb = /\bverb:\s*'([^']+)'/.exec(block.slice(from, until));
+ return { path: m[1]!, verb: verb ? verb[1]! : null };
+ });
+}
+
+interface MatrixRow {
+ label: string;
+ verb: string | null;
+ /** Sidebar destinations the row stands for; empty for the entries the shell
+ * builds itself and for the topbar's 3D-map entry. */
+ covers: string[];
+ /** False when the row omits `covers` altogether — a row the gate could not
+ * check, reported rather than passed over. */
+ declaresCovers: boolean;
+}
+
+/** The menu-visibility rows of the Roles board. Each is a flat object
+ * literal, so no nesting analysis is needed to split them. */
+function matrixRows(): MatrixRow[] {
+ const block = registryLiteral(rolesView, 'MENU_GATES');
+ return [...block.matchAll(/\{[^{}]*\}/g)].map((m) => {
+ const row = m[0];
+ const label = /label:\s*t\('([^']*)'\)/.exec(row);
+ const verb = /\bverb:\s*'([^']+)'/.exec(row);
+ const covers = /covers:\s*\[([^\]]*)\]/.exec(row);
+ return {
+ label: label ? label[1]! : row,
+ verb: verb ? verb[1]! : null,
+ covers: covers ? [...covers[1]!.matchAll(/'([^']+)'/g)].map((c) => c[1]!) : [],
+ declaresCovers: covers !== null,
+ };
+ });
+}
+
+describe('reserved verbs — declared but enforced nowhere', () => {
+ it.each([...RESERVED_VERBS])('%s is checked by nothing', (verb) => {
+ expect(enforcementSites(verb)).toEqual([]);
+ });
+
+ it('no stock role grants a reserved verb', () => {
+ const roles = configSchema.parse({}).rbac.roles;
+ for (const [role, grants] of Object.entries(roles)) {
+ if (grants.includes('*')) continue; // the admin role is the escape hatch
+ for (const reserved of RESERVED_VERBS) {
+ expect(
+ { role, grants: grants.filter((g) => hasVerb([g], reserved)) },
+ `role "${role}" grants reserved verb ${reserved}`,
+ ).toEqual({ role, grants: [] });
+ }
+ }
+ });
+});
+
+describe('every other verb earns its place', () => {
+ it.each(ALL_VERBS.filter((v) => !RESERVED_VERBS.includes(v)))(
+ '%s is enforced somewhere',
+ (verb) => {
+ expect(enforcementSites(verb).length, `${verb} has no enforcement site`).toBeGreaterThan(0);
+ },
+ );
+
+ it('the sentinel grants everything and is required by no route', () => {
+ expect([...POLICY_VERBS]).not.toContain(GRANT_SENTINEL);
+ for (const verb of ALL_VERBS) expect(hasVerb([GRANT_SENTINEL], verb)).toBe(true);
+ });
+});
+
+describe('enforcement detection tells a gate from a log field', () => {
+ const LIVE_DEBUG = join(BFF_SRC, 'http/admin/live-debug.ts');
+
+ it('does not read an audit `verb:` field as a check', () => {
+ const src = readFileSync(LIVE_DEBUG, 'utf8');
+ expect(src, 'live-debug still audits the capability it exercises').toContain(
+ "verb: 'live-debug:write'",
+ );
+ expect([...checkedVerbs(src, { ui: false })]).not.toContain('live-debug:write');
+ });
+
+ it('reads a real check — literal, via a local const, and a UI nav gate', () => {
+ expect([
+ ...checkedVerbs("if (!ensureVerb(req, reply, deps, 'live-debug:write')) return;", {
+ ui: false,
+ }),
+ ]).toContain('live-debug:write');
+ expect([
+ ...checkedVerbs(
+ "const v = force ? 'rule:write:structural' : 'rule:write';\nif (!ensureVerb(req, reply, deps, v)) return;",
+ { ui: false },
+ ),
+ ]).toEqual(expect.arrayContaining(['rule:write:structural', 'rule:write']));
+ expect([...checkedVerbs("{ to: '/operate/cluster', verb: 'cluster:read' }", { ui: true })])
+ .toContain('cluster:read');
+ });
+
+ // The regression this whole file exists to catch, run end to end. The route
+ // table is `live-debug:write`'s only gate: take it away and the verb survives
+ // in the sources purely as audit metadata and a Roles-board row, so every
+ // site must disappear. Before the detector was strengthened it reported those
+ // audit records as gates, and 44 green tests said a verb was enforced while
+ // it enforced nothing.
+ it('reports live-debug:write unenforced the moment the route table stops requiring it', () => {
+ const withoutPolicy = new Set([...POLICY_VERBS].filter((v) => v !== 'live-debug:write'));
+ expect(enforcementSites('live-debug:write', withoutPolicy)).toEqual([]);
+ expect(enforcementSites('live-debug:write')).toContain('ROUTE_POLICY');
+ });
+});
+
+describe('the live debugger is gated on live-debug alone', () => {
+ it('start and stop require live-debug:write', () => {
+ expect(ROUTE_POLICY['POST /api/debug/session']).toEqual('live-debug:write');
+ expect(ROUTE_POLICY['POST /api/debug/session/:id/stop']).toEqual('live-debug:write');
+ });
+
+ // A rule verb added here would gate the Live Debugger under a name that reads
+ // like the DSL pages, which is how a permission ends up in the `rule:*` family
+ // while only ever gating captures.
+ it('no /api/debug/* route requires a rule verb', () => {
+ for (const [route, policy] of Object.entries(ROUTE_POLICY)) {
+ if (!route.includes(' /api/debug/')) continue;
+ const verbs = Array.isArray(policy) ? policy : [policy];
+ expect(verbs.filter((v) => v.startsWith('rule:')), route).toEqual([]);
+ }
+ });
+
+ it('the session reads require live-debug:read and nothing else', () => {
+ for (const route of [
+ 'GET /api/debug/session/:id',
+ 'GET /api/debug/sessions',
+ 'GET /api/debug/status',
+ ]) {
+ expect(ROUTE_POLICY[route], route).toEqual('live-debug:read');
+ }
+ expect([...checkedVerbs(readFileSync(join(BFF_SRC, 'http/admin/live-debug.ts'), 'utf8'), {
+ ui: false,
+ })], 'no handler-side verb may re-tighten a live-debug read').toEqual([]);
+ });
+});
+
+describe('the Roles board matches the namespace', () => {
+ it('describes every known verb — nothing falls into the uncategorised bucket', () => {
+ const undescribed = Object.values(VERBS).filter((v) => !boardVerbs.has(v));
+ expect(undescribed, 'add these to a VERB_GROUPS group in RolesView.vue').toEqual([]);
+ });
+
+ it('labels every known verb in plain English', () => {
+ const unlabeled = Object.values(VERBS).filter((v) => !labeledVerbs.has(v));
+ expect(unlabeled, 'add these to VERB_LABELS in RolesView.vue').toEqual([]);
+ });
+
+ it('shows no verb the server does not declare', () => {
+ const known = new Set<string>(Object.values(VERBS));
+ expect([...boardVerbs].filter((v) => !known.has(v))).toEqual([]);
+ });
+
+ it('every menu-visibility row names a verb the UI really gates on', () => {
+ // `null` rows (visible to any signed-in user) carry no verb to check.
+ const menuVerbs = matrixRows()
+ .map((r) => r.verb)
+ .filter((v): v is string => v !== null);
+ expect(menuVerbs.length).toBeGreaterThan(0);
+ for (const verb of menuVerbs) {
+ const uiSites = enforcementSites(verb).filter((s) => s.startsWith('apps/ui/'));
+ expect(uiSites.length, `menu row claims ${verb} hides an entry, but no UI gate reads it`)
+ .toBeGreaterThan(0);
+ }
+ });
+ // A route's policy verb runs in the preHandler hook, BEFORE the handler. So
+ // when a handler also calls ensureVerb, the STRICTER of the two decides, and
+ // the handler's own check can never be reached with anything the hook
+ // rejected. If the two disagree, the handler's verb — the one the page and
+ // the docs are written against — becomes a lie: the button renders enabled
+ // and the request 403s. This caught `GET /api/dump*`, policy-gated on a debug
+ // verb while its handler, its button and its docs all said `rule:read`.
+ it('never gates a route on a different verb than its own handler enforces', () => {
+ const httpDir = join(BFF_SRC, 'http');
+ const files: string[] = [];
+ const walk = (d: string): void => {
+ for (const e of readdirSync(d, { withFileTypes: true })) {
+ const f = join(d, e.name);
+ if (e.isDirectory()) walk(f);
+ else if (f.endsWith('.ts') && !f.endsWith('.test.ts')) files.push(f);
+ }
+ };
+ walk(httpDir);
+
+ const mismatches: string[] = [];
+ for (const file of files) {
+ const src = readFileSync(file, 'utf8');
+ const handlerVerbs = new Set(
+ [...src.matchAll(/ensureVerb\(\s*req,\s*reply,\s*deps,\s*'([a-z0-9:-]+)'/g)].map((m) => m[1]),
+ );
+ if (handlerVerbs.size === 0) continue;
+ // The route paths this file registers, as they appear in ROUTE_POLICY.
+ for (const [route, policy] of Object.entries(ROUTE_POLICY)) {
+ const path = route.split(' ').slice(1).join(' ');
+ if (!src.includes(`'${path}'`) && !src.includes(`"${path}"`)) continue;
+ const policyVerbs = Array.isArray(policy) ? policy : [policy];
+ if (policyVerbs.includes('auth')) continue;
+ // Only flag when NO policy verb appears anywhere in the handler file.
+ // A file registering several routes may select its verb through a
+ // variable, which the literal scan above cannot resolve — pooling one
+ // file's verbs against all its routes would call that a mismatch. If
+ // the string is absent entirely, the handler cannot be enforcing it.
+ if (policyVerbs.some((v) => src.includes(`'${v}'`))) continue;
+ mismatches.push(
+ `${route} is policy-gated on ${policyVerbs.map((v) => `'${v}'`).join(' + ')} but ` +
+ `${relative(REPO, file)} enforces ` +
+ `${[...handlerVerbs].map((v) => `'${v}'`).join(' / ')} — the hook wins, so the handler's verb never decides`,
+ );
+ }
+ }
+ expect(mismatches, mismatches.join('\n')).toEqual([]);
+ });
+});
+
+/**
+ * The board tells an admin the matrix is every navigation entry. Checking only
+ * that each row names a real gate (above) leaves the promise open at the other
+ * end, and that is where it broke: Trace inspect, Log inspect, Translations and
+ * the 3D-map editor were all added to the sidebar re-using a verb the matrix
+ * already listed, so every row still named a working gate and no row existed
+ * for any of them. A verb-level check cannot see that — these assertions
+ * compare ENTRIES.
+ */
+describe('the menu-visibility matrix mirrors the navigation', () => {
+ const entries = sidebarEntries();
+ const rows = matrixRows();
+ const covered = new Set(rows.flatMap((r) => r.covers));
+
+ it('resolves the gate on every sidebar entry', () => {
+ expect(entries.length, 'no sidebar entries parsed — the registry shape moved').toBeGreaterThan(
+ 0,
+ );
+ expect(
+ entries.filter((e) => e.verb === null).map((e) => e.path),
+ 'no verb between this entry and the next: the matrix cannot be checked against it',
+ ).toEqual([]);
+ });
+
+ it('states what every row covers', () => {
+ expect(
+ rows.filter((r) => !r.declaresCovers).map((r) => r.label),
+ 'give each MENU_GATES row a `covers` list ([] for an entry the menu registry does not build)',
+ ).toEqual([]);
+ });
+
+ it('gives every sidebar entry a row', () => {
+ const orphans = [...new Set(entries.map((e) => e.path))].filter((p) => !covered.has(p));
+ expect(orphans, 'these sidebar entries appear in no MENU_GATES row — add them').toEqual([]);
+ });
+
+ it('claims no entry the sidebar no longer builds', () => {
+ const paths = new Set(entries.map((e) => e.path));
+ expect(
+ [...covered].filter((p) => !paths.has(p)),
+ 'MENU_GATES covers a destination the sidebar menu does not render',
+ ).toEqual([]);
+ });
+
+ it('names the verb the sidebar really hides each covered entry by', () => {
+ const gateOf = new Map(entries.map((e) => [e.path, e.verb]));
+ const disagree = rows.flatMap((r) =>
+ r.covers
+ .filter((p) => gateOf.get(p) !== r.verb)
+ .map((p) => `row "${r.label}" says ${r.verb} — the sidebar gates ${p} on ${gateOf.get(p)}`),
+ );
+ expect(disagree, disagree.join('\n')).toEqual([]);
+ });
+
+ // The rows with an empty `covers` stand for nav AppSidebar builds itself —
+ // the overview links, Alarms, the layer rows — which are template blocks,
+ // not registry rows, so their entries cannot be counted from here. What can
+ // be checked is that no such block is gated on a verb the matrix never
+ // mentions: it catches a new gated section, not a new entry inside one.
+ it('carries every verb the shell gates a nav block on', () => {
+ const shellVerbs = new Set(
+ [...readFileSync(APP_SIDEBAR, 'utf8').matchAll(/auth\.hasVerb\('([^']+)'\)/g)].map(
+ (m) => m[1]!,
+ ),
+ );
+ const rowVerbs = new Set(rows.map((r) => r.verb));
+ expect(
+ [...shellVerbs].filter((v) => !rowVerbs.has(v)),
+ 'AppSidebar hides a nav block on a verb no menu-visibility row names',
+ ).toEqual([]);
+ });
+});
diff --git a/apps/bff/src/rbac/verbs.ts b/apps/bff/src/rbac/verbs.ts
index 0f53fcf..cbff30f 100644
--- a/apps/bff/src/rbac/verbs.ts
+++ b/apps/bff/src/rbac/verbs.ts
@@ -38,7 +38,6 @@ export const VERBS = {
ruleWrite: 'rule:write',
ruleWriteStructural: 'rule:write:structural',
ruleDelete: 'rule:delete',
- ruleDebug: 'rule:debug',
dashboardRead: 'dashboard:read',
dashboardWrite: 'dashboard:write',
overviewRead: 'overview:read',
@@ -78,6 +77,28 @@ export const VERBS = {
export type KnownVerb = (typeof VERBS)[keyof typeof VERBS];
+/**
+ * Declared, but nothing checks them: no route in `ROUTE_POLICY`, no in-handler
+ * check, no UI gate. Granting one neither opens nor closes anything today, so
+ * the Roles board renders them marked instead of as capabilities. They keep
+ * their names so a `horizon.yaml` that already lists one still validates, and
+ * so the name is stable if a capability ever binds to it.
+ *
+ * `alarm-rule:write` has nothing to bind to: OAP's alarm-rule catalog is
+ * read-only upstream (rules change in its YAML + watcher reload), so Horizon
+ * has no write to gate.
+ *
+ * `verb-enforcement.test.ts` re-derives this list from the policy sources —
+ * a verb that gains (or loses) an enforcement site fails the test until this
+ * list is updated.
+ */
+export const RESERVED_VERBS: readonly Verb[] = [
+ 'alarm-rule:write',
+ 'audit:read',
+ 'role:write',
+ 'user:write',
+];
+
function matchOne(grant: Verb, required: Verb): boolean {
if (grant === '*' || grant === 'admin') return true;
if (grant === required) return true;
diff --git a/apps/ui/src/api/scopes/admin-auth.ts b/apps/ui/src/api/scopes/admin-auth.ts
index beeebd1..3726541 100644
--- a/apps/ui/src/api/scopes/admin-auth.ts
+++ b/apps/ui/src/api/scopes/admin-auth.ts
@@ -73,6 +73,9 @@ export interface AuthStatus {
roles: Record<string, string[]>;
landingByRole: Record<string, string>;
knownVerbs: string[];
+ /** Subset of `knownVerbs` that nothing enforces — the Roles board marks
+ * these instead of presenting them as capabilities. */
+ reservedVerbs: string[];
};
}
diff --git a/apps/ui/src/features/admin/infra-3d/Infra3dAdminView.vue b/apps/ui/src/features/admin/infra-3d/Infra3dAdminView.vue
index 6f12a56..2ef4f56 100644
--- a/apps/ui/src/features/admin/infra-3d/Infra3dAdminView.vue
+++ b/apps/ui/src/features/admin/infra-3d/Infra3dAdminView.vue
@@ -366,7 +366,7 @@ const stats = computed(() => {
<span class="kicker">{{ t('Dashboard setup · 3D Infra Map') }}</span>
<h1>{{ t('3D Infrastructure Map') }}</h1>
<p class="lede">
- <i18n-t keypath="Config for the {mapPath} view, published to OAP. Levels control the vertical stack; per-layer color + metrics drive each cube. Edits save to a {localDraft}; {checkDiffPush} publishes to OAP — the map renders the remote, with bundled defaults as fallback." tag="span" scope="global">
+ <i18n-t keypath="Config for the {mapPath} view, published to OAP. Levels control the vertical stack; per-layer color + metrics drive each cube. Edits save to a {localDraft}; {checkDiffPush} publishes to OAP — the map renders the OAP-published copy only, and reports an error when none can be read." tag="span" scope="global">
<template #mapPath><code>/3d/map</code></template>
<template #localDraft><strong>{{ t('local draft in this browser') }}</strong></template>
<template #checkDiffPush><strong>{{ t('Check diff & push') }}</strong></template>
diff --git a/apps/ui/src/features/admin/roles/RolesView.test.ts b/apps/ui/src/features/admin/roles/RolesView.test.ts
new file mode 100644
index 0000000..17a39b7
--- /dev/null
+++ b/apps/ui/src/features/admin/roles/RolesView.test.ts
@@ -0,0 +1,110 @@
+/*
+ * 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.
+ */
+
+/**
+ * The board reads "what each role can do", so a row that does nothing has to
+ * say so on screen. Reserved-ness is served by the BFF (which owns the list);
+ * these drive the rendering of it — including that a granted reserved verb
+ * still shows its check mark, so an admin auditing a config sees the grant
+ * they wrote AND that it buys nothing.
+ *
+ * Which verbs are reserved is asserted against the policy sources in the
+ * BFF's `rbac/verb-enforcement.test.ts`.
+ */
+
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { flushPromises, mount, type VueWrapper } from '@vue/test-utils';
+import { i18n } from '@/i18n';
+import RolesView from './RolesView.vue';
+
+const RESERVED = 'audit:read';
+const ENFORCED = 'alarms:read';
+
+function fakeAuthStatus(): typeof fetch {
+ const body = {
+ configPath: '/etc/horizon.yaml',
+ configMtime: null,
+ configSizeBytes: null,
+ backend: 'local',
+ bothPresent: false,
+ sessions: { active: 1 },
+ local: { users: 1, role: 'primary' },
+ ldap: null,
+ breakGlass: { configured: false, armed: false, username: null },
+ rbac: {
+ enabled: true,
+ roles: { viewer: [ENFORCED], operator: [ENFORCED, RESERVED] },
+ landingByRole: { viewer: '/' },
+ knownVerbs: [ENFORCED, RESERVED],
+ reservedVerbs: [RESERVED],
+ },
+ };
+ return vi.fn(async () =>
+ new Response(JSON.stringify(body), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ }),
+ ) as unknown as typeof fetch;
+}
+
+async function mountRoles(): Promise<VueWrapper> {
+ const w = mount(RolesView, { global: { plugins: [i18n] } });
+ await flushPromises();
+ return w;
+}
+
+/** The capability row for a verb, found by its rendered label. */
+function rowFor(w: VueWrapper, label: string): { text: string; marked: boolean } {
+ const row = w.findAll('.perm tbody tr').find((tr) => tr.text().includes(label));
+ if (!row) throw new Error(`no capability row rendered for ${JSON.stringify(label)}`);
+ return { text: row.text(), marked: row.classes().includes('is-reserved') };
+}
+
+afterEach(() => vi.unstubAllGlobals());
+
+describe('Roles board — reserved capabilities', () => {
+ it('marks a reserved verb and says granting it does nothing', async () => {
+ vi.stubGlobal('fetch', fakeAuthStatus());
+ const w = await mountRoles();
+
+ const row = rowFor(w, 'Read the audit log');
+ expect(row.marked).toBe(true);
+ expect(row.text).toContain('Reserved');
+ expect(row.text).toContain('granting it has no effect');
+ });
+
+ it('leaves an enforced verb unmarked', async () => {
+ vi.stubGlobal('fetch', fakeAuthStatus());
+ const w = await mountRoles();
+
+ const row = rowFor(w, 'See alarms');
+ expect(row.marked).toBe(false);
+ expect(row.text).not.toContain('Reserved');
+ });
+
+ it('still shows the grant a role holds on a reserved verb', async () => {
+ vi.stubGlobal('fetch', fakeAuthStatus());
+ const w = await mountRoles();
+
+ const row = w
+ .findAll('.perm tbody tr')
+ .find((tr) => tr.text().includes('Read the audit log'))!;
+ // viewer · operator, in that column order — only operator was granted it.
+ const cells = row.findAll('.td-cell .check');
+ expect(cells.map((c) => c.classes().includes('check-on'))).toEqual([false, true]);
+ });
+});
diff --git a/apps/ui/src/features/admin/roles/RolesView.vue b/apps/ui/src/features/admin/roles/RolesView.vue
index d7f1b08..7fff409 100644
--- a/apps/ui/src/features/admin/roles/RolesView.vue
+++ b/apps/ui/src/features/admin/roles/RolesView.vue
@@ -65,30 +65,62 @@ const roleNames = computed(() => Object.keys(status.value?.rbac.roles ?? {}).sor
function grantsFor(role: string): readonly string[] {
return status.value?.rbac.roles?.[role] ?? [];
}
-/** Sidebar menu → the read verb that gates its visibility (mirrors
- * AppSidebar.vue). Drives the visibility matrix so operators can read,
- * per role, exactly which navigation items appear. `null` verb = shown
- * to any signed-in user (the core data layers, cap-gated only). */
-const MENU_GATES = computed<ReadonlyArray<{ label: string; verb: string | null }>>(() => [
- { label: t('Layers (per-layer data)'), verb: null },
- { label: t('Alarms'), verb: 'alarms:read' },
- { label: t('Overviews'), verb: 'overview:read' },
- { label: t('3D infrastructure map'), verb: 'infra-3d:read' },
- { label: t('Cluster status'), verb: 'cluster:read' },
- { label: t('Platform monitoring (layers)'), verb: 'cluster:read' },
- { label: t('Metrics inspect'), verb: 'inspect:read' },
- { label: t('Data retention'), verb: 'ttl:read' },
- { label: t('OAP configuration'), verb: 'config:read' },
- { label: t('Alerting rules'), verb: 'alarm-rule:read' },
- { label: t('Live debugger · Capture history'), verb: 'live-debug:read' },
- { label: t('DSL management'), verb: 'rule:read' },
- { label: t('Overview templates'), verb: 'overview:write' },
- { label: t('Layer dashboards'), verb: 'dashboard:read' },
- { label: t('Alert page'), verb: 'alarm-setup:read' },
- { label: t('Global defaults'), verb: 'setup:read' },
- { label: t('Users'), verb: 'user:read' },
- { label: t('Auth status'), verb: 'auth:read' },
- { label: t('Roles & permissions'), verb: 'role:read' },
+/** Every navigation entry Horizon renders → the read verb that decides
+ * whether a role sees it. `null` verb = shown to any signed-in user.
+ *
+ * `covers` names the sidebar destinations a row stands for, in sidebar
+ * order. Usually one row per entry; a row may stand for several when they
+ * need the same permission — an expandable entry's sub-entries, or sibling
+ * entries of one feature (Live debugger and its Capture history). It is empty for the nav the shell
+ * builds itself rather than from the menu registry — the overview links,
+ * Alarms, the layer rows, the per-layer platform-monitoring rows — and
+ * for the 3D map, which is reached from the topbar. A drift gate in the
+ * BFF's rbac tests reads `covers` against that registry, so an entry
+ * added to the sidebar with no row here fails. */
+interface MenuGate {
+ label: string;
+ verb: string | null;
+ covers: readonly string[];
+}
+const MENU_GATES = computed<readonly MenuGate[]>(() => [
+ { label: t('Overviews'), verb: 'overview:read', covers: [] },
+ { label: t('Alarms'), verb: 'alarms:read', covers: [] },
+ { label: t('Layers (per-layer data)'), verb: null, covers: [] },
+ { label: t('3D infrastructure map'), verb: 'infra-3d:read', covers: [] },
+ { label: t('Cluster status'), verb: 'cluster:read', covers: ['/operate/cluster'] },
+ { label: t('Data retention'), verb: 'ttl:read', covers: ['/operate/ttl'] },
+ { label: t('OAP configuration'), verb: 'config:read', covers: ['/operate/config'] },
+ { label: t('Platform monitoring (layers)'), verb: 'cluster:read', covers: [] },
+ { label: t('Alerting rules'), verb: 'alarm-rule:read', covers: ['/operate/alerting-rules'] },
+ {
+ label: t('DSL management'),
+ verb: 'rule:read',
+ covers: [
+ '/operate/dsl/otel-rules',
+ '/operate/dsl/telegraf-rules',
+ '/operate/dsl/lal',
+ '/operate/dsl/log-mal-rules',
+ '/operate/oal',
+ '/operate/dsl/dump',
+ ],
+ },
+ {
+ label: t('Live debugger · Capture history'),
+ verb: 'live-debug:read',
+ covers: ['/operate/live-debug', '/operate/live-debug/history'],
+ },
+ { label: t('Metrics inspect'), verb: 'inspect:read', covers: ['/operate/inspect'] },
+ { label: t('Trace inspect'), verb: 'inspect:read', covers: ['/operate/trace-inspect'] },
+ { label: t('Log inspect'), verb: 'inspect:read', covers: ['/operate/log-inspect'] },
+ { label: t('Overview templates'), verb: 'overview:write', covers: ['/admin/overview-templates'] },
+ { label: t('Layer dashboards'), verb: 'dashboard:read', covers: ['/admin/layer-dashboards'] },
+ { label: t('Translations'), verb: 'overview:write', covers: ['/admin/translations'] },
+ { label: t('Alert page'), verb: 'alarm-setup:read', covers: ['/admin/alert-page-setup'] },
+ { label: t('3D Infra Map setup'), verb: 'overview:write', covers: ['/admin/3d-map'] },
+ { label: t('Global defaults'), verb: 'setup:read', covers: ['/admin/global-defaults'] },
+ { label: t('Users'), verb: 'user:read', covers: ['/admin/users'] },
+ { label: t('Auth status'), verb: 'auth:read', covers: ['/admin/auth-status'] },
+ { label: t('Roles & permissions'), verb: 'role:read', covers: ['/admin/roles'] },
]);
/** Is the menu row visible to the role? `null` verb ⇒ any signed-in user. */
function menuVisible(role: string, verb: string | null): boolean {
@@ -137,14 +169,18 @@ const VERB_LABELS = computed<Record<string, { label: string; hint?: string }>>((
'dashboard:read': { label: t('See layer dashboard templates') },
'dashboard:write': { label: t('Edit layer dashboard templates') },
'setup:read': { label: t('See Global defaults') },
+ 'ttl:read': { label: t('See data retention settings') },
+ 'config:read': { label: t('See the OAP runtime configuration') },
+ 'browser-errors:read': { label: t('See browser error logs') },
+ 'source-map:write': { label: t('Upload and remove source maps') },
+ 'ai:read': { label: t('Use the AI assistant') },
'alarm-rule:read': { label: t('See alarm rules') },
'alarm-rule:write': { label: t('Edit alarm rules') },
'alarm-setup:read': { label: t('See Alert page setup') },
'rule:read': { label: t('See DSL / OAL / MQE rules') },
'rule:write': { label: t('Edit existing rules') },
- 'rule:write:structural': { label: t('Change rule schema (OAL files)'), hint: t('schema-breaking edits') },
+ 'rule:write:structural': { label: t('Apply a rule edit that changes storage, or revert a rule'), hint: t('schema-breaking edits') },
'rule:delete': { label: t('Delete rules') },
- 'rule:debug': { label: t('Test queries in the MQE sandbox') },
'live-debug:read': { label: t('See live-debug sessions') },
'live-debug:write': { label: t('Start and stop live-debug sessions') },
'profile:enable': { label: t('Start a profiling task on a target') },
@@ -160,6 +196,16 @@ function labelFor(verb: string): { label: string; hint?: string } {
return VERB_LABELS.value[verb] ?? { label: verb };
}
+/** Verbs the server declares but nothing checks. Rendered marked so the row
+ * isn't read as a working capability — the check marks still reflect the
+ * live grant, which is what an admin auditing their config wants to see. */
+const reservedVerbs = computed<ReadonlySet<string>>(
+ () => new Set(status.value?.rbac.reservedVerbs ?? []),
+);
+function isReserved(verb: string): boolean {
+ return reservedVerbs.value.has(verb);
+}
+
/** Groups verbs by feature area + supplies a scope mock-sidebar so the
* reader can see WHICH menu items the group governs. */
interface ScopeItem {
@@ -191,12 +237,24 @@ const VERB_GROUPS = computed<VerbGroup[]>(() => [
},
{
title: t('Platform monitoring'),
- blurb: t('Watching SkyWalking itself: is the OAP cluster healthy, are modules loaded, what does the internal metric catalog look like.'),
+ blurb: t('Watching SkyWalking itself: is the OAP cluster healthy, what does the internal metric catalog look like, how long is data kept, what is the running configuration.'),
scope: [
{ label: t('OAP cluster status'), icon: '⌬' },
{ label: t('Module inspector'), icon: '⌕' },
+ { label: t('MQE sandbox'), icon: '▶' },
+ { label: t('Data retention'), icon: '⏱' },
+ { label: t('OAP configuration'), icon: '⚙' },
+ ],
+ verbs: ['cluster:read', 'inspect:read', 'ttl:read', 'config:read'],
+ },
+ {
+ title: t('Browser monitoring'),
+ blurb: t('The JavaScript error logs a BROWSER-layer application reports, plus the source maps that turn a minified stack trace into readable frames.'),
+ scope: [
+ { label: t('Browser Logs'), icon: '≡' },
+ { label: t('Source maps'), icon: '⌗' },
],
- verbs: ['cluster:read', 'inspect:read'],
+ verbs: ['browser-errors:read', 'source-map:write'],
},
{
title: t('Overview templates'),
@@ -236,21 +294,29 @@ const VERB_GROUPS = computed<VerbGroup[]>(() => [
},
{
title: t('Diagnostics & debug'),
- blurb: t('Interactive troubleshooting: the live debugger that streams events from a running rule, and the MQE sandbox for testing queries without saving anything.'),
+ blurb: t('Interactive troubleshooting: the live debugger runs a rule against live traffic and streams what it produced. Watching a capture needs only the live-debug read permission; starting or stopping one needs live-debug write.'),
scope: [
{ label: t('Live debugger'), icon: '◉' },
- { label: t('MQE sandbox'), icon: '▶' },
],
- verbs: ['rule:debug', 'live-debug:read', 'live-debug:write'],
+ verbs: ['live-debug:read', 'live-debug:write'],
},
{
title: t('Profiling'),
- blurb: t('Starting a profiling task — agent-side sampling, async-profiler, pprof, or eBPF. Reading the results is part of "Data catalog" above.'),
+ blurb: t('Starting a profiling task — agent-side sampling, async-profiler, pprof, or eBPF — and arming the continuous-profiling policies that raise tasks on their own. Reading the results is part of "Data catalog" above.'),
scope: [
{ label: t('Start a profiling task'), icon: '▦' },
+ { label: t('Continuous-profiling policies'), icon: '◍' },
],
verbs: ['profile:enable'],
},
+ {
+ title: t('AI Assistant'),
+ blurb: t('Sending a message to the assistant. Each of its data tools re-checks the read permission the matching screen needs, so the assistant never reads more than the signed-in user can.'),
+ scope: [
+ { label: t('AI Assistant'), icon: '✧' },
+ ],
+ verbs: ['ai:read'],
+ },
{
title: t('Users & access admin'),
blurb: t('The administrator surface: who can sign in, what role they have, and the live status of the auth backend.'),
@@ -341,13 +407,14 @@ function grantsOf(role: string): string[] {
</div>
</section>
- <!-- Menu visibility matrix: which sidebar items each role sees.
- Computed live from the policy via the same verb gates the
- sidebar uses, so it stays honest if roles are reconfigured. -->
+ <!-- Menu visibility matrix. The check marks are computed live from the
+ policy through the same verb gates the sidebar itself applies, so
+ they stay honest when roles are reconfigured; that the row LIST
+ stays complete is held by the drift gate described on MENU_GATES. -->
<section class="sw-card menu-matrix">
<header class="card-head">
<h3>{{ t('Menu visibility') }}</h3>
- <span class="muted">{{ t('which sidebar items each role sees · gated by the read verb in the last column (UI hides; the BFF enforces the same server-side)') }}</span>
+ <span class="muted">{{ t('every navigation entry, and the read verb that decides which roles see it · an entry that expands stands for the sub-entries under it, which need the same permission (the UI hides the entry; the BFF enforces the same verb server-side)') }}</span>
</header>
<div class="matrix-scroll">
<table class="matrix">
@@ -417,10 +484,16 @@ function grantsOf(role: string): string[] {
</tr>
</thead>
<tbody>
- <tr v-for="v in g.items" :key="v">
+ <tr v-for="v in g.items" :key="v" :class="{ 'is-reserved': isReserved(v) }">
<td class="td-cap">
- <div class="cap-label">{{ labelFor(v).label }}</div>
- <div v-if="labelFor(v).hint" class="cap-hint">{{ labelFor(v).hint }}</div>
+ <div class="cap-label">
+ <span>{{ labelFor(v).label }}</span>
+ <span v-if="isReserved(v)" class="pill pill-muted">{{ t('Reserved') }}</span>
+ </div>
+ <div v-if="isReserved(v)" class="cap-hint">
+ {{ t('No screen or request checks this — granting it has no effect.') }}
+ </div>
+ <div v-else-if="labelFor(v).hint" class="cap-hint">{{ labelFor(v).hint }}</div>
</td>
<td v-for="r in roleNames" :key="r" class="td-cell">
<span v-if="hasVerb(grantsOf(r), v)" class="check check-on" :aria-label="t('allowed')">✓</span>
@@ -621,8 +694,17 @@ function grantsOf(role: string): string[] {
.perm tr:last-child td { border-bottom: none; }
.perm tbody tr:hover { background: rgba(255,255,255,0.02); }
.td-cap { padding: 8px 14px; vertical-align: top; }
-.cap-label { color: var(--sw-fg-0); font-weight: var(--sw-fw-medium); }
+.cap-label {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: var(--sw-fg-0);
+ font-weight: var(--sw-fw-medium);
+}
.cap-hint { color: var(--sw-fg-3); font-size: var(--sw-fs-xs); margin-top: 2px; }
+/* A reserved row is still readable — it just stops competing with the
+ capabilities that do something. */
+.perm tr.is-reserved .cap-label { color: var(--sw-fg-2); font-weight: var(--sw-fw-regular); }
.td-cell { text-align: center; padding: 8px 12px; }
.check {
display: inline-block;
@@ -659,6 +741,7 @@ function grantsOf(role: string): string[] {
.pill-err { color: var(--sw-err); background: rgba(239,68,68,0.16); border-color: rgba(239,68,68,0.33); }
.pill-info { color: var(--sw-info); background: rgba(56,189,248,0.16); border-color: rgba(56,189,248,0.33); }
.pill-cyan { color: var(--sw-cyan, #22d3ee); background: rgba(34,211,238,0.14); border-color: rgba(34,211,238,0.33); }
+.pill-muted { color: var(--sw-fg-3); background: transparent; border-color: var(--sw-line); }
.rules-card { padding: 0; }
.rules-grid {
diff --git a/apps/ui/src/features/admin/roles/menuVisibility.test.ts b/apps/ui/src/features/admin/roles/menuVisibility.test.ts
new file mode 100644
index 0000000..275ad2b
--- /dev/null
+++ b/apps/ui/src/features/admin/roles/menuVisibility.test.ts
@@ -0,0 +1,233 @@
+/*
+ * 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.
+ */
+
+/**
+ * The Roles board states its menu matrix is every navigation entry and the
+ * verb that decides who sees it. That the row LIST stays complete is held
+ * statically by the BFF's `rbac/verb-enforcement.test.ts`; this holds the two
+ * BEHAVIOURS against each other — the sidebar composable actually run for a
+ * role, and the matrix actually rendered for the same grants. A row that reads
+ * "hidden" while the entry still renders (or the reverse) is a lie an admin
+ * plans access around.
+ */
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { defineComponent } from 'vue';
+import { flushPromises, mount, type VueWrapper } from '@vue/test-utils';
+import { createMemoryHistory, createRouter, type Router } from 'vue-router';
+import { createPinia, setActivePinia, type Pinia } from 'pinia';
+import { i18n } from '@/i18n';
+import { useAuthStore } from '@/state/auth';
+import { useSidebarMenu } from '@/shell/useSidebarMenu';
+import RolesView from './RolesView.vue';
+
+/**
+ * One case per verb the static menu hides an entry by: the matrix rows that
+ * name it, and the sidebar destinations it gates. Withholding the verb must
+ * take away exactly these, on both surfaces.
+ */
+const GATES: ReadonlyArray<{ verb: string; rows: string[]; paths: string[] }> = [
+ { verb: 'cluster:read', rows: ['Cluster status', 'Platform monitoring (layers)'], paths: ['/operate/cluster'] },
+ { verb: 'ttl:read', rows: ['Data retention'], paths: ['/operate/ttl'] },
+ { verb: 'config:read', rows: ['OAP configuration'], paths: ['/operate/config'] },
+ { verb: 'alarm-rule:read', rows: ['Alerting rules'], paths: ['/operate/alerting-rules'] },
+ {
+ verb: 'rule:read',
+ rows: ['DSL management'],
+ // The expandable L1 and every sub-entry under it.
+ paths: [
+ '/operate/dsl/otel-rules',
+ '/operate/dsl/telegraf-rules',
+ '/operate/dsl/lal',
+ '/operate/dsl/log-mal-rules',
+ '/operate/oal',
+ '/operate/dsl/dump',
+ ],
+ },
+ {
+ verb: 'live-debug:read',
+ rows: ['Live debugger · Capture history'],
+ paths: ['/operate/live-debug', '/operate/live-debug/history'],
+ },
+ {
+ verb: 'inspect:read',
+ rows: ['Metrics inspect', 'Trace inspect', 'Log inspect'],
+ paths: ['/operate/inspect', '/operate/trace-inspect', '/operate/log-inspect'],
+ },
+ {
+ verb: 'overview:write',
+ rows: ['Overview templates', 'Translations', '3D Infra Map setup'],
+ paths: ['/admin/overview-templates', '/admin/translations', '/admin/3d-map'],
+ },
+ { verb: 'dashboard:read', rows: ['Layer dashboards'], paths: ['/admin/layer-dashboards'] },
+ { verb: 'alarm-setup:read', rows: ['Alert page'], paths: ['/admin/alert-page-setup'] },
+ { verb: 'setup:read', rows: ['Global defaults'], paths: ['/admin/global-defaults'] },
+ { verb: 'user:read', rows: ['Users'], paths: ['/admin/users'] },
+ { verb: 'auth:read', rows: ['Auth status'], paths: ['/admin/auth-status'] },
+ { verb: 'role:read', rows: ['Roles & permissions'], paths: ['/admin/roles'] },
+];
+
+/** The verbs the shell's own nav blocks are gated on — no static-menu entry
+ * rides on these, so they only move check marks. */
+const SHELL_ONLY = ['overview:read', 'alarms:read', 'infra-3d:read'];
+
+const ALL_VERBS = [...GATES.map((g) => g.verb), ...SHELL_ONLY];
+
+/** Renders the real menu registry's destinations for the signed-in grants —
+ * L1 rows and the sub-entries of an expandable one. */
+const SidebarHarness = defineComponent({
+ setup: () => useSidebarMenu(),
+ template: `
+ <ul>
+ <template v-for="s in (platformSection ? [platformSection, ...menuSections] : menuSections)" :key="s.kicker">
+ <li v-for="r in s.links" :key="r.to">
+ <span class="entry">{{ r.to }}</span>
+ <span v-for="c in (r.children ?? [])" :key="c.to" class="entry">{{ c.to }}</span>
+ </li>
+ </template>
+ </ul>`,
+});
+
+let router: Router;
+let pinia: Pinia;
+
+/** Destinations the sidebar renders for a user holding exactly `verbs`. */
+function sidebarEntries(verbs: string[]): string[] {
+ setActivePinia(pinia);
+ useAuthStore().user = { username: 'op', roles: ['test'], verbs };
+ const w = mount(SidebarHarness, { global: { plugins: [pinia, router, i18n] } });
+ return w.findAll('.entry').map((e) => e.text());
+}
+
+/** The board's live policy read. Two roles: one holding everything the matrix
+ * can show, one holding all of it but `without`. */
+function fakeAuthStatus(without: string | null): typeof fetch {
+ const limited = ALL_VERBS.filter((v) => v !== without);
+ const body = {
+ configPath: '/etc/horizon.yaml',
+ configMtime: null,
+ configSizeBytes: null,
+ backend: 'local',
+ bothPresent: false,
+ sessions: { active: 1 },
+ local: { users: 2, role: 'primary' },
+ ldap: null,
+ breakGlass: { configured: false, armed: false, username: null },
+ rbac: {
+ enabled: true,
+ roles: { full: ALL_VERBS, limited },
+ landingByRole: {},
+ knownVerbs: ALL_VERBS,
+ reservedVerbs: [],
+ },
+ };
+ return vi.fn(async () =>
+ new Response(JSON.stringify(body), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ }),
+ ) as unknown as typeof fetch;
+}
+
+interface MatrixRow {
+ label: string;
+ /** Per role column, in `roleNames` order: `full`, then `limited`. */
+ visible: boolean[];
+ verb: string;
+}
+
+async function matrixFor(without: string | null): Promise<MatrixRow[]> {
+ vi.stubGlobal('fetch', fakeAuthStatus(without));
+ const w: VueWrapper = mount(RolesView, { global: { plugins: [i18n] } });
+ await flushPromises();
+ const rows = w.findAll('.matrix tbody tr').map((tr) => ({
+ label: tr.get('td.m-menu').text(),
+ visible: tr.findAll('td.m-cell').map((td) => td.find('.yes').exists()),
+ verb: tr.get('td.m-verb').text(),
+ }));
+ expect(rows.length, 'the menu matrix rendered no rows').toBeGreaterThan(0);
+ return rows;
+}
+
+beforeEach(async () => {
+ pinia = createPinia();
+ setActivePinia(pinia);
+ router = createRouter({
+ history: createMemoryHistory(),
+ routes: [{ path: '/:pathMatch(.*)*', component: { template: '<div />' } }],
+ });
+ await router.push('/admin/roles');
+ await router.isReady();
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe('menu visibility — the matrix and the sidebar answer alike', () => {
+ it('renders every entry the matrix accounts for when nothing is withheld', () => {
+ const shown = sidebarEntries(ALL_VERBS);
+ for (const g of GATES) {
+ for (const path of g.paths) expect(shown, `${path} never rendered`).toContain(path);
+ }
+ });
+
+ it.each(GATES)('withholding $verb hides its entries and its rows alike', async (gate) => {
+ const kept = ALL_VERBS.filter((v) => v !== gate.verb);
+ const shown = sidebarEntries(kept);
+
+ for (const path of gate.paths) {
+ expect(shown, `${path} still renders without ${gate.verb}`).not.toContain(path);
+ }
+ // Only its own: withholding one verb must not take an unrelated entry with it.
+ const others = GATES.filter((g) => g.verb !== gate.verb).flatMap((g) => g.paths);
+ for (const path of others) {
+ expect(shown, `${path} disappeared with ${gate.verb}, which does not gate it`).toContain(path);
+ }
+
+ const rows = await matrixFor(gate.verb);
+ for (const row of rows) {
+ const gatedByThis = gate.rows.includes(row.label);
+ expect(row.visible[0], `${row.label}: hidden from a role holding everything`).toBe(true);
+ expect(
+ row.visible[1],
+ `${row.label} reads ${row.visible[1] ? 'visible' : 'hidden'} for a role without ${gate.verb}`,
+ ).toBe(!gatedByThis);
+ }
+ });
+
+ it('labels each row with the verb the sidebar hides it by', async () => {
+ const rows = await matrixFor(null);
+ const verbOf = new Map(rows.map((r) => [r.label, r.verb]));
+ for (const gate of GATES) {
+ for (const label of gate.rows) {
+ expect(verbOf.get(label), `no matrix row labelled "${label}"`).toBe(gate.verb);
+ }
+ }
+ });
+
+ // The four rows that carry no destination stand for nav the shell builds
+ // itself; they still have to move with their verb.
+ it('moves the shell rows with their own verb', async () => {
+ for (const verb of SHELL_ONLY) {
+ const rows = await matrixFor(verb);
+ const hidden = rows.filter((r) => !r.visible[1]).map((r) => r.label);
+ expect(hidden.length, `withholding ${verb} hid no row`).toBeGreaterThan(0);
+ for (const label of hidden) expect(rows.find((r) => r.label === label)?.verb).toBe(verb);
+ }
+ });
+});
diff --git a/apps/ui/src/features/operate/dsl/DslDumpView.vue b/apps/ui/src/features/operate/dsl/DslDumpView.vue
index ff363f1..3527f4a 100644
--- a/apps/ui/src/features/operate/dsl/DslDumpView.vue
+++ b/apps/ui/src/features/operate/dsl/DslDumpView.vue
@@ -38,7 +38,7 @@ function dumpCatalog(c: Catalog): void {
<div class="dump">
<AdminFeatureWarning module="receiver-runtime-rule" :feature-label="t('Runtime-rule dump')" />
<header class="dump__header">
- <h1 class="dump__h1">{{ t('Dump & restore') }}</h1>
+ <h1 class="dump__h1">{{ t('Runtime-rule dump') }}</h1>
<Pill tone="dim">{{ t('tar.gz') }}</Pill>
</header>
@@ -71,16 +71,6 @@ function dumpCatalog(c: Catalog): void {
</Btn>
</div>
</section>
-
- <section class="dump__section dump__section--disabled">
- <header class="dump__sectionhead">
- {{ t('restore') }}
- <Pill tone="dim">{{ t('later release') }}</Pill>
- </header>
- <i18n-t keypath="OAP doesn't expose a restore endpoint yet. Operators recover from a dump by looping {endpoint} over each YAML file in the archive. A restore upload affordance will land here when the upstream API ships — see Studio's deferred-features memory." tag="p" class="dump__hint">
- <template #endpoint><code>POST /runtime/rule/addOrUpdate</code></template>
- </i18n-t>
- </section>
</div>
</template>
@@ -118,10 +108,6 @@ function dumpCatalog(c: Catalog): void {
padding: 16px 18px;
}
-.dump__section--disabled {
- opacity: 0.7;
-}
-
.dump__sectionhead {
display: flex;
align-items: center;
diff --git a/apps/ui/src/i18n/locales/de.json b/apps/ui/src/i18n/locales/de.json
index cf40425..6e2d2ac 100644
--- a/apps/ui/src/i18n/locales/de.json
+++ b/apps/ui/src/i18n/locales/de.json
@@ -42,7 +42,6 @@
"Alerting rules": "Alarmregeln",
"DSL management": "DSL-Verwaltung",
"OAL · read-only": "OAL · schreibgeschützt",
- "Dump & restore": "Sichern & Wiederherstellen",
"Live debugger": "Live-Debugger",
"Capture history": "Aufzeichnungsverlauf",
"Metrics inspect": "Metrik-Inspektion",
@@ -612,7 +611,7 @@
"Tried {url}. Confirm the OAP admin-server module is on (SW_ADMIN_SERVER=default) and the port is exposed on the network / k8s Service / ingress.": "Versucht: {url}. Stellen Sie sicher, dass das OAP-Modul admin-server aktiv ist (SW_ADMIN_SERVER=default) und der Port im Netzwerk / k8s Service / Ingress freigegeben ist.",
"OAP's Zipkin v2 endpoint, source for the OpenTelemetry & Zipkin trace menu (shown when a layer's trace source is zipkin or both). This is the only page affected — if it's unreachable, native traces and every other observability page keep working.": "Der Zipkin-v2-Endpunkt von OAP, Quelle für das Trace-Menü von OpenTelemetry und Zipkin (sichtbar, wenn die Trace-Quelle eines Layers zipkin oder both ist). Nur diese Seite ist betroffen — bei Nichterreichbarkeit funktionieren native Traces [...]
"Tried {url}. Confirm OAP's Zipkin receiver / query is enabled and the oap.zipkinUrl in horizon's config points at the right host:port (shared GraphQL port → <queryUrl>/zipkin; standalone → :9412/zipkin). Only the Zipkin trace menu is affected.": "Versucht: {url}. Stellen Sie sicher, dass OAPs Zipkin-Receiver / -Query aktiv ist und oap.zipkinUrl in der Horizon-Konfiguration auf den richtigen host:port zeigt (gemeinsamer GraphQL-Port → <queryUrl>/zipkin; eigenständig → :9412/zipkin). Be [...]
- "Everything Studio does against the admin port. Without admin-server, the other three modules fail at boot with ModuleNotFoundException.": "Alles, was Studio gegen den Admin-Port macht. Ohne admin-server scheitern die anderen drei Module beim Start mit ModuleNotFoundException.",
+ "Everything Horizon does against the admin port. Without admin-server, the other modules fail at boot with ModuleNotFoundException.": "Alles, was Horizon über den Admin-Port macht. Ohne admin-server scheitern die anderen Module beim Start mit ModuleNotFoundException.",
"DSL Management (Catalog, OAL catalog), Editor save/load, Cluster status rule matrix, Live debugger rule picker, and the Inspect drawer's source attribution.": "DSL-Verwaltung (Catalog, OAL catalog), Speichern/Laden im Editor, Regelmatrix in Cluster Status, Regelauswahl im Live Debugger und die Quellenzuordnung im Inspect-Drawer.",
"Live debugger across MAL / LAL / OAL (start / poll / stop) and the DSL-debugging health pane on Cluster status.": "Live Debugger über MAL / LAL / OAL (Start / Abfrage / Stopp) und das DSL-debugging-Statuspanel in Cluster Status.",
"The Inspect page — every /api/inspect/* call returns 404 inspect_not_enabled and the page shows a banner instead of the board.": "Die Inspect-Seite — jeder /api/inspect/*-Aufruf liefert 404 inspect_not_enabled, und die Seite zeigt einen Banner statt der Übersicht.",
@@ -656,7 +655,7 @@
"Capabilities that have not been described yet.": "Fähigkeiten, die noch nicht beschrieben wurden.",
"Catalog load failed:": "Katalog konnte nicht geladen werden:",
"Change role grants": "Rollen-Berechtigungen ändern",
- "Change rule schema (OAL files)": "Regelschema ändern (OAL-Dateien)",
+ "Apply a rule edit that changes storage, or revert a rule": "Regeländerung mit Speicherwirkung anwenden oder Regel zurücksetzen",
"Config file": "Konfigurationsdatei",
"Configures alerts, dashboards, rules, and runs diagnostics.": "Konfiguriert Alerts, Dashboards, Regeln und führt Diagnosen aus.",
"Currently watching ({n})": "Aktuell beobachtet ({n})",
@@ -665,6 +664,10 @@
"Dashboard setup · Alert page": "Dashboard-Setup · Alert-Seite",
"Dashboard setup · Global defaults": "Dashboard-Setup · Globale Standards",
"Data catalog": "Datenkatalog",
+ "Browser monitoring": "Browser-Monitoring",
+ "The JavaScript error logs a BROWSER-layer application reports, plus the source maps that turn a minified stack trace into readable frames.": "Die JavaScript-Fehlerlogs einer Anwendung im BROWSER-Layer sowie die Source-Maps, die einen minifizierten Stacktrace in lesbare Frames verwandeln.",
+ "Sending a message to the assistant. Each of its data tools re-checks the read permission the matching screen needs, so the assistant never reads more than the signed-in user can.": "Eine Nachricht an den Assistenten senden. Jedes seiner Daten-Tools prüft erneut die Leseberechtigung, die auch die entsprechende Ansicht verlangt — der Assistent liest also nie mehr, als die angemeldete Person sehen darf.",
+ "Watching SkyWalking itself: is the OAP cluster healthy, what does the internal metric catalog look like, how long is data kept, what is the running configuration.": "SkyWalking selbst im Blick: Ist der OAP-Cluster gesund, wie sieht der interne Metrik-Katalog aus, wie lange werden Daten aufbewahrt, welche Konfiguration läuft gerade.",
"Default time window": "Standard-Zeitfenster",
"Default window for the topbar time picker, which feeds {scope}. The OAP {step} precision is derived from the window — you don't pick it separately:": "Standardfenster für den Zeitwähler in der oberen Leiste, das {scope} versorgt. Die OAP-Genauigkeit ({step}) wird aus dem Fenster abgeleitet — du wählst sie nicht separat:",
"Defaults are": "Standardwerte sind",
@@ -700,7 +703,7 @@
"Inspect": "Inspizieren",
"Inspect API not enabled on OAP.": "Die Inspect-API ist auf dem OAP nicht aktiviert.",
"Inspect OAP internals (catalog, MQE)": "OAP-Interna inspizieren (Katalog, MQE)",
- "Interactive troubleshooting: the live debugger that streams events from a running rule, and the MQE sandbox for testing queries without saving anything.": "Interaktive Fehlersuche: der Live-Debugger, der Ereignisse aus einer laufenden Regel streamt, und die MQE-Sandbox zum Testen von Abfragen, ohne etwas zu speichern.",
+ "Interactive troubleshooting: the live debugger runs a rule against live traffic and streams what it produced. Watching a capture needs only the live-debug read permission; starting or stopping one needs live-debug write.": "Interaktive Fehlersuche: Der Live-Debugger führt eine Regel am Live-Traffic aus und streamt, was dabei entsteht. Zum Mitlesen einer Aufzeichnung genügt die Live-Debug-Leseberechtigung; zum Starten oder Stoppen braucht es die Live-Debug-Schreibberechtigung.",
"LDAP + local fallback": "LDAP + lokaler Fallback",
"LDAP backend": "LDAP-Backend",
"LDAP group DN": "LDAP-Gruppen-DN",
@@ -761,6 +764,8 @@
"Reachable": "Erreichbar",
"Read the audit log": "Audit-Log lesen",
"Read verb": "Lese-Verb",
+ "Reserved": "Reserviert",
+ "No screen or request checks this — granting it has no effect.": "Keine Ansicht und keine Anfrage prüft dies — die Vergabe bleibt wirkungslos.",
"Read-only catalog of every alarm rule loaded by OAP, with its body and per-node load state. Rules edit through OAP's {file} + watcher reload — there's no mutation surface here by design.": "Schreibgeschützter Katalog jeder vom OAP geladenen Alarmregel, mit ihrem Inhalt und ihrem Ladezustand pro Knoten. Regeln werden über die {file} des OAP plus Watcher-Reload bearbeitet — hier gibt es bewusst keine Änderungsschnittstelle.",
"Read-only data screens. Everyone signed in sees these by default — they are the core observability surface.": "Schreibgeschützte Datenansichten. Jeder angemeldete Benutzer sieht sie standardmäßig — sie bilden die Kern-Observability-Oberfläche.",
"Reads dashboards, traces, logs, and alarms.": "Liest Dashboards, Traces, Logs und Alarme.",
@@ -783,6 +788,11 @@
"See live-debug sessions": "Live-Debug-Sitzungen ansehen",
"See logs": "Logs ansehen",
"See metric dashboards": "Metrik-Dashboards ansehen",
+ "See data retention settings": "Einstellungen zur Datenaufbewahrung ansehen",
+ "See the OAP runtime configuration": "Laufzeitkonfiguration des OAP ansehen",
+ "See browser error logs": "Browser-Fehlerlogs ansehen",
+ "Upload and remove source maps": "Source-Maps hochladen und entfernen",
+ "Use the AI assistant": "KI-Assistenten nutzen",
"See overview templates": "Übersichts-Vorlagen ansehen",
"See profiling results": "Profiling-Ergebnisse ansehen",
"See service & endpoint topology": "Service- und Endpoint-Topologie ansehen",
@@ -803,11 +813,11 @@
"Source: LDAP": "Quelle: LDAP",
"Source: Local": "Quelle: Lokal",
"Start a profiling task": "Profiling-Aufgabe starten",
+ "Continuous-profiling policies": "Continuous-Profiling-Policies",
"Start a profiling task on a target": "Profiling-Aufgabe auf einem Ziel starten",
"Start and stop live-debug sessions": "Live-Debug-Sitzungen starten und stoppen",
- "Starting a profiling task — agent-side sampling, async-profiler, pprof, or eBPF. Reading the results is part of \"Data catalog\" above.": "Eine Profiling-Aufgabe starten — Agent-seitiges Sampling, async-profiler, pprof oder eBPF. Das Lesen der Ergebnisse ist Teil von „Datenkatalog“ oben.",
+ "Starting a profiling task — agent-side sampling, async-profiler, pprof, or eBPF — and arming the continuous-profiling policies that raise tasks on their own. Reading the results is part of \"Data catalog\" above.": "Ein Profiling-Task starten — Agent-Sampling, async-profiler, pprof oder eBPF — und die Continuous-Profiling-Policies scharf schalten, die Tasks selbstständig auslösen. Das Lesen der Ergebnisse gehört zum „Datenkatalog“ weiter oben.",
"Test a username": "Einen Benutzernamen testen",
- "Test queries in the MQE sandbox": "Abfragen in der MQE-Sandbox testen",
"The MQE, OAL and runtime-rule editing surface. Schema-breaking edits and deletes are split out so a junior operator can author rules without being able to break things irreversibly.": "Die Bearbeitungsoberfläche für MQE, OAL und Laufzeitregeln. Schemabrechende Änderungen und Löschungen sind ausgelagert, sodass ein unerfahrener Operator Regeln verfassen kann, ohne unwiderruflich Schaden anrichten zu können.",
"The administrator surface: who can sign in, what role they have, and the live status of the auth backend.": "Die Administrationsoberfläche: wer sich anmelden kann, welche Rolle er hat und der Live-Status des Auth-Backends.",
"The auditable record of every sign-in, rule change, and configuration edit. Written to a file the operator can ship to an SIEM; no in-app viewer yet.": "Der prüfbare Verlauf jeder Anmeldung, Regeländerung und Konfigurationsbearbeitung. Wird in eine Datei geschrieben, die der Operator an ein SIEM weitergeben kann; noch kein In-App-Viewer.",
@@ -828,7 +838,6 @@
"Users defined": "Definierte Benutzer",
"Waiting for the first poll to return records…": "Warten auf die ersten Datensätze aus dem Polling…",
"Watches the SkyWalking platform itself (cluster, internals).": "Überwacht die SkyWalking-Plattform selbst (Cluster, Interna).",
- "Watching SkyWalking itself: is the OAP cluster healthy, are modules loaded, what does the internal metric catalog look like.": "SkyWalking selbst beobachten: Ist der OAP-Cluster gesund, sind die Module geladen, wie sieht der interne Metrik-Katalog aus.",
"What each role can do": "Was jede Rolle tun kann",
"What you can do": "Was du tun kannst",
"Window": "Fenster",
@@ -1014,7 +1023,7 @@
"value": "Wert",
"values": "Werte",
"visible": "sichtbar",
- "which sidebar items each role sees · gated by the read verb in the last column (UI hides; the BFF enforces the same server-side)": "welche Sidebar-Einträge jede Rolle sieht · gesteuert durch das Lese-Verb in der letzten Spalte (das UI blendet aus; das BFF erzwingt dasselbe serverseitig)",
+ "every navigation entry, and the read verb that decides which roles see it · an entry that expands stands for the sub-entries under it, which need the same permission (the UI hides the entry; the BFF enforces the same verb server-side)": "jeder Navigationseintrag und das Lese-Verb, das entscheidet, welche Rollen ihn sehen · ein aufklappbarer Eintrag steht zugleich für seine Untereinträge, die dieselbe Berechtigung benötigen (das UI blendet den Eintrag aus; das BFF erzwingt dasselbe Ver [...]
"with": "mit",
"{field} is required for scope {scope}": "{field} ist erforderlich für Scope {scope}",
"{minutes} → {precision} step, {buckets} per query.": "{minutes} → Schritt {precision}, {buckets} pro Abfrage.",
@@ -1080,7 +1089,6 @@
"No rules match the current filter.": "Keine Regeln passen zum aktuellen Filter.",
"Normal": "Normal",
"OAL hot-update is upstream-deferred. Each": "OAL-Hot-Update ist upstream-verschoben. Jede",
- "OAP doesn't expose a restore endpoint yet. Operators recover from a dump by looping {endpoint} over each YAML file in the archive. A restore upload affordance will land here when the upstream API ships — see Studio's deferred-features memory.": "OAP bietet noch keinen Restore-Endpunkt. Operatoren stellen aus einem Dump wieder her, indem sie {endpoint} über jede YAML-Datei im Archiv laufen lassen. Eine Restore-Upload-Funktion erscheint hier, sobald die Upstream-API verfügbar ist — sieh [...]
"OAP drops the existing measure’s data on BanyanDB and orphans rows on JDBC / Elasticsearch.": "OAP verwirft die Daten des bestehenden Measure auf BanyanDB und lässt Zeilen auf JDBC / Elasticsearch verwaisen.",
"OAP runs the standard apply pipeline against the bundled YAML — this is a schema change.": "OAP führt die Standard-Apply-Pipeline gegen das gebündelte YAML aus — dies ist eine Schemaänderung.",
"Observability Analysis Language": "Observability Analysis Language",
@@ -1133,7 +1141,6 @@
"inactivated · {status}": "deaktiviert · {status}",
"inactive": "inaktiv",
"it does not union the two": "es vereinigt die beiden nicht",
- "later release": "späteres Release",
"loading bundled…": "gebündelte Version wird geladen…",
"metrics": "Metriken",
"modified": "geändert",
@@ -1152,7 +1159,6 @@
"read-only · change on the OAP side": "schreibgeschützt · auf der OAP-Seite ändern",
"records": "Datensätze",
"recovery: {force} on save": "Wiederherstellung: {force} beim Speichern",
- "restore": "wiederherstellen",
"revert to bundled": "auf gebündelte Version zurücksetzen",
"revert to bundled (STRUCTURAL apply)": "auf gebündelte Version zurücksetzen (STRUKTURELLES Apply)",
"reverted · {status}": "zurückgesetzt · {status}",
@@ -1299,7 +1305,7 @@
"3D Infrastructure Map": "3D-Infrastrukturkarte",
"3D infrastructure map": "3D-Infrastrukturkarte",
"See the 3D infrastructure map": "Die 3D-Infrastrukturkarte ansehen",
- "Config for the {mapPath} view, published to OAP. Levels control the vertical stack; per-layer color + metrics drive each cube. Edits save to a {localDraft}; {checkDiffPush} publishes to OAP — the map renders the remote, with bundled defaults as fallback.": "Konfiguration für die Ansicht {mapPath}, auf OAP veröffentlicht. Ebenen steuern die vertikale Stapelung; Farbe und Metriken pro Ebene bestimmen jeden Würfel. Änderungen werden in einem {localDraft} gespeichert; {checkDiffPush} verö [...]
+ "Config for the {mapPath} view, published to OAP. Levels control the vertical stack; per-layer color + metrics drive each cube. Edits save to a {localDraft}; {checkDiffPush} publishes to OAP — the map renders the OAP-published copy only, and reports an error when none can be read.": "Konfiguration für die Ansicht {mapPath}, auf OAP veröffentlicht. Ebenen steuern die vertikale Stapelung; Farbe und Metriken pro Ebene bestimmen jeden Würfel. Änderungen werden in einem {localDraft} gespeic [...]
"local draft in this browser": "lokaler Entwurf in diesem Browser",
"editing: {source}": "Bearbeitung: {source}",
"Reset to": "Zurücksetzen auf",
@@ -1494,6 +1500,7 @@
"No spans (older than the detail window).": "Keine Spans (älter als das Detail-Zeitfenster).",
"http.status_code=500, …": "http.status_code=500, …",
"3D Infra Map": "3D-Infrastrukturkarte",
+ "3D Infra Map setup": "Einrichtung der 3D-Infrastrukturkarte",
"No logs in this window.": "Keine Logs in diesem Zeitfenster.",
"Pick": "Auswählen",
"Query any service across layers — pick it, type its name, or leave it blank.": "Beliebigen Service über Layer hinweg abfragen — auswählen, Namen eingeben oder leer lassen.",
diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json
index 5f3d8f7..8725769 100644
--- a/apps/ui/src/i18n/locales/en.json
+++ b/apps/ui/src/i18n/locales/en.json
@@ -42,7 +42,6 @@
"Alerting rules": "Alerting rules",
"DSL management": "DSL management",
"OAL · read-only": "OAL · read-only",
- "Dump & restore": "Dump & restore",
"Live debugger": "Live debugger",
"Capture history": "Capture history",
"Metrics inspect": "Metrics inspect",
@@ -583,7 +582,7 @@
"Tried {url}. Confirm the OAP admin-server module is on (SW_ADMIN_SERVER=default) and the port is exposed on the network / k8s Service / ingress.": "Tried {url}. Confirm the OAP admin-server module is on (SW_ADMIN_SERVER=default) and the port is exposed on the network / k8s Service / ingress.",
"OAP's Zipkin v2 endpoint, source for the OpenTelemetry & Zipkin trace menu (shown when a layer's trace source is zipkin or both). This is the only page affected — if it's unreachable, native traces and every other observability page keep working.": "OAP's Zipkin v2 endpoint, source for the OpenTelemetry & Zipkin trace menu (shown when a layer's trace source is zipkin or both). This is the only page affected — if it's unreachable, native traces and every other observability page keep w [...]
"Tried {url}. Confirm OAP's Zipkin receiver / query is enabled and the oap.zipkinUrl in horizon's config points at the right host:port (shared GraphQL port → <queryUrl>/zipkin; standalone → :9412/zipkin). Only the Zipkin trace menu is affected.": "Tried {url}. Confirm OAP's Zipkin receiver / query is enabled and the oap.zipkinUrl in horizon's config points at the right host:port (shared GraphQL port → <queryUrl>/zipkin; standalone → :9412/zipkin). Only the Zipkin trace menu is affected.",
- "Everything Studio does against the admin port. Without admin-server, the other three modules fail at boot with ModuleNotFoundException.": "Everything Studio does against the admin port. Without admin-server, the other three modules fail at boot with ModuleNotFoundException.",
+ "Everything Horizon does against the admin port. Without admin-server, the other modules fail at boot with ModuleNotFoundException.": "Everything Horizon does against the admin port. Without admin-server, the other modules fail at boot with ModuleNotFoundException.",
"DSL Management (Catalog, OAL catalog), Editor save/load, Cluster status rule matrix, Live debugger rule picker, and the Inspect drawer's source attribution.": "DSL Management (Catalog, OAL catalog), Editor save/load, Cluster status rule matrix, Live debugger rule picker, and the Inspect drawer's source attribution.",
"Live debugger across MAL / LAL / OAL (start / poll / stop) and the DSL-debugging health pane on Cluster status.": "Live debugger across MAL / LAL / OAL (start / poll / stop) and the DSL-debugging health pane on Cluster status.",
"The Inspect page — every /api/inspect/* call returns 404 inspect_not_enabled and the page shows a banner instead of the board.": "The Inspect page — every /api/inspect/* call returns 404 inspect_not_enabled and the page shows a banner instead of the board.",
@@ -627,7 +626,7 @@
"Capabilities that have not been described yet.": "Capabilities that have not been described yet.",
"Catalog load failed:": "Catalog load failed:",
"Change role grants": "Change role grants",
- "Change rule schema (OAL files)": "Change rule schema (OAL files)",
+ "Apply a rule edit that changes storage, or revert a rule": "Apply a rule edit that changes storage, or revert a rule",
"Config file": "Config file",
"Configures alerts, dashboards, rules, and runs diagnostics.": "Configures alerts, dashboards, rules, and runs diagnostics.",
"Currently watching ({n})": "Currently watching ({n})",
@@ -636,6 +635,10 @@
"Dashboard setup · Alert page": "Dashboard setup · Alert page",
"Dashboard setup · Global defaults": "Dashboard setup · Global defaults",
"Data catalog": "Data catalog",
+ "Browser monitoring": "Browser monitoring",
+ "The JavaScript error logs a BROWSER-layer application reports, plus the source maps that turn a minified stack trace into readable frames.": "The JavaScript error logs a BROWSER-layer application reports, plus the source maps that turn a minified stack trace into readable frames.",
+ "Sending a message to the assistant. Each of its data tools re-checks the read permission the matching screen needs, so the assistant never reads more than the signed-in user can.": "Sending a message to the assistant. Each of its data tools re-checks the read permission the matching screen needs, so the assistant never reads more than the signed-in user can.",
+ "Watching SkyWalking itself: is the OAP cluster healthy, what does the internal metric catalog look like, how long is data kept, what is the running configuration.": "Watching SkyWalking itself: is the OAP cluster healthy, what does the internal metric catalog look like, how long is data kept, what is the running configuration.",
"Default time window": "Default time window",
"Default window for the topbar time picker, which feeds {scope}. The OAP {step} precision is derived from the window — you don't pick it separately:": "Default window for the topbar time picker, which feeds {scope}. The OAP {step} precision is derived from the window — you don't pick it separately:",
"Defaults are": "Defaults are",
@@ -671,7 +674,7 @@
"Inspect": "Inspect",
"Inspect API not enabled on OAP.": "Inspect API not enabled on OAP.",
"Inspect OAP internals (catalog, MQE)": "Inspect OAP internals (catalog, MQE)",
- "Interactive troubleshooting: the live debugger that streams events from a running rule, and the MQE sandbox for testing queries without saving anything.": "Interactive troubleshooting: the live debugger that streams events from a running rule, and the MQE sandbox for testing queries without saving anything.",
+ "Interactive troubleshooting: the live debugger runs a rule against live traffic and streams what it produced. Watching a capture needs only the live-debug read permission; starting or stopping one needs live-debug write.": "Interactive troubleshooting: the live debugger runs a rule against live traffic and streams what it produced. Watching a capture needs only the live-debug read permission; starting or stopping one needs live-debug write.",
"LDAP + local fallback": "LDAP + local fallback",
"LDAP backend": "LDAP backend",
"LDAP group DN": "LDAP group DN",
@@ -743,6 +746,8 @@
"Reachable": "Reachable",
"Read the audit log": "Read the audit log",
"Read verb": "Read verb",
+ "Reserved": "Reserved",
+ "No screen or request checks this — granting it has no effect.": "No screen or request checks this — granting it has no effect.",
"Read-only catalog of every alarm rule loaded by OAP, with its body and per-node load state. Rules edit through OAP's {file} + watcher reload — there's no mutation surface here by design.": "Read-only catalog of every alarm rule loaded by OAP, with its body and per-node load state. Rules edit through OAP's {file} + watcher reload — there's no mutation surface here by design.",
"Read-only data screens. Everyone signed in sees these by default — they are the core observability surface.": "Read-only data screens. Everyone signed in sees these by default — they are the core observability surface.",
"Reads dashboards, traces, logs, and alarms.": "Reads dashboards, traces, logs, and alarms.",
@@ -765,6 +770,11 @@
"See live-debug sessions": "See live-debug sessions",
"See logs": "See logs",
"See metric dashboards": "See metric dashboards",
+ "See data retention settings": "See data retention settings",
+ "See the OAP runtime configuration": "See the OAP runtime configuration",
+ "See browser error logs": "See browser error logs",
+ "Upload and remove source maps": "Upload and remove source maps",
+ "Use the AI assistant": "Use the AI assistant",
"See overview templates": "See overview templates",
"See profiling results": "See profiling results",
"See service & endpoint topology": "See service & endpoint topology",
@@ -785,11 +795,11 @@
"Source: LDAP": "Source: LDAP",
"Source: Local": "Source: Local",
"Start a profiling task": "Start a profiling task",
+ "Continuous-profiling policies": "Continuous-profiling policies",
"Start a profiling task on a target": "Start a profiling task on a target",
"Start and stop live-debug sessions": "Start and stop live-debug sessions",
- "Starting a profiling task — agent-side sampling, async-profiler, pprof, or eBPF. Reading the results is part of \"Data catalog\" above.": "Starting a profiling task — agent-side sampling, async-profiler, pprof, or eBPF. Reading the results is part of \"Data catalog\" above.",
+ "Starting a profiling task — agent-side sampling, async-profiler, pprof, or eBPF — and arming the continuous-profiling policies that raise tasks on their own. Reading the results is part of \"Data catalog\" above.": "Starting a profiling task — agent-side sampling, async-profiler, pprof, or eBPF — and arming the continuous-profiling policies that raise tasks on their own. Reading the results is part of \"Data catalog\" above.",
"Test a username": "Test a username",
- "Test queries in the MQE sandbox": "Test queries in the MQE sandbox",
"The MQE, OAL and runtime-rule editing surface. Schema-breaking edits and deletes are split out so a junior operator can author rules without being able to break things irreversibly.": "The MQE, OAL and runtime-rule editing surface. Schema-breaking edits and deletes are split out so a junior operator can author rules without being able to break things irreversibly.",
"The administrator surface: who can sign in, what role they have, and the live status of the auth backend.": "The administrator surface: who can sign in, what role they have, and the live status of the auth backend.",
"The auditable record of every sign-in, rule change, and configuration edit. Written to a file the operator can ship to an SIEM; no in-app viewer yet.": "The auditable record of every sign-in, rule change, and configuration edit. Written to a file the operator can ship to an SIEM; no in-app viewer yet.",
@@ -810,7 +820,6 @@
"Users defined": "Users defined",
"Waiting for the first poll to return records…": "Waiting for the first poll to return records…",
"Watches the SkyWalking platform itself (cluster, internals).": "Watches the SkyWalking platform itself (cluster, internals).",
- "Watching SkyWalking itself: is the OAP cluster healthy, are modules loaded, what does the internal metric catalog look like.": "Watching SkyWalking itself: is the OAP cluster healthy, are modules loaded, what does the internal metric catalog look like.",
"What each role can do": "What each role can do",
"What you can do": "What you can do",
"Window": "Window",
@@ -995,7 +1004,7 @@
"value": "value",
"values": "values",
"visible": "visible",
- "which sidebar items each role sees · gated by the read verb in the last column (UI hides; the BFF enforces the same server-side)": "which sidebar items each role sees · gated by the read verb in the last column (UI hides; the BFF enforces the same server-side)",
+ "every navigation entry, and the read verb that decides which roles see it · an entry that expands stands for the sub-entries under it, which need the same permission (the UI hides the entry; the BFF enforces the same verb server-side)": "every navigation entry, and the read verb that decides which roles see it · an entry that expands stands for the sub-entries under it, which need the same permission (the UI hides the entry; the BFF enforces the same verb server-side)",
"with": "with",
"{field} is required for scope {scope}": "{field} is required for scope {scope}",
"{minutes} → {precision} step, {buckets} per query.": "{minutes} → {precision} step, {buckets} per query.",
@@ -1061,7 +1070,6 @@
"No rules match the current filter.": "No rules match the current filter.",
"Normal": "Normal",
"OAL hot-update is upstream-deferred. Each": "OAL hot-update is upstream-deferred. Each",
- "OAP doesn't expose a restore endpoint yet. Operators recover from a dump by looping {endpoint} over each YAML file in the archive. A restore upload affordance will land here when the upstream API ships — see Studio's deferred-features memory.": "OAP doesn't expose a restore endpoint yet. Operators recover from a dump by looping {endpoint} over each YAML file in the archive. A restore upload affordance will land here when the upstream API ships — see Studio's deferred-features memory.",
"OAP drops the existing measure’s data on BanyanDB and orphans rows on JDBC / Elasticsearch.": "OAP drops the existing measure’s data on BanyanDB and orphans rows on JDBC / Elasticsearch.",
"OAP runs the standard apply pipeline against the bundled YAML — this is a schema change.": "OAP runs the standard apply pipeline against the bundled YAML — this is a schema change.",
"Observability Analysis Language": "Observability Analysis Language",
@@ -1114,7 +1122,6 @@
"inactivated · {status}": "inactivated · {status}",
"inactive": "inactive",
"it does not union the two": "it does not union the two",
- "later release": "later release",
"loading bundled…": "loading bundled…",
"metrics": "metrics",
"modified": "modified",
@@ -1133,7 +1140,6 @@
"read-only · change on the OAP side": "read-only · change on the OAP side",
"records": "records",
"recovery: {force} on save": "recovery: {force} on save",
- "restore": "restore",
"revert to bundled": "revert to bundled",
"revert to bundled (STRUCTURAL apply)": "revert to bundled (STRUCTURAL apply)",
"reverted · {status}": "reverted · {status}",
@@ -1281,7 +1287,7 @@
"3D Infrastructure Map": "3D Infrastructure Map",
"3D infrastructure map": "3D infrastructure map",
"See the 3D infrastructure map": "See the 3D infrastructure map",
- "Config for the {mapPath} view, published to OAP. Levels control the vertical stack; per-layer color + metrics drive each cube. Edits save to a {localDraft}; {checkDiffPush} publishes to OAP — the map renders the remote, with bundled defaults as fallback.": "Config for the {mapPath} view, published to OAP. Levels control the vertical stack; per-layer color + metrics drive each cube. Edits save to a {localDraft}; {checkDiffPush} publishes to OAP — the map renders the remote, with bundle [...]
+ "Config for the {mapPath} view, published to OAP. Levels control the vertical stack; per-layer color + metrics drive each cube. Edits save to a {localDraft}; {checkDiffPush} publishes to OAP — the map renders the OAP-published copy only, and reports an error when none can be read.": "Config for the {mapPath} view, published to OAP. Levels control the vertical stack; per-layer color + metrics drive each cube. Edits save to a {localDraft}; {checkDiffPush} publishes to OAP — the map rende [...]
"local draft in this browser": "local draft in this browser",
"editing: {source}": "editing: {source}",
"Reset to": "Reset to",
@@ -1484,6 +1490,7 @@
"No spans (older than the detail window).": "No spans (older than the detail window).",
"http.status_code=500, …": "http.status_code=500, …",
"3D Infra Map": "3D Infra Map",
+ "3D Infra Map setup": "3D Infra Map setup",
"No BROWSER layer": "No BROWSER layer",
"No logs in this window.": "No logs in this window.",
"Pick a browser service": "Pick a browser service",
diff --git a/apps/ui/src/i18n/locales/es.json b/apps/ui/src/i18n/locales/es.json
index 1a5500c..7b4f5ad 100644
--- a/apps/ui/src/i18n/locales/es.json
+++ b/apps/ui/src/i18n/locales/es.json
@@ -42,7 +42,6 @@
"Alerting rules": "Reglas de alerta",
"DSL management": "Gestión de DSL",
"OAL · read-only": "OAL · solo lectura",
- "Dump & restore": "Exportar y restaurar",
"Live debugger": "Depurador en vivo",
"Capture history": "Historial de capturas",
"Metrics inspect": "Inspección de métricas",
@@ -612,7 +611,7 @@
"Tried {url}. Confirm the OAP admin-server module is on (SW_ADMIN_SERVER=default) and the port is exposed on the network / k8s Service / ingress.": "Se intentó {url}. Confirma que el módulo admin-server de OAP esté activo (SW_ADMIN_SERVER=default) y que el puerto esté expuesto en la red / Service de k8s / ingress.",
"OAP's Zipkin v2 endpoint, source for the OpenTelemetry & Zipkin trace menu (shown when a layer's trace source is zipkin or both). This is the only page affected — if it's unreachable, native traces and every other observability page keep working.": "Endpoint Zipkin v2 de OAP, fuente del menú de trazas de OpenTelemetry y Zipkin (visible cuando la fuente de trazas de un layer es zipkin o both). Es la única página afectada — si no es accesible, las trazas nativas y todas las demás página [...]
"Tried {url}. Confirm OAP's Zipkin receiver / query is enabled and the oap.zipkinUrl in horizon's config points at the right host:port (shared GraphQL port → <queryUrl>/zipkin; standalone → :9412/zipkin). Only the Zipkin trace menu is affected.": "Se intentó {url}. Confirma que el receiver / query de Zipkin de OAP esté habilitado y que oap.zipkinUrl en la configuración de horizon apunte al host:port correcto (puerto GraphQL compartido → <queryUrl>/zipkin; independiente → :9412/zipkin). [...]
- "Everything Studio does against the admin port. Without admin-server, the other three modules fail at boot with ModuleNotFoundException.": "Todo lo que Studio hace contra el puerto de administración. Sin admin-server, los otros tres módulos fallan al arrancar con ModuleNotFoundException.",
+ "Everything Horizon does against the admin port. Without admin-server, the other modules fail at boot with ModuleNotFoundException.": "Todo lo que Horizon hace contra el puerto de administración. Sin admin-server, los demás módulos fallan al arrancar con ModuleNotFoundException.",
"DSL Management (Catalog, OAL catalog), Editor save/load, Cluster status rule matrix, Live debugger rule picker, and the Inspect drawer's source attribution.": "Gestión de DSL (Catalog, OAL catalog), guardar/cargar del editor, matriz de reglas de Cluster Status, selector de reglas del Live Debugger y la atribución de origen del cajón Inspect.",
"Live debugger across MAL / LAL / OAL (start / poll / stop) and the DSL-debugging health pane on Cluster status.": "Live Debugger sobre MAL / LAL / OAL (iniciar / consultar / detener) y el panel de salud de DSL-debugging en Cluster Status.",
"The Inspect page — every /api/inspect/* call returns 404 inspect_not_enabled and the page shows a banner instead of the board.": "La página Inspect — cada llamada a /api/inspect/* devuelve 404 inspect_not_enabled y la página muestra un aviso en lugar del tablero.",
@@ -656,7 +655,7 @@
"Capabilities that have not been described yet.": "Capacidades que aún no han sido descritas.",
"Catalog load failed:": "Error al cargar el catálogo:",
"Change role grants": "Cambiar concesiones de rol",
- "Change rule schema (OAL files)": "Cambiar el esquema de reglas (archivos OAL)",
+ "Apply a rule edit that changes storage, or revert a rule": "Aplicar un cambio de regla que altera el almacenamiento, o revertir una regla",
"Config file": "Archivo de configuración",
"Configures alerts, dashboards, rules, and runs diagnostics.": "Configura alertas, tableros y reglas, y ejecuta diagnósticos.",
"Currently watching ({n})": "Observando actualmente ({n})",
@@ -665,6 +664,10 @@
"Dashboard setup · Alert page": "Configuración del tablero · Página de alertas",
"Dashboard setup · Global defaults": "Configuración del tablero · Predeterminados globales",
"Data catalog": "Catálogo de datos",
+ "Browser monitoring": "Monitorización del navegador",
+ "The JavaScript error logs a BROWSER-layer application reports, plus the source maps that turn a minified stack trace into readable frames.": "Los logs de errores de JavaScript que reporta una aplicación de la capa BROWSER, junto con los source maps que convierten una traza minificada en fotogramas legibles.",
+ "Sending a message to the assistant. Each of its data tools re-checks the read permission the matching screen needs, so the assistant never reads more than the signed-in user can.": "Enviar un mensaje al asistente. Cada una de sus herramientas de datos vuelve a comprobar el permiso de lectura que exige la pantalla equivalente, así que el asistente nunca lee más de lo que puede ver quien ha iniciado sesión.",
+ "Watching SkyWalking itself: is the OAP cluster healthy, what does the internal metric catalog look like, how long is data kept, what is the running configuration.": "Vigilar el propio SkyWalking: si el clúster OAP está sano, qué aspecto tiene el catálogo interno de métricas, cuánto tiempo se conservan los datos y cuál es la configuración en ejecución.",
"Default time window": "Ventana de tiempo predeterminada",
"Default window for the topbar time picker, which feeds {scope}. The OAP {step} precision is derived from the window — you don't pick it separately:": "Ventana predeterminada para el selector de tiempo de la barra superior, que alimenta {scope}. La precisión {step} de OAP se deriva de la ventana — no la eliges por separado:",
"Defaults are": "Los predeterminados son",
@@ -700,7 +703,7 @@
"Inspect": "Inspect",
"Inspect API not enabled on OAP.": "API Inspect no habilitada en OAP.",
"Inspect OAP internals (catalog, MQE)": "Inspeccionar internos de OAP (catálogo, MQE)",
- "Interactive troubleshooting: the live debugger that streams events from a running rule, and the MQE sandbox for testing queries without saving anything.": "Resolución de problemas interactiva: el live debugger que transmite eventos de una regla en ejecución, y el sandbox de MQE para probar consultas sin guardar nada.",
+ "Interactive troubleshooting: the live debugger runs a rule against live traffic and streams what it produced. Watching a capture needs only the live-debug read permission; starting or stopping one needs live-debug write.": "Diagnóstico interactivo: el depurador en vivo ejecuta una regla sobre el tráfico real y transmite lo que produce. Observar una captura solo requiere el permiso de lectura de live-debug; iniciarla o detenerla requiere el de escritura.",
"LDAP + local fallback": "LDAP + respaldo local",
"LDAP backend": "Backend LDAP",
"LDAP group DN": "DN de grupo LDAP",
@@ -761,6 +764,8 @@
"Reachable": "Disponible",
"Read the audit log": "Leer el registro de auditoría",
"Read verb": "Verbo de lectura",
+ "Reserved": "Reservado",
+ "No screen or request checks this — granting it has no effect.": "Ninguna pantalla ni petición lo comprueba: concederlo no cambia nada.",
"Read-only catalog of every alarm rule loaded by OAP, with its body and per-node load state. Rules edit through OAP's {file} + watcher reload — there's no mutation surface here by design.": "Catálogo de solo lectura con cada regla de alarma cargada por OAP, con su cuerpo y estado de carga por nodo. Las reglas se editan a través de {file} de OAP + recarga del watcher — por diseño no hay superficie de mutación aquí.",
"Read-only data screens. Everyone signed in sees these by default — they are the core observability surface.": "Pantallas de datos de solo lectura. Toda persona con sesión iniciada las ve de forma predeterminada — son la superficie principal de observabilidad.",
"Reads dashboards, traces, logs, and alarms.": "Lee tableros, trazas, logs y alarmas.",
@@ -783,6 +788,11 @@
"See live-debug sessions": "Ver sesiones de live-debug",
"See logs": "Ver logs",
"See metric dashboards": "Ver tableros de métricas",
+ "See data retention settings": "Ver la configuración de retención de datos",
+ "See the OAP runtime configuration": "Ver la configuración en ejecución del OAP",
+ "See browser error logs": "Ver los logs de errores del navegador",
+ "Upload and remove source maps": "Subir y eliminar source maps",
+ "Use the AI assistant": "Usar el asistente de IA",
"See overview templates": "Ver plantillas de resumen",
"See profiling results": "Ver resultados de profiling",
"See service & endpoint topology": "Ver topología de Service y Endpoint",
@@ -803,11 +813,11 @@
"Source: LDAP": "Fuente: LDAP",
"Source: Local": "Fuente: Local",
"Start a profiling task": "Iniciar una tarea de profiling",
+ "Continuous-profiling policies": "Políticas de profiling continuo",
"Start a profiling task on a target": "Iniciar una tarea de profiling sobre un objetivo",
"Start and stop live-debug sessions": "Iniciar y detener sesiones de live-debug",
- "Starting a profiling task — agent-side sampling, async-profiler, pprof, or eBPF. Reading the results is part of \"Data catalog\" above.": "Iniciar una tarea de profiling — muestreo en el agente, async-profiler, pprof o eBPF. La lectura de resultados forma parte del \"Catálogo de datos\" de arriba.",
+ "Starting a profiling task — agent-side sampling, async-profiler, pprof, or eBPF — and arming the continuous-profiling policies that raise tasks on their own. Reading the results is part of \"Data catalog\" above.": "Iniciar una tarea de profiling — muestreo del agente, async-profiler, pprof o eBPF — y activar las políticas de profiling continuo que lanzan tareas por su cuenta. Leer los resultados forma parte de «Catálogo de datos», más arriba.",
"Test a username": "Probar un usuario",
- "Test queries in the MQE sandbox": "Probar consultas en el sandbox de MQE",
"The MQE, OAL and runtime-rule editing surface. Schema-breaking edits and deletes are split out so a junior operator can author rules without being able to break things irreversibly.": "La superficie de edición de MQE, OAL y reglas en tiempo de ejecución. Las ediciones que rompen el esquema y los borrados están separados para que un operador junior pueda crear reglas sin poder romper cosas de manera irreversible.",
"The administrator surface: who can sign in, what role they have, and the live status of the auth backend.": "La superficie de administración: quién puede iniciar sesión, qué rol tiene y el estado en vivo del backend de autenticación.",
"The auditable record of every sign-in, rule change, and configuration edit. Written to a file the operator can ship to an SIEM; no in-app viewer yet.": "El registro auditable de cada inicio de sesión, cambio de regla y edición de configuración. Se escribe en un archivo que el operador puede enviar a un SIEM; aún no hay visor dentro de la aplicación.",
@@ -828,7 +838,6 @@
"Users defined": "Usuarios definidos",
"Waiting for the first poll to return records…": "Esperando a que el primer sondeo devuelva registros…",
"Watches the SkyWalking platform itself (cluster, internals).": "Observa la propia plataforma SkyWalking (clúster, internos).",
- "Watching SkyWalking itself: is the OAP cluster healthy, are modules loaded, what does the internal metric catalog look like.": "Observando SkyWalking en sí: si el clúster de OAP está saludable, si los módulos están cargados, cómo se ve el catálogo interno de métricas.",
"What each role can do": "Qué puede hacer cada rol",
"What you can do": "Lo que puedes hacer",
"Window": "Ventana",
@@ -1014,7 +1023,7 @@
"value": "valor",
"values": "valores",
"visible": "visible",
- "which sidebar items each role sees · gated by the read verb in the last column (UI hides; the BFF enforces the same server-side)": "qué elementos del menú lateral ve cada rol · controlado por el verbo de lectura en la última columna (la UI oculta; el BFF aplica lo mismo del lado del servidor)",
+ "every navigation entry, and the read verb that decides which roles see it · an entry that expands stands for the sub-entries under it, which need the same permission (the UI hides the entry; the BFF enforces the same verb server-side)": "cada entrada de navegación y el verbo de lectura que decide qué roles la ven · una entrada desplegable representa también las subentradas que contiene, que requieren el mismo permiso (la UI oculta la entrada; el BFF aplica el mismo verbo del lado del [...]
"with": "con",
"{field} is required for scope {scope}": "{field} es obligatorio para el scope {scope}",
"{minutes} → {precision} step, {buckets} per query.": "{minutes} → step {precision}, {buckets} por consulta.",
@@ -1080,7 +1089,6 @@
"No rules match the current filter.": "Ninguna regla coincide con el filtro actual.",
"Normal": "Normal",
"OAL hot-update is upstream-deferred. Each": "El hot-update de OAL está diferido upstream. Cada",
- "OAP doesn't expose a restore endpoint yet. Operators recover from a dump by looping {endpoint} over each YAML file in the archive. A restore upload affordance will land here when the upstream API ships — see Studio's deferred-features memory.": "OAP aún no expone un endpoint de restauración. Los operadores recuperan desde un dump iterando {endpoint} sobre cada archivo YAML del archivo. Una opción de carga para restauración aterrizará aquí cuando la API upstream se publique — ver la me [...]
"OAP drops the existing measure’s data on BanyanDB and orphans rows on JDBC / Elasticsearch.": "OAP descarta los datos de la measure existente en BanyanDB y deja filas huérfanas en JDBC / Elasticsearch.",
"OAP runs the standard apply pipeline against the bundled YAML — this is a schema change.": "OAP ejecuta el pipeline estándar de apply contra el YAML integrado — esto es un cambio de esquema.",
"Observability Analysis Language": "Observability Analysis Language",
@@ -1133,7 +1141,6 @@
"inactivated · {status}": "desactivada · {status}",
"inactive": "inactiva",
"it does not union the two": "no une las dos",
- "later release": "versión posterior",
"loading bundled…": "cargando integrado…",
"metrics": "métricas",
"modified": "modificado",
@@ -1152,7 +1159,6 @@
"read-only · change on the OAP side": "solo lectura · cambia en el lado de OAP",
"records": "registros",
"recovery: {force} on save": "recuperación: {force} al guardar",
- "restore": "restaurar",
"revert to bundled": "revertir al integrado",
"revert to bundled (STRUCTURAL apply)": "revertir al integrado (apply ESTRUCTURAL)",
"reverted · {status}": "revertida · {status}",
@@ -1299,7 +1305,7 @@
"3D Infrastructure Map": "3D Infrastructure Map",
"3D infrastructure map": "Mapa de infraestructura 3D",
"See the 3D infrastructure map": "Ver el mapa de infraestructura 3D",
- "Config for the {mapPath} view, published to OAP. Levels control the vertical stack; per-layer color + metrics drive each cube. Edits save to a {localDraft}; {checkDiffPush} publishes to OAP — the map renders the remote, with bundled defaults as fallback.": "Configuración de la vista {mapPath}, publicada en OAP. Los niveles controlan el apilado vertical; el color y las métricas de cada capa definen cada cubo. Las ediciones se guardan en un {localDraft}; {checkDiffPush} publica en OAP — [...]
+ "Config for the {mapPath} view, published to OAP. Levels control the vertical stack; per-layer color + metrics drive each cube. Edits save to a {localDraft}; {checkDiffPush} publishes to OAP — the map renders the OAP-published copy only, and reports an error when none can be read.": "Configuración de la vista {mapPath}, publicada en OAP. Los niveles controlan el apilado vertical; el color y las métricas de cada capa definen cada cubo. Las ediciones se guardan en un {localDraft}; {check [...]
"local draft in this browser": "borrador local en este navegador",
"editing: {source}": "editando: {source}",
"Reset to": "Restablecer a",
@@ -1494,6 +1500,7 @@
"No spans (older than the detail window).": "Sin spans (anteriores a la ventana de detalle).",
"http.status_code=500, …": "http.status_code=500, …",
"3D Infra Map": "Mapa de infraestructura 3D",
+ "3D Infra Map setup": "Configuración del mapa de infraestructura 3D",
"No logs in this window.": "No hay logs en esta ventana.",
"Pick": "Elegir",
"Query any service across layers — pick it, type its name, or leave it blank.": "Consulta cualquier Service entre Layers: selecciónalo, escribe su nombre o déjalo en blanco.",
diff --git a/apps/ui/src/i18n/locales/fr.json b/apps/ui/src/i18n/locales/fr.json
index 923d8eb..4d520a8 100644
--- a/apps/ui/src/i18n/locales/fr.json
+++ b/apps/ui/src/i18n/locales/fr.json
@@ -42,7 +42,6 @@
"Alerting rules": "Règles d'alerte",
"DSL management": "Gestion DSL",
"OAL · read-only": "OAL · lecture seule",
- "Dump & restore": "Export / restauration",
"Live debugger": "Débogueur en direct",
"Capture history": "Historique des captures",
"Metrics inspect": "Inspection des métriques",
@@ -612,7 +611,7 @@
"Tried {url}. Confirm the OAP admin-server module is on (SW_ADMIN_SERVER=default) and the port is exposed on the network / k8s Service / ingress.": "Tentative sur {url}. Vérifiez que le module admin-server d'OAP est actif (SW_ADMIN_SERVER=default) et que le port est exposé sur le réseau / Service k8s / ingress.",
"OAP's Zipkin v2 endpoint, source for the OpenTelemetry & Zipkin trace menu (shown when a layer's trace source is zipkin or both). This is the only page affected — if it's unreachable, native traces and every other observability page keep working.": "Endpoint Zipkin v2 d'OAP, source du menu de traces OpenTelemetry et Zipkin (visible quand la source de traces d'un layer est zipkin ou both). C'est la seule page concernée — s'il est inaccessible, les traces natives et toutes les autres pa [...]
"Tried {url}. Confirm OAP's Zipkin receiver / query is enabled and the oap.zipkinUrl in horizon's config points at the right host:port (shared GraphQL port → <queryUrl>/zipkin; standalone → :9412/zipkin). Only the Zipkin trace menu is affected.": "Tentative sur {url}. Vérifiez que le receiver / query Zipkin d'OAP est activé et que oap.zipkinUrl dans la configuration de horizon pointe vers le bon host:port (port GraphQL partagé → <queryUrl>/zipkin ; indépendant → :9412/zipkin). Seul le [...]
- "Everything Studio does against the admin port. Without admin-server, the other three modules fail at boot with ModuleNotFoundException.": "Tout ce que Studio fait contre le port d'administration. Sans admin-server, les trois autres modules échouent au démarrage avec ModuleNotFoundException.",
+ "Everything Horizon does against the admin port. Without admin-server, the other modules fail at boot with ModuleNotFoundException.": "Tout ce que Horizon fait sur le port d'administration. Sans admin-server, les autres modules échouent au démarrage avec ModuleNotFoundException.",
"DSL Management (Catalog, OAL catalog), Editor save/load, Cluster status rule matrix, Live debugger rule picker, and the Inspect drawer's source attribution.": "Gestion DSL (Catalog, OAL catalog), enregistrement/chargement de l'éditeur, matrice de règles de Cluster Status, sélecteur de règles du Live Debugger et l'attribution de source du panneau Inspect.",
"Live debugger across MAL / LAL / OAL (start / poll / stop) and the DSL-debugging health pane on Cluster status.": "Live Debugger sur MAL / LAL / OAL (démarrer / interroger / arrêter) et le panneau de santé DSL-debugging dans Cluster Status.",
"The Inspect page — every /api/inspect/* call returns 404 inspect_not_enabled and the page shows a banner instead of the board.": "La page Inspect — chaque appel /api/inspect/* renvoie 404 inspect_not_enabled et la page affiche un bandeau au lieu du tableau.",
@@ -656,7 +655,7 @@
"Capabilities that have not been described yet.": "Capacités non encore décrites.",
"Catalog load failed:": "Échec du chargement du catalogue :",
"Change role grants": "Modifier les attributions de rôles",
- "Change rule schema (OAL files)": "Modifier le schéma des règles (fichiers OAL)",
+ "Apply a rule edit that changes storage, or revert a rule": "Appliquer une modification de règle qui change le stockage, ou rétablir une règle",
"Config file": "Fichier de configuration",
"Configures alerts, dashboards, rules, and runs diagnostics.": "Configure les alertes, les tableaux de bord, les règles, et exécute les diagnostics.",
"Currently watching ({n})": "Sous observation ({n})",
@@ -665,6 +664,10 @@
"Dashboard setup · Alert page": "Configuration du tableau de bord · Page d'alertes",
"Dashboard setup · Global defaults": "Configuration du tableau de bord · Valeurs globales par défaut",
"Data catalog": "Catalogue de données",
+ "Browser monitoring": "Supervision navigateur",
+ "The JavaScript error logs a BROWSER-layer application reports, plus the source maps that turn a minified stack trace into readable frames.": "Les logs d’erreurs JavaScript remontés par une application de la couche BROWSER, ainsi que les source maps qui transforment une pile minifiée en frames lisibles.",
+ "Sending a message to the assistant. Each of its data tools re-checks the read permission the matching screen needs, so the assistant never reads more than the signed-in user can.": "Envoyer un message à l’assistant. Chacun de ses outils de données revérifie le droit de lecture exigé par l’écran correspondant : l’assistant ne lit donc jamais plus que l’utilisateur connecté.",
+ "Watching SkyWalking itself: is the OAP cluster healthy, what does the internal metric catalog look like, how long is data kept, what is the running configuration.": "Surveiller SkyWalking lui-même : le cluster OAP est-il en bonne santé, à quoi ressemble le catalogue de métriques interne, combien de temps les données sont-elles conservées, quelle configuration tourne actuellement.",
"Default time window": "Fenêtre temporelle par défaut",
"Default window for the topbar time picker, which feeds {scope}. The OAP {step} precision is derived from the window — you don't pick it separately:": "Fenêtre par défaut pour le sélecteur temporel de la barre supérieure, qui alimente {scope}. La précision OAP {step} est dérivée de la fenêtre — vous ne la choisissez pas séparément :",
"Defaults are": "Les valeurs par défaut sont",
@@ -700,7 +703,7 @@
"Inspect": "Inspect",
"Inspect API not enabled on OAP.": "API Inspect non activée sur OAP.",
"Inspect OAP internals (catalog, MQE)": "Inspecter les internes OAP (catalogue, MQE)",
- "Interactive troubleshooting: the live debugger that streams events from a running rule, and the MQE sandbox for testing queries without saving anything.": "Dépannage interactif : le débogueur en direct qui diffuse les événements d'une règle en cours d'exécution, et le bac à sable MQE pour tester des requêtes sans rien enregistrer.",
+ "Interactive troubleshooting: the live debugger runs a rule against live traffic and streams what it produced. Watching a capture needs only the live-debug read permission; starting or stopping one needs live-debug write.": "Dépannage interactif : le débogueur en direct exécute une règle sur le trafic réel et diffuse ce qu’elle produit. Observer une capture ne demande que le droit de lecture live-debug ; la démarrer ou l’arrêter demande le droit d’écriture live-debug.",
"LDAP + local fallback": "LDAP + repli local",
"LDAP backend": "Backend LDAP",
"LDAP group DN": "DN de groupe LDAP",
@@ -761,6 +764,8 @@
"Reachable": "Joignable",
"Read the audit log": "Lire le journal d'audit",
"Read verb": "Verbe de lecture",
+ "Reserved": "Réservé",
+ "No screen or request checks this — granting it has no effect.": "Aucun écran ni aucune requête ne le vérifie — l’accorder ne change rien.",
"Read-only catalog of every alarm rule loaded by OAP, with its body and per-node load state. Rules edit through OAP's {file} + watcher reload — there's no mutation surface here by design.": "Catalogue en lecture seule de toutes les règles d'alarme chargées par OAP, avec leur corps et leur état de chargement par nœud. Les règles se modifient via le {file} d'OAP + le rechargement par le watcher — il n'y a délibérément aucune surface de mutation ici.",
"Read-only data screens. Everyone signed in sees these by default — they are the core observability surface.": "Écrans de données en lecture seule. Toute personne connectée les voit par défaut — ils constituent la surface d'observabilité principale.",
"Reads dashboards, traces, logs, and alarms.": "Lit les tableaux de bord, les traces, les logs et les alarmes.",
@@ -783,6 +788,11 @@
"See live-debug sessions": "Voir les sessions de débogage en direct",
"See logs": "Voir les logs",
"See metric dashboards": "Voir les tableaux de bord de métriques",
+ "See data retention settings": "Voir les réglages de rétention des données",
+ "See the OAP runtime configuration": "Voir la configuration en cours de l’OAP",
+ "See browser error logs": "Voir les logs d’erreurs navigateur",
+ "Upload and remove source maps": "Téléverser et supprimer des source maps",
+ "Use the AI assistant": "Utiliser l’assistant IA",
"See overview templates": "Voir les modèles de vue d'ensemble",
"See profiling results": "Voir les résultats de profilage",
"See service & endpoint topology": "Voir la topologie des services et des endpoints",
@@ -803,11 +813,11 @@
"Source: LDAP": "Source : LDAP",
"Source: Local": "Source : locale",
"Start a profiling task": "Démarrer une tâche de profilage",
+ "Continuous-profiling policies": "Politiques de profilage continu",
"Start a profiling task on a target": "Démarrer une tâche de profilage sur une cible",
"Start and stop live-debug sessions": "Démarrer et arrêter des sessions de débogage en direct",
- "Starting a profiling task — agent-side sampling, async-profiler, pprof, or eBPF. Reading the results is part of \"Data catalog\" above.": "Démarrer une tâche de profilage — échantillonnage côté agent, async-profiler, pprof, ou eBPF. La lecture des résultats fait partie du « Catalogue de données » ci-dessus.",
+ "Starting a profiling task — agent-side sampling, async-profiler, pprof, or eBPF — and arming the continuous-profiling policies that raise tasks on their own. Reading the results is part of \"Data catalog\" above.": "Lancer une tâche de profilage — échantillonnage par l’agent, async-profiler, pprof ou eBPF — et activer les politiques de profilage continu qui déclenchent des tâches d’elles-mêmes. La lecture des résultats relève du « Catalogue de données » ci-dessus.",
"Test a username": "Tester un nom d'utilisateur",
- "Test queries in the MQE sandbox": "Tester des requêtes dans le bac à sable MQE",
"The MQE, OAL and runtime-rule editing surface. Schema-breaking edits and deletes are split out so a junior operator can author rules without being able to break things irreversibly.": "La surface d'édition MQE, OAL et règles d'exécution. Les modifications qui cassent le schéma et les suppressions sont séparées afin qu'un opérateur junior puisse écrire des règles sans pouvoir tout casser de manière irréversible.",
"The administrator surface: who can sign in, what role they have, and the live status of the auth backend.": "La surface administrateur : qui peut se connecter, quel rôle il détient, et l'état en direct du backend d'authentification.",
"The auditable record of every sign-in, rule change, and configuration edit. Written to a file the operator can ship to an SIEM; no in-app viewer yet.": "L'enregistrement auditable de chaque connexion, modification de règle et édition de configuration. Écrit dans un fichier que l'opérateur peut transmettre à un SIEM ; pas encore de visualiseur intégré à l'application.",
@@ -828,7 +838,6 @@
"Users defined": "Utilisateurs définis",
"Waiting for the first poll to return records…": "En attente du premier sondage pour recevoir des enregistrements…",
"Watches the SkyWalking platform itself (cluster, internals).": "Surveille la plateforme SkyWalking elle-même (cluster, internes).",
- "Watching SkyWalking itself: is the OAP cluster healthy, are modules loaded, what does the internal metric catalog look like.": "Surveiller SkyWalking lui-même : le cluster OAP est-il sain, les modules sont-ils chargés, à quoi ressemble le catalogue interne de métriques.",
"What each role can do": "Ce que chaque rôle peut faire",
"What you can do": "Ce que vous pouvez faire",
"Window": "Fenêtre",
@@ -1014,7 +1023,7 @@
"value": "valeur",
"values": "valeurs",
"visible": "visible",
- "which sidebar items each role sees · gated by the read verb in the last column (UI hides; the BFF enforces the same server-side)": "quels éléments de la barre latérale chaque rôle voit · contrôlé par le verbe de lecture dans la dernière colonne (l'UI masque ; le BFF applique la même règle côté serveur)",
+ "every navigation entry, and the read verb that decides which roles see it · an entry that expands stands for the sub-entries under it, which need the same permission (the UI hides the entry; the BFF enforces the same verb server-side)": "chaque entrée de navigation et le verbe de lecture qui détermine les rôles qui la voient · une entrée dépliable représente aussi les sous-entrées qu'elle contient, lesquelles exigent la même permission (l'UI masque l'entrée ; le BFF applique le même v [...]
"with": "avec",
"{field} is required for scope {scope}": "{field} est requis pour la portée {scope}",
"{minutes} → {precision} step, {buckets} per query.": "{minutes} → pas {precision}, {buckets} par requête.",
@@ -1080,7 +1089,6 @@
"No rules match the current filter.": "Aucune règle ne correspond au filtre actuel.",
"Normal": "Normal",
"OAL hot-update is upstream-deferred. Each": "La mise à jour à chaud OAL est reportée en amont. Chaque",
- "OAP doesn't expose a restore endpoint yet. Operators recover from a dump by looping {endpoint} over each YAML file in the archive. A restore upload affordance will land here when the upstream API ships — see Studio's deferred-features memory.": "OAP n'expose pas encore d'endpoint de restauration. Les opérateurs récupèrent depuis un dump en bouclant {endpoint} sur chaque fichier YAML de l'archive. Une fonction de téléversement de restauration apparaîtra ici lorsque l'API en amont sera [...]
"OAP drops the existing measure’s data on BanyanDB and orphans rows on JDBC / Elasticsearch.": "OAP supprime les données de la mesure existante sur BanyanDB et rend orphelines les lignes sur JDBC / Elasticsearch.",
"OAP runs the standard apply pipeline against the bundled YAML — this is a schema change.": "OAP exécute le pipeline d'application standard sur le YAML intégré — il s'agit d'un changement de schéma.",
"Observability Analysis Language": "Observability Analysis Language",
@@ -1133,7 +1141,6 @@
"inactivated · {status}": "désactivé · {status}",
"inactive": "inactif",
"it does not union the two": "il ne fusionne pas les deux",
- "later release": "version ultérieure",
"loading bundled…": "chargement de la version intégrée…",
"metrics": "métriques",
"modified": "modifié",
@@ -1152,7 +1159,6 @@
"read-only · change on the OAP side": "lecture seule · modifier côté OAP",
"records": "enregistrements",
"recovery: {force} on save": "récupération : {force} à l'enregistrement",
- "restore": "restaurer",
"revert to bundled": "rétablir la version intégrée",
"revert to bundled (STRUCTURAL apply)": "rétablir la version intégrée (application STRUCTURELLE)",
"reverted · {status}": "rétabli · {status}",
@@ -1299,7 +1305,7 @@
"3D Infrastructure Map": "Carte d'infrastructure 3D",
"3D infrastructure map": "Carte d'infrastructure 3D",
"See the 3D infrastructure map": "Voir la carte d'infrastructure 3D",
- "Config for the {mapPath} view, published to OAP. Levels control the vertical stack; per-layer color + metrics drive each cube. Edits save to a {localDraft}; {checkDiffPush} publishes to OAP — the map renders the remote, with bundled defaults as fallback.": "Configuration de la vue {mapPath}, publiée sur OAP. Les niveaux contrôlent l'empilement vertical ; la couleur et les métriques propres à chaque couche pilotent chaque cube. Les modifications sont enregistrées dans un {localDraft} ; [...]
+ "Config for the {mapPath} view, published to OAP. Levels control the vertical stack; per-layer color + metrics drive each cube. Edits save to a {localDraft}; {checkDiffPush} publishes to OAP — the map renders the OAP-published copy only, and reports an error when none can be read.": "Configuration de la vue {mapPath}, publiée sur OAP. Les niveaux contrôlent l'empilement vertical ; la couleur et les métriques propres à chaque couche pilotent chaque cube. Les modifications sont enregistr [...]
"local draft in this browser": "brouillon local dans ce navigateur",
"editing: {source}": "édition : {source}",
"Reset to": "Réinitialiser à",
@@ -1494,6 +1500,7 @@
"No spans (older than the detail window).": "Aucun span (antérieur à la fenêtre de détail).",
"http.status_code=500, …": "http.status_code=500, …",
"3D Infra Map": "Carte d'infrastructure 3D",
+ "3D Infra Map setup": "Configuration de la carte d'infrastructure 3D",
"No logs in this window.": "Aucun log dans cette fenêtre.",
"Pick": "Choisir",
"Query any service across layers — pick it, type its name, or leave it blank.": "Interrogez n'importe quel Service entre les Layers — sélectionnez-le, saisissez son nom ou laissez vide.",
diff --git a/apps/ui/src/i18n/locales/ja.json b/apps/ui/src/i18n/locales/ja.json
index 4908331..9572480 100644
--- a/apps/ui/src/i18n/locales/ja.json
+++ b/apps/ui/src/i18n/locales/ja.json
@@ -42,7 +42,6 @@
"Alerting rules": "アラートルール",
"DSL management": "DSL 管理",
"OAL · read-only": "OAL · 読み取り専用",
- "Dump & restore": "ダンプとリストア",
"Live debugger": "ライブデバッガ",
"Capture history": "キャプチャ履歴",
"Metrics inspect": "メトリクス検査",
@@ -612,7 +611,7 @@
"Tried {url}. Confirm the OAP admin-server module is on (SW_ADMIN_SERVER=default) and the port is exposed on the network / k8s Service / ingress.": "{url} を試行しました。OAP の admin-server モジュールが有効(SW_ADMIN_SERVER=default)で、ポートがネットワーク / k8s Service / ingress で公開されていることを確認してください。",
"OAP's Zipkin v2 endpoint, source for the OpenTelemetry & Zipkin trace menu (shown when a layer's trace source is zipkin or both). This is the only page affected — if it's unreachable, native traces and every other observability page keep working.": "OAP の Zipkin v2 エンドポイントで、OpenTelemetry および Zipkin トレース メニューのデータ ソースです(レイヤーの trace ソースが zipkin または both のときに表示)。影響を受けるのはこのページのみで、到達不可でもネイティブ トレースおよび他の可観測ページはそのまま動作します。",
"Tried {url}. Confirm OAP's Zipkin receiver / query is enabled and the oap.zipkinUrl in horizon's config points at the right host:port (shared GraphQL port → <queryUrl>/zipkin; standalone → :9412/zipkin). Only the Zipkin trace menu is affected.": "{url} を試行しました。OAP の Zipkin receiver / query が有効で、horizon の設定の oap.zipkinUrl が正しい host:port を指していることを確認してください(GraphQL と同一ポート → <queryUrl>/zipkin、独立ポート → :9412/zipkin)。影響を受けるのは Zipkin トレース メニューのみです。",
- "Everything Studio does against the admin port. Without admin-server, the other three modules fail at boot with ModuleNotFoundException.": "Studio が管理ポートに対して行うすべての操作。admin-server が無いと、他の 3 モジュールは ModuleNotFoundException で起動に失敗します。",
+ "Everything Horizon does against the admin port. Without admin-server, the other modules fail at boot with ModuleNotFoundException.": "Horizon が管理ポートに対して行うすべての操作。admin-server が無いと、他のモジュールは ModuleNotFoundException で起動に失敗します。",
"DSL Management (Catalog, OAL catalog), Editor save/load, Cluster status rule matrix, Live debugger rule picker, and the Inspect drawer's source attribution.": "DSL 管理(Catalog、OAL catalog)、エディタの保存/読込、Cluster Status のルールマトリックス、Live Debugger のルール選択、Inspect ドロワーのソース帰属。",
"Live debugger across MAL / LAL / OAL (start / poll / stop) and the DSL-debugging health pane on Cluster status.": "MAL / LAL / OAL を横断する Live Debugger(開始 / ポーリング / 停止)と、Cluster Status の DSL-debugging ヘルスパネル。",
"The Inspect page — every /api/inspect/* call returns 404 inspect_not_enabled and the page shows a banner instead of the board.": "Inspect ページ — すべての /api/inspect/* 呼び出しが 404 inspect_not_enabled を返し、ダッシュボードの代わりにバナーが表示されます。",
@@ -656,7 +655,7 @@
"Capabilities that have not been described yet.": "まだ記述されていない機能。",
"Catalog load failed:": "カタログの読み込みに失敗しました:",
"Change role grants": "ロール権限の変更",
- "Change rule schema (OAL files)": "ルールスキーマの変更(OAL ファイル)",
+ "Apply a rule edit that changes storage, or revert a rule": "ストレージが変わるルール編集の適用、またはルールの復元",
"Config file": "設定ファイル",
"Configures alerts, dashboards, rules, and runs diagnostics.": "アラート、ダッシュボード、ルールを設定し、診断を実行します。",
"Currently watching ({n})": "現在監視中({n})",
@@ -665,6 +664,10 @@
"Dashboard setup · Alert page": "ダッシュボード設定 · アラートページ",
"Dashboard setup · Global defaults": "ダッシュボード設定 · グローバルデフォルト",
"Data catalog": "データカタログ",
+ "Browser monitoring": "ブラウザ監視",
+ "The JavaScript error logs a BROWSER-layer application reports, plus the source maps that turn a minified stack trace into readable frames.": "BROWSER レイヤーのアプリケーションが報告する JavaScript エラーログと、圧縮されたスタックトレースを読めるフレームに戻す source map。",
+ "Sending a message to the assistant. Each of its data tools re-checks the read permission the matching screen needs, so the assistant never reads more than the signed-in user can.": "アシスタントにメッセージを送ります。アシスタントのデータツールは対応する画面と同じ読み取り権限をその都度確認するため、ログイン中のユーザーが見られる範囲を超えて読むことはありません。",
+ "Watching SkyWalking itself: is the OAP cluster healthy, what does the internal metric catalog look like, how long is data kept, what is the running configuration.": "SkyWalking 自身の監視 — OAP クラスタは健全か、内部のメトリクスカタログはどうなっているか、データはどれだけ保持されるか、実行中の設定は何か。",
"Default time window": "デフォルトの時間範囲",
"Default window for the topbar time picker, which feeds {scope}. The OAP {step} precision is derived from the window — you don't pick it separately:": "トップバーのタイムピッカーのデフォルト範囲で、{scope} に渡されます。OAP の {step} 精度は範囲から自動算出され、個別に選択することはできません:",
"Defaults are": "デフォルトは",
@@ -700,7 +703,7 @@
"Inspect": "Inspect",
"Inspect API not enabled on OAP.": "OAP で Inspect API が有効になっていません。",
"Inspect OAP internals (catalog, MQE)": "OAP 内部を検査(カタログ、MQE)",
- "Interactive troubleshooting: the live debugger that streams events from a running rule, and the MQE sandbox for testing queries without saving anything.": "対話的なトラブルシューティング: 実行中のルールからイベントをストリーミングするライブデバッガと、何も保存せずにクエリをテストできる MQE サンドボックス。",
+ "Interactive troubleshooting: the live debugger runs a rule against live traffic and streams what it produced. Watching a capture needs only the live-debug read permission; starting or stopping one needs live-debug write.": "対話的なトラブルシューティング。ライブデバッガーは実トラフィックに対してルールを実行し、その出力をストリームします。キャプチャの閲覧には live-debug の読み取り権限だけで足り、開始と停止には live-debug の書き込み権限が必要です。",
"LDAP + local fallback": "LDAP + ローカルフォールバック",
"LDAP backend": "LDAP バックエンド",
"LDAP group DN": "LDAP グループ DN",
@@ -761,6 +764,8 @@
"Reachable": "到達可能",
"Read the audit log": "監査ログの閲覧",
"Read verb": "Read 動詞",
+ "Reserved": "予約済み",
+ "No screen or request checks this — granting it has no effect.": "この権限を確認する画面もリクエストもありません。付与しても効果はありません。",
"Read-only catalog of every alarm rule loaded by OAP, with its body and per-node load state. Rules edit through OAP's {file} + watcher reload — there's no mutation surface here by design.": "OAP にロードされたすべてのアラームルールの読み取り専用カタログで、本文とノードごとのロード状態を表示します。ルールの編集は OAP の {file} + ウォッチャーのリロードで行います — 仕様上、ここに変更操作はありません。",
"Read-only data screens. Everyone signed in sees these by default — they are the core observability surface.": "読み取り専用のデータ画面。サインインしたすべてのユーザーがデフォルトで参照できます — 可観測性のコアサーフェスです。",
"Reads dashboards, traces, logs, and alarms.": "ダッシュボード、トレース、ログ、アラームを閲覧します。",
@@ -783,6 +788,11 @@
"See live-debug sessions": "ライブデバッグセッションの閲覧",
"See logs": "ログの閲覧",
"See metric dashboards": "メトリクスダッシュボードの閲覧",
+ "See data retention settings": "データ保持の設定を見る",
+ "See the OAP runtime configuration": "OAP の実行時設定を見る",
+ "See browser error logs": "ブラウザのエラーログを見る",
+ "Upload and remove source maps": "Source map をアップロード・削除する",
+ "Use the AI assistant": "AI アシスタントを使う",
"See overview templates": "概要テンプレートの閲覧",
"See profiling results": "プロファイリング結果の閲覧",
"See service & endpoint topology": "Service / Endpoint トポロジーの閲覧",
@@ -803,11 +813,11 @@
"Source: LDAP": "ソース: LDAP",
"Source: Local": "ソース: ローカル",
"Start a profiling task": "プロファイリングタスクを開始",
+ "Continuous-profiling policies": "継続的プロファイリングポリシー",
"Start a profiling task on a target": "ターゲットでプロファイリングタスクを開始",
"Start and stop live-debug sessions": "ライブデバッグセッションの開始と停止",
- "Starting a profiling task — agent-side sampling, async-profiler, pprof, or eBPF. Reading the results is part of \"Data catalog\" above.": "プロファイリングタスクの開始 — エージェント側サンプリング、async-profiler、pprof、または eBPF。結果の閲覧は上記の「データカタログ」に含まれます。",
+ "Starting a profiling task — agent-side sampling, async-profiler, pprof, or eBPF — and arming the continuous-profiling policies that raise tasks on their own. Reading the results is part of \"Data catalog\" above.": "プロファイリングタスクの開始 — エージェントのサンプリング、async-profiler、pprof、eBPF — と、自動的にタスクを起こす継続的プロファイリングポリシーの有効化。結果の閲覧は上の「データカタログ」に含まれます。",
"Test a username": "ユーザー名のテスト",
- "Test queries in the MQE sandbox": "MQE サンドボックスでのクエリテスト",
"The MQE, OAL and runtime-rule editing surface. Schema-breaking edits and deletes are split out so a junior operator can author rules without being able to break things irreversibly.": "MQE、OAL、ランタイムルールの編集サーフェス。スキーマを破壊する編集と削除は分離されており、経験の浅いオペレーターでも取り返しのつかない破壊を起こさずにルールを作成できます。",
"The administrator surface: who can sign in, what role they have, and the live status of the auth backend.": "管理者向けサーフェス: 誰がサインインでき、どのロールを持ち、認証バックエンドのライブステータスはどうなっているか。",
"The auditable record of every sign-in, rule change, and configuration edit. Written to a file the operator can ship to an SIEM; no in-app viewer yet.": "すべてのサインイン、ルール変更、設定編集の監査可能な記録。オペレーターが SIEM に転送できるファイルに書き込まれます。アプリ内ビューアはまだありません。",
@@ -828,7 +838,6 @@
"Users defined": "定義済みユーザー",
"Waiting for the first poll to return records…": "最初のポーリングがレコードを返すのを待っています…",
"Watches the SkyWalking platform itself (cluster, internals).": "SkyWalking プラットフォーム自体(クラスタ、内部)を監視します。",
- "Watching SkyWalking itself: is the OAP cluster healthy, are modules loaded, what does the internal metric catalog look like.": "SkyWalking 自体の監視: OAP クラスタは正常か、モジュールはロードされているか、内部メトリクスカタログはどうなっているか。",
"What each role can do": "各ロールができること",
"What you can do": "実行できる操作",
"Window": "範囲",
@@ -1014,7 +1023,7 @@
"value": "値",
"values": "値",
"visible": "表示",
- "which sidebar items each role sees · gated by the read verb in the last column (UI hides; the BFF enforces the same server-side)": "各ロールが参照できるサイドバー項目 · 最終列の read 動詞でゲートされます(UI は非表示にし、BFF がサーバー側で同じ制御を強制します)",
+ "every navigation entry, and the read verb that decides which roles see it · an entry that expands stands for the sub-entries under it, which need the same permission (the UI hides the entry; the BFF enforces the same verb server-side)": "すべてのナビゲーション項目と、どのロールに表示するかを決める read 動詞 · 展開できる項目は配下のサブ項目も兼ねており、サブ項目にも同じ権限が必要です(UI は項目を非表示にし、BFF がサーバー側で同じ動詞を強制します)",
"with": "with",
"{field} is required for scope {scope}": "スコープ {scope} には {field} が必須です",
"{minutes} → {precision} step, {buckets} per query.": "{minutes} → {precision} ステップ、1 クエリあたり {buckets}。",
@@ -1080,7 +1089,6 @@
"No rules match the current filter.": "現在のフィルターに一致するルールがありません。",
"Normal": "Normal",
"OAL hot-update is upstream-deferred. Each": "OAL のホットアップデートはアップストリームで保留中です。各",
- "OAP doesn't expose a restore endpoint yet. Operators recover from a dump by looping {endpoint} over each YAML file in the archive. A restore upload affordance will land here when the upstream API ships — see Studio's deferred-features memory.": "OAP はまだリストアエンドポイントを公開していません。オペレーターはアーカイブ内の各 YAML ファイルに対して {endpoint} をループ実行することでダンプから復旧します。アップストリーム API が出荷されたら、ここにリストアアップロードの導線が追加されます — Studio の deferred-features メモリを参照してください。",
"OAP drops the existing measure’s data on BanyanDB and orphans rows on JDBC / Elasticsearch.": "OAP は BanyanDB 上の既存 measure データを破棄し、JDBC / Elasticsearch の行は孤立します。",
"OAP runs the standard apply pipeline against the bundled YAML — this is a schema change.": "OAP はバンドル YAML に対して標準の apply パイプラインを実行します — これはスキーマ変更です。",
"Observability Analysis Language": "Observability Analysis Language",
@@ -1133,7 +1141,6 @@
"inactivated · {status}": "無効化済み · {status}",
"inactive": "inactive",
"it does not union the two": "両者は結合されません",
- "later release": "今後のリリース",
"loading bundled…": "バンドル版を読み込んでいます…",
"metrics": "メトリクス",
"modified": "変更済み",
@@ -1152,7 +1159,6 @@
"read-only · change on the OAP side": "読み取り専用 · OAP 側で変更してください",
"records": "レコード",
"recovery: {force} on save": "復旧: 保存時に {force}",
- "restore": "リストア",
"revert to bundled": "バンドル版に戻す",
"revert to bundled (STRUCTURAL apply)": "バンドル版に戻す(STRUCTURAL apply)",
"reverted · {status}": "復元済み · {status}",
@@ -1299,7 +1305,7 @@
"3D Infrastructure Map": "3D インフラマップ",
"3D infrastructure map": "3D インフラマップ",
"See the 3D infrastructure map": "3D インフラマップを確認",
- "Config for the {mapPath} view, published to OAP. Levels control the vertical stack; per-layer color + metrics drive each cube. Edits save to a {localDraft}; {checkDiffPush} publishes to OAP — the map renders the remote, with bundled defaults as fallback.": "{mapPath} ビューの設定で、OAP に公開されます。レベルは垂直方向の積み重ねを制御し、レイヤーごとの色とメトリックが各キューブを表現します。編集内容は {localDraft} に保存され、{checkDiffPush} で OAP に公開されます。マップはリモート版を表示し、バンドルのデフォルトがフォールバックとなります。",
+ "Config for the {mapPath} view, published to OAP. Levels control the vertical stack; per-layer color + metrics drive each cube. Edits save to a {localDraft}; {checkDiffPush} publishes to OAP — the map renders the OAP-published copy only, and reports an error when none can be read.": "{mapPath} ビューの設定で、OAP に公開されます。レベルは垂直方向の積み重ねを制御し、レイヤーごとの色とメトリックが各キューブを表現します。編集内容は {localDraft} に保存され、{checkDiffPush} で OAP に公開されます。マップは OAP に公開されたコピーのみを表示し、読み取れるものがない場合はエラーを表示します。",
"local draft in this browser": "このブラウザ内のローカル草稿",
"editing: {source}": "編集中: {source}",
"Reset to": "次にリセット",
@@ -1494,6 +1500,7 @@
"No spans (older than the detail window).": "スパンがありません(詳細時間範囲より前)。",
"http.status_code=500, …": "http.status_code=500, …",
"3D Infra Map": "3D インフラマップ",
+ "3D Infra Map setup": "3D インフラマップ設定",
"No logs in this window.": "この時間範囲にログはありません。",
"Pick": "選択",
"Query any service across layers — pick it, type its name, or leave it blank.": "Layer をまたいで任意の Service を検索 — 選択するか、名前を入力するか、空のままにします。",
diff --git a/apps/ui/src/i18n/locales/ko.json b/apps/ui/src/i18n/locales/ko.json
index 1c84c3f..2ad6b88 100644
--- a/apps/ui/src/i18n/locales/ko.json
+++ b/apps/ui/src/i18n/locales/ko.json
@@ -42,7 +42,6 @@
"Alerting rules": "알림 규칙",
"DSL management": "DSL 관리",
"OAL · read-only": "OAL · 읽기 전용",
- "Dump & restore": "덤프 및 복원",
"Live debugger": "라이브 디버거",
"Capture history": "캡처 기록",
"Metrics inspect": "메트릭 검사",
@@ -612,7 +611,7 @@
"Tried {url}. Confirm the OAP admin-server module is on (SW_ADMIN_SERVER=default) and the port is exposed on the network / k8s Service / ingress.": "{url}을(를) 시도했습니다. OAP의 admin-server 모듈이 켜져 있는지(SW_ADMIN_SERVER=default), 포트가 네트워크 / k8s Service / ingress를 통해 노출되어 있는지 확인하세요.",
"OAP's Zipkin v2 endpoint, source for the OpenTelemetry & Zipkin trace menu (shown when a layer's trace source is zipkin or both). This is the only page affected — if it's unreachable, native traces and every other observability page keep working.": "OAP의 Zipkin v2 엔드포인트로, OpenTelemetry 및 Zipkin 트레이스 메뉴의 데이터 소스입니다(레이어의 trace 소스가 zipkin 또는 both일 때 표시). 영향을 받는 페이지는 이 페이지뿐이며, 연결 불가 상태여도 네이티브 트레이스와 다른 모든 옵저버빌리티 페이지는 정상 동작합니다.",
"Tried {url}. Confirm OAP's Zipkin receiver / query is enabled and the oap.zipkinUrl in horizon's config points at the right host:port (shared GraphQL port → <queryUrl>/zipkin; standalone → :9412/zipkin). Only the Zipkin trace menu is affected.": "{url}을(를) 시도했습니다. OAP의 Zipkin receiver / query가 활성화되어 있고, horizon 설정의 oap.zipkinUrl이 올바른 host:port를 가리키는지 확인하세요(공유 GraphQL 포트 → <queryUrl>/zipkin, 독립 → :9412/zipkin). 영향을 받는 것은 Zipkin 트레이스 메뉴뿐입니다.",
- "Everything Studio does against the admin port. Without admin-server, the other three modules fail at boot with ModuleNotFoundException.": "Studio가 관리 포트에서 수행하는 모든 작업입니다. admin-server가 없으면 나머지 세 모듈은 ModuleNotFoundException으로 부팅에 실패합니다.",
+ "Everything Horizon does against the admin port. Without admin-server, the other modules fail at boot with ModuleNotFoundException.": "Horizon이 관리 포트에서 수행하는 모든 작업입니다. admin-server가 없으면 나머지 모듈은 ModuleNotFoundException으로 부팅에 실패합니다.",
"DSL Management (Catalog, OAL catalog), Editor save/load, Cluster status rule matrix, Live debugger rule picker, and the Inspect drawer's source attribution.": "DSL 관리(Catalog, OAL catalog), 에디터 저장/불러오기, Cluster Status 규칙 매트릭스, Live Debugger 규칙 선택기, Inspect 드로어의 소스 출처.",
"Live debugger across MAL / LAL / OAL (start / poll / stop) and the DSL-debugging health pane on Cluster status.": "MAL / LAL / OAL 전반의 Live Debugger(시작 / 폴링 / 중지)와 Cluster Status의 DSL-debugging 상태 패널.",
"The Inspect page — every /api/inspect/* call returns 404 inspect_not_enabled and the page shows a banner instead of the board.": "Inspect 페이지 — 모든 /api/inspect/* 호출이 404 inspect_not_enabled를 반환하며 보드 대신 배너가 표시됩니다.",
@@ -656,7 +655,7 @@
"Capabilities that have not been described yet.": "아직 설명되지 않은 기능입니다.",
"Catalog load failed:": "카탈로그를 불러오지 못했습니다:",
"Change role grants": "역할 권한 부여 변경",
- "Change rule schema (OAL files)": "규칙 스키마 변경 (OAL 파일)",
+ "Apply a rule edit that changes storage, or revert a rule": "스토리지가 바뀌는 규칙 편집 적용 또는 규칙 되돌리기",
"Config file": "설정 파일",
"Configures alerts, dashboards, rules, and runs diagnostics.": "알림, 대시보드, 규칙을 설정하고 진단을 수행합니다.",
"Currently watching ({n})": "현재 감시 중 ({n})",
@@ -665,6 +664,10 @@
"Dashboard setup · Alert page": "대시보드 설정 · 알림 페이지",
"Dashboard setup · Global defaults": "대시보드 설정 · 전역 기본값",
"Data catalog": "데이터 카탈로그",
+ "Browser monitoring": "브라우저 모니터링",
+ "The JavaScript error logs a BROWSER-layer application reports, plus the source maps that turn a minified stack trace into readable frames.": "BROWSER 레이어 애플리케이션이 보고하는 JavaScript 오류 로그와, 압축된 스택 트레이스를 읽을 수 있는 프레임으로 되돌리는 source map입니다.",
+ "Sending a message to the assistant. Each of its data tools re-checks the read permission the matching screen needs, so the assistant never reads more than the signed-in user can.": "어시스턴트에게 메시지를 보냅니다. 어시스턴트의 데이터 도구는 해당 화면이 요구하는 읽기 권한을 매번 다시 확인하므로, 로그인한 사용자가 볼 수 있는 범위를 넘어서 읽지 않습니다.",
+ "Watching SkyWalking itself: is the OAP cluster healthy, what does the internal metric catalog look like, how long is data kept, what is the running configuration.": "SkyWalking 자체를 지켜봅니다 — OAP 클러스터가 정상인지, 내부 메트릭 카탈로그는 어떤 모습인지, 데이터는 얼마나 보관되는지, 어떤 설정으로 실행 중인지.",
"Default time window": "기본 시간 범위",
"Default window for the topbar time picker, which feeds {scope}. The OAP {step} precision is derived from the window — you don't pick it separately:": "상단 시간 선택기의 기본 범위로, {scope}에 적용됩니다. OAP {step} 정밀도는 범위에서 자동으로 결정되며 별도로 선택하지 않습니다:",
"Defaults are": "기본값은",
@@ -700,7 +703,7 @@
"Inspect": "Inspect",
"Inspect API not enabled on OAP.": "OAP에서 Inspect API가 활성화되어 있지 않습니다.",
"Inspect OAP internals (catalog, MQE)": "OAP 내부 검사 (카탈로그, MQE)",
- "Interactive troubleshooting: the live debugger that streams events from a running rule, and the MQE sandbox for testing queries without saving anything.": "대화형 문제 해결: 실행 중인 규칙에서 이벤트를 스트리밍하는 라이브 디버거와 저장 없이 쿼리를 테스트하는 MQE 샌드박스.",
+ "Interactive troubleshooting: the live debugger runs a rule against live traffic and streams what it produced. Watching a capture needs only the live-debug read permission; starting or stopping one needs live-debug write.": "대화형 문제 해결: 라이브 디버거는 실제 트래픽에 규칙을 실행하고 그 결과를 스트리밍합니다. 캡처를 보기만 하려면 live-debug 읽기 권한이면 충분하고, 시작하거나 중지하려면 live-debug 쓰기 권한이 필요합니다.",
"LDAP + local fallback": "LDAP + 로컬 폴백",
"LDAP backend": "LDAP 백엔드",
"LDAP group DN": "LDAP 그룹 DN",
@@ -761,6 +764,8 @@
"Reachable": "연결 가능",
"Read the audit log": "감사 로그 읽기",
"Read verb": "읽기 권한",
+ "Reserved": "예약됨",
+ "No screen or request checks this — granting it has no effect.": "이 권한을 확인하는 화면이나 요청이 없습니다. 부여해도 아무 변화가 없습니다.",
"Read-only catalog of every alarm rule loaded by OAP, with its body and per-node load state. Rules edit through OAP's {file} + watcher reload — there's no mutation surface here by design.": "OAP가 로드한 모든 경보 규칙의 읽기 전용 카탈로그로, 규칙 본문과 노드별 로드 상태를 포함합니다. 규칙은 OAP의 {file}과 watcher 리로드로 편집되며 — 의도적으로 여기에 변경 수단을 두지 않았습니다.",
"Read-only data screens. Everyone signed in sees these by default — they are the core observability surface.": "읽기 전용 데이터 화면입니다. 로그인한 모든 사용자가 기본적으로 볼 수 있으며 — 핵심 옵저버빌리티 화면입니다.",
"Reads dashboards, traces, logs, and alarms.": "대시보드, 트레이스, 로그, 경보를 읽습니다.",
@@ -783,6 +788,11 @@
"See live-debug sessions": "라이브 디버그 세션 보기",
"See logs": "로그 보기",
"See metric dashboards": "메트릭 대시보드 보기",
+ "See data retention settings": "데이터 보존 설정 보기",
+ "See the OAP runtime configuration": "OAP 런타임 설정 보기",
+ "See browser error logs": "브라우저 오류 로그 보기",
+ "Upload and remove source maps": "Source map 업로드 및 삭제",
+ "Use the AI assistant": "AI 어시스턴트 사용",
"See overview templates": "개요 템플릿 보기",
"See profiling results": "프로파일링 결과 보기",
"See service & endpoint topology": "서비스 및 엔드포인트 토폴로지 보기",
@@ -803,11 +813,11 @@
"Source: LDAP": "소스: LDAP",
"Source: Local": "소스: 로컬",
"Start a profiling task": "프로파일링 작업 시작",
+ "Continuous-profiling policies": "지속 프로파일링 정책",
"Start a profiling task on a target": "대상에 대한 프로파일링 작업 시작",
"Start and stop live-debug sessions": "라이브 디버그 세션 시작 및 중지",
- "Starting a profiling task — agent-side sampling, async-profiler, pprof, or eBPF. Reading the results is part of \"Data catalog\" above.": "프로파일링 작업 시작 — 에이전트 측 샘플링, async-profiler, pprof, eBPF. 결과 조회는 위의 \"데이터 카탈로그\"에 포함됩니다.",
+ "Starting a profiling task — agent-side sampling, async-profiler, pprof, or eBPF — and arming the continuous-profiling policies that raise tasks on their own. Reading the results is part of \"Data catalog\" above.": "프로파일링 작업 시작 — 에이전트 샘플링, async-profiler, pprof, eBPF — 및 스스로 작업을 만드는 지속 프로파일링 정책 활성화. 결과 조회는 위의 \"데이터 카탈로그\"에 속합니다.",
"Test a username": "사용자 이름 테스트",
- "Test queries in the MQE sandbox": "MQE 샌드박스에서 쿼리 테스트",
"The MQE, OAL and runtime-rule editing surface. Schema-breaking edits and deletes are split out so a junior operator can author rules without being able to break things irreversibly.": "MQE, OAL 및 런타임 규칙 편집 영역입니다. 스키마를 깨뜨리는 편집과 삭제는 분리되어 있어, 주니어 운영자가 되돌릴 수 없는 손상을 일으키지 않고도 규칙을 작성할 수 있습니다.",
"The administrator surface: who can sign in, what role they have, and the live status of the auth backend.": "관리자 화면: 누가 로그인할 수 있는지, 어떤 역할을 가지는지, 인증 백엔드의 실시간 상태.",
"The auditable record of every sign-in, rule change, and configuration edit. Written to a file the operator can ship to an SIEM; no in-app viewer yet.": "모든 로그인, 규칙 변경, 설정 편집에 대한 감사 가능한 기록입니다. 운영자가 SIEM으로 전송할 수 있는 파일에 기록되며, 아직 앱 내 뷰어는 없습니다.",
@@ -828,7 +838,6 @@
"Users defined": "정의된 사용자",
"Waiting for the first poll to return records…": "첫 폴링에서 레코드가 반환되기를 기다리는 중…",
"Watches the SkyWalking platform itself (cluster, internals).": "SkyWalking 플랫폼 자체를 감시합니다(클러스터, 내부).",
- "Watching SkyWalking itself: is the OAP cluster healthy, are modules loaded, what does the internal metric catalog look like.": "SkyWalking 자체 감시: OAP 클러스터가 정상인지, 모듈이 로드되었는지, 내부 메트릭 카탈로그가 어떻게 생겼는지.",
"What each role can do": "각 역할이 할 수 있는 작업",
"What you can do": "수행 가능한 작업",
"Window": "범위",
@@ -1014,7 +1023,7 @@
"value": "값",
"values": "값",
"visible": "표시됨",
- "which sidebar items each role sees · gated by the read verb in the last column (UI hides; the BFF enforces the same server-side)": "각 역할이 보는 사이드바 항목 · 마지막 열의 읽기 권한으로 제어됨(UI에서 숨김 처리, BFF가 서버 측에서 동일하게 강제 적용)",
+ "every navigation entry, and the read verb that decides which roles see it · an entry that expands stands for the sub-entries under it, which need the same permission (the UI hides the entry; the BFF enforces the same verb server-side)": "모든 내비게이션 항목과 어떤 역할에 표시할지 결정하는 읽기 권한 · 펼칠 수 있는 항목은 그 아래 하위 항목까지 대표하며, 하위 항목에도 같은 권한이 필요합니다(UI는 항목을 숨기고, BFF가 서버 측에서 동일한 권한을 강제합니다)",
"with": "with",
"{field} is required for scope {scope}": "{field}은(는) 범위 {scope}에 필수입니다",
"{minutes} → {precision} step, {buckets} per query.": "{minutes} → {precision} 단계, 쿼리당 {buckets}.",
@@ -1080,7 +1089,6 @@
"No rules match the current filter.": "현재 필터와 일치하는 규칙이 없습니다.",
"Normal": "보통",
"OAL hot-update is upstream-deferred. Each": "OAL 핫 업데이트는 업스트림에서 보류 중입니다. 각",
- "OAP doesn't expose a restore endpoint yet. Operators recover from a dump by looping {endpoint} over each YAML file in the archive. A restore upload affordance will land here when the upstream API ships — see Studio's deferred-features memory.": "OAP는 아직 복원 엔드포인트를 제공하지 않습니다. 운영자는 아카이브의 각 YAML 파일에 대해 {endpoint}를 반복 호출하여 덤프에서 복구합니다. 복원 업로드 기능은 업스트림 API 출시 시 여기에 추가됩니다 — Studio의 deferred-features 메모를 참조하세요.",
"OAP drops the existing measure’s data on BanyanDB and orphans rows on JDBC / Elasticsearch.": "OAP는 BanyanDB에서 기존 measure의 데이터를 삭제하고, JDBC / Elasticsearch에서는 행을 고아 상태로 만듭니다.",
"OAP runs the standard apply pipeline against the bundled YAML — this is a schema change.": "OAP는 번들 YAML에 대해 표준 apply 파이프라인을 실행합니다 — 이는 스키마 변경입니다.",
"Observability Analysis Language": "Observability Analysis Language",
@@ -1133,7 +1141,6 @@
"inactivated · {status}": "비활성화됨 · {status}",
"inactive": "비활성",
"it does not union the two": "두 가지를 합치지 않습니다",
- "later release": "추후 릴리스",
"loading bundled…": "번들 로드 중…",
"metrics": "메트릭",
"modified": "수정됨",
@@ -1152,7 +1159,6 @@
"read-only · change on the OAP side": "읽기 전용 · OAP 측에서 변경",
"records": "레코드",
"recovery: {force} on save": "복구: 저장 시 {force}",
- "restore": "복원",
"revert to bundled": "번들 버전으로 되돌리기",
"revert to bundled (STRUCTURAL apply)": "번들 버전으로 되돌리기 (STRUCTURAL apply)",
"reverted · {status}": "되돌림 · {status}",
@@ -1299,7 +1305,7 @@
"3D Infrastructure Map": "3D 인프라 맵",
"3D infrastructure map": "3D 인프라 맵",
"See the 3D infrastructure map": "3D 인프라 맵 보기",
- "Config for the {mapPath} view, published to OAP. Levels control the vertical stack; per-layer color + metrics drive each cube. Edits save to a {localDraft}; {checkDiffPush} publishes to OAP — the map renders the remote, with bundled defaults as fallback.": "OAP에 게시되는 {mapPath} 화면의 설정입니다. 레벨은 수직 적층 순서를 정하고, 각 큐브는 레이어별 색상과 메트릭으로 표현됩니다. 편집 내용은 {localDraft}에 저장되며, {checkDiffPush}로 OAP에 게시합니다 — 맵은 원격 버전을 렌더링하고 번들 기본값을 폴백으로 사용합니다.",
+ "Config for the {mapPath} view, published to OAP. Levels control the vertical stack; per-layer color + metrics drive each cube. Edits save to a {localDraft}; {checkDiffPush} publishes to OAP — the map renders the OAP-published copy only, and reports an error when none can be read.": "OAP에 게시되는 {mapPath} 화면의 설정입니다. 레벨은 수직 적층 순서를 정하고, 각 큐브는 레이어별 색상과 메트릭으로 표현됩니다. 편집 내용은 {localDraft}에 저장되며, {checkDiffPush}로 OAP에 게시합니다 — 맵은 OAP에 게시된 사본만 렌더링하며, 읽을 수 있는 사본이 없으면 오류를 표시합니다.",
"local draft in this browser": "이 브라우저의 로컬 초안",
"editing: {source}": "편집 중: {source}",
"Reset to": "재설정 대상",
@@ -1494,6 +1500,7 @@
"No spans (older than the detail window).": "스팬이 없습니다(상세 기간보다 이전).",
"http.status_code=500, …": "http.status_code=500, …",
"3D Infra Map": "3D 인프라 맵",
+ "3D Infra Map setup": "3D 인프라 맵 설정",
"No logs in this window.": "이 기간에 로그가 없습니다.",
"Pick": "선택",
"Query any service across layers — pick it, type its name, or leave it blank.": "Layer 전반에서 임의의 Service를 조회 — 선택하거나 이름을 입력하거나 비워 둡니다.",
diff --git a/apps/ui/src/i18n/locales/pt.json b/apps/ui/src/i18n/locales/pt.json
index 2fbbfd4..ef6f784 100644
--- a/apps/ui/src/i18n/locales/pt.json
+++ b/apps/ui/src/i18n/locales/pt.json
@@ -42,7 +42,6 @@
"Alerting rules": "Regras de alerta",
"DSL management": "Gerenciamento de DSL",
"OAL · read-only": "OAL · somente leitura",
- "Dump & restore": "Exportar e restaurar",
"Live debugger": "Depurador ao vivo",
"Capture history": "Histórico de capturas",
"Metrics inspect": "Inspeção de métricas",
@@ -612,7 +611,7 @@
"Tried {url}. Confirm the OAP admin-server module is on (SW_ADMIN_SERVER=default) and the port is exposed on the network / k8s Service / ingress.": "Tentou-se {url}. Confirme que o módulo admin-server do OAP está ativo (SW_ADMIN_SERVER=default) e que a porta está exposta na rede / Service do k8s / ingress.",
"OAP's Zipkin v2 endpoint, source for the OpenTelemetry & Zipkin trace menu (shown when a layer's trace source is zipkin or both). This is the only page affected — if it's unreachable, native traces and every other observability page keep working.": "Endpoint Zipkin v2 do OAP, fonte do menu de traces OpenTelemetry e Zipkin (exibido quando a fonte de traces de um layer é zipkin ou both). É a única página afetada — se ficar inacessível, os traces nativos e todas as outras páginas de obse [...]
"Tried {url}. Confirm OAP's Zipkin receiver / query is enabled and the oap.zipkinUrl in horizon's config points at the right host:port (shared GraphQL port → <queryUrl>/zipkin; standalone → :9412/zipkin). Only the Zipkin trace menu is affected.": "Tentou-se {url}. Confirme que o receiver / query do Zipkin no OAP está habilitado e que oap.zipkinUrl na configuração do horizon aponta para o host:port correto (porta GraphQL compartilhada → <queryUrl>/zipkin; independente → :9412/zipkin). A [...]
- "Everything Studio does against the admin port. Without admin-server, the other three modules fail at boot with ModuleNotFoundException.": "Tudo o que o Studio faz na porta administrativa. Sem admin-server, os outros três módulos falham ao iniciar com ModuleNotFoundException.",
+ "Everything Horizon does against the admin port. Without admin-server, the other modules fail at boot with ModuleNotFoundException.": "Tudo o que o Horizon faz na porta administrativa. Sem admin-server, os demais módulos falham ao iniciar com ModuleNotFoundException.",
"DSL Management (Catalog, OAL catalog), Editor save/load, Cluster status rule matrix, Live debugger rule picker, and the Inspect drawer's source attribution.": "Gestão de DSL (Catalog, OAL catalog), salvar/carregar do editor, matriz de regras de Cluster Status, seletor de regras do Live Debugger e a atribuição de origem do drawer Inspect.",
"Live debugger across MAL / LAL / OAL (start / poll / stop) and the DSL-debugging health pane on Cluster status.": "Live Debugger em MAL / LAL / OAL (iniciar / consultar / parar) e o painel de saúde do DSL-debugging em Cluster Status.",
"The Inspect page — every /api/inspect/* call returns 404 inspect_not_enabled and the page shows a banner instead of the board.": "A página Inspect — cada chamada a /api/inspect/* retorna 404 inspect_not_enabled e a página exibe um aviso em vez do painel.",
@@ -656,7 +655,7 @@
"Capabilities that have not been described yet.": "Capacidades ainda não descritas.",
"Catalog load failed:": "Falha ao carregar o catálogo:",
"Change role grants": "Alterar permissões de papéis",
- "Change rule schema (OAL files)": "Alterar o esquema das regras (arquivos OAL)",
+ "Apply a rule edit that changes storage, or revert a rule": "Aplicar uma edição de regra que altera o armazenamento, ou reverter uma regra",
"Config file": "Arquivo de configuração",
"Configures alerts, dashboards, rules, and runs diagnostics.": "Configura alertas, dashboards e regras, e executa diagnósticos.",
"Currently watching ({n})": "Monitorando agora ({n})",
@@ -665,6 +664,10 @@
"Dashboard setup · Alert page": "Configuração de dashboard · Página de alertas",
"Dashboard setup · Global defaults": "Configuração de dashboard · Padrões globais",
"Data catalog": "Catálogo de dados",
+ "Browser monitoring": "Monitoramento do navegador",
+ "The JavaScript error logs a BROWSER-layer application reports, plus the source maps that turn a minified stack trace into readable frames.": "Os logs de erro JavaScript que uma aplicação da camada BROWSER reporta, junto com os source maps que transformam uma pilha minificada em quadros legíveis.",
+ "Sending a message to the assistant. Each of its data tools re-checks the read permission the matching screen needs, so the assistant never reads more than the signed-in user can.": "Enviar uma mensagem ao assistente. Cada ferramenta de dados dele reverifica a permissão de leitura exigida pela tela equivalente, então o assistente nunca lê mais do que quem está autenticado pode ver.",
+ "Watching SkyWalking itself: is the OAP cluster healthy, what does the internal metric catalog look like, how long is data kept, what is the running configuration.": "Acompanhar o próprio SkyWalking: o cluster OAP está saudável, como está o catálogo interno de métricas, por quanto tempo os dados são mantidos, qual é a configuração em execução.",
"Default time window": "Janela de tempo padrão",
"Default window for the topbar time picker, which feeds {scope}. The OAP {step} precision is derived from the window — you don't pick it separately:": "Janela padrão do seletor de tempo da topbar, que alimenta {scope}. A precisão {step} do OAP é derivada da janela — você não a escolhe separadamente:",
"Defaults are": "Os padrões são",
@@ -700,7 +703,7 @@
"Inspect": "Inspect",
"Inspect API not enabled on OAP.": "API Inspect não habilitada no OAP.",
"Inspect OAP internals (catalog, MQE)": "Inspecionar os internals do OAP (catálogo, MQE)",
- "Interactive troubleshooting: the live debugger that streams events from a running rule, and the MQE sandbox for testing queries without saving anything.": "Troubleshooting interativo: o live debugger, que faz streaming de eventos de uma regra em execução, e o MQE sandbox, para testar queries sem salvar nada.",
+ "Interactive troubleshooting: the live debugger runs a rule against live traffic and streams what it produced. Watching a capture needs only the live-debug read permission; starting or stopping one needs live-debug write.": "Diagnóstico interativo: o depurador ao vivo executa uma regra sobre o tráfego real e transmite o que ela produz. Acompanhar uma captura exige apenas a permissão de leitura de live-debug; iniciá-la ou pará-la exige a de escrita.",
"LDAP + local fallback": "LDAP + fallback local",
"LDAP backend": "Backend LDAP",
"LDAP group DN": "DN do grupo LDAP",
@@ -761,6 +764,8 @@
"Reachable": "Disponível",
"Read the audit log": "Ler o log de auditoria",
"Read verb": "Verbo de leitura",
+ "Reserved": "Reservado",
+ "No screen or request checks this — granting it has no effect.": "Nenhuma tela ou requisição verifica isto — concedê-lo não muda nada.",
"Read-only catalog of every alarm rule loaded by OAP, with its body and per-node load state. Rules edit through OAP's {file} + watcher reload — there's no mutation surface here by design.": "Catálogo somente leitura de toda regra de alarme carregada pelo OAP, com seu corpo e o estado de carga por nó. As regras são editadas pelo {file} do OAP + watcher reload — não há superfície de mutação aqui, por design.",
"Read-only data screens. Everyone signed in sees these by default — they are the core observability surface.": "Telas de dados somente leitura. Todo usuário autenticado as vê por padrão — são a superfície central de observabilidade.",
"Reads dashboards, traces, logs, and alarms.": "Lê dashboards, traces, logs e alarmes.",
@@ -783,6 +788,11 @@
"See live-debug sessions": "Ver sessões de live debug",
"See logs": "Ver logs",
"See metric dashboards": "Ver dashboards de métricas",
+ "See data retention settings": "Ver as configurações de retenção de dados",
+ "See the OAP runtime configuration": "Ver a configuração em execução do OAP",
+ "See browser error logs": "Ver os logs de erro do navegador",
+ "Upload and remove source maps": "Enviar e remover source maps",
+ "Use the AI assistant": "Usar o assistente de IA",
"See overview templates": "Ver templates de visão geral",
"See profiling results": "Ver resultados de profiling",
"See service & endpoint topology": "Ver topologia de serviços e endpoints",
@@ -803,11 +813,11 @@
"Source: LDAP": "Origem: LDAP",
"Source: Local": "Origem: Local",
"Start a profiling task": "Iniciar uma tarefa de profiling",
+ "Continuous-profiling policies": "Políticas de profiling contínuo",
"Start a profiling task on a target": "Iniciar uma tarefa de profiling em um alvo",
"Start and stop live-debug sessions": "Iniciar e parar sessões de live debug",
- "Starting a profiling task — agent-side sampling, async-profiler, pprof, or eBPF. Reading the results is part of \"Data catalog\" above.": "Iniciar uma tarefa de profiling — amostragem no agente, async-profiler, pprof ou eBPF. A leitura dos resultados faz parte de \"Catálogo de dados\" acima.",
+ "Starting a profiling task — agent-side sampling, async-profiler, pprof, or eBPF — and arming the continuous-profiling policies that raise tasks on their own. Reading the results is part of \"Data catalog\" above.": "Iniciar uma tarefa de profiling — amostragem pelo agente, async-profiler, pprof ou eBPF — e armar as políticas de profiling contínuo que criam tarefas sozinhas. Ler os resultados faz parte do \"Catálogo de dados\" acima.",
"Test a username": "Testar um nome de usuário",
- "Test queries in the MQE sandbox": "Testar queries no MQE sandbox",
"The MQE, OAL and runtime-rule editing surface. Schema-breaking edits and deletes are split out so a junior operator can author rules without being able to break things irreversibly.": "A superfície de edição de MQE, OAL e regras em runtime. Edições que quebram o esquema e exclusões são separadas para que um operador júnior possa criar regras sem conseguir quebrar nada de forma irreversível.",
"The administrator surface: who can sign in, what role they have, and the live status of the auth backend.": "A superfície administrativa: quem pode entrar, qual papel possui, e o status em tempo real do backend de autenticação.",
"The auditable record of every sign-in, rule change, and configuration edit. Written to a file the operator can ship to an SIEM; no in-app viewer yet.": "O registro auditável de cada login, alteração de regra e edição de configuração. Gravado em um arquivo que o operador pode enviar para um SIEM; ainda não há visualizador no app.",
@@ -828,7 +838,6 @@
"Users defined": "Usuários definidos",
"Waiting for the first poll to return records…": "Aguardando o primeiro poll retornar registros…",
"Watches the SkyWalking platform itself (cluster, internals).": "Monitora a própria plataforma SkyWalking (cluster, internals).",
- "Watching SkyWalking itself: is the OAP cluster healthy, are modules loaded, what does the internal metric catalog look like.": "Monitorando o próprio SkyWalking: o cluster OAP está saudável, os módulos estão carregados, como está o catálogo interno de métricas.",
"What each role can do": "O que cada papel pode fazer",
"What you can do": "O que você pode fazer",
"Window": "Janela",
@@ -1014,7 +1023,7 @@
"value": "valor",
"values": "valores",
"visible": "visível",
- "which sidebar items each role sees · gated by the read verb in the last column (UI hides; the BFF enforces the same server-side)": "quais itens da barra lateral cada papel vê · controlado pelo verbo de leitura na última coluna (a UI oculta; o BFF impõe o mesmo no servidor)",
+ "every navigation entry, and the read verb that decides which roles see it · an entry that expands stands for the sub-entries under it, which need the same permission (the UI hides the entry; the BFF enforces the same verb server-side)": "cada entrada de navegação e o verbo de leitura que decide quais papéis a veem · uma entrada expansível também representa as subentradas abaixo dela, que exigem a mesma permissão (a UI oculta a entrada; o BFF impõe o mesmo verbo no servidor)",
"with": "com",
"{field} is required for scope {scope}": "{field} é obrigatório para o escopo {scope}",
"{minutes} → {precision} step, {buckets} per query.": "{minutes} → step {precision}, {buckets} por query.",
@@ -1080,7 +1089,6 @@
"No rules match the current filter.": "Nenhuma regra corresponde ao filtro atual.",
"Normal": "Normal",
"OAL hot-update is upstream-deferred. Each": "O hot-update de OAL está adiado upstream. Cada",
- "OAP doesn't expose a restore endpoint yet. Operators recover from a dump by looping {endpoint} over each YAML file in the archive. A restore upload affordance will land here when the upstream API ships — see Studio's deferred-features memory.": "O OAP ainda não expõe um endpoint de restore. Operadores se recuperam de um dump iterando {endpoint} sobre cada arquivo YAML do arquivo. Um affordance de upload de restore aparecerá aqui quando a API upstream chegar — consulte a memória de def [...]
"OAP drops the existing measure’s data on BanyanDB and orphans rows on JDBC / Elasticsearch.": "O OAP descarta os dados da measure existente no BanyanDB e deixa linhas órfãs em JDBC / Elasticsearch.",
"OAP runs the standard apply pipeline against the bundled YAML — this is a schema change.": "O OAP executa o pipeline padrão de apply contra o YAML embutido — esta é uma alteração de esquema.",
"Observability Analysis Language": "Observability Analysis Language",
@@ -1133,7 +1141,6 @@
"inactivated · {status}": "inativado · {status}",
"inactive": "inativo",
"it does not union the two": "ela não faz a união dos dois",
- "later release": "release posterior",
"loading bundled…": "carregando embutido…",
"metrics": "métricas",
"modified": "modificado",
@@ -1152,7 +1159,6 @@
"read-only · change on the OAP side": "somente leitura · altere no lado do OAP",
"records": "registros",
"recovery: {force} on save": "recuperação: {force} ao salvar",
- "restore": "restaurar",
"revert to bundled": "reverter para o embutido",
"revert to bundled (STRUCTURAL apply)": "reverter para o embutido (apply ESTRUTURAL)",
"reverted · {status}": "revertido · {status}",
@@ -1299,7 +1305,7 @@
"3D Infrastructure Map": "3D Infrastructure Map",
"3D infrastructure map": "Mapa de infraestrutura 3D",
"See the 3D infrastructure map": "Ver o mapa de infraestrutura 3D",
- "Config for the {mapPath} view, published to OAP. Levels control the vertical stack; per-layer color + metrics drive each cube. Edits save to a {localDraft}; {checkDiffPush} publishes to OAP — the map renders the remote, with bundled defaults as fallback.": "Configuração da visão {mapPath}, publicada no OAP. Os níveis controlam o empilhamento vertical; a cor e as métricas de cada camada definem cada cubo. As edições são salvas em um {localDraft}; {checkDiffPush} publica no OAP — o mapa [...]
+ "Config for the {mapPath} view, published to OAP. Levels control the vertical stack; per-layer color + metrics drive each cube. Edits save to a {localDraft}; {checkDiffPush} publishes to OAP — the map renders the OAP-published copy only, and reports an error when none can be read.": "Configuração da visão {mapPath}, publicada no OAP. Os níveis controlam o empilhamento vertical; a cor e as métricas de cada camada definem cada cubo. As edições são salvas em um {localDraft}; {checkDiffPus [...]
"local draft in this browser": "rascunho local neste navegador",
"editing: {source}": "editando: {source}",
"Reset to": "Redefinir para",
@@ -1494,6 +1500,7 @@
"No spans (older than the detail window).": "Sem spans (anteriores à janela de detalhes).",
"http.status_code=500, …": "http.status_code=500, …",
"3D Infra Map": "Mapa de infraestrutura 3D",
+ "3D Infra Map setup": "Configuração do mapa de infraestrutura 3D",
"No logs in this window.": "Nenhum log nesta janela.",
"Pick": "Escolher",
"Query any service across layers — pick it, type its name, or leave it blank.": "Consulte qualquer Service entre Layers — selecione, digite o nome ou deixe em branco.",
diff --git a/apps/ui/src/i18n/locales/zh-CN.json b/apps/ui/src/i18n/locales/zh-CN.json
index eea9489..76d1674 100644
--- a/apps/ui/src/i18n/locales/zh-CN.json
+++ b/apps/ui/src/i18n/locales/zh-CN.json
@@ -42,7 +42,6 @@
"Alerting rules": "告警规则",
"DSL management": "DSL 管理",
"OAL · read-only": "OAL · 只读",
- "Dump & restore": "导出与恢复",
"Live debugger": "实时调试器",
"Capture history": "抓取历史",
"Metrics inspect": "指标检视",
@@ -613,7 +612,7 @@
"Tried {url}. Confirm the OAP admin-server module is on (SW_ADMIN_SERVER=default) and the port is exposed on the network / k8s Service / ingress.": "已尝试 {url}。请确认 OAP 的 admin-server 模块已开启(SW_ADMIN_SERVER=default),且端口已通过网络 / k8s Service / ingress 暴露。",
"OAP's Zipkin v2 endpoint, source for the OpenTelemetry & Zipkin trace menu (shown when a layer's trace source is zipkin or both). This is the only page affected — if it's unreachable, native traces and every other observability page keep working.": "OAP 的 Zipkin v2 端点,是 OpenTelemetry 与 Zipkin 链路菜单的数据源(当某个 layer 的 trace 源为 zipkin 或 both 时显示)。仅此一处会受影响 —— 即便不可达,原生 trace 以及其他所有可观测页面仍可正常使用。",
"Tried {url}. Confirm OAP's Zipkin receiver / query is enabled and the oap.zipkinUrl in horizon's config points at the right host:port (shared GraphQL port → <queryUrl>/zipkin; standalone → :9412/zipkin). Only the Zipkin trace menu is affected.": "已尝试 {url}。请确认 OAP 的 Zipkin receiver / query 已启用,且 horizon 配置中的 oap.zipkinUrl 指向正确的 host:port(共享 GraphQL 端口 → <queryUrl>/zipkin;独立端口 → :9412/zipkin)。仅 Zipkin trace 菜单会受影响。",
- "Everything Studio does against the admin port. Without admin-server, the other three modules fail at boot with ModuleNotFoundException.": "Studio 在管理端口上做的所有事情。缺少 admin-server,另外三个模块都会以 ModuleNotFoundException 启动失败。",
+ "Everything Horizon does against the admin port. Without admin-server, the other modules fail at boot with ModuleNotFoundException.": "Horizon 在管理端口上做的所有事情。缺少 admin-server,其他模块都会以 ModuleNotFoundException 启动失败。",
"DSL Management (Catalog, OAL catalog), Editor save/load, Cluster status rule matrix, Live debugger rule picker, and the Inspect drawer's source attribution.": "DSL 管理(Catalog、OAL catalog)、编辑器保存/加载、Cluster Status 规则矩阵、Live Debugger 规则选择器,以及 Inspect 抽屉的来源归属。",
"Live debugger across MAL / LAL / OAL (start / poll / stop) and the DSL-debugging health pane on Cluster status.": "覆盖 MAL / LAL / OAL 的 Live Debugger(启动 / 轮询 / 停止),以及 Cluster Status 上的 DSL-debugging 健康面板。",
"The Inspect page — every /api/inspect/* call returns 404 inspect_not_enabled and the page shows a banner instead of the board.": "Inspect 页面 —— 所有 /api/inspect/* 请求都会返回 404 inspect_not_enabled,页面显示横幅而非仪表板。",
@@ -657,7 +656,7 @@
"Capabilities that have not been described yet.": "尚未描述的能力。",
"Catalog load failed:": "目录加载失败:",
"Change role grants": "调整角色授权",
- "Change rule schema (OAL files)": "修改规则结构(OAL 文件)",
+ "Apply a rule edit that changes storage, or revert a rule": "应用会改变存储结构的规则修改,或将规则还原",
"Config file": "配置文件",
"Configures alerts, dashboards, rules, and runs diagnostics.": "配置告警、仪表板、规则并执行诊断。",
"Currently watching ({n})": "当前监听中({n})",
@@ -666,6 +665,10 @@
"Dashboard setup · Alert page": "仪表板设置 · 告警页面",
"Dashboard setup · Global defaults": "仪表板设置 · 全局默认",
"Data catalog": "数据目录",
+ "Browser monitoring": "浏览器监控",
+ "The JavaScript error logs a BROWSER-layer application reports, plus the source maps that turn a minified stack trace into readable frames.": "BROWSER 层应用上报的 JavaScript 错误日志,以及把压缩后的堆栈还原为可读调用帧的 source map。",
+ "Sending a message to the assistant. Each of its data tools re-checks the read permission the matching screen needs, so the assistant never reads more than the signed-in user can.": "向助手发送消息。助手的每个数据工具都会重新校验对应页面所需的读取权限,因此它读到的内容不会超出当前登录用户可见的范围。",
+ "Watching SkyWalking itself: is the OAP cluster healthy, what does the internal metric catalog look like, how long is data kept, what is the running configuration.": "关注 SkyWalking 自身:OAP 集群是否健康、内部指标目录是什么样、数据保留多久、当前运行的是什么配置。",
"Default time window": "默认时间窗口",
"Default window for the topbar time picker, which feeds {scope}. The OAP {step} precision is derived from the window — you don't pick it separately:": "顶栏时间选择器的默认窗口,作用于 {scope}。OAP {step} 精度由该窗口推导得出,无需单独选择:",
"Defaults are": "默认值为",
@@ -701,7 +704,7 @@
"Inspect": "Inspect",
"Inspect API not enabled on OAP.": "OAP 尚未启用 Inspect API。",
"Inspect OAP internals (catalog, MQE)": "查看 OAP 内部信息(目录、MQE)",
- "Interactive troubleshooting: the live debugger that streams events from a running rule, and the MQE sandbox for testing queries without saving anything.": "交互式排查:实时调试器可从运行中的规则流式获取事件,MQE 沙箱可在不保存的前提下测试查询。",
+ "Interactive troubleshooting: the live debugger runs a rule against live traffic and streams what it produced. Watching a capture needs only the live-debug read permission; starting or stopping one needs live-debug write.": "交互式排查:实时调试器让规则在真实流量上运行,并流式返回它产生的结果。查看抓取只需 live-debug 读权限;启动或停止则需要 live-debug 写权限。",
"LDAP + local fallback": "LDAP + 本地兜底",
"LDAP backend": "LDAP 后端",
"LDAP group DN": "LDAP 组 DN",
@@ -762,6 +765,8 @@
"Reachable": "可达",
"Read the audit log": "查看审计日志",
"Read verb": "读权限",
+ "Reserved": "预留",
+ "No screen or request checks this — granting it has no effect.": "没有任何页面或请求会校验它,授予它不会产生任何效果。",
"Read-only catalog of every alarm rule loaded by OAP, with its body and per-node load state. Rules edit through OAP's {file} + watcher reload — there's no mutation surface here by design.": "OAP 已加载的所有告警规则的只读目录,包括规则正文与各节点的加载状态。规则通过 OAP 的 {file} + 监听重载进行编辑 —— 此处按设计不提供变更入口。",
"Read-only data screens. Everyone signed in sees these by default — they are the core observability surface.": "只读数据页面。所有已登录用户默认可见 —— 它们是核心的可观测性入口。",
"Reads dashboards, traces, logs, and alarms.": "读取仪表板、trace、日志与告警。",
@@ -784,6 +789,11 @@
"See live-debug sessions": "查看实时调试会话",
"See logs": "查看日志",
"See metric dashboards": "查看指标仪表板",
+ "See data retention settings": "查看数据保留设置",
+ "See the OAP runtime configuration": "查看 OAP 运行时配置",
+ "See browser error logs": "查看浏览器错误日志",
+ "Upload and remove source maps": "上传和删除 source map",
+ "Use the AI assistant": "使用 AI 助手",
"See overview templates": "查看概览模板",
"See profiling results": "查看剖析结果",
"See service & endpoint topology": "查看 Service 与 Endpoint 拓扑",
@@ -804,11 +814,11 @@
"Source: LDAP": "来源:LDAP",
"Source: Local": "来源:本地",
"Start a profiling task": "启动一次性能剖析任务",
+ "Continuous-profiling policies": "持续性能分析策略",
"Start a profiling task on a target": "在目标上启动一次性能剖析任务",
"Start and stop live-debug sessions": "启动与停止实时调试会话",
- "Starting a profiling task — agent-side sampling, async-profiler, pprof, or eBPF. Reading the results is part of \"Data catalog\" above.": "启动一次性能剖析任务 —— Agent 端采样、async-profiler、pprof 或 eBPF。读取结果归属于上方的\"数据目录\"。",
+ "Starting a profiling task — agent-side sampling, async-profiler, pprof, or eBPF — and arming the continuous-profiling policies that raise tasks on their own. Reading the results is part of \"Data catalog\" above.": "发起性能分析任务 —— 探针采样、async-profiler、pprof 或 eBPF —— 以及启用会自动创建任务的持续性能分析策略。查看结果属于上面的“数据目录”。",
"Test a username": "测试用户名",
- "Test queries in the MQE sandbox": "在 MQE 沙箱中测试查询",
"The MQE, OAL and runtime-rule editing surface. Schema-breaking edits and deletes are split out so a junior operator can author rules without being able to break things irreversibly.": "MQE、OAL 与运行时规则的编辑入口。会破坏 schema 的编辑与删除被单独拆出,以便初级运维人员能在不造成不可逆破坏的前提下编写规则。",
"The administrator surface: who can sign in, what role they have, and the live status of the auth backend.": "管理员入口:谁能登录、各自持有什么角色,以及认证后端的实时状态。",
"The auditable record of every sign-in, rule change, and configuration edit. Written to a file the operator can ship to an SIEM; no in-app viewer yet.": "每一次登录、规则修改与配置编辑的可审计记录。会写入文件,运维人员可将其投递到 SIEM;当前尚无应用内查看器。",
@@ -829,7 +839,6 @@
"Users defined": "已定义用户",
"Waiting for the first poll to return records…": "等待首次轮询返回记录…",
"Watches the SkyWalking platform itself (cluster, internals).": "监控 SkyWalking 平台自身(集群、内部)。",
- "Watching SkyWalking itself: is the OAP cluster healthy, are modules loaded, what does the internal metric catalog look like.": "监控 SkyWalking 自身:OAP 集群是否健康、模块是否加载、内部指标目录长什么样。",
"What each role can do": "每个角色能做什么",
"What you can do": "你能做什么",
"Window": "窗口",
@@ -1015,7 +1024,7 @@
"value": "值",
"values": "值",
"visible": "可见",
- "which sidebar items each role sees · gated by the read verb in the last column (UI hides; the BFF enforces the same server-side)": "每个角色看到的侧边栏条目 · 由最后一列的读权限控制(UI 隐藏;BFF 在服务端执行同样的限制)",
+ "every navigation entry, and the read verb that decides which roles see it · an entry that expands stands for the sub-entries under it, which need the same permission (the UI hides the entry; the BFF enforces the same verb server-side)": "全部导航条目,以及决定哪些角色可见的读权限 · 可展开的条目同时代表其下的子条目,子条目需要相同的权限(UI 隐藏该条目;BFF 在服务端执行同样的限制)",
"with": "与",
"{field} is required for scope {scope}": "{field} 在 {scope} 范围下为必填项",
"{minutes} → {precision} step, {buckets} per query.": "{minutes} → {precision} step,每次查询 {buckets} 个桶。",
@@ -1081,7 +1090,6 @@
"No rules match the current filter.": "没有规则匹配当前过滤条件。",
"Normal": "普通",
"OAL hot-update is upstream-deferred. Each": "OAL 热更新由上游延后实现。每个",
- "OAP doesn't expose a restore endpoint yet. Operators recover from a dump by looping {endpoint} over each YAML file in the archive. A restore upload affordance will land here when the upstream API ships — see Studio's deferred-features memory.": "OAP 尚未提供 restore 接口。运维人员可对归档中的每个 YAML 文件循环调用 {endpoint} 来从 dump 中恢复。当上游 API 发布后,restore 上传入口会在此提供 —— 详见 Studio 的 deferred-features 备忘。",
"OAP drops the existing measure’s data on BanyanDB and orphans rows on JDBC / Elasticsearch.": "OAP 会在 BanyanDB 上丢弃现有 measure 的数据,并在 JDBC / Elasticsearch 上留下孤儿行。",
"OAP runs the standard apply pipeline against the bundled YAML — this is a schema change.": "OAP 会对打包的 YAML 执行标准的 apply 流程 —— 这是一次 schema 变更。",
"Observability Analysis Language": "Observability Analysis Language",
@@ -1134,7 +1142,6 @@
"inactivated · {status}": "已停用 · {status}",
"inactive": "未启用",
"it does not union the two": "它不会对二者取并集",
- "later release": "后续版本",
"loading bundled…": "加载打包版本中…",
"metrics": "指标",
"modified": "已修改",
@@ -1153,7 +1160,6 @@
"read-only · change on the OAP side": "只读 · 请在 OAP 端修改",
"records": "记录",
"recovery: {force} on save": "恢复:保存时 {force}",
- "restore": "恢复",
"revert to bundled": "恢复为打包版本",
"revert to bundled (STRUCTURAL apply)": "恢复为打包版本(结构性 apply)",
"reverted · {status}": "已恢复 · {status}",
@@ -1300,7 +1306,7 @@
"3D Infrastructure Map": "3D 基础设施地图",
"3D infrastructure map": "3D 基础设施地图",
"See the 3D infrastructure map": "查看 3D 基础设施地图",
- "Config for the {mapPath} view, published to OAP. Levels control the vertical stack; per-layer color + metrics drive each cube. Edits save to a {localDraft}; {checkDiffPush} publishes to OAP — the map renders the remote, with bundled defaults as fallback.": "{mapPath} 视图的配置,发布到 OAP。层级(level)控制垂直堆叠;每个 layer 的颜色与指标决定对应方块的呈现。修改会保存到{localDraft};{checkDiffPush}将其发布到 OAP —— 地图渲染的是远端版本,内置默认值作为兜底。",
+ "Config for the {mapPath} view, published to OAP. Levels control the vertical stack; per-layer color + metrics drive each cube. Edits save to a {localDraft}; {checkDiffPush} publishes to OAP — the map renders the OAP-published copy only, and reports an error when none can be read.": "{mapPath} 视图的配置,发布到 OAP。层级(level)控制垂直堆叠;每个 layer 的颜色与指标决定对应方块的呈现。修改会保存到{localDraft};{checkDiffPush}将其发布到 OAP —— 地图仅渲染 OAP 上已发布的副本;若无法读取,则显示错误。",
"local draft in this browser": "当前浏览器中的本地草稿",
"editing: {source}": "正在编辑:{source}",
"Reset to": "重置为",
@@ -1495,6 +1501,7 @@
"No spans (older than the detail window).": "没有 span(早于详情时间范围)。",
"http.status_code=500, …": "http.status_code=500, …",
"3D Infra Map": "3D 基础设施地图",
+ "3D Infra Map setup": "3D 基础设施地图设置",
"No logs in this window.": "此时间范围内没有日志。",
"Pick": "选择",
"Query any service across layers — pick it, type its name, or leave it blank.": "跨 Layer 查询任意 Service —— 选择、输入名称,或留空。",
diff --git a/apps/ui/src/shell/AppSidebar.vue b/apps/ui/src/shell/AppSidebar.vue
index 414a5f8..a77d592 100644
--- a/apps/ui/src/shell/AppSidebar.vue
+++ b/apps/ui/src/shell/AppSidebar.vue
@@ -319,7 +319,7 @@ function isLayerDiverged(key: string): boolean {
hostname-driven (on for localhost, off elsewhere). A plain
button, not a link, so clicks toggle rather than navigate. -->
<button
- v-if="entry.kicker === 'Admin'"
+ v-if="entry.kind === 'admin'"
type="button"
class="sw-nav-item sw-nav-toggle"
:class="{ 'is-active': debugPanelEnabled }"
diff --git a/apps/ui/src/shell/InfraMapPill.vue b/apps/ui/src/shell/InfraMapPill.vue
index ef251d7..8872138 100644
--- a/apps/ui/src/shell/InfraMapPill.vue
+++ b/apps/ui/src/shell/InfraMapPill.vue
@@ -18,9 +18,16 @@
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { RouterLink } from 'vue-router';
+import { useAuthStore } from '@/state/auth';
const { t } = useI18n({ useScope: 'global' });
+// The map's config and traffic reads are gated on `infra-3d:read`; without it
+// the page renders nothing, so the entry is removed rather than leading to an
+// empty canvas. Same rule the sidebar rows follow.
+const auth = useAuthStore();
+const canSeeMap = computed<boolean>(() => auth.hasVerb('infra-3d:read'));
+
/** The 3D Infra Map entry pill lives in the topbar on every page. It
* stays compact ("3D Infra") by default and expands to the full
* "3D Infrastructure Map" wordmark ONLY on hover — including
@@ -44,6 +51,7 @@ const exp3dExpanded = computed<boolean>(() => exp3dHover.value);
mesh / infra) using the same tint colors, so the pill reads as a
microcosm of the view it leads to. -->
<RouterLink
+ v-if="canSeeMap"
to="/3d/map"
class="exp-badge"
:class="{ 'is-on': exp3dExpanded }"
diff --git a/apps/ui/src/shell/useSidebarMenu.ts b/apps/ui/src/shell/useSidebarMenu.ts
index 1f70dca..53347b6 100644
--- a/apps/ui/src/shell/useSidebarMenu.ts
+++ b/apps/ui/src/shell/useSidebarMenu.ts
@@ -101,7 +101,7 @@ export function useSidebarMenu() {
{ icon: 'set', label: 'LAL', to: '/operate/dsl/lal', verb: 'rule:read' },
{ icon: 'set', label: 'LAL → MAL', to: '/operate/dsl/log-mal-rules', verb: 'rule:read' },
{ icon: 'trace', label: t('OAL · read-only'), to: '/operate/oal', verb: 'rule:read' },
- { icon: 'download', label: t('Dump & restore'), to: '/operate/dsl/dump', verb: 'rule:read' },
+ { icon: 'download', label: t('Runtime-rule dump'), to: '/operate/dsl/dump', verb: 'rule:read' },
],
},
{
@@ -159,9 +159,10 @@ export function useSidebarMenu() {
}
/**
- * Verb-filtered view of `sections`: rows with a `verb` the current user
- * lacks are removed; rows without a `verb` always show; sections that
- * end up empty are dropped so we don't render orphan headers.
+ * Verb-filtered view of `sections`: rows — and the sub-rows under an
+ * expandable one — with a `verb` the current user lacks are removed; rows
+ * without a `verb` always show; sections that end up empty are dropped so
+ * we don't render orphan headers.
*
* Hiding is a UX nicety — the BFF enforces the same verbs server-side,
* so this is "don't show controls that won't work," not security.
@@ -173,7 +174,11 @@ export function useSidebarMenu() {
.filter((r) => !r.verb || auth.hasVerb(r.verb))
.map((r) => {
const badge = syncBadgeFor(r.to);
- return badge ? { ...r, badge } : r;
+ // L2 rows render inside the open L1 with no gate of their own, so
+ // the filter has to reach them here.
+ const children = r.children?.filter((c) => !c.verb || auth.hasVerb(c.verb));
+ if (!badge && !children) return r;
+ return { ...r, ...(badge ? { badge } : {}), ...(children ? { children } : {}) };
});
if (links.length === 0) continue;
out.push({ kind: sec.kind, kicker: sec.kicker, links });
diff --git a/docs/README.md b/docs/README.md
index 300668e..7d08b5d 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -13,6 +13,7 @@ The sidebar on the left of this site is the canonical entry point — every sect
- **Access Control** — local users, LDAP login, break-glass access, roles, audit log, and admin pages.
- **Customization** — layer menus, layer and overview dashboard templates, adding a layer, languages and translations, and themes and global defaults.
- **Reference** — design target and widget reference for template authors.
+- **Release Notes** — what changed in each release, one page per version. One version line covers everything Horizon ships: the browser UI, the Horizon server, and the server's reported version.
## Quick orientation
diff --git a/docs/access-control/admin-pages.md b/docs/access-control/admin-pages.md
index 391b859..e995d61 100644
--- a/docs/access-control/admin-pages.md
+++ b/docs/access-control/admin-pages.md
@@ -86,7 +86,7 @@ The Users page is **read-only**. To add a local user, edit `horizon.yaml`. To re
## Roles & Permissions
**Path:** `/admin/roles`
-**Verb:** `role:read` (admin)
+**Verb:** `role:read` + `auth:read` (admin) — the board is drawn from the same status read as the Auth Status page, so a role granted only `role:read` reaches the page and gets a load failure.
Renders a read-only board of roles × verbs as a check-mark grid. The intent is to answer "what can role X do?" without having to re-derive it from `horizon.yaml`.
@@ -94,7 +94,8 @@ Layout:
- **Menu visibility matrix**: which sidebar destinations each role can see — sidebar item × role.
- **Per-area cards**: one card per feature area (Data catalog, Platform monitoring, Layer dashboards, …). Each pairs a **scope** list (the screens that area governs) with an **Actions** grid of roles × capabilities, check-marked where the role holds the verb.
-- Any verb not yet placed in an area falls into an **Other** card, so nothing is silently dropped.
+- Capabilities that nothing enforces are marked **Reserved** — see [reserved verbs](rbac.md#reserved-verbs). A check mark on such a row still shows the grant the role holds; it just buys nothing.
+- Any verb not placed in an area falls into an **Other** card, so nothing is silently dropped.
A red banner appears when `rbac.enabled: false` — the page warns that every authenticated session is granted `*`.
@@ -109,7 +110,7 @@ Read-only. To change roles, edit `rbac.roles` in `horizon.yaml`; hot-reload appl
| `/operate/cluster` | `cluster:read` | maintainer, operator, admin |
| `/admin/auth-status` | `auth:read` | (none built-in; assign explicitly) |
| `/admin/users` | `user:read` | (none built-in; assign explicitly) |
-| `/admin/roles` | `role:read` | (none built-in; assign explicitly) |
+| `/admin/roles` | `role:read` + `auth:read` | (none built-in; assign explicitly) |
| `/operate/inspect` | `inspect:read` | viewer, maintainer, operator, admin |
| `/operate/trace-inspect` | `inspect:read` | viewer, maintainer, operator, admin |
| `/operate/log-inspect` | `inspect:read` | viewer, maintainer, operator, admin |
diff --git a/docs/access-control/local-backend.md b/docs/access-control/local-backend.md
index 6ff3d86..30af724 100644
--- a/docs/access-control/local-backend.md
+++ b/docs/access-control/local-backend.md
@@ -52,7 +52,7 @@ What this means in practice:
| Action | How |
|---|---|
| Add a user | Append to `local.users` in `horizon.yaml`. Hot-reload picks it up; no restart needed. |
-| Remove a user | Delete the entry. Existing sessions for that user are **not** invalidated immediately — they expire on their next request when the session lookup finds no matching active user (or just on session TTL). For immediate revocation, restart the BFF. |
+| Remove a user | Delete the entry. This stops them logging in again, but does **not** touch a session they already hold: nothing re-checks the user list on a request, so an existing session stays valid until they sign out, the session TTL elapses, or the BFF restarts. For immediate revocation, restart the BFF. |
| Reset a password | Generate a new hash, replace `passwordHash`. The next login uses the new hash; existing sessions stay valid until TTL. |
| Change a user's roles | Edit `roles`. Existing sessions retain **the role list captured at login time** — they will not pick up the change until re-login. New logins get the new role list. |
diff --git a/docs/access-control/rbac.md b/docs/access-control/rbac.md
index e804a50..fdcec4a 100644
--- a/docs/access-control/rbac.md
+++ b/docs/access-control/rbac.md
@@ -27,8 +27,8 @@ Known verbs are grouped into areas:
| `browser-errors:read` | Browser Logs tab (BROWSER layer): list JS error logs, list source maps, resolve a stack. |
| `inspect:read` | The read-only inspect tools: Metrics Inspect (`/operate/inspect`), Trace Inspect (`/operate/trace-inspect`), Log Inspect (`/operate/log-inspect`). |
| `topology:read` | Topology tab, topology widgets on overviews. |
-| `profile:read` | Profiling tab (results read-only). |
-| `overview:read` | Public overview dashboards. |
+| `profile:read` | Profiling tab (results read-only) and the continuous-profiling policy list. |
+| `overview:read` | Public overview dashboards. The template-administration pages read their sync status through it too, so a role built to edit only layer dashboards needs it alongside `dashboard:read`. |
| `infra-3d:read` | 3D Infrastructure Map — the map's config + live traffic metrics. |
| `ai:read` | [AI assistant](../operate/ai-assistant.md): send a chat message. Grants no data access by itself — each of the assistant's data tools re-checks its own read verb, so the assistant never reads more than the session could. |
@@ -40,16 +40,15 @@ Known verbs are grouped into areas:
| `dashboard:read` / `dashboard:write` | Layer dashboard templates admin page: list / edit. |
| `alarm-setup:read` | Shows the Alert page setup entry (`/admin/alert-page-setup`). Publishing an edit there requires `overview:write`, like every other template write. |
| `alarm-rule:read` | Alarm Rule catalog: list (read-only — alarm-rule edits go through the OAP alarm-rule YAML, not this page). |
-| `alarm-rule:write` | Reserved (the catalog is read-only; no write endpoint). |
+| `alarm-rule:write` | **Reserved** — OAP's alarm-rule catalog is read-only, so there is no write for Horizon to gate. |
| `setup:read` | Shows the Global defaults entry (`/admin/global-defaults`) — default theme and time defaults. Publishing an edit there requires `overview:write`, like every other template write. |
-| `rule:read` | DSL Management — list rules. |
-| `rule:write` | DSL Management — content edits (non-structural). |
-| `rule:write:structural` | DSL Management — add / remove rules, change rule kind. |
+| `rule:read` | DSL Management — list rules, read a rule body, and download a runtime-rule dump. |
+| `rule:write` | DSL Management — save a rule whose change is not structural, and inactivate a rule. |
+| `rule:write:structural` | DSL Management — save an edit that moves a metric's storage identity (scope, downsampling, single ↔ labeled ↔ histogram), force a re-apply to recover a degraded rule, and revert a rule to its bundled version. |
| `rule:delete` | DSL Management — delete a rule. |
-| `rule:debug` | DSL Management — debug a rule against sample input. |
-| `live-debug:read` / `live-debug:write` | Live Debugger — observe / start sessions. |
+| `live-debug:read` / `live-debug:write` | Live Debugger — watch captures (the page, the active-session list, per-node status, capture history) / start and stop them. These two are the whole of the Live Debugger's access control: `live-debug:read` is enough on its own to watch, `live-debug:write` is enough on its own to start and stop, and no `rule:*` verb takes part. |
| `source-map:write` | Browser Logs — upload / remove source maps (held in BFF memory). |
-| `profile:enable` | Create a profiling task on a layer. |
+| `profile:enable` | Create a profiling task on a layer, and arm a continuous-profiling policy. |
### Platform monitoring
@@ -63,20 +62,26 @@ Known verbs are grouped into areas:
| Verb | Gates |
|---|---|
-| `user:read` | Users admin page (`/admin/users`). |
-| `user:write` | Reserved (no current write endpoint). |
-| `role:read` | Roles & Permissions admin page (`/admin/roles`). |
-| `role:write` | Reserved. |
-| `auth:read` | Auth Status admin page (`/admin/auth-status`) + LDAP probe. |
-| `audit:read` | Reserved (audit log not yet exposed via API). |
+| `user:read` | Users admin page (`/admin/users`) — the list is read-only; local users are defined in `horizon.yaml`. |
+| `user:write` | **Reserved** — the user list has no write. |
+| `role:read` | Shows the Roles & Permissions entry and page (`/admin/roles`). The board is drawn from the same status read as the Auth Status page, so grant `auth:read` alongside it or the page opens and reports a load failure. |
+| `role:write` | **Reserved** — role definitions are edited in `horizon.yaml`, not from the UI. |
+| `auth:read` | Auth Status admin page (`/admin/auth-status`) + LDAP probe. Also the data behind the Roles & Permissions board. |
+| `audit:read` | **Reserved** — the audit trail is a file for you to ship to an SIEM; Horizon does not serve it. |
### Special
| Verb | Meaning |
|---|---|
-| `admin` | Synonym for `*`. Matches anything. |
+| `admin` | Synonym for `*`. Matches anything. Never *required* by a request — it is only ever a grant. |
| `*` | Wildcard. Matches anything. |
+### Reserved verbs
+
+Four verbs — `alarm-rule:write`, `user:write`, `role:write`, `audit:read` — are part of the vocabulary but **nothing checks them**. Granting one opens nothing and closes nothing. They keep their names so a `horizon.yaml` that already lists one still validates, and so the name stays stable if a capability is ever bound to it.
+
+No built-in role names a reserved verb — `admin`'s `*` matches them like everything else, which still does nothing — and the Roles & Permissions page marks each one on screen rather than presenting it as a capability. If a custom role of yours grants one, you can drop it: it is doing nothing today, and leaving it in means the grant takes effect silently on the day something enforces it.
+
## Grant matching
A user's grant string is matched against a required verb using these rules:
@@ -85,7 +90,7 @@ A user's grant string is matched against a required verb using these rules:
|---|---|
| `*` or `admin` | Any verb. |
| `area:verb` (exact) | The exact required verb (case-sensitive). |
-| `area:*` | Any verb in that area, including sub-actions: `rule:*` matches `rule:read`, `rule:write`, `rule:write:structural`, `rule:delete`, `rule:debug`. |
+| `area:*` | Any verb in that area, including sub-actions: `rule:*` matches `rule:read`, `rule:write`, `rule:write:structural`, `rule:delete`. |
| `*:read` | The `read` action in any area: matches `metrics:read`, `alarms:read`, `cluster:read`, etc. Does **not** match `rule:write:structural` (the action is not `read`). |
Effective verbs for a session are the **union** of all grants from all roles.
@@ -112,7 +117,7 @@ viewer baseline + cluster:read, ttl:read, config:read
### `operator`
-Configures observability. Inherits maintainer's reads + write access to dashboards, alarms, rules, live-debug, profiling, source maps.
+Configures observability. Inherits maintainer's reads + write access to dashboards, rules, live-debug, profiling and source maps. Alarm rules stay read-only for every role — see `alarm-rule:write`.
```
maintainer baseline +
@@ -121,8 +126,8 @@ source-map:write,
setup:read,
dashboard:read, dashboard:write,
alarm-setup:read,
-alarm-rule:read, alarm-rule:write,
-rule:read, rule:write, rule:write:structural, rule:delete, rule:debug,
+alarm-rule:read,
+rule:read, rule:write, rule:write:structural, rule:delete,
live-debug:read, live-debug:write,
profile:enable
```
@@ -173,7 +178,9 @@ Every authenticated session is granted `*`. Useful for local development. **Neve
## Visualizing the policy
-The Admin → Roles page (`/admin/roles`, verb `role:read`) renders a read-only board of roles × verbs with check marks. It pulls live data — what you see is exactly what the BFF will use to evaluate the next request. Use it to verify role changes after editing `horizon.yaml`.
+The Admin → Roles page (`/admin/roles`, verbs `role:read` + `auth:read`) renders a read-only board of roles × verbs with check marks, grouped by feature area and preceded by a menu-visibility matrix showing which navigation entries each role sees. It pulls live data — what you see is exactly what the BFF will use to evaluate the next request. Use it to verify role changes after editing `horizon.yaml`.
+
+Rows for [reserved verbs](#reserved-verbs) are marked as such. A check mark on a reserved row still reflects the grant a role holds — it just tells you the grant buys nothing.
## Common patterns
@@ -205,15 +212,20 @@ landingByRole:
`*:read` grants every read — useful for audit access without write capability.
-### Separate alarm-tuning role
+### Separate alarm-triage role
```yaml
roles:
- alarm-tuner:
- - metrics:read, alarms:read, topology:read, traces:read, logs:read
- - alarm-setup:read
- - alarm-rule:read, alarm-rule:write
- - rule:read, rule:debug
+ alarm-triage:
+ - metrics:read
+ - alarms:read
+ - topology:read
+ - traces:read
+ - logs:read
+ - alarm-rule:read # the rule behind a firing alarm
+ - alarm-setup:read # which layers the alarm overview covers
+landingByRole:
+ alarm-triage: /alarms
```
-Can view operational data and edit alarm rules but cannot touch DSL rule structure or live-debug.
+Reads operational data plus the alarm rule behind each firing alarm, and can open the Alert page setup to see how the alarm overview is composed. It cannot change any of it: publishing an Alert page edit needs `overview:write`, and alarm rules are read-only for every role.
diff --git a/docs/changelog/0.1.0.md b/docs/changelog/0.1.0.md
new file mode 100644
index 0000000..b98dbb6
--- /dev/null
+++ b/docs/changelog/0.1.0.md
@@ -0,0 +1,8 @@
+# 0.1.0
+
+Foundational scaffolding. The shell renders, auth works, OAP is reachable, and CI is green. No operator-facing data surfaces yet.
+
+- pnpm monorepo: `apps/ui` (Vue 3 + Vite), `apps/bff` (Fastify), shared `packages/api-client` (typed REST + GraphQL clients), shared `packages/design-tokens` (CSS custom properties).
+- BFF — Fastify skeleton with `horizon.yaml` config + hot reload, local auth (argon2 + cookie sessions), RBAC verb gating + JSONL audit log, OAP proxy with cluster fan-out + preflight.
+- UI — AppShell (sidebar, topbar) with design tokens, Pinia auth store with on-401 redirect, login view with route guard + sign-out, stub admin / operate pages.
+- CI — monorepo workspace build + dependency license check via `skywalking-eyes`.
diff --git a/docs/changelog/0.2.0.md b/docs/changelog/0.2.0.md
new file mode 100644
index 0000000..de7b09c
--- /dev/null
+++ b/docs/changelog/0.2.0.md
@@ -0,0 +1,35 @@
+# 0.2.0
+
+Per-layer dashboards become real, the layer-template editor ships, and topology gets its booster-ui port.
+
+### Per-layer dashboards
+
+- Real widget grid per layer driven by JSON templates. 43 layer dashboards migrated from booster-ui.
+- Per-scope widget sets: each layer template defines its own `service`, `instance`, `endpoint`, `topology`, `traces`, `logs`, profiling variants.
+- Visibility predicates per widget (`visibleWhen`) so MQ / DB widgets only render when the relevant metrics are reporting.
+
+### Layer admin
+
+- Read-only template browser, then full edit UI: components editor (toggle which per-layer views exist), metrics editor (header columns), separate Overview tile card, scope-aware visibleWhen hints.
+
+### Service deep-dive
+
+- APIs widget (formerly Services), MQ widgets gated by visibleWhen, TopList multi-expression switcher with MQE preview in tooltip, smaller widget height, per-metric color alignment, dual-axis MQ.
+
+### Topology
+
+- Polished linear-chain variant, dual-panel detail, per-side line charts. Drag-to-move + barycentric layout for smaller graphs. RPM-only chip variant. Istio renamed.
+
+### Logs
+
+- Legend at top of table (drop service facet duplication), workflow notes.
+
+### Charting
+
+- TimeChart: legend formatting fix for dual-axis widgets, value dots, tooltip escape for clipped charts, no more legend / axis-name crowding at chart top.
+
+### Sidebar + chrome
+
+- Group toggle + group click cascades to first layer's first tab.
+- Topbar 60m widget format hints (int / decimal / compact).
+- Per-layer image pipeline (icons) shipped.
diff --git a/docs/changelog/0.3.0.md b/docs/changelog/0.3.0.md
new file mode 100644
index 0000000..2174b52
--- /dev/null
+++ b/docs/changelog/0.3.0.md
@@ -0,0 +1,39 @@
+# 0.3.0
+
+The shell unifies, the operate stack lands, and the first round of public documentation ships.
+
+### Operate stack
+
+- **Alarms page** — incident-merged active-alarms view, severity tabs, alarm list with right-side detail (trigger expression, channel routing), inline Live Debug card (Run / Step / Pause / Copy as MQE, execution-trace ladder with per-step output + latency, matched entities, eval-window chart, raw OAP response).
+- **Inspect** — metric catalog + entity enumerator with search, type filter, scope (Service / Instance / Endpoint / Process / All), and source attribution.
+- **Live Debugger** — MAL / LAL / OAL session start, poll, stop. Per-node status fan-out, sample payloads, capture history with replay-ready recordings.
+- **Profiling** — flame graph + stack table over five profilers: trace-driven thread profiling, eBPF CPU/off-CPU, JVM async-profiler, network profiling (process conversation graph), Go pprof.
+- **Zipkin trace explorer** — service / span search, waterfall popout with per-service color bands, sticky time-axis.
+- **Overview dashboards** — cross-layer war-room views (Services, Mesh) with per-layer KPI tiles, alarm rails, and the existing chart widgets.
+
+### Auth + access control
+
+- Local + LDAP authentication backends. Break-glass admin honored only when `backend: ldap` AND the LDAP probe is failing.
+- Three admin pages — Users, Auth status, Roles & permissions.
+- 4 built-in roles (viewer / maintainer / operator / admin) and a 28-verb permission model. Every BFF route gated by a single policy table.
+- Login view redesigned (canyon hero, status pill, configured-backend banner).
+
+### Reliability + UX
+
+- Cascade-clear, then load — every dependent area visibly resets and shows "Reading data…" between an upstream control change (service / instance / endpoint pick, time-range change, layer / scope nav) and the new data landing. No silent freezes; no stale value sitting under a spinner.
+- Global time picker in the topbar wired into the landing + widget query keys; the picker only applies to dashboards / overviews (triage pages keep their own per-page time).
+- Single-shot bundle preload: layer dashboards + overview list arrive in one round-trip, cached in localStorage with ETag revalidation.
+- Framework event ticker in the topbar replaces breadcrumb+search; Admin-toggled debug panel surfaces a 200-event buffer with operator click capture.
+- Auto-pick first instance / endpoint when a scope needs one and the list is non-empty.
+- Topology + dashboard fixes, multi-layer service attribution, sticky service selection across navigations.
+
+### Documentation
+
+- First public docs tree (`docs/`) — Setup, Compatibility, Access Control, Customization, Components, Operate. Lives in-repo and publishes to skywalking.apache.org.
+
+### Container + CI
+
+- Real `packages/*` builds + self-contained `dist/` + copy-in image (no compile in the container).
+- Zero-config boot: image defaults `HORIZON_SERVER_HOST=0.0.0.0`.
+- Multi-arch publish-image — native amd64 + arm64 builds, OCI manifest list.
+- Unit-test job in CI; 107 UTs covering entity-scope construction + routing decisions.
diff --git a/docs/changelog/0.4.0.md b/docs/changelog/0.4.0.md
new file mode 100644
index 0000000..48068cb
--- /dev/null
+++ b/docs/changelog/0.4.0.md
@@ -0,0 +1,45 @@
+# 0.4.0
+
+OAP becomes the runtime source of truth for UI templates, the 5-theme system lands, and the app supports being served behind a gateway prefix.
+
+### Templates synced to OAP
+
+- Five reserved template families now live on OAP's UI-template REST surface (`/ui-management/templates*` on the admin port): overview dashboards, per-layer dashboards, alert page setup, theme selection, time-defaults. Bundled JSON ships as the seed + read-only fallback.
+- One-shot seed on BFF boot pushes any missing bundled template to OAP; runtime sync is read-only with a 30-second single-flight cache.
+- New admin endpoints: `GET /api/admin/templates/sync-status`, `POST /api/admin/templates/save`, `POST /api/admin/templates/resync`, `POST /api/admin/templates/:name/push-bundled`.
+- When the admin port is unreachable, every admin page goes read-only with a red banner; Save / Create / Delete are disabled; render falls back to bundled.
+- Diverged rows surface a "Show diff & reset" Monaco modal with a destructive-confirm (type the template key to arm reset).
+
+### Themes
+
+- Five bundled themes — **Horizon** (default), **Meridian**, **Obsidian**, **Daybreak**, **Aurora** — each shipping a complete token set (bg, fg, accent, info/ok/warn/err, font, radius, density).
+- New `/admin/global-defaults` admin page replaces the old "Setup" link. Theme picker uses preview cards lifted from the design (hero strip, mini-app mockup with Primary/Tonal/Ghost buttons, KPI tiles, sparkline, density/font/radius badges).
+- Per-user theme override via a labelled topbar chip — three-tier resolution `localStorage user → OAP org default → bundled`, written to `<html data-theme>` / `<html data-appearance>` synchronously on boot so the pre-auth login page already respects the local override.
+- Sidebar SkyWalking logo swaps to the official brand blue (`#1368B3`) on light-appearance themes.
+- Widget series colors (Zipkin trace palette, AlarmSnapshotChart, AlarmsTimeline) track the active theme's `--sw-accent` via a shared `readAccent()` util.
+- Sign-in button gradient derives both stops from the theme accent.
+
+### Time defaults
+
+- `/admin/global-defaults` also owns the global picker's default window (60 minutes shipped). OAP `step` precision is derived from window size — ≤ 4 h MINUTE, 6 h–14 d HOUR, ≥ 30 d DAY — and surfaced inline on the page.
+- Per-user override in the topbar time picker: "Save as my default" / "Reset to org default".
+
+### Reliability + diagnostics
+
+- Topology cluster boundary now grows to encompass dragged nodes; the chip moved inside the cluster header so it stays visible at any drag position.
+- Alarms page gains an **Other** KPI tile that surfaces the residual count between `Active` and the sum of pinned-layer chips — `Active = General + Mesh + Other` reconciles even when alarms land in unmapped layers.
+- Overview "Active alarms" widget now reads the admin's configured `defaultWindowMs` from `/admin/alert-page-setup`; all three alarm surfaces (overview widget, alarms page, topbar badge) share one window.
+- Every backend call failure (network throw or non-2xx) writes a `pushEvent('api', 'err', …)` into the debug event log with the BFF's `code` / `message` envelope inlined when present.
+- Dashboards with more than 40 widgets (e.g. the General/instance page, 56 widgets) now succeed: the UI splits oversize requests into ≤40-widget chunks fired in parallel, then merges results.
+
+### Deployment
+
+- Gateway-prefix support: `BffClient.request()` prepends `import.meta.env.BASE_URL` to every API path. Build with `vite build --base=/horizon/` and a gateway that strips the prefix and the SPA + every API call resolves cleanly under the sub-path.
+- Cluster Status route corrected from `/admin/cluster` → `/operate/cluster` (the prior default 404'd because no route by that name existed).
+
+### Cleanup
+
+- Documentation rewritten as an orientation map; the left-side menu is the canonical navigation now. All `SWIP-*` references removed from user-visible text and docs.
+- "Coming in Phase 6 / 7" placeholder strip on Cluster Status removed.
+- Dead code dropped — `LandingView.vue`, `LayerTabPlaceholder.vue`, the orphaned disk-write template routes (`POST /api/admin/overview-templates/:id` + `POST /api/admin/layer-templates/:key`), and stale `Phase X` markers across BFF + UI + docs.
+- The OAP UTC-offset chip is gone from the topbar; the health dot stays.
diff --git a/docs/changelog/0.5.0.md b/docs/changelog/0.5.0.md
new file mode 100644
index 0000000..f86d94c
--- /dev/null
+++ b/docs/changelog/0.5.0.md
@@ -0,0 +1,46 @@
+# 0.5.0
+
+First Apache-style release cut from this repo: source + binary tarballs, GPG-signed and SHA-512 checksummed, with a self-contained binary that boots via `node server.js` and no `pnpm install` step. Binary distribution ships a regenerated `LICENSE` + `NOTICE` that enumerate every bundled third-party package — produced by `scripts/collect-dist-licenses.mjs` during packaging and validated against a deny-list before signing.
+
+### Profiling
+
+- **pprof (Go) profiling** is fully wired: pick one event per task (CPU / HEAP / BLOCK / MUTEX / GOROUTINE / ALLOCS / THREADCREATE), with duration shown for CPU/BLOCK/MUTEX and a sampling-rate field for BLOCK/MUTEX. Create and analyze both match OAP's single-event pprof schema.
+- **eBPF profiling** gets a reworked process picker — click a row to expand its full attributes, selection lives on the checkbox, anchored pop-out — a refresh button on every task list, Intl-formatted times, and a hover-info frame on the flame graph. Flame-graph thrash on re-analyze is gone.
+- The shared flame graph fixes "% of root" (it read a never-aggregated count), highlights the selected frame across all four profilers, and shows a single hover card (the library's duplicate native tooltip is suppressed).
+- After creating any profiling task (trace / async / eBPF / network / pprof) the list now polls up to 4× at 10s until the new task shows up, instead of leaving a stale pre-create list.
+
+### Network profiling & process topology
+
+- A booster-style honeycomb process topology: pods as hexagons, peers hugging the boundary, animated protocol-coloured edges (HTTP/TCP/TLS), a node pop-over, and a wide client | server edge-metric dashboard. Network task creation and the task-list query now use OAP's schema field names.
+
+### Platform monitoring (operate)
+
+- Two new read-only operate pages: **Data retention** (TTL — `getRecordsTTL` / `getMetricsTTL`) and **OAP configuration** (the admin-port config dump, with OAP-masked secrets). Gated on new `ttl:read` / `config:read` verbs granted to maintainer and above. Data retention now loads on non-BanyanDB backends too (the `metadata` TTL field is optional).
+- The operate sidebar now leads with a single **Platform monitoring** group (cluster status, data retention, OAP configuration) above the per-layer self-observability dashboards.
+
+### Dashboards & templates
+
+- **The global time picker now drives dashboards.** Layer dashboards query OAP at the picker's window and precision (MINUTE / HOUR / DAY) instead of a fixed last-hour minute window, and line charts label the x-axis with real times per step (e.g. `MM-DD` for a 30-day view) rather than `-Nm`.
+- **New `table` widget** for label-dimensioned metrics — pod phase per service, node condition, deployment replicas, etc. — rendered as one column per label (e.g. `Condition | Node`) instead of a scalar card or a misleading flat line. The K8S dashboards (and kong / mongodb / elasticsearch) now use it where upstream booster-ui does; widgets that were charting a single `latest(…)` value as a line are now cards. The K8S Cluster view is realigned to the upstream layout (totals cards · resour [...]
+- **Edit locally, publish on your terms.** Saving a dashboard/overview template now writes the local bundled copy (so the edit renders immediately for preview) and marks it diverged — nothing reaches OAP until you press **Sync all to OAP**, which pushes only the templates that differ, behind a confirmation listing exactly what will be written. A post-save tip spells out that the change is local-only until published.
+- **Local-vs-remote, made explicit.** When local edits diverge from OAP, a per-session prompt (by menu name, not file name) asks which to render — **keep my local edits** (preview) or **use live** (which overwrites the local copy with the remote version, confirmed). The layer-templates admin page carries the same Local/Remote display toggle next to Sync all, and a **Diverged only** filter; each diverged layer shows a yellow warning icon in the sidebar.
+
+### Traces
+
+- The native trace view auto-selects OAP's trace-query API — `queryTraces` (whole trace inline, BanyanDB) vs `queryBasicTraces` (segment list + a per-trace fetch on click, every other backend) — and a banner states which is in use; in segment-list mode the list reads "Segments" and a click loads the full trace.
+- Span kind (Entry / Exit / Local) renders as a colored word, not a filled pill.
+
+### Auth, RBAC & resilience
+
+- Every OAP call — GraphQL, admin REST, and Zipkin — now carries the configured basic-auth credentials, so a secured OAP no longer 401s pages.
+- The sidebar is RBAC-gated by read verb, the Roles page shows a per-role menu-visibility matrix, and the Users page labels per-node "Active (24h)" / "Last seen" honestly (these are tracked per BFF replica, not cluster-wide).
+- **Routes are verb-gated, not just menus.** A user without the required read verb is bounced from a restricted page (e.g. a viewer can no longer reach Cluster Status via the topbar OAP chip or a direct URL); the chip only links there for `cluster:read`. This sits on top of the existing per-route BFF verb enforcement.
+- **LDAP** resolves group membership with the service account, not the logging-in user — directories that hide the group subtree from ordinary users no longer collapse every login to the fallback role.
+- When OAP is unreachable the menu and admin loaders fall back to bundled templates, and non-JSON OAP responses surface a clear diagnostic.
+
+### Smaller touches
+
+- Top-N widgets get hover tooltips for long names and a title-bar pop-out to the full ranked list; redundant single-service name prefixes are dropped.
+- The admin template-diff modal is a wide side-by-side view with labelled bundled-vs-OAP columns and an explanation of what the template drives; the layer-dashboards admin rail gains an in-page search.
+- Per-layer alarm filtering uses the singular `queryAlarms` layer condition.
+- Dependency hygiene for the release: `dompurify` ≥ 3.3.2 and `@fastify/static` ≥ 9.1.1 (clears the known advisories); the `general` layer drops `networkProfiling`, which is instance-scoped to k8s / mesh.
diff --git a/docs/changelog/0.6.0.md b/docs/changelog/0.6.0.md
new file mode 100644
index 0000000..325a747
--- /dev/null
+++ b/docs/changelog/0.6.0.md
@@ -0,0 +1,192 @@
+# 0.6.0
+
+This release is the production-readiness pass for Horizon UI: every page now renders correctly across the eight supported languages on non-UTC OAP deployments, with deliberate caps and validation on the load surfaces that operators reach. The pillars below describe the operator-visible result.
+
+### Eight-locale internationalization
+
+Horizon now ships with eight first-class UI languages — English (source) plus zh-CN, ja, ko, es, pt, de, fr — selectable from the top-bar locale chip on every page (including the pre-auth login). The choice persists per device.
+
+- **UI chrome.** Every routed page and every shared sub-component renders through `vue-i18n`; non-English locales now cover every admin page (Roles, Users, Auth status, Alert page setup, Global defaults, 3D-map config), every operate page (Alerting rules, DSL catalog / editor / dump, OAL catalog, Live debugger + MAL / LAL / OAL, Capture history, Metrics inspect, OAP config, TTL), the alarms surface, and the shared modals. Long lede paragraphs that previously rendered as `English | one tr [...]
+- **BFF-shipped templates.** All 42 layer dashboards and both overview dashboards carry per-locale overlay catalogs alongside the source template. Coverage is ~2,300 translatable leaves per non-English locale across the layer set. The BFF picks the locale from the request's `X-Horizon-Locale` header (auto-set by the SPA), merges the overlay onto the source, and serves the localised template to the renderer — translation resolves once on the BFF, never on every chart mount.
+- **Operator-runnable Translations page.** A new admin surface (Dashboard setup → Translations) edits the per-locale overlays through the live preview: pick a target language, click any widget in the rendered dashboard, type the translation. Per-locale status chips on the template picker show at a glance which dashboards have drafts, which are synced, which diverge from disk, and which are empty for a given locale. Push writes the sibling overlay row on OAP; pushing zh-CN never touches ja.
+- **Tech-term policy.** Product, project, and protocol names (SkyWalking, Kubernetes, OAP, MQE, eBPF, Zipkin, OpenTelemetry, Istio, GraphQL, etc.), OAP scope enums (Service, ServiceInstance, Endpoint, Process), layer keys, MQE function names, env vars, HTTP status codes, and per-language runtimes (JVM, Go, Python, …) stay verbatim in every locale per CLAUDE.md. Phrases containing tech terms are translated around the term (`HTTP Connections` → `HTTP 连接` / `HTTP 接続`), not transliterated.
+- **OAP-supplied data is never translated.** Service names, alarm rule names, trace span operation names, log messages — anything arriving over the OAP wire — render verbatim regardless of locale.
+- **Validator gate.** `i18n:validate` is stricter: every source template must have a sibling overlay file per advertised locale, and empty `{}` overlays are now a finding (used to pass silently — surfaced as "structurally complete" while every translatable string still rendered in English).
+
+### Typography + self-hosted fonts
+
+- **Inter + JetBrains Mono are now self-hosted.** The Google Fonts CDN dependency is gone — air-gapped or firewalled deployments render the intended typography instead of silently falling back to system fonts.
+- **One typescale across every page.** Older admin pages that drifted to a mixed pixel palette (9.5 / 10 / 10.5 / 11 / 11.5 / 12 / 14 / 18 / 20 / 22) now share the same six-step scale + uppercase-label vocabulary as the newer dashboards. Sidebar, kpi labels, table headers, kickers all line up.
+
+### Wire-correctness on non-UTC OAP
+
+Every BFF query route now spells `Duration.start` / `end` in the OAP server's timezone (probed once per minute, cached). Previously only the alarms route did this; dashboards / landing / topology / endpoint / endpoint-dependency / instance / eBPF / traces / logs / trace-tag all emitted UTC, which silently shifted every query on non-UTC OAP installs by the server's offset.
+
+Traces and logs additionally query at SECOND precision now (records, not metric buckets) — a trace that just finished falls inside the window instead of getting rounded off the MINUTE boundary.
+
+### Performance hardening
+
+- **Landing batches no longer 5xx on wide layers.** The per-layer landing route used to build one GraphQL with up to 250 aliased fragments (25 services × 10 metric columns) and trip OAP's per-request complexity ceiling, blanking every cell. Chunks at 6 services per round-trip and fires them in parallel — same pattern the dashboard route already uses.
+- **Trace waterfall opens fast on huge traces.** Rows render lazily via the browser's `content-visibility` window — a 5000-span trace no longer freezes the main thread on open.
+- **Backgrounded tabs stop polling.** The shared auto-refresh ticker pauses when the tab is hidden and resumes (with one immediate tick) on return. An unattended browser no longer streams queries at the topbar interval × every subscribed widget.
+
+### RBAC + input-validation hardening
+
+- **`/api/health` no longer leaks the active session count** to unauthenticated callers — the public liveness probe returns only status + version. The authenticated `/api/auth/health` surface still carries detail.
+- **`pageSize` capped server-side** on every trace / log route (trace 200, log 100). OAP forwards `paging.pageSize` straight to the storage `LIMIT`, so a client posting `pageSize: 50000` previously cascaded the load to OAP. The UI picker's matching cap is now defended at the BFF boundary too.
+- **Profiling task bodies validated.** Async-profiler, pprof, eBPF fixed-task, and network-profiling create routes now sanitize and bound their bodies — duration caps, target-instance and event-list caps, payload-size clamps. Closes a DoS vector where a user with `profile:enable` could submit a multi-hour profile that pegs the target instance's CPU.
+
+### Diff modal console error fixed
+
+The four admin "Check diff & push" modals (Layer dashboards, Overview templates, Translations, the shared TemplateDiffModal) used to log `Uncaught (in promise) Error: Missing requestHandler or method: resetSchema` (two per modal — one per diff pane) the moment the operator opened them. Monaco's JSON language service was sending the message to a worker that didn't know how to handle it. Now wired correctly — the diff itself rendered all along, but the console is quiet again.
+
+### DSL / OAL catalog renames + admin editor seeding
+
+- **Catalog pages spell out the language name** in the header: `Metrics Analysis Language - OpenTelemetry Rules` (and `… - Telegraf Rules` / `… - Log MAL Rules`); LAL renders as `Log Analysis Language`; the OAL browse as `Observability Analysis Language`. The sidebar keeps the abbreviated MAL · OTEL form for space.
+- **Layer Dashboards + Overview Templates editor opens REMOTE on diverged rows.** The runtime menu already renders remote content when the row is diverged, but the editor used to seed from bundled — so operators were editing a copy that silently disagreed with what end users saw. Priority is now local → remote (diverged / remote-only) → bundled; the source pill on both editors reads `from local / from remote / from bundled` consistently.
+- **Rule card arrow stays next to the rule name.** Short names like `default` / `mesh-dp` / `vm` used to float in the middle of the card with a big gap from the ▶ run arrow — the arrow + name are now grouped on the left, with the status pill alone pushed right.
+
+### Sync-status counters don't include translation rows
+
+Per-locale overlay rows on OAP share their parent template's kind, so they were inflating the `remote-only` and `diverged` counts on the Overview Templates and Layer Dashboards admin pages (the banners would read `14 remote-only` / `294 remote-only` when most of those entries were translation rows that belong on the Translations page only). Filtered at bundle assembly so the sync-status banners count source rows only.
+
+### Cluster Status + admin polish
+
+- **Cluster Status — Pane B + Pane C fully translatable.** Module column headers, gate descriptions (the SWIP-13 affects strings), per-row enabled/missing badges, the admin-host status badge (`loading…` / `unreachable` / `all selectors on` / `{n} selectors off`), the admin-host-unreachable hint, the Zipkin / OTLP pane lede, the Endpoint card heading, the Zipkin badge and the Zipkin-unreachable hint all now render in the active locale.
+- **Hide redundant BUNDLED chrome when synced.** On the Overview templates and Layer dashboards admin pages, when the row's sync badge is `synced` the BUNDLED and REMOTE versions are byte-equal, so the `from {source}` pill and the `Reset to / Preview Bundled` dropdown items add no information and are hidden. LOCAL drafts always show; BUNDLED resurfaces the moment the row diverges.
+- **Translations picker prefers the English bundle.** REMOTE-only rows with non-English titles (legacy duplicates from prior import cycles) no longer appear as separate dashboards in the picker — the picker lists the canonical English bundled dashboards once each, and the preview renders the English source as the baseline.
+
+### Alerting rules — running entities show their OAP node
+
+The **Operate › Alerting rules** detail pane's **Currently watching** list now spans the whole cluster and tags each entity with the OAP node evaluating it. Each OAP instance evaluates a rule independently over the slice of entities it holds, so the watched set is the union across nodes — the page previously showed only the first responding node's entities, which misread as "these are all the entities the rule watches." The list now aggregates every instance's entities and labels each ro [...]
+
+Clicking a watched entity now opens a **running-context popup** — the live evaluation window the rule is computing for that entity, per OAP node. It shows the current state (`FIRING` / `SILENCED_FIRING` / `RECOVERY_OBSERVATION`), the window size and silence / recovery countdowns, the window end, the last-alarm time and message, and the per-metric snapshot the expression was evaluated against — rendered as a sparkline plus per-bucket values so an operator can see exactly why a rule is (or [...]
+
+### Live debugger fixes
+
+A clutch of small but visible bugs were caught while exercising the i18n surfaces:
+
+- **History → debug deep-link rendered blank.** MAL and LAL views crashed silently on `?historyId=…` because `loadHistorical` reset refs (`selectedRow`, `expandedEntities`, `foldedRecords`; `selectedCell` for LAL) that the file declared further down. The `watch(historyId, …, { immediate: true })` fires during setup, so the TDZ ReferenceError aborted setup before the page rendered, with no console trace. Resettable refs are now hoisted above their consumer.
+- **Captured records wiped on stop.** After `stop()`, the per-DSL view did one final `session()` refresh against an already-cleaned- up OAP, which returned nodes-only-with-empty-records and overwrote the rich live snapshot in localStorage. `save()` now refuses to shrink an existing entry's `recordCount` for the same `sessionId` — only metadata (`retentionDeadline`, `retentionMillis`) updates.
+- **Stable node ordering.** MAL / LAL / OAL node cards now sort by `nodeId ?? peer` so they don't reshuffle between polls.
+- **Tab buttons jumped nowhere.** `LiveDebuggerView.selectTab` pushed `/debug/<tab>` (a path that doesn't exist); fixed to `/operate/live-debug/<tab>`. Same correction in `DebugHistoryView.loadEntry` deep-links and surrounding doc-comments in `RuleCard.vue` / `DslEditorView.vue`.
+- **Empty-capture placeholder.** When a saved session has zero populated nodes, `DebugView` now shows an explicit "This capture has no records" rather than rendering blank, so an honest empty capture is visibly different from a bug.
+- **Per-locale lookup widened.** Per-DSL views look up the historyId via `history.all` (not the widget-filtered `history.entries`); the route already pinned us to the right widget, and double-filtering by `widget` was a silent way for a stale field to drop the entry.
+
+### Other small fixes
+
+- **MAL / LAL editor gutter glyph.** The green ▶ live-debug entrance on every `- name:` row was referencing an unstyled CSS class — Monaco reserved the gutter and wired clicks, but the icon was invisible. Restored.
+- **Rule catalog cards.** Duplicate BUNDLED pill removed; the header status pill already conveyed it.
+- **K8s instance Node Status.** Switched from a single-scalar card (rendering just `1`) to a table of currently-true Kubernetes conditions, matching the cluster-scope sibling and aligning widget heights.
+
+### Upstream `ui-management` compatibility
+
+Aligned with upstream [skywalking#13884](https://github.com/apache/skywalking/pull/13884) ("remove auto generate id for UI templates"): Horizon now sends `id = <envelope name>` on `POST /ui-management/templates`. Current OAP requires it (POSTs without `id` are rejected); legacy OAP releases ignored the field and auto-generated UUIDs, so the same payload works against both. Horizon already treated `r.id` as opaque; mixed-id deployments self-heal via `reconcileDuplicates` on the next boot.
+
+### Smartscape service hierarchy
+
+OAP 10's cross-layer service hierarchy is now reachable from any layer's service map — a logical service projected across observation layers (GENERAL agent ↔ MESH sidecar ↔ MESH_DP data-plane ↔ K8S_SERVICE pod) is one click away on every selected hex.
+
+- **Lazy-probed chip on the selected hex.** Picking a node fires one `getServiceHierarchy` call; if the service has cross-layer peers, a small chevron-stack chip clips to the hex's right edge. No probe, no chip on services with no peers.
+- **Focus + context + suggestions overlay.** Click the chip and the topology dims under a transparent canvas; the focused hex re-renders bright at the exact same screen position and scale as the underlying hex (the topology's d3 zoom transform is mirrored onto the overlay). Peers fan vertically from the focus column using OAP's `listLayerLevels` order — higher-level (request-near) layers above, lower-level (infra-near) layers below, matching booster-ui's hierarchy rendering rule.
+- **Auto-refresh pauses while the overlay is open** so the background topology and KPI panels don't shift under the operator. Closing the overlay (`×` button, ESC, or click-on-dim) resumes the ticker and fires one immediate tick so the page snaps back to live data.
+- **Two-step peer open.** First click on a peer hex arms it (selection halo + side `↗ Open in <Layer>` action chip); second click on the chip opens the destination layer in a new browser tab, pre-selecting the peer service. Peers in layers Horizon has no template for render dimmed with a `cursor: not-allowed`; clicking them logs *"No layer template configured for <Layer>"* to the event log instead.
+- **URL-pinned service validator on the destination tab.** Every per-layer page now validates the URL-hydrated `?service=<id>` against the layer's real service roster (the new `GET /api/layer/:key/services`, served from the BFF's 60s catalog cache so it adds no extra OAP traffic). A genuinely missing id pops a `Service not found in this layer` modal with a one-click fallback to the first available service; a valid id is trusted even when landing's top-N rollup doesn't sample it (the caus [...]
+- **Service-name resolution** on the layer dashboard now consults the roster after landing's top-N, so deep links to low-traffic services no longer sit on *"Resolving service…"* forever waiting for a row that won't arrive.
+
+### On-demand pod logs (live tail)
+
+A new per-layer **Pod Logs** tab live-tails a Kubernetes pod's container logs, pulled on demand from the K8s API through OAP and never persisted.
+
+- **Instance-pinned tail.** Pick a pod, pick one of its containers, press Start; the trailing window (30s / 1m / 5m / 15m / 30m) streams into a read-only log pane and refreshes on a chosen interval (2s / 5s / 10s / 30s) until paused. A header strip shows the container, line count, a live dot, and "updated Ns ago".
+- **Include / Exclude filtering** forwards to OAP's content keyword filters — full-line regex, so a substring match reads `.*error.*`.
+- **Enabled on the Kubernetes-deployed layers** — Kubernetes Services (`K8S_SERVICE`), Istio Managed Services (`MESH`), and Istio Data Plane (`MESH_DP`) — whose service instances resolve to a pod. The tab is gated by a new `podLogs` component flag added to those bundled layer templates; an existing OAP whose stored template predates the flag still gets the tab, because the flag is back-filled from the bundled default (no re-push needed).
+- The page **owns its own refresh** — the global auto-refresh ticker and the topbar time picker are paused while on it, the same as Traces / Logs.
+- When the selected instance carries no pod metadata (or the pod has rotated away), OAP's reason is shown verbatim with a hint to pick a currently-running pod or enable the feature on OAP.
+
+### BanyanDB cold-stage query
+
+The cold lifecycle stage is now reachable from the UI on BanyanDB deployments — operators can query data that has aged past the hot + warm window without leaving the page.
+
+- **Topbar `Cold` pill** appears only when the connected OAP is BanyanDB. Toggling it switches every page to read from the cold stage instead of hot + warm — it replaces the read, it does NOT union the two stages, so the pill label flips to `Cold only` while on. The choice is sticky per browser and re-runs every visible query so what you see matches the new mode immediately.
+- **Cold-trap banner.** When the pill is on AND the current time range is within the hot + warm window, a yellow strip appears under the topbar: *"Cold-only read is active — your time range is within the last N d (hot + warm), where the cold stage returns nothing."* A one-click *Turn Cold off* sits on the right.
+- **Trace lookup from a log row** now passes the row's timestamp through the popout so the trace lookup spans a window around that timestamp instead of OAP's default last-1-day search — paired with the Cold pill, a trace that lives in cold resolves from a cold-era log row instead of silently failing to load.
+
+### Data retention page
+
+- **Per-data-class data lifecycle bar.** One row per data class (Normal / Trace / Zipkin trace / Log / Browser error log / Metadata / Minute metric / Hour metric / Day metric) with proportional Hot+Warm and (when configured) Cold segments. Widths are proportional to total retention across all rows, so a class retained longer visibly stretches further than its peers.
+- When every class in a category shares the same TTL pair, the rows collapse to `All records (5)` / `All metrics (4)` — the page never renders nine identical bars.
+- The page **branches sharply by backend.** BanyanDB shows the full lifecycle bar + stage vocabulary; on any other backend, the page renders a single `Retention` pane with per-class values and skips the stage vocabulary entirely.
+- A footer note names the wire-level truth: OAP's TTL response collapses hot + warm into one number per class, BanyanDB migrates between stages in segments so records near a boundary may briefly exist in both, and property data is omitted (forever-retained, no TTL reported).
+
+### Time picker
+
+- **Custom range seeds from the last applied range** when you re-open the picker, instead of resetting to "half the max ending now". Reopening also auto-expands the Custom form on the matching precision tab when the current range is custom.
+- Locale-bleed fix on the alarms page custom-range stamp and the log row date column (was rendering `5月08日` on zh-CN browsers; now uniform `MM-DD`).
+
+### Overview widgets follow the global time picker
+
+- The **Services Dashboard** (and any overview using metric / KPI / table widgets) now honors the topbar time picker. Previously the per-layer landing and topology routes were hardcoded to the last 60 minutes, so picking 12 days back kept showing recent numbers; now picker + Cold pill flow end-to-end. The layer dashboard's header KPIs follow the picker too (was showing live numbers while the body honored the picker).
+- The **Active alarms** widget title now shows the actual window (e.g. `· last 10m`) and the empty-state copy uses the same value instead of a hardcoded "last 60m".
+
+### Polish
+
+- Sentence-case fixes on a couple of leftover Title-Case labels (`DSL management`, `Metrics inspect`) so the menu and roles tables read consistently; acronyms (DSL / OAP / MAL / LAL / OAL) stay uppercase.
+- Public-demo (`demo.skywalking.apache.org`) references removed from setup docs — the demo doesn't accept anonymous traffic.
+
+### Dashboard authoring
+
+The Layer dashboards and Overview templates admin pages now share one editing model where your work-in-progress lives in your browser, and the live, shared version is whatever OAP serves.
+
+- **Edits are local to your browser.** "Save (local)" stores your draft in this browser only — it is never written to the server and nobody else sees it. Everyone (including you, in normal viewing) keeps seeing the published remote dashboard until you publish.
+- **Load any source into the editor.** A single **Reset to ▾** control loads the **Bundled** (shipped default) or **Remote** (OAP live) version into the editor; editing from there becomes your local draft.
+- **Preview any source on the real page.** A **Preview ▾** control opens the actual layer / overview page in a new tab rendering your **Local** draft, the **Bundled** default, or **Remote** — via `?mode=preview&source=…`, which stays in the URL and propagates as you navigate the menu. A banner names what you're previewing (dismiss with × or Esc).
+- **Publish with a diff.** **Check diff & push** shows a side-by-side local→remote diff and publishes to OAP; it's enabled only when your local draft actually differs from remote. Bundled can also be pushed straight to OAP. Resetting to remote clears the local draft. **Reset to bundled** then publishes correctly when the bundled default differs from remote — Save (local) and Check diff & push now compare the editor against remote, not just against what was first loaded, so a bundled-vs-r [...]
+- Preview faithfully reflects your draft's **enabled components / menu labels** — disabled tabs disappear and renamed nouns ("Nodes", "Topics") show through — without pushing anything to the server. Preview works even for layers OAP currently reports no services for.
+- An editors-only reminder lists any unpublished local drafts with quick links to the relevant edit page (no more "use local vs remote" prompt — remote is always the live source).
+- **Create mirrors edit.** "+ New dashboard" writes a local draft (the id is the template name, checked unique) — edit and preview it, then **Check diff & push** publishes it. A pushed dashboard with no bundled default is **remote-only** and now renders everywhere (live page + sidebar), not just in the editor.
+- **Delete = soft-disable** (OAP has no hard delete). A local-only draft is removed from the browser; a dashboard on OAP is disabled — dropped from the picker's live state, the sidebar, and the live page. A **disabled** status chip shows it. Confirmations are styled in-app dialogs, not the browser's native box.
+
+### Layer dashboards editor
+
+- The layer picker is a single filterable dropdown showing alias + key + sync status. A live **menu preview** sits beside the Alias / Components / **Menu labels** (per-layer slot aliases) editor; clicking a menu item jumps to that component's config. Scope tabs and section headings read in the layer's own vocabulary.
+- The service-list metrics editor gains a **sample-data preview** (plus a faithful **landing KPI tile** preview that reuses the real header components), the column remove-button alignment is fixed, and the landing KPI tile config makes clear it just picks which existing column feeds the headline + sparkline.
+- The picker's Diverged / Local filters now sit **inside the dropdown** (next to the search), and the editor header reads on one line — `Layer: <name> <key> <status>` — showing the same sync-status chip the picker does.
+- **Disable / Reactivate a layer.** Disabling soft-disables the layer on OAP and drops it from the sidebar (the menu honors disabled templates); a disabled layer offers **Reactivate**, which re-enables it from the bundled default (the OAP update path clears the disabled flag).
+
+### Overview templates editor
+
+- Rebuilt as a **layer-style canvas**: a 12-column grid you drag to reorder and corner-drag to resize, click-to-edit in a right drawer that appears on selection (Esc / deselect hides it), with section-breaks and the dashboard title selectable and unselected widgets hinted by a dashed outline. The canvas mirrors the live grid (fixed row height), so the layout matches the real page — including side-by-side widgets like topology + alarms.
+- The **composite-metrics** KPI editor stacks each row as a card (label / source / MQE / unit / aggr / style / max) so nothing truncates in the narrow drawer.
+
+### Navigation & shell
+
+- The **main sidebar folds** to a narrow rail to reclaim width; the logo moves into the topbar while folded (the original wordmark is unchanged).
+- The active menu item now reliably expands, highlights, and scrolls into view on entering a page (route matching is case-insensitive). Fixed a regression where the sidebar scrolled to the very bottom on every navigation (the "Debug events" toggle's active state was being treated as the scroll target).
+- **Overview dashboards appear in the sidebar only when their layers are reporting services.** Visibility is derived from each dashboard's widgets (their `layer` field) ∪ the explicit `layers[]` list, gated against the live `availableLayers`. A dashboard you create via "+ New" inherits this automatically — no need to maintain the `layers[]` field by hand. Polls on the 60s menu cadence + window focus, so entries appear / disappear as services start and stop reporting.
+- **Smarter landing.** Root `/` cascades through a sensible chain so the user never sees a blank page: first available public overview → first layer with services → the **empty landing** (`/landing-empty`). The cascade only lands on destinations that are also in the sidebar — a bundled-but-inactive layer (no services yet) is deliberately not a fallback, since it would put the user on a page they can't navigate back to via the menu. The empty page is also a real bookmarkable route, with t [...]
+- **Debug events panel now defaults OFF on every host** (was on for localhost). Same baseline for operators and developers so reproductions match what operators see.
+- **Zipkin trace mode** drops the per-layer service-KPI header — the Zipkin explorer is a self-contained, cross-service view.
+
+### 3D Infrastructure Map
+
+A standalone, bird's-eye view of the deployment at `/3d/map`: services render as cubes on stacked tier-planes (apps · service mesh · middleware · infra), each tier subdivided into per-layer zones with the layer's brand mark stamped on its colored swatch. Drag to rotate, scroll to zoom, arrow keys / WASD to pan; click a cube for its detail card and a link into that layer's dashboard.
+
+- **Live data windows.** The map auto-refreshes every minute — per-cube traffic rolls up the last 2h of metrics (HOUR step) and alarmed services light up from the last 20m of alarms. A toolbar chip shows the active scopes (`metrics 2h · alarms 20m · ↻ 1m`). An alarmed cube burns red with a radiating ripple, matched to its service by (layer, name) so only the firing service in the right tier is flagged.
+- **Live topology.** The deployment structure is read live from OAP rather than a bundled snapshot: each layer's service roster and service map are fetched one at a time (low concurrency) and assembled into the scene, so the map is correct on any deployment. It refreshes on the same one-minute cycle — an unchanged structure updates metrics/alarms in place without disturbing the camera, while a service appearing or disappearing rebuilds the affected tier. The load progresses stage by stag [...]
+- **Beacon mode.** A toolbar toggle dims every healthy cube to a wireframe ghost and lets only alarming cubes glow, so the services that are firing jump out instantly during an incident.
+- **Logic groups.** Related layers can be clustered into a single labelled block on a tier — the bundled config ships a **Self-Observability** group (OAP, Satellite, BanyanDB, and the Java / Go agents) on the middleware tier. Members keep their own cube colors but read as one block on the map.
+- **Configurable tiers + layers.** Tier order, per-layer plane mapping, cube colors, the traffic MQE per layer, and the logic groups are all driven by the 3D map config, edited on a structured admin page at `/admin/3d-map`. Pin each layer to a tier (with a single global layer filter as the top-level gate), edit each layer's color + traffic metric, manage logic groups (members, color, icon, tier), and choose the single failover tier for anything unpinned. The config is published to OAP an [...]
+- **Topology clustering.** Within layers that carry a service map, services group into named clusters drawn as a wireframe frame with the cluster name baked into the frame's lower-left corner — service-mesh services cluster by their showcase group, Kubernetes services by namespace. Clustering follows each layer's naming rule, so layers without one keep rendering flat.
+- **Navigate by tier.** The right-side **Tiers** panel is a two-level tree (tier → layer / logic-group). Clicking any entry resets the view, glides the camera to face that region, zooms in, and flashes the region for a few seconds so it is easy to spot. A **Reset** button in the panel header restores the initial framing.
+- **Hover = preview.** Hovering a cube shows the same detail card as a click — tier, layer, service, and (when present) group and cluster — anchored at the cube, minus the open-in-dashboard link.
+- **Call-direction flow.** Call relationships animate directional particles so the direction of traffic reads at a glance.
+- **Focus one layer.** The per-layer service map gains a **View in 3D** link that opens the map focused on just that layer.
+- **Refresh countdown.** The load status strip shows a live countdown to the next refresh, anchored to the stage that will run next.
+
+### Reliability
+
+- When the BFF is unreachable the UI now shows a clear "Cannot reach the server" message instead of the cryptic "body stream already read" — the API client reads each error response body once and surfaces the real status/text (or a wrapped network error).
+- **Server-global service-by-layer catalog.** One singleton on the BFF (60s TTL + single-flight) now owns the `listLayers` + aliased `listServices(layer)` fan-out. The sidebar menu's per-layer counts and the alarms layer-tagger share this one cache instead of each running their own poll, so OAP sees at most one fan-out per minute regardless of how many routes are polling — and the two views can no longer drift by 60s relative to each other.
+- The brand link and the post-login redirect no longer resolve to an empty address — both now land on the operator's actual landing route.
+- Trace span detail now labels the span direction as **Kind** (the noun) rather than the mistranslated verb form, and long tag keys wrap inside the panel instead of overflowing their column.
diff --git a/docs/changelog/0.7.0.md b/docs/changelog/0.7.0.md
new file mode 100644
index 0000000..1084fda
--- /dev/null
+++ b/docs/changelog/0.7.0.md
@@ -0,0 +1,169 @@
+# 0.7.0
+
+### Browser errors & source maps
+
+- **New "Browser Logs" tab on the BROWSER layer** — lists the JS error logs the browser agent reports (message, category, page, app version, time, and the minified `line:col`), filterable by category and time window. Expanding a row shows the raw stack alongside a de-obfuscated view.
+- **Source-map de-obfuscation (issue [#6784](https://github.com/apache/skywalking/issues/6784)).** Upload a `.map` file from the tab and resolve any error's minified stack back to the original source — file, line, column, symbol name, and a source snippet — by picking which map to apply. Maps are held in the BFF's **memory only** (no backend storage): they're surfaced as *temporary*, evicted least-recently-used when the configured budget is hit, and lost on restart. For durable provision [...]
+
+### Layers
+
+- **Split a layer's menu by service group.** A new per-layer **Split menu by service group** toggle (Layer dashboards admin, right after **Alias**; default off) fans the layer out into one **level-0 sidebar entry per OAP `Service.group`** — the `<group>::` prefix. The entry's display name leads with the group so it reads everywhere (sidebar, page header, KPI tile) and survives narrow-sidebar truncation — e.g. `agent · General Service`. Each entry is scoped to its group: the service heade [...]
+- **The navigation sidebar is now resizable** — drag the divider between the sidebar and the page to widen or narrow it (double-click the divider to reset to the default width); the chosen width persists per browser. Useful when long entries — like group-split names (`agent · General Service`) or deep namespaces — would otherwise truncate.
+- **The per-layer service picker shows each service's group.** When a layer has no topology-cluster naming rule, the service-list rows now surface the OAP `<group>::` prefix (e.g. `agent`) as the group chip — so the group is visible there as it already is on the topology map.
+- **Every layer OAP reports now appears in the sidebar**, including ones with no Horizon template (they render with default capabilities — a plain Service page). The previous hard-coded hidden-layer list (which dropped `BanyanDB`) is gone; a layer is hidden only when an admin explicitly disables its template, or when it is listed in the new config-driven `layers.excluded` block in `horizon.yaml` (defaults: `FAAS` and `VIRTUAL_GATEWAY`; clear the list to surface every reported layer).
+- **The admin Layer dashboards page is now layer-list-oriented.** It lists every available layer — not just the ones shipping a bundled JSON or living on OAP. A layer with no template yet opens on a blank default you can configure (components, metric columns, widgets, topology) and **Save**, which publishes the template to OAP on first save. No per-layer JSON has to be shipped for a layer to be configurable. The picker gains a **Not configured** filter (beside Diverged / Local) and the s [...]
+- **Removed the legacy per-layer `overview` block** from every bundled layer template (and its translation overlays). It no longer rendered anything — the standalone **Overview Dashboards** replaced the old per-layer Overview tile — so it was dead config; the per-layer KPI strip is driven by the layer-header columns.
+
+### Airflow monitoring layer (SWIP-7)
+
+- New **Airflow** layer under **Workflow Scheduler** — service dashboard (scheduler / executor / pool KPIs and trends), Components dashboard (per-host scheduler and triggerer metrics for **Airflow 3.x** native OTel), and a 3D Infra Map load ring for **Tasks Executable**. Pairs with OAP backend SWIP-7 (`meter_airflow_*` / `meter_airflow_instance_*`).
+
+### BanyanDB self-observability layer (SWIP-15)
+
+- New **BanyanDB** layer under **Self-Observability**, modeling a clustered, role- and tier-aware BanyanDB deployment scraped through its FODC proxy. The cluster is one **Cluster** (service), each container is one **Container** (instance, carrying its `container_name` role and `node_type` tier as attributes), and each storage **Group** is an endpoint:
+ - **Cluster** dashboard — write / query / error-rate KPIs, CPU / memory / disk capacity, a throughput + errors trend, and a **Containers by Role** table.
+ - **Container** dashboard adapts to the selected container's role: every container shows CPU / memory / Go-runtime resources (and, where the system collector runs, uptime / disk / network); a **liaison** adds ingestion, query, gRPC errors, the tier-2 publish pipeline and write-queue depth; a **data** node adds storage totals, merge/compaction, inverted index, subscribe queue and retention; a **lifecycle** sidecar shows migration cycles and last-run time / status. Liaison and data panel [...]
+ - **Group** dashboard — metrics split **per data-model** (measure / stream / trace / property): each model gets write rate, query latency, stored data, merge rate / latency / partitions, series write + term-search and total series, plus the type-agnostic subscribe / publish queue (throughput, p99, batch + message rate, publish bytes). Because a BanyanDB group stores one catalog, only the matching model's panels render for a given group — gated by the model's series-count flag — so a `m [...]
+ - A **Deployment** tab renders the cluster's container **inventory** — every container grouped into its node's role/tier box (liaison, data hot/warm/cold) and its pod, with per-role health metrics (liaison query rate + gRPC errors, data ingest rate + disk usage, lifecycle migration cycles + last-run status). The node health-ring legend names **each role's own** ring metric **and its colour-band thresholds** (driven by the layer template) instead of a single shared, hard-coded one. Cont [...]
+ - The whole deployment model — clustering / grouping rules, per-role node metrics, and role-pair edge metrics — is editable from the **Layer dashboards** admin → **Deployment** scope.
+- Pairs with OAP backend SWIP-15 (`meter_banyandb_*` cluster / `meter_banyandb_instance_*` container / `meter_banyandb_endpoint_*` group). Queue-batch and lifecycle last-run panels appear once the cluster runs a BanyanDB build that emits those metrics.
+
+### Dashboard widget value formatting
+
+- Card widgets gain a **`enum`** format with a **value→label map**: a coded metric (e.g. a 1/0 success gauge) renders a readable label (`1 → OK`, `0 → Failed`) instead of the raw number. Labels are **translatable per locale** (BFF-side template i18n overlay) and the map is editable in the Layer dashboards admin. BanyanDB's lifecycle **Last Sync** card uses it.
+- New **`duration`** format renders a SECONDS metric as a human time-ago (`5m 20s ago`; compact `5m` / `2h` on axes) — used by BanyanDB's **Time Since Last Sync** card.
+
+### Record widgets — jump to trace & copy
+
+- **Record widgets now drill into the originating trace.** Each sampled row gets a **jump-to-trace icon** at the row head — shown only when the sample actually carries a trace id (these are sampled, so it can be absent) — that opens the trace waterfall in the global popout. It resolves the trace **by id, not by layer**, so it works even though the trace belongs to the calling service on a different layer (a virtual-target layer has no traces tab of its own). The statement text itself is [...]
+
+### Instance-list badge
+
+- The badge on each row of the **instance list** (Containers / Pods / Nodes / …) is now **configurable per layer** (`instances.badge` on the layer template) — it can show any instance **attribute** instead of the fixed agent `language`. BanyanDB shows `container_name` (**liaison / data / lifecycle**), the role that actually distinguishes a container; agent-traced layers keep `language` (Java / Go / …). The badge is now **hidden when the value is empty or `UNKNOWN`**, so OpenTelemetry-scr [...]
+
+### Dashboard widget visibility
+
+- Layer-dashboard widgets gain a structured **Visible when** gate (Layer dashboards admin → widget drawer) so a widget only renders when it's relevant to the selected entity. Two kinds:
+ - **MQE metric** — show the widget only when an expression *has value*, or when any value is **>** / **<** a threshold. Naming the widget's *own* metric self-gates it (the JVM widgets appear only on JVM instances, the MQ widgets only on MQ producers, …); naming a *different* metric gates a whole group on one shared signal — that metric is checked once and the entire group's queries are **skipped** when it's empty, so e.g. a non-JVM instance no longer runs the JVM widget queries at all.
+ - **Entity attribute** — on the Instance scope, gate on the selected instance's attributes, e.g. *language equals JAVA* (case-insensitive) or an attribute simply being present. Service / Endpoint entities carry no attributes, so entity gates are ignored on those scopes.
+- Gates are evaluated server-side; gated-out widgets just don't appear in the grid. **Note:** a layer dashboard saved before this release that used the old free-text predicate loses its gate (the widget renders ungated) until you re-set the gate in the new editor and save the dashboard.
+
+### Topology node filter & component icons
+
+- The per-layer **Topology** map (and the embedded topology widget on the Services / Mesh overview dashboards) gains a **Filter** control to hide the conjectured peers that clutter a dense map. One auto-derived facet — by **layer**, presented exactly as the sidebar shows it: each row carries the layer's own icon and its localized display name (*General Service*, *Virtual Database*, *Java Agent*, …), plus an **Others** bucket for nodes OAP couldn't resolve, alongside a standalone **User** [...]
+- **Technology component icons on the nodes.** Service-map nodes now render the icon for their detected component — the same icon set the trace waterfall uses, so a PostgreSQL node looks like PostgreSQL — falling back to the generic service / external / user glyph when the component ships no icon or couldn't be resolved.
+- The topology's **service selector** (the "All services" picker) now **groups its list by service group** — OAP's `Service.group` (the `<group>::` prefix, e.g. `agent`) shown under a value-first `<name> [GROUP]` header — so a layer whose services share a group reads grouped instead of as one flat list. This is a per-service attribute and needs no per-layer naming-rule setup; services with no group stay in a single header-less section. Clicking a group header **batch-selects or unselects [...]
+
+### Instance topology
+
+- The per-layer **Topology** map gains an **instance map** drill-down on layers that enable instance topology. Click a call between two services and then **Instance map →** to open it: the instances of each service as two columns (left = client, right = server) with the instance-level calls between them — pan/zoom, animated client→server flow, the same node health-ring + per-call client/server metric sidebar the service map uses, and a node popover with **Open instance dashboard**. A bac [...]
+- **Configurable like the service map.** The Layer-dashboards admin → **Topology** scope now has an **Enable instance topology** toggle and its own node / server-edge / client-edge metric editors, kept visually separate from the service-topology metrics so the two are never confused. Enabled out of the box on **General**, **Service Mesh**, **Kubernetes Service**, and **Cilium Service**; the config rides each layer's topology template (so it travels with template export/import).
+- When OAP's template store is unreachable, the instance map now shows the same empty + connectivity-banner state as the service map, rather than a misleading "not supported" — block and unsupported are no longer conflated.
+- **Localized across all eight UI languages.** The instance-map UI, the template-store-unreachable banner, and the remaining alarm / live-debugger strings are now translated in zh-CN, ja, ko, es, pt, de and fr (English stays the source) — no feature renders English-only for non-English operators.
+
+### Lock & compare entities on a layer dashboard
+
+- **Lock several services, instances, or endpoints — including ones from different services — and compare them in place.** Compare is standard on every service / instance / endpoint layer dashboard — no flag, nothing to enable. Pin entities from the service picker or the instance / endpoint list; instance and endpoint pins are cross-service, so instances belonging to different services can be compared side by side. A persistent, scope-aware **comparison bar** shows the cohort regardless [...]
+- **The entity you're viewing is always part of the comparison** — it appears first, tagged `CURRENT` in the accent color (and still drives the header KPIs); pinned entities add to it, each in its own stable hue (up to six pins). The comparison-bar chips are display-only: clicking a chip never changes what you're viewing (no disruptive reload) and `×` unpins — switch the focused entity from the top selector / list as usual.
+- **Each widget compares inline in its own tile** — line widgets overlay one hued series per entity; card widgets show one row per entity; top-N and record widgets get per-entity tabs plus a merged "All" tab; table widgets gain an **Entity** column that groups rows by entity and folds each entity's long tail into one `(others)` row (summed for counts, count-only for latencies / percentiles where a sum would mislead). With nothing locked, every page renders exactly as before.
+- **Labeled series lead with the meaningful dimension** — a multi-label series reads `<label> · <service> · <instance>` so the label (e.g. a JVM thread state) survives when a long instance / endpoint id has to truncate; entities stay distinguishable by color. A widget that only some compared entities expose (e.g. JVM widgets when a Java service is pinned alongside a non-JVM one) shows whenever *any* compared entity has it.
+- **Progressive, per-entity loading** — each entity loads as its own request; tiles fill in as entities arrive and one slow or failed entity never blanks the others.
+- The Topology, Deployment, trace, and log pages are unaffected — comparison applies to the service / instance / endpoint dashboards only.
+- **Widget tooltips and legends show the widget title, never the raw MQE expression**, for un-labeled single-series line widgets; the multi-series tooltip is a fixed, aligned table — the entity name truncates and the values form one clean right-aligned column with the unit in the header.
+
+### Charts & bundled metrics
+
+- **Large numbers on chart axes and tooltips now use compact SI suffixes** (`45.1k`, `1.34M`, `2.5G`) instead of scientific notation (`4.51e4`), which operators found hard to read.
+- **Go runtime "Metadata Mspan" and "Metadata Mcache" widgets now report KB**, fixing values that were displayed as a mislabeled "MB" of raw bytes (≈1000× too large) — they now read in the same KB scale as the other Go metadata-size widgets.
+
+### Deployment
+
+- New per-layer **Deployment** tab — the **deployment topology of all of a service's instances**: the instance-to-instance call graph **within a single service**. Where the instance map drills into the instances *between* two services, this shows how one service's own instances are deployed and talk to each other (e.g. a clustered store's nodes calling each other). Pick a service from the layer's Service header and the tab draws its instances as health-ring nodes with the intra-service c [...]
+- **Node clustering.** Instances can group into labelled boxes by a single **instance attribute** (e.g. role / tier), by **several attributes** combined into one key (e.g. `node_role` + `node_type`, where an attribute absent on a node drops out — so a BanyanDB cluster splits its data nodes into hot / warm / cold boxes while the liaison nodes, which carry no tier, stay one box), or by a **name regex** run on the instance name — so a fleet of mixed-role nodes reads as one box per role inst [...]
+- **Optional + configurable.** Off by default for every layer; a layer opts in from the Layer-dashboards admin → **Deployment** scope, which has its own node / server-edge / client-edge metric editors (instance scope) plus the clustering-rule picker. The config is a self-contained block on the layer template, so it travels with template export/import and is independent of the service-map topology config.
+- **Pod / sibling model.** Instances render as **hexagons** and can bundle into pods: a pod's **main** container is a full hex with its **sibling** containers attached as smaller hexes around its edges. Three independent rules drive it — **cluster** (the dashed boxes), **sibling** (which containers form one pod), and **role** (per-container-type metrics + which container is the main). Edges resolve to the exact container, so cross-pod sidecar links (e.g. a lifecycle agent calling its pee [...]
+- **Tiered layout + draggable pods.** Each cluster box lays its pods out by call depth — sources on the left, the pods they call to the right — so a hot → warm → cold lifecycle chain reads as left-to-right tiers. Pods stack vertically within a tier, and a tier with more than four pods wraps into additional stacked columns of four. Drag any pod to rearrange; its cluster box re-flows to keep every node enclosed.
+- **Role-to-role edge metrics + a Flows view.** Deployment edge metrics are keyed by the **(source-role → target-role)** pair, so each kind of link shows its own metrics rather than one flat set — a `liaison → data` edge can surface write / query / part-sync throughput while a `lifecycle → data` edge surfaces migration volume. Pairs match most-specific-first with a `*` wildcard fallback. Each pair names a **primary** metric that prints **inline on the edge** in the map, so the headline n [...]
+
+### API dependency
+
+- The per-layer **API dependency** tab renders an endpoint's caller → callee chain as a graph. Pick an endpoint and it lays out in columns by direction — callers on the left, the focus endpoint in the centre, callees on the right — with the same node health-ring border, SLA-coloured RPM, and latency you read on the service map; edges animate the call direction and label the heaviest by RPM.
+- **Expand to walk the chain.** A selected endpoint shows a single **+** handle that pulls in *its* own callers and callees in one click (new callers land left, callees right). The handle spins while the dependency query is in flight; when an endpoint is a leaf with nothing further to load it fades and a brief banner says so — a silent "nothing happened" never reads as a bug.
+- **Rearrange freely.** Drag any node box to pull a dense graph apart — edges follow live. Pan, wheel-zoom, and a fit button act on the whole canvas, and a node holds a steady on-screen size whether or not the detail sidebar is open.
+- **Drill straight out, in a new tab.** The node detail's **Open endpoint** and **Service →**, and the service-map node/edge jumps (**Open service**, **API map →**, **Instance map →**), now open in a new browser tab — so you keep the graph you're exploring while the drill-down opens alongside it.
+- Nodes share the service-map's visual vocabulary (SLA-band border, an agent badge on instrumented endpoints, the focus star), and the tab is localized across all eight UI languages.
+
+### Dashboard template portability
+
+- Every template admin page — Overview templates, Layer dashboards, and the 3D-map config — now has **Export** and **Import** actions. Export downloads the *in-use* version (what end users render: the version live on OAP, or the bundled default when OAP has none) as a JSON file, for backup, sharing, or moving a dashboard to another OAP. Import reads a JSON file, validates it, and loads it as a local draft in this browser — preview it, then publish with “Check diff & push” as usual. Impor [...]
+- The **Translations** page has matching **Export** / **Import**, scoped to the current language: export the in-use translation for a template + locale as a JSON file, or import one as a local draft to review and push. (Source templates and their translations are edited on separate pages, so their import/export are separate too — each on its own page.)
+
+### Template store reliability
+
+- **Runtime config is strictly what's on OAP.** Layer dashboards, overviews, and topology now render only the version published to OAP's UI-template store (or the in-code minimal default for a layer that has none). The disk-bundled templates reach a running UI **only** by being synced to OAP (first boot / admin reset) or through the admin **Preview** button — they are never a silent live fallback. So an operator always sees the live published config, not a stale bundled copy masquerading [...]
+- **Unreachable template store is a visible block, not a quiet fallback.** When OAP's UI-template host can't be reached, a banner (same red treatment as the OAP-query-unreachable strip) reports it, and the dashboard / overview / topology surfaces stay empty rather than back-filling bundled defaults that could be read as real. The sidebar still navigates so the rest of the app is reachable.
+- The admin **Preview** button now drives **every** template-rendered page — the **overview detail** view and the per-layer **topology** (incl. the **instance map**), **API dependency**, **traces**, and **network-profiling** pages — not just the layer dashboards. Previewing renders the draft's metrics/config against live OAP, so an edit to topology or dependency metrics is visible before you publish. Preview and the absent-remote path stay strictly separate: a draft renders only in `?mod [...]
+- **Editors no longer silently fall back to the bundled default.** When a layer / overview / translation has no version published to OAP, the editor shows a *"No published version on OAP"* panel instead of quietly loading the shipped bundled copy as if it were live. Bundled now reaches the editor only when you click **Reset to bundled** — matching the runtime, which renders the published version or blocks, never the bundle.
+
+### Layer landing & service list
+
+- **The layer landing now shows your services, not just an arbitrary 25.** It used to cap the metric fan-out at the first 25 services *by list order* — so larger layers hid the rest, and the "top" services weren't even the true top (the cap happened before the ranking). Now it probes **all** services up to a configurable cap and, when a layer exceeds it, runs a cheap single- metric ranking pass to pick the **true top-N** by the landing's order-by column. The service picker surfaces **"to [...]
+- New `query.landingServiceCap` in `horizon.yaml` (default **100**) tunes how many services a landing probes per request — raise it if your OAP + storage can take the larger fan-out, lower it to protect a modest deployment.
+- **The service picker now lists the *whole* layer, not only the metric-probed top-N.** Services that ranked below the metric cap on the order-by column now appear as their own rows with **`low`** in that column (and `—` for the others, which were never probed) instead of being hidden — every service stays browsable, searchable, and selectable regardless of the cap. The header chip reads **"metrics: top N"** to make the metric trim explicit.
+- **Removed the stale "Landing KPI tile" controls** (Headline / Trend line) from the Layer-dashboards admin. They no longer matched the rendered layer header — which shows every configured metric column as its own KPI with its own trend line — so editing them changed nothing on screen. The header is driven entirely by the service-list columns + default sort; the preview now reflects that.
+- **Selecting a low-traffic (below-cap) service now works on *every* tab**, not just the dashboard. Logs, traces, and endpoint-dependency resolved the picked service's name from the landing sample only — so a tail service queried as blank (and Logs even snapped the pick back to the top service). All per-layer tabs now resolve the name from the full roster, so a `low` service drills in everywhere.
+- **Profiling scopes no longer show an editor grid that goes nowhere.** Trace / eBPF / async profiling are built-in runtime views with nothing to author, so the admin now shows a "configured at runtime" note for them instead of a widget grid whose widgets never rendered.
+
+### Access control & permissions
+
+- The **Roles & Permissions** board now lists `infra-3d:read` — the permission to view the **3D Infrastructure Map** — under the data-catalog group, with a matching "3D infrastructure map" row in the menu-visibility matrix. It was already enforced and granted to every built-in role (viewer and up), but it never appeared on the board, so an admin couldn't see who held it.
+- Editing a **layer dashboard template** is now gated on the `dashboard:write` permission the editor already advertises; publishing overview, alert, and 3D-map configs stays on `overview:write`. The required permission is resolved per template kind at save time. Built-in roles are unaffected (`operator` and `admin` hold both), but a custom role granted only `dashboard:write` can now save layer dashboards.
+- The **Cluster Status debug view** (`/api/debug/status`) now requires only `live-debug:read`. It previously also demanded `cluster:read`, so a role granted live-debug access but not cluster-read was wrongly blocked.
+- Saving a **local draft** of a template (the "Save local" action) now enforces the same per-kind permission as publishing — a layer draft needs `dashboard:write`, other kinds `overview:write` — instead of a blanket `overview:write`.
+
+### Performance hardening
+
+- **Layer dashboards skip a redundant service lookup on every load.** The dashboard route used to issue its own `listServices` to auto-pick the service and carry its entity-scope flags; it now reads the shared per-layer service catalog the sidebar already keeps warm, so a dashboard's first paint costs one fewer OAP round-trip in the common case. It still falls back to a live lookup for a just-registered service, a cold snapshot, or to surface an OAP outage (the "OAP unreachable" state no [...]
+- **The alarms list and count fire their two startup probes in parallel.** The server-time offset and backend-capability probes that precede every alarms query now run concurrently instead of one-after-the-other.
+- **The 3D Infra Map loads its metrics in parallel.** Per-node metric values used to fetch one batch at a time; they now load in bounded-concurrency batches, so the load rings and traffic values fill in sooner on large layers. A new `metricConcurrency` setting in the Infra Map config (default 4) caps how many metric batches run at once.
+- **Oversized layer topologies fail with a clear message instead of an unreadable map.** When a layer's service graph exceeds the render ceiling (5,000 services or 15,000 calls), the service map shows a "Topology too large to render" notice with the live counts and a hint to pick a specific service or lower the depth, rather than attempting to lay out a graph too dense to read.
+- **Partial metric-load failures are now surfaced on every topology map.** If some metric batches fail to load (a transient OAP error) on the service map, instance topology, deployment, or endpoint-dependency map, a banner now explains that blank values may be unavailable rather than zero — and on the endpoint-dependency map, that some endpoints or links may be missing — so a backend hiccup isn't misread as real "no traffic" data.
+
+### Fixes
+
+- **Metrics Inspect** — the crosshair value tooltip is no longer clipped behind the navigation sidebar when you hover near a widget's left edge; it now renders above the page chrome.
+- **3D Infrastructure Map config** — "Check diff & push" now requires saving a local draft first (it was selectable while edits were still unsaved), and the push dialog renders the side-by-side before/after JSON diff instead of an empty panel.
+- **The API-dependency tab now honors the topbar time picker.** It was pinned to the last hour regardless of the selected range; changing the range (and expanding a node) now re-queries the chosen window, like the service map and instance map already did.
+- **A dashboard no longer blanks entirely when one metric group fails.** A transient backend error (timeout / 5xx / query-complexity limit) on a single batch of widgets now marks only those widgets as failed; the rest of the dashboard renders normally instead of every cell going blank.
+- **Trace list rows pick the correct root span on BanyanDB.** A multi-service trace could surface a downstream span's endpoint / duration / start time in the list; the row now reliably reflects the trace's true entry span.
+- **Correct timestamps right after repointing OAP.** The server-timezone offset is now cached per OAP URL, so a configuration reload that switches to a different-timezone OAP re-probes immediately instead of serving the previous server's offset for up to a minute.
+- **Baseline security response headers** (`X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer`) are now sent on every response.
+- Removed an internal `?mockTop=` debug query parameter that padded top-N widgets with synthetic rows; it no longer ships in release builds.
+- **The profiling pages now use more of the page height.** The Trace / eBPF / Async / pprof / network profiling layouts were sized off a viewport offset that over-counted the chrome above them, leaving dead space at the bottom on taller screens; they now extend closer to the bottom of the view.
+- **Overview dashboard templates are validated before save.** A malformed overview (missing a required field, an unknown widget type) is now rejected with a clear field-level error instead of being written to OAP — restoring a guard that was lost when overview editing moved to the OAP-backed save path.
+
+### Documentation & release tooling
+
+- A further accuracy pass corrected the Cluster Status page (three panes — Query, Admin, Zipkin/OTLP — and no per-node member list), the Kubernetes readiness-probe guidance (point it at the public `/api/health`, not the authenticated `/api/oap/info`), the layer-template `components` default (only the service dashboard is on when a key is omitted) and the `aliases` authoring key, the removed `visibleWhen` free-text and embedded-i18n template shapes, and the data-retention cold-stage controls.
+- The website docs were brought current with the 0.6.0 build and the configuration pages restructured around the admin UI — the JSON shape is now a reference appendix, not an authoring surface (these admin pages are structured editors, not raw-JSON editors). Accuracy fixes span the RBAC verbs (incl. `infra-3d:read`), the audit-log action set, the Metrics Inspect API paths, the layer-template component flags, and the redesigned 3D-map config + loading stages. A new `docs/CLAUDE.md` record [...]
+- The container image is published to Docker Hub by CI on every `v*` tag; the post-vote finalize script now only verifies the published tags (the manual local-push fallback and Docker Hub login preflight were removed).
+
+### Layer drill-down fixes
+
+- The per-layer **Instance** and **Endpoint** pages now honor the layer's configured aliases in their section headers and in the service-picker's name column — e.g. ActiveMQ reads **Brokers** / **Destinations** and Virtual MQ reads **Topics** / **MQ clusters**, matching the sidebar — instead of the generic "Instance" / "Endpoint" / "Service" labels. Layers that define no alias still read the generic words.
+- A layer's Instance or Endpoint page no longer hangs on a perpetual "Reading data…" when the selected service reports no instances or endpoints (or a search matches nothing). It now shows the empty picker and renders the metric widgets in their normal "no data" state, so the layout stays visible and ready for services that do report them.
+- **Clearer cluster boundaries on every topology view.** The dashed grouping boxes — namespaces on the service map, per-service boxes on the instance map, role/tier clusters on the Deployment tab — now draw with a bolder, brighter dashed border and a fully transparent background, so the boundary reads clearly on every theme (light themes included) instead of fading into the canvas. The Deployment tab also packs its cluster boxes evenly: boxes sit at a uniform spacing with no dead corrido [...]
+
+### Live debugger
+
+- **MAL sample groups.** A captured step that fans out to many samples no longer dumps every label set on screen: the samples are grouped by metric name into a one-line summary — `<metric> · N samples · values=…` — and you expand only the groups you care about to see each sample's full labels. Groups are collapsed by default.
+- **Diff is the default when a group is expanded.** A multi-sample group opens straight into diff view: the labels shared by every sample collapse into a dimmed "common" block and only the labels that differ are highlighted per sample — so it is immediate which label distinguishes each one (e.g. `node_role` / `pod_name`) and what value it maps to. A **diff** toggle beside the group's header switches back to the full per-sample label list. The "common" set is computed across the whole gro [...]
+- **Multiple output entities collapse the same way.** When a record materialises one metric for several entities (e.g. a per-endpoint write rate over `sw_metricsMinute` / `sw_metricsHour` / `sw_metricsDay`), the repeated meter cards fold into one block: a shared header (metric / function / time bucket), a `N outputs · values=…` summary, and a diff that surfaces only the entity fields that actually differ — whichever they are, not a fixed field — with each output's value beside it.
+- **Readable sample values.** Long fractional values from `rate()` / `avg()` (e.g. `57.0333333333…`) are trimmed to a few significant digits for display so they stop overflowing the value column; integer counters still render exact, and the precise value stays available on hover.
+
+### DSL management — live apply progress & recovery
+
+- **A structural rule change now shows live apply progress.** Saving an edit that moves a metric's storage shape (scope, downsampling, or the metric set) no longer just flashes "submitted" — the editor tracks the apply across the cluster through a phase stepper (Compiled → Confirming across the cluster → Committing → Done) and reports success only once OAP confirms the change is durable. **Revert to bundled** (also a schema change) goes through the same stepper. Body- and filter-only edi [...]
+- **"Applied — cluster propagation unconfirmed" is a warning, not an error.** When a structural change is committed and durable but one or more nodes hadn't confirmed the new schema within OAP's fence budget, the editor names the lagging nodes and explains they self-converge on their next scan — the rule is applied, not rolled back. Reloading the editor reads it back as applied (from the stored rule).
+- **A failed apply is called out as rolled back** — the cluster stays on the previous rule, the failure reason is shown inline, and your edit is kept in the editor so you can fix and save again. A compile error now surfaces as an inline diagnostic under the editor instead of a transient toast.
+- **Force re-apply to recover.** A degraded or transiently-failed apply offers a one-click **Force re-apply (recover)** that re-runs the apply across the cluster to re-confirm the schema and un-stick any waiting node — gated behind a confirm that spells out it briefly pauses collection for that rule's metrics, even when the content is unchanged. This subsumes the old Advanced `force` toggle for the recovery case.
diff --git a/docs/changelog/1.0.0.md b/docs/changelog/1.0.0.md
new file mode 100644
index 0000000..a23d3fc
--- /dev/null
+++ b/docs/changelog/1.0.0.md
@@ -0,0 +1,273 @@
+# 1.0.0
+
+### AI assistant (new)
+
+- **Ask about your system in plain language and get answers built from the real dashboard widgets — not just text.** A new floating **AI Assistant** launcher (right edge, after login) opens a chat: describe what you want to know ("what's unhealthy right now?", "investigate latency for a service") and the assistant reads **live OAP data** through the same query path the dashboards use, then streams back an ordered narrative with **inline charts, top-N lists and tables** drawn by the same [...]
+- **Read-only, and it inherits your permissions.** The assistant can list layers/services, read active **alarms** (the "what's unhealthy" signal), browse the per-layer **metric catalog** (the curated MQE for each metric), drill a service down to its instances/endpoints, and render those metrics — every data tool checks the same read verb you already hold, so the assistant never sees more than you can, and it never changes configuration, rules, or dashboards.
+- **It renders the real feature views inline — same components, focused on the service.** Rather than a link you open, the assistant mounts the *actual* product views inside the chat, read-only and scoped to the service: ask for **topology** and it embeds the real service map focused one hop (hex nodes, edges, RPM/latency, zoom controls); ask how a service maps across layers and it shows the real **Smartscape hierarchy** fan; ask for **traces** and it embeds the Traces view — the trace l [...]
+- **Read live Kubernetes pod logs, right in the chat.** For a k8s workload the assistant pulls a pod container's on-demand logs (the error stack) and shows the fetched lines inline as a read-only result — the same on-demand-log path as the Pod Logs tab, so nothing is stored and it inherits your `logs:read` permission. It's a result, not a console: no tail or refresh controls (operate a live tail in the Pod Logs tab); when a content filter was applied the block shows it, so an empty resul [...]
+- **It can propose profiling — the right kind for the target — and only you start it.** When metrics and traces can't localise a cause, the assistant presents a **decision card** explaining what it found, why profiling would help, and what it expects to reveal; nothing runs until you **approve it in the popout**, and only if you hold the profiling permission. It picks the profiling flavour that fits the target — **trace** sampling, **async-profiler** for a JVM service, **pprof** for a Go [...]
+- **Your backend decides what profiling is allowed — the assistant no longer refuses on its own guesses.** Previously the assistant could decline to even show a profiling card: because the layer's template didn't list that profiling type, because the instances reported a runtime the profiler doesn't match, because no process had advertised eBPF support recently, or because no process had reported in the last 30 minutes — and it phrased those as facts about your deployment ("the GENERAL l [...]
+- **Off-CPU eBPF profiles are ranked by time blocked, not by how often a thread yielded.** An OFF_CPU profile is what you reach for when the question is "what is this service waiting on", but its frames were ranked by scheduler switch *count* — so a method that yields thousands of times for microseconds outranked the one that blocks for a second, inverting the answer. Off-CPU results now aggregate by duration. Relatedly, the assistant now names each profiler's real unit when it reads a f [...]
+- **An approved profiling task starts immediately, timed by your backend rather than your laptop.** The task's start time was sent as the browser's wall clock, but OAP treats it as "begin after this moment" and only dispatches tasks starting within ±5 minutes of *its own* clock — so on a workstation whose clock had drifted (a suspended VM, a container host without NTP) the task was accepted, returned an id, the card said "started", and nothing ever ran. Both trace and eBPF tasks now use [...]
+- **An event the assistant doesn't recognise is no longer silently swapped for CPU.** Asking for a profiling event outside its known list produced a card that read as requested but fired a CPU profile instead. The event is now passed through for OAP to accept or reject by name, and the assistant flags that it didn't recognise it — a clear rejection beats a wrong profile. Relatedly, when a **HEAP** or **ALLOCS** pprof profile is read back, the assistant now states that the ranking is a co [...]
+- **Long eBPF profiles say when only part of the range was analyzed.** A wide eBPF task could exceed what OAP fetches in a single analysis, and the excess was dropped server-side with nothing on screen to indicate it — a partial flame graph presented as complete. The assistant now bounds the request to the most recent profiling schedules and tells you when it did.
+- **An empty network process graph is no longer reported as a missing agent.** Because a network task runs for a fixed 10 minutes, analyzing shortly after approval legitimately finds nothing yet; that now reads as "still collecting", and a missing Rover agent is offered as the likely cause only once the window has actually elapsed.
+- **A profiling task that is still collecting is no longer reported as one that found nothing.** Asking the assistant to analyze a task shortly after you approved it — the obvious thing to do — could come back with "the task ran but produced no analyzable stacks; do not retry", because *any* progress log was read as "it finished". The first log a task gets means only that it was handed to the agent. The assistant now distinguishes the three real states: **still collecting** (wait and ask [...]
+- **Allocation and multi-event profiles are read whole.** An async-profiler **ALLOC** profile was showing only half its data: OAP splits allocations into in-TLAB and outside-TLAB trees, and only the first was read — so the large-object allocation paths, usually the reason for opening an allocation profile, were missing. A task started with several events (say CPU + ALLOC + LOCK) likewise rendered only the first. Both now render every tree the task produced.
+- **Network profiling results no longer come back empty on a wide time range.** The process-conversation graph is built from metrics OAP only keeps at minute granularity, so reading it against an hour- or day-scale chat window returned nothing — which the assistant then reported as "no Rover agent / network profiling unavailable". The read is now always minute-granular.
+- **Trace profile analysis stopped asking OAP the same question many times over.** One analysis query was issued per profiled *span*, though OAP marks profiling per *segment* — so a segment with 20 spans cost 20 near-identical queries, exhausted the analysis budget 20× faster, and could trip OAP's "only part of the snapshots were analyzed" warning, which was then relayed to you as a truncated profile that was never truncated. One query per segment now.
+- **A point-in-time pprof profile (HEAP, GOROUTINE, ALLOCS, THREADCREATE) is labelled as one** instead of showing a "0 min" collection window, which read as a task that collected for no time. Relatedly, the process list used to confirm an instance can be network-profiled no longer counts virtual processes, which OAP's own check excludes — it was possible to be offered an instance OAP would then reject.
+- **pprof collection windows are validated in the unit OAP actually uses.** The pprof task duration is measured in **minutes** and capped at 15 by OAP, but Horizon was bounding it as if it were 600 seconds — so an over-long request sailed through and came back as an opaque backend error after you approved it. It is now bounded at OAP's real limit. Likewise, an async or pprof task targeting more instances than a single task allows is now **rejected with a clear reason** instead of quietly [...]
+- **Guided root-cause analysis.** Ask "what's the root cause?" and the assistant follows built-in investigation playbooks — a master method (locate the root service → calling chain → error stack; walk the dependency topology upstream and fix a sick upstream first; a remote / Virtual_* dependency exposes only its client-side edge metric) plus latency, error-rate/SLA, saturation, middleware, **Kubernetes-workload**, and service-mesh specializations. It can also follow the **cross-layer hie [...]
+- **Bring your own LLM — vendor-neutral, and off by default.** Enable it with the new `ai:` config block (`HORIZON_AI_*`). The default transport is **OpenAI-compatible** (any OpenAI-shaped endpoint — a hosted model, a local model, or an AI gateway; set model + base URL + API key); **Amazon Bedrock** is also supported (`provider: bedrock`). The API key is a secret, env-only, redacted from logs and excluded from the audit trail. The launcher shows for every signed-in user so the AI-powered [...]
+- **Both prompts are yours.** The assistant's system prompt (`ai.systemPrompt`) and the starter example chips shown in an empty chat (`ai.starters`) ship with sensible defaults and can be replaced entirely in `horizon.yaml`.
+- **Starter chips can ask for a service first.** A starter that names a `<service>` (e.g. "Investigate latency for <service>") opens a one-field fill-in on click: type the service free-form — a partial name or a description is fine — and it's dropped into the prompt before sending. You don't have to know the exact name or its layer; the assistant matches what you typed to a real service through its own cross-layer search. Plain starters without a placeholder still send in one click [...]
+- **Your question stays in view while the answer streams.** The message you just sent pins to the top of the chat and stays there as a sticky header — while its answer streams in and as you scroll through it — so you never lose sight of what you asked; opening or switching to a past conversation pins that chat's last question the same way. Each turn now carries a timestamp: when you sent the question, and when its answer finished.
+- **The assistant orients on a layer's template before reading it — so it answers in the layer's own terms.** A new capability lookup surfaces, per layer: what a Service / Instance / Endpoint is CALLED there (a K8S_SERVICE instance is a Pod, a mesh instance a Sidecar, a K8S service a Cluster), which components the layer carries, whether its traces are native or Zipkin (so the assistant picks the right trace tool instead of guessing from the layer name), and each relationship's metric leg [...]
+- **Embedded maps are captured evidence now — they replay exactly on reload, offline, with their edge sparklines.** When the assistant draws a **topology**, **deployment**, **instance map**, **API-dependency chain** or **cross-layer hierarchy**, it now captures the whole graph the moment it reads it — every node and edge WITH its metric values and the per-edge trend series — and the chat block carries that snapshot. So reopening the conversation later re-renders the identical point-in-ti [...]
+- **Embedded traces, logs and browser errors are captured evidence too — the list AND its detail replay offline on reload.** When the assistant shows a service's **traces**, **Zipkin traces**, **logs** or **browser errors**, it now freezes the list the moment it reads it and the chat block carries that snapshot — so reopening the conversation replays the exact same rows (each stamped **"captured <time>"**) with no fresh query, and clicking a row opens its detail (the span **waterfa [...]
+- **Every captured block now wears a replay badge.** Each frozen point-in-time block — figures, maps, the profiling flame, traces/logs/browser errors, and pod logs — carries a small **replay icon** next to its "captured <time>" stamp, so it's unmistakable at a glance that you're looking at a snapshot the assistant took, not live data. Because the assistant captures every answer's data server-side and feeds the UI that snapshot, these blocks are *always* replaying — the badge shows [...]
+- **A charted metric is the real dashboard widget — with its explanation, exact MQE and capture time.** When the assistant shows a metric from a layer's catalog it now renders that dashboard widget directly, carrying its full configuration (unit, number formatting, value thresholds and legends) instead of a rebuilt approximation — so the chart in the chat matches the dashboard exactly. Each figure documents what it is: the metric's plain-language explanation, the precise MQE expression t [...]
+- **Conversation history now persists per user in your browser, with controls.** Past chats move to the browser's IndexedDB — far larger than before, so long conversations with embedded charts survive — and are scoped to your username, so a shared browser keeps each person's history separate. A **Save history** toggle (on by default) turns persistence off entirely; a usage meter shows how much of the client budget (default 500 MB, `HORIZON_AI_HISTORY_MAX_MB`) is in use; **Clear all** (wi [...]
+- **Two tabs can't quietly overwrite each other's chats.** History is read once when the assistant opens, and each conversation is stored under its own id, so saving one never touches another. Before every save the assistant checks whether that same conversation was continued somewhere else — in a second tab — and if the two have diverged **nothing is overwritten**: the conversation is marked **Not saved** in the History sidebar and the chat asks which version you want to keep, this one [...]
+
+### Profiling
+
+- **Continuous-profiling saves are now consistent under a failed confirmation, a mid-save edit, or a mid-save service switch.** A save that succeeded but whose confirmation read failed no longer reseeds the editor from stale, pre-save data — the applied badge now stays honest rather than showing an old policy as current. An edit made while Apply is still in flight is no longer discarded by that same reseed, and neither is switching to a different service mid-save: the confirmation now al [...]
+
+- **A profiling request that cannot be honoured is refused, not repaired.** pprof's `dumpPeriod` is a sampling *rate* — lower means more samples — and an invalid value used to be replaced with `1`, turning a bad request into the most expensive profile the agent can produce. Invalid values are now rejected with the reason.
+
+- **Async profiling accepts the full 15 minutes it offers.** The New Task dialog has always listed a 15-minute option, but the task was quietly shortened to 10. The cap now matches what the dialog offers, and a request above any cap is refused with the limit named rather than silently trimmed — the same for target instances, where asking for more than a task accepts is an error, not a silent slice of the fleet.
+
+- **Network profiling no longer refuses a task OAP would accept.** Create was disabled whenever no process had reported on the instance in the last 30 minutes, but OAP's own check has no time limit — so an idle-but-healthy instance was blocked for a task the backend would have run. The process list is now advice: it warns, and lets you create.
+
+- **Profiling tasks that a continuous policy started are now visible.** The eBPF and Network Profiling tabs — and the assistant's profile reads — asked OAP only for tasks somebody started by hand, so a policy could fire and its profile appeared nowhere. Both kinds are now listed together, newest first.
+
+- **Continuous profiling now has a home: a per-layer Continuous Profiling tab.** Every profiling surface until now started a task *on demand*. Continuous profiling is the opposite: arm a policy once and the task starts **itself** when a process crosses a threshold, with nobody present — which is how you catch the problem that only shows up at 3 a.m. It is **eBPF profiling only** and needs a Rover agent, which both evaluates the thresholds and runs the task; trace, async-profiler and ppro [...]
+
+- **Profiling task creation is consistent and tells you upfront what it needs.** Across all five task types (Trace / eBPF / Network / pprof / Async) the **New Task** button enables as soon as the basic entity is chosen and always carries a tooltip, and inside the create box a missing target is explained next to **Create** rather than by a silently greyed-out button — a hard requirement (no instances on the service) disables Create with the reason, while an advisory one (no rover-reported [...]
+
+- **Network profiling picks its target instance in the create box and checks it has processes before you submit.** The create modal selects the instance inline and lists that instance's rover-monitored processes; if it has none, a warning explains the task will likely collect nothing — **Create** stays available, since OAP itself accepts a task on an idle-but-healthy instance.
+
+- **pprof and async-profiling tasks open a detail modal with their captured logs.**
+
+- **K8S_SERVICE gains a Network Profiling tab.** Kubernetes services, already observed by SkyWalking Rover's eBPF probes, now expose Network Profiling — pick a pod and capture the process-to-process network conversations as a topology, the same capability the Mesh layer offers.
+
+- **Profiling create dialogs are clearer and harder to misuse.** After a create the hint counts down to its single list refresh (`refreshing in Ns`), Escape closes the Async and pprof create dialogs, and Analyze stays disabled until at least one instance is selected.
+
+- **The Async result's event-type picker shows only what the task captured.** It lists just the JFR trees the selected task's events produce (EXECUTION_SAMPLE, LOCK, OBJECT_ALLOCATION_*), dropping options like PROFILER_LIVE_OBJECT that no Horizon-created task can produce — so you can't pick a type that renders an empty graph.
+
+- **A profiling task list that can't scope to its service now says so, instead of looking empty.** The Trace, eBPF, Network, async-profiler and pprof task reads returned an empty list when they couldn't establish which service to ask about, which reads as "this service has never been profiled". Each now reports the reason in the tab's error line. The Network task read went further and dropped the service from the request entirely, answering with every service's network-profiling tasks — [...]
+
+### Traces & logs
+
+- **Custom time ranges on the Traces and Logs tabs now return results when your browser and the OAP server sit in different timezones.** A custom range (and the metric→trace drill's centered window) was sent as a browser-local wall-clock string that the server re-read in its own timezone — so on a UTC-container deployment the window shifted by your UTC offset and came back empty, while the rolling presets kept working. Both paths now send absolute timestamps and the server applies the OA [...]
+- **Opening a Zipkin trace's spans is now instant — no second query to OAP.** On a Zipkin-tracing layer (mesh / Kubernetes), clicking a trace in the results list rendered its span waterfall by re-fetching the trace by id — even though Zipkin's list response already contained every span. The list now carries those spans through, so the waterfall opens immediately from what's already on screen, one round-trip lighter. Opening a trace by a pasted id or a shared deep link still fetches it directly.
+
+- **A one-service query can no longer widen into a whole-layer one.** Traces, Logs and Browser errors are scoped to the service you picked, and when that scoping couldn't be established the service filter was simply left out of the query. OAP reads a missing service as *all* services, so the tab filled with other services' traces / log lines / JS errors under a page that still named yours. Those reads now come back empty, stating that the service identity didn't arrive intact, and the Lo [...]
+
+- **A service-scoped tab no longer queries the whole layer in the moment before it knows which service you picked.** The service is resolved after the tab opens — from the layer's sampled rollup, or from the full service roster when the pick sits outside that sample (a deep link, a service in the long tail) — and a query started inside that window carried no service, which OAP answers with every service in the layer. Traces, Logs (its level facet counts included) and Browser errors now w [...]
+
+- **Switching service on the Traces tab clears the previous service's results.** The committed query survived the switch, so the trace list, the duration distribution and an open span waterfall stayed on screen under the new service's name — read as its data — and a distribution pick left behind could filter the next query's results down to nothing. A switch now resets the tab to **Pick your conditions, then click Run query.**, dropping what the previous service returned, the way the Log [...]
+
+- **The Traces tab prints why a read failed, in the page.** The reason — an unreachable OAP, or the refusal above naming the service and the layer — was only reachable by hovering the small **unreachable** marker on the results header. It now appears above the results, the way the Logs tab already reports a failed read.
+
+- **The service you picked is now queried by its exact identity, not by its display name.** The pickers select a service by SkyWalking's own service id, but several reads then threw that id away and sent the *name* they were showing, leaving the backend to look it up again. That round-trip can land somewhere else: SkyWalking holds an agent-detected service and a conjectured (virtual) one of the same name as two different entities, and a service name can equally read like another service' [...]
+
+- **A service whose name looks like an internal id — `api.1`, `orders.2026` — is queried correctly again.** Service names ending in a dot and digits happen to match the shape of SkyWalking's own service ids, and several reads guessed by that shape: the name was forwarded as if it were an id, matched nothing, and the tab came back empty for a service sitting right there in the picker. Traces, Logs, Browser errors, every profiling task list, and the Instance and Endpoint pickers now carry [...]
+
+### Result counts and paging
+
+- **A last page that happens to be exactly full no longer offers a Next that lands on an empty screen.** The Logs and Browser Logs pagers decided whether another page existed by looking at how many rows came back: a page that filled the page size was assumed to have more behind it. When the window's row count happened to be a multiple of the page size — a window holding 24 rows read 12 at a time — the final page was full, **Next** stayed enabled, and clicking it showed nothing with no ex [...]
+
+- **The Logs pager stopped claiming a total it never had.** The footer read `page 3 · showing 50 of 50 total` — where "total" was the count of rows on the page you were looking at, so it always equalled what was shown and told you nothing. OAP's log query does not report a cross-page total (no list query in the query protocol does), so the footer now states the page and what is on it, and the honest "there is more" signal lives on the **Next** button.
+
+- **Changing the Logs page size restarts at page 1.** Page size feeds the query's offset, so sitting on page 5 at 20 rows and switching to 100 jumped from row 80 to row 400 — usually past the end of the window, leaving a blank page with nothing to explain it. The Browser Logs tab already did this; the Logs tab now matches.
+
+- **"Truncated" and "capped" notices are now facts instead of guesses at the boundary.** A result set whose size landed exactly on the cap was reported as truncated when it was complete: the topbar alarm badge showed `200+` for exactly 200 events, the alarms page warned that the window might be clipped when it wasn't, the events popout offered "more available — narrow the range" for a window that held nothing more, and the Logs level counts couldn't say whether their sample covered the w [...]
+
+- **Trace, log and Explore result lists say when they were capped — a signal they never had.** A Traces, Zipkin-traces or Explore/Inspect result that exactly filled the **Limit** you chose was indistinguishable from a complete one: there was no total, no indicator, nothing. Those lists now carry a **capped at N — narrow the window** note when the backend held more than the limit returned. Same for the endpoint search behind the Endpoint / Page pickers, which now says **more matches — nar [...]
+
+- **A next-page read no longer risks skipping rows.** OAP derives a query's offset from its page size, so asking for "one row more" past the first page shifts the offset too and silently pages in the wrong stride. Horizon asks for the extra row only where that is exact, and everywhere else reads the next page's first row as a separate one-row probe carried in the same request against the same time window — so the page you see and the answer about what follows it always describe the same window.
+
+### Deployment & configuration
+- **Serving Horizon under a path prefix is a first-class build option now, and the docs say how.** Set `HORIZON_UI_BASE=/horizon/` when building from source and the prefix is baked into the artifact — page URLs, static assets and every API call resolve under it — for a reverse proxy that strips the same prefix before forwarding. Server Listener documents both halves and what each one looks like when only one of them is in place. Previously this needed a hand-passed build flag and the set [...]
+- **The runtime-rule dump download works under a path prefix.** Downloading a runtime-rule dump (all catalogs or one) was the single link in the app that ignored the deploy prefix: it aimed at the site root, so behind a gateway serving Horizon at `/horizon/` it hit whatever else owns `/api` there — usually a 404 instead of the archive. It now resolves against the same base as the rest of the UI.
+- **Running against an OAP whose template store Horizon cannot read? `templates.mode: readonly` is required.** In the default `live` mode Horizon renders only what is published to OAP, and an unreachable template store is a deliberate block: layer-driven pages stay empty behind the connectivity banner instead of quietly rendering a bundled copy that may not be what you published. The most visible casualty is **Traces**, which reads its query configuration from the layer template. That is [...]
+- **Run on the bundled templates, read-only — no OAP ui_template API needed.** A new `templates.mode` setting (`HORIZON_TEMPLATES_MODE`) adds a `readonly` mode: Horizon renders every dashboard / overview / alert-page / 3D-map / translation from the **local bundle** and never calls OAP's ui_template admin API. The whole config surface goes **read-only** — the admin pages still open and show the bundled config, but editing and publishing are disabled (and the BFF rejects a write even if it [...]
+- **The container image runs with environment variables only — no mounted config file.** There is now **one committed, env-driven `horizon.yaml`** (the former `horizon.example.yaml` / local-copy split is gone): every field is a `${HORIZON_…:default}` token, and the image bakes that same file. So `docker run -e HORIZON_OAP_QUERY_URL=… -e HORIZON_AUTH_LOCAL_USERS='[…]' …` is enough — no `-v` mount, no repackaging. Previously `oap.*`, `auth.*`, users, LDAP, RBAC, and performance tuning were [...]
+- **Per-layer and alarm-page setup live only in OAP now — two local state files retired.** Horizon no longer writes `horizon-setup.json` or `horizon-alarms.json`: the `setup.file` / `HORIZON_SETUP_FILE` config is gone (the per-layer override it backed was unused — per-layer config is authored in the layer-dashboards template), and the **alarm-page setup** (pinned layers, default window, overview fetch cap) now reads and writes only OAP's `horizon.alert.page-setup` template, exactly like [...]
+- **Audit logging can be turned off.** `audit.enabled` (env `HORIZON_AUDIT_ENABLED`, default `true`) — the audit trail stays on by default (it is a security record) but can be disabled entirely, mirroring `debugLog`'s enabled + file shape.
+- **Cluster Status now reports admin-feature reachability, not just config-presence.** The admin-host pane fires a safe GET at the real REST path each feature calls on OAP — dashboard templates → `/ui-management/templates`, DSL management → `/runtime/rule/list`, live debugger → `/dsl-debugging/status`, Inspect → `/inspect/metrics` — and colors each row by whether that path actually responds. A feature whose module is loaded but whose endpoint 404s (a renamed or forked module, a selector [...]
+- **An invalid `horizon.yaml` edit at runtime is rejected out loud, not silently ignored.** `horizon.yaml` is hot-reloaded, but a reload that failed validation used to be swallowed with no trace — the file said one thing while the server kept running another. A rejected reload is now logged with the exact field path(s) that failed (the same per-field shape a boot failure prints), and the previous valid config keeps serving until the file is fixed.
+- **The role editor no longer offers two permissions that did nothing.** `setup:write` and `alarm-setup:write` appeared as *Edit per-layer setup* and *Edit alarm-page setup* in the role editor, but no request ever checked them — publishing a Global-defaults or Alert-page-setup change goes through the same template-write path as every other template and is gated by `overview:write`. Withholding them fenced nothing off, so they are gone; the two read verbs stay (they decide whether the sid [...]
+
+- **The release process now gates the exact commit it tags, and stable images wait for the vote.** The release script pushed the tag before it installed, packaged or license-checked anything, ran no tests / type-check / lint / i18n at all, and validated the caller's working tree while tagging a freshly cloned one — so it could green-light one tree and release another. Repository CI never covered the gap either: it runs on pull requests and pushes to `main`, never on a tag. The full batte [...]
+
+- **A release is verified before it is published, and a half-finished finalize can be re-run.** Finalizing a passed vote moved the candidate into the official `dist/release` location FIRST and only downloaded it afterwards, and it never checked the `.asc` detached signatures at all — so a candidate that was corrupt, truncated or signed by the wrong key became the official Apache release before anything looked at it. The candidate is now fetched and verified before a single byte is promot [...]
+
+- **The key a release is signed with is the key the script showed you.** On a workstation holding more than one GPG secret key the release script could report one key, validate a *different* key's `@apache.org` address, and then hand the signing over to whichever key GPG picks by default — three keys, no guarantee any two were the same. It now resolves exactly one signing key (`HORIZON_RELEASE_GPG_KEY`, else `git config user.signingkey`, else the sole key in the keyring) and refuses to g [...]
+
+- **The official release directory holds exactly what was voted on — nothing rides along.** Promotion verifies six artifacts but moves the whole candidate directory in one step, so anything else left in it (a stale tarball from an earlier candidate, a hand-uploaded file) would have landed in the official Apache release location without ever being verified or voted on. The candidate's contents are now compared against the six expected artifacts before anything is published; an unexpected [...]
+
+- **Cleaning up the superseded release can no longer delete a newer one.** After promoting, finalize offers to remove the release this one supersedes — ASF keeps only the current release live and older ones fall to the archive — but it picked the highest OTHER version on the release path, so finalizing an older maintenance line (1.0.1 while 1.2.0 was already out) proposed deleting the NEWER release. Only versions below the one being finalized are considered now; any newer release is repo [...]
+
+- **The CHANGELOG is validated on the commit being tagged, and the published notes are read from the tag.** The release section check runs inside the release clone with the rest of the pre-tag battery, so a section that exists only in the release manager's local checkout — or one still carrying the `(In development …)` stub on the branch being released — fails the release instead of passing it while a different tree gets tagged. The GitHub release body finalize publishes is likewise extr [...]
+
+- **Finalize expects the moving `latest` image tag only when the release really is the newest.** The promotion run deliberately leaves `apache/skywalking-ui:latest` where it is when the promoted tag is not the highest, so that publishing a patch on a superseded line never drags `latest` backwards — but finalize announced `latest` as expected for every release. It now applies the same rule: `latest` is checked only when the release is the highest tag, and then it is also confirmed to poin [...]
+
+- **Dependencies refreshed — the shipped image and tarball now carry no known-vulnerable package.** The bundled runtime tree was upgraded (notably the static-file server the BFF serves the UI with, and the charting library), clearing every advisory `pnpm audit` reported against the production dependency tree, and the binary LICENSE/NOTICE were regenerated to match.
+
+- **The production default log level is now `warn` (was `error`).** The misconfiguration and security warnings Horizon emits — break-glass logins, LDAP failures, rejected config reloads — were discarded by the old default, so none of them reached stdout unless the operator had already raised `LOG_LEVEL`. They now show by default; set `LOG_LEVEL=error` to restore the old quietness.
+- **The `debugLog` wire log is implemented — enabling it now actually captures OAP traffic.** The config block was documented (see the [wire debug log docs](docs/setup/debug-log.md)) but nothing wrote the file; with `debugLog.enabled: true` every outbound OAP request/response pair (GraphQL, admin REST, Zipkin) is now appended to `debugLog.file` as one JSON line — `Authorization` headers redacted by default, bodies truncated at `maxBodyChars` — and every setting, including on/off and the [...]
+- **A duplicated template row on OAP is now reported, never resolved behind your back.** When the same dashboard / overview / alert-page template ends up on two OAP rows (two row ids for one template name — residue of an OAP release that minted a random id per create), earlier versions quietly disabled one of them at every BFF start, and picked the untouched bundled copy over the one you had edited. Retiring a row does not bring its content back — OAP has no delete, only disable, and Rea [...]
+- **A duplicated template is now impossible to miss — and impossible to open by accident.** A layer **or overview dashboard** whose template name sits on more than one enabled OAP record, where the copies carry **different** content, is **hidden from the sidebar** rather than rendered from whichever copy happened to win, so nobody navigates to a dashboard whose definition is ambiguous (the server log names every duplicate it finds, and the hidden layers specifically). Opening such a laye [...]
+- **Publishing a layer dashboard now refuses malformed content before anything reaches OAP.** Overview and 3D-map templates were already checked on the way out; layer dashboards were not, so a hand-edited or imported template could be published and break that layer for everyone until someone pushed a good copy over it. Publishing — **Check diff & push**, **Reset to bundled**, or a bulk sync — now names the offending field (an unknown key, a misspelled component flag or dashboard scope, a [...]
+
+
+### General Service — PHP runtime (PHM)
+
+- **Six instance dashboard line widgets for PHP Health Metrics** — process CPU utilization, memory used/peak, virtual memory, thread count, and open file descriptors (`meter_instance_php_*`). Each line widget uses `visibleWhen` so widgets render only when the PHP agent reports PHM data (Linux `/proc` sampling of the parent PHP process via `getppid()`).
+
+### General Service — Node.js runtime
+
+- **Six instance dashboard line widgets for Node.js runtime metrics** — process CPU, V8 heap used/total/limit, RSS, and external memory (`meter_instance_nodejs_*`). Each line widget uses `visibleWhen` so widgets render only when the Node.js agent reports runtime data.
+
+### General Service — metric-to-trace drill-down
+
+- **Removed the deprecated "Layer-scoped" widget toggle from the dashboard editor.** It emitted OAP's `scope: All` entity — deprecated in the query-protocol since OAP 9.4.0 — and no bundled dashboard used it. Any legacy stored dashboard that still carries the flag now renders at normal service scope (the unknown key is ignored). Layer-wide rollups belong on the Overview dashboards.
+
+- **Click a latency or error point on a General-service chart to open the matching traces.** Drill-capable line widgets — Avg Response Time, Response Time Percentile, Error Rate, Apdex, Success Rate, and MQ consume latency, across the Service, Instance, and Endpoint scopes — carry a **traces** flag in their header, and their datapoints are clickable. Clicking a point opens the native Traces list in a **new browser tab**, pre-filtered to that service (and the selected instance / endpoint) [...]
+
+### Access control & permissions
+
+- **The Roles & Permissions board no longer offers permissions that do nothing.** Four entries on it — "Edit alarm rules", "Add / remove local users", "Change role grants", "Read the audit log" — read as capabilities you could delegate, but nothing in Horizon checks them: alarm rules are read-only upstream (they change in OAP's YAML), the user list and the role definitions are edited in `horizon.yaml`, and the audit trail is a file you ship to your SIEM rather than something Horizon serv [...]
+
+- **The Live Debugger is `live-debug:read` / `live-debug:write` and nothing else — the `rule:debug` permission is removed.** Every Live Debugger call except the per-node status read demanded `rule:debug` on top of its own permission: watching a capture was granted by `live-debug:read` and then refused unless you also held `rule:debug`, so the read-only role the documentation describes could not open the page. Starting and stopping a capture required `live-debug:write` and `rule:debug` to [...]
+
+- **Read this if you hand-wrote a role that reached the Live Debugger through `rule:debug` — including by granting the `rule:*` wildcard.** `rule:*` matched `rule:debug`, so a role holding the wildcard held it too. Removing a permission from a two-permission requirement loosens what remains: a role granted `live-debug:write` without `rule:debug` could previously neither start nor stop a capture, and now can do both. Conversely a role that reached the Live Debugger only through `rule:debu [...]
+
+- **The 3D infrastructure map entry is hidden from roles that cannot open it.** The topbar entry to the map showed for everyone, but the map's configuration and traffic reads require `infra-3d:read` — so a role without it followed the entry to a page that could only tell it that it lacked access. The entry is now removed for those roles, and the Roles board's menu-visibility matrix — which already claimed the entry was hidden — is now telling the truth. The map's own route still loads fo [...]
+
+- **The Roles board's menu-visibility matrix lists every navigation entry — four were missing.** The matrix presented itself as exactly what each role sees in the menu, but **Trace inspect**, **Log inspect**, **Translations** and the **3D Infra Map** setup page had no row, so a role planned from the table reached pages the table never mentioned. All four now have one. The sub-entries under **DSL management** are covered by its row and need the same permission it does; the row for the 3D [...]
+
+- **What one operator's session read no longer reaches the next person to sign in on that browser tab.** Horizon caches fetched responses so a page you return to paints instantly — but that cache outlived the session: after a sign-out and a fresh sign-in in the same tab, the new user could be served the previous user's already-fetched data (service lists, alarms, traces, dashboards), and the reads Horizon deliberately holds for the life of a page (alarms, events, the layer service list) [...]
+
+- **Shortening the session timeout now actually shortens the sessions.** `session.ttlMinutes` hot-reloads, but only half of it was applying: the cookie handed to the browser carried the new timeout while the server went on honouring session ids for whatever timeout was in force when the BFF started. Tightening the window — the thing you do right after an incident — was the damaging direction, because it looked like it had worked: the browser was told to forget the cookie sooner, while an [...]
+
+- **A custom role no longer loses the org theme, the default time window and the alarm badge on sign-in.** The three org-wide settings an admin publishes on **Global defaults** and **Alert page setup** — the default theme, the default time window for dashboards and overviews, and the alert page setup that also sizes the topbar alarm badge and the overview "Active alarms" widget — were read through the template-administration endpoint, which requires `overview:read` and returns every dash [...]
+
+- **When OAP's template store can't be read, org settings fall back to Horizon's built-in defaults instead of the files shipped in the image.** In the default `live` template mode the copy stored on OAP is the only source of truth for configuration, but if the template store was unreachable — or a setting's row was missing or had been disabled — Horizon quietly rendered the theme, time window and alert page setup that ship inside the image as though they were your organization's publishe [...]
+
+### Alarms
+
+- **The alarm timeline reads more clearly** — a clearer selection band and legend, and the detail sidebar reflows cleanly on narrow windows. Hovering the timeline now hints both affordances — click a minute to filter, or drag across the timeline to select a range — so range-selection is no longer hidden.
+
+- **Filtering alarms by a virtual service now finds them.** The databases, caches, message queues and gateways SkyWalking infers from an agent's calls (`VIRTUAL_DATABASE`, `VIRTUAL_CACHE`, `VIRTUAL_MQ`, `VIRTUAL_GATEWAY`, …) are conjectural services, and OAP distinguishes them from agent-reporting ones by a flag that is part of the service's identity. The alarms filter dropped that flag and always asked as if the service were agent-reporting, so picking a virtual service — on its own or [...]
+
+### Events
+
+- **A per-service events popout on the service banner.** Every layer drill-down's service banner gains an Events button that opens a modal for that one service's lifecycle events — agent restarts, Kubernetes events, and other point-in-time records from OAP `queryEvents` — without leaving the page you're on. The service is fixed, so the view is a swimlane of **instance × time**: one row per service instance in its own color, each event a bar on a time axis (an event with no end time is an [...]
+
+- **Built for scale and honest about limits.** A rolling restart of a large service is one bar per instance stacked at the same moment — the granularity is the point, and a search box filters the instance rows by name for services that run hundreds of them. Scrolling is fully internal (sticky time-axis header + sticky instance column, horizontal scroll for long ranges, opened scrolled to the newest events). The newest events are fetched up to a configurable cap (200 by default); the popo [...]
+
+### User experience
+
+- **The runtime-rule page is a dump, and now says only that.** Under DSL management it was titled **Dump & restore** and carried a dimmed second panel headed **restore**, tagged "later release", promising an upload affordance once an upstream API shipped. There is no restore and none is planned — reverting a rule to its bundled version is the reset Horizon offers — so the placeholder panel is gone and the page and its sidebar entry are now **Runtime-rule dump**. Exporting the rules, all [...]
+
+- **Cluster Status describes the admin-server row in your language, under the product's name.** The row's "what this affects" line named the project by an internal working name and had drifted out of the translation catalogs, so every locale silently fell back to English. It now reads correctly in all 8 locales, and no longer claims a fixed count of dependent modules.
+
+- **The whole console now speaks your language.** Large surfaces that previously rendered English in every locale — the per-layer sidebar sub-navigation, the Logs tab filter bar, all five profiling tabs and their task dialogs, the admin template editors (layer dashboards, overview, topology, deployment, 3D map, global defaults), the 3D Infrastructure Map, the service-map detail panels, and the remaining topbar chips — are now fully wired into the translation catalogs and ship translated [...]
+
+- **The page's `<html lang>` attribute now follows the active locale** — screen readers, spell checkers, and translation tooling see the language you actually picked instead of a hardcoded `en`.
+
+- **Escape closes any dismissible panel** — modals, row popouts, and the topology focus / node-filter dropdowns all dismiss on Esc.
+
+- **Switching service clears the dependent filters** (log level / tags, browser-error category) back to a clean state, so a stale filter never silently hides the new service's data.
+
+- **Denser Kubernetes dashboard tables** — the K8s layer's table widgets show more rows without scrolling.
+
+- **Live debugger reads cleanly on tall and wide results.** The LAL pipeline matrix's frozen first column now stays pinned when you scroll the grid sideways (it used to drift off with the rest of the matrix), clicking a source line flashes the whole matching step row — not just its label — and the MAL / LAL / OAL debugger pages now scroll as one page for tall captures instead of trapping the result in a fixed-height inner box.
+
+- **The LAL pipeline matrix renders Envoy access-log (ALS) and any non-generic log format.** Each cell now shows whatever fields OAP serialized for the record rather than a fixed `LogData` subset — so an `EnvoyAccessLogBuilder` snapshot displays its service / endpoint / response data where it used to render blank, a record whose raw proto input OAP couldn't serialize surfaces the reason (`jsonformat-failed …`) instead of an empty cell, and each cell names its payload class. The free-text [...]
+
+- **The LAL matrix gains per-row filtering and is correct across cluster nodes.** A filter on each step row — shown only when that row has gaps — narrows the grid to the records that actually produced data for that step (e.g. just the records that emitted output), and the row counts now reflect the whole capture rather than the visible page. Each OAP node's matrix filters and column-pins independently, so acting on one node's grid no longer changes another's. Oversized cells are height-c [...]
+
+- **A LAL step that dropped a record now says why.** When a pipeline step stops a record, the matrix cell and its popout show OAP's human-readable drop reason — a parse exception, a non-matching regexp, an input-type mismatch — instead of leaving you to reconstruct the cause from the payload. An OAP version that predates the reason field simply shows no reason, as before.
+
+- **Inspect a LAL cell's full data and diff pipeline stages.** Every cell carries a persistent button — `VIEW` on the input row, `DIFF` on the builder rows — that opens the complete payload in a syntax-highlighted JSON viewer with the nested log `content` inlined as real JSON. For the builder snapshots a compare picker renders the captured DSL itself, with per-statement steps marked on their line and the `extractor` / `sink` block snapshots drawn as selectable ranges; picking one shows a [...]
+
+- **A failed live-debug or OAL request now names OAP's own error code.** Those endpoints report a failure as a code plus a message — `rule_not_found` when no node has the rule loaded, `too_many_sessions` when every node is at its capture ceiling, `injection_disabled` when debug capture is switched off — but Horizon read the field the rule-apply endpoints use instead, so the code was dropped and the failure rendered as `undefined: <message>` in the server log and in the per-node live-debu [...]
+
+### Dashboards
+
+- **A dashboard can no longer be published under a name that isn't what it holds.** Pushing a layer dashboard to a name the runtime never reads — `horizon.layer.general` instead of `horizon.layer.GENERAL`, or an OAP legacy alias like `horizon.layer.CACHE` where the layer is read as `VIRTUAL_CACHE` — used to succeed and then change nothing on screen, with no explanation anywhere: the record existed but no page ever asked for it. Publishing an overview dashboard under one id while its cont [...]
+
+- **Overview dashboards are checked against the same bar as everything else when you publish them.** The push previously ran a weaker check than the one the shipped dashboards pass in CI, so a misspelled field, a widget type the renderer has no case for, or an out-of-range width was stored as dead config and simply never rendered. Half-authored work still publishes — a blank MQE, an empty KPI list, a cleared title, a brand-new dashboard with no widgets yet are all normal states of an unf [...]
+
+- **A refused push now tells you why on the layer and overview editors.** The reason (which field, or the name to publish under) was already in the response but the pages showed only "failed (400)".
+
+- **Cards can render values as colored status chips.** A card widget with `format: enum` now takes an optional chip color per value-map entry — `ok` (green), `warn` (amber), `err` (red), `info` (blue), `neutral` (grey) — and renders each matched value, or metric label, as a colored chip instead of a bare number. Set it in the layer-dashboard admin's value-map editor, next to the existing value → label mapping.
+
+- **The Kubernetes Node Status card now reads as a status, not a number.** Instead of a raw `1`, it shows the node's active conditions as colored chips — `Ready` in green, the `*Pressure` / `NetworkUnavailable` conditions in amber/red — so node health is legible at a glance.
+
+- **The Kubernetes Node dashboard gains a Pod Total card.** A compact card now sits directly under Node Status showing the current count of pods scheduled on the selected node (all phases) — the latest value of the same metric the "Pods on Node" trend already charts — so the space beside the status card is no longer blank.
+
+- **Satellite event and queue widgets break out per pipeline.** The SO11Y_SATELLITE Receive Events, Fetch Events, Queue Input / Output, and Queue Used widgets now label each series by its Satellite pipeline (`tracingpipe`, `jvmpipe`, `logpipe`, …) instead of collapsing every line onto a single `all`, so you can see which collection pipeline drives the rate.
+
+- **HOUR-step line charts no longer collide their x-axis labels.** An hourly label carries the date (`07-02 05:00`), so on a narrow widget several wide labels overlapped into an unreadable smear; overlapping labels are now hidden (the label density target is unchanged — only the colliding ones drop), so any dashboard viewed at HOUR step keeps a legible axis.
+
+### Overview dashboards
+
+- **The per-layer KPI tiles now report the whole layer, not a single service.** A tile such as "General services · RPM / Latency / SLA" was aggregating just one service (an internal top-1 cap), so a busy layer read far too low. Each tile now rolls up **every** service in its layer — throughput summed, latency and SLA averaged — for General, Mesh, and the Virtual Database / Cache / MQ / GenAI layers. Mesh's second tile is now **Latency** (average response time) in place of the old P95.
+- **New per-widget aggregation control for Overview composites.** An Overview data widget defaults to a self-aggregating expression that OAP rolls up server-side; a widget can instead pick **page-side aggregation** (an *Aggregation* mode choice in the Overview templates admin, with a top-`limit` services window) for metrics that can't be expressed that way — the Kubernetes cluster-capacity and Istio pilot composites use it (pilot over up to 5 control-plane services). A page-side widget a [...]
+
+### Performance & behavior tuning
+
+- **New `performance` section in `horizon.yaml`.** Tune how hard the BFF fans metric queries out to OAP — per-route bulk (request) sizes and concurrency for the topology, 3D-map, landing, and dashboard fan-outs — plus protective caps: the service-map render valve (`topologyMaxNodes` / `topologyMaxEdges`) and per-request record caps for traces / logs / browser logs. Operational, hot-reloaded, per-deployment; defaults match the previous built-in values, so the whole block is optional. Rais [...]
+- **3D-map fan-out tuning moved out of the dashboard template into `horizon.yaml`** (`performance.bulk.infra3d`). These metric concurrency / batch knobs were operational settings misplaced in a published-to-OAP dashboard template (not even surfaced in the admin editor); a stale template still carrying the old `pipeline` block is accepted and stripped on save, so a 3D config that was synced before the move converges back to `synced` after one re-push (instead of showing `diverged` forever).
+- **Unified page-size pickers across the event lists.** Traces, Logs, and Browser Logs share a `20 / 30 / 50 / 100` page-size dropdown — and Browser Logs gains a picker it never had (it had a fixed 100). Each picker's max matches the server-side fetch cap in `performance.limits.maxPageSize`.
+- **Node memory sizing guidance.** The container image now sets a default `NODE_OPTIONS=--max-old-space-size`, and the docs cover sizing the Node heap to your container memory limit and the in-memory source-map budget.
+
+### Trace explorer
+
+- **Zipkin traces now render with the full native trace experience.** The Zipkin trace detail and popout gained the KPI strip, service legend, the duration-distribution scatter (drag to filter, click to open), time-positioned waterfall bars (service · operation inside the bar, kind/status affordances), and a centered span-detail modal — matching the SkyWalking-native trace view. Zipkin annotation codes (`cr` / `cs` / `sr` / `ss` …) show an inline plain-language hint.
+- **Shareable trace links are unified.** Native and Zipkin traces both open from a single `?traceId=` link under the layer's trace tab; the viewer auto-selects native vs Zipkin by the trace-ID shape, so `/layer/<layer>/trace?traceId=…` always opens the right one.
+- **Trace filters are searchable, on-theme dropdowns.** The native Service / Instance / Endpoint pickers and the Zipkin Service / Remote service / Span name pickers use a dark type-to-filter dropdown that reopens correctly after a pick.
+
+### Logs
+
+- **Log and browser-error lists query on demand, not on every edit.** The per-layer Logs tab, cross-layer Log inspect, and the Browser Errors tab now stage condition changes and fetch only when you press **Run query** — a fresh tab shows a "Pick your conditions, then click Run query" prompt, and switching service resets to that prompt (clearing the level / tag / category filters), so the previous service's data never lingers under the new one.
+- **Log inspect uses the full width.** The cross-layer Log inspect form (Target + Tags / Trace ID / Time / Limit conditions) now spans the whole page instead of sharing a two-column strip with empty space.
+- **Clicking a log row opens a centered popout.** Both the cross-layer Log inspect and the per-layer Logs tab now open the same full-payload popout on row click — format-aware pretty-print (JSON pretty-printed by content type), the tags table, service / instance / endpoint / time meta, a copy button, and the trace link. Escape or the close button dismisses it.
+- **Log inspect can now query Browser errors across the page.** A new **Browser** source on Log inspect (beside Raw) queries the BROWSER layer's JS error logs from anywhere — pick a browser service or type a service name (or leave it blank for all services), then narrow by category (AJAX / RESOURCE / VUE / PROMISE / JS / UNKNOWN), version, page, and time window, and read the error list (message, category, page path, app version, time, minified `line:col`). Upload and manage source maps i [...]
+- **Tag fields autocomplete on theme.** The Tags filter on Trace inspect, Log inspect, and the per-layer Traces / Logs tabs now suggests tag keys (before `=`) and per-key values (after) in a dark, dense dropdown anchored under the field, replacing the browser's native `<datalist>` popup. On Trace / Log inspect, pressing Enter commits the current tag and starts the next, mirroring the per-layer chip tabs.
+- **Log inspect can now read Kubernetes Pod logs across the page.** A new **Kubernetes Pod logs** source on Log inspect (beside Raw and Browser) tails a specific pod's container logs on demand from the K8s API through OAP — pick a Kubernetes layer, then **pick or type** a service, choose a pod and container, set a trailing window (30s … 30m) and optional keyword filters, and read the dense, read-only log lines (timestamp + content). The Layer field lists only Kubernetes-deployed layers ( [...]
+
+### Metrics Inspect
+
+- **Inspect can now chart a "foreign" metric — one the connected OAP doesn't define.** When a metric is written into shared storage by another OAP (an older version, or a different distribution, that the OAP behind Horizon doesn't carry the analysis rule for), it never shows up in the catalog drawer. The + add metric drawer now has a **Foreign metric** tab (beside the catalog browse): type the metric name, pick its scope (Service / ServiceInstance / Endpoint / the three relations), and g [...]
+
+### Bundled layer dashboards
+
+- **Single-value metrics now render as cards, not flat lines, on several layer dashboards.** Widgets whose expression collapses the window to one number (a `latest(...)` total) had been mis-ported as line charts — drawn as a lone dot that misreads as a time series and shares one axis with an unrelated average trend. Each is now split into a proper single-value **card** (the total) plus a trend **line** (the average), matching the metric's shape, the way booster-ui rendered them. Affects [...]
+- **The Kubernetes Service dashboard tiles without a gap.** The Service and Pod views left an empty half-row where a run of same-width widgets didn't divide evenly into the grid, and the layout back-filled the hole by pulling a later chart up out of order. The CPU / Memory Resources and Pod HTTP RPM / Response Time widgets are re-sized so every row fills the grid in order — no gap, and the charts stay in reading order.
+
+- **The Kafka layer's cluster metrics are back.** Its **Max Lag** column asked for a roll-up the service-list query does not offer (`max`), and one unusable column rejects the whole request — so the Kafka page came back with no cluster measured at all: every metric column blank, an empty KPI strip, and no cluster selected for you (the clusters themselves stayed listed and selectable). Max Lag now rolls up as the **average across the listed clusters**, matching how the same metric is char [...]
+
+- **The AWS DynamoDB account list sorts by throttling again.** Its default sort named a `throttled` column that does not exist — the columns are Read / Write Throttled and Read / Write Sys Err — so accounts came back in name order instead of worst-first, and on a deployment with more accounts than the metric-probe cap the accounts that got measured were not the throttled ones. It now sorts by **Read Throttled**.
+
+- **Every bundled layer and overview template is checked before release.** The templates Horizon ships are validated on each build against what the pages actually accept — roll-up values, widget kinds, required fields, and every internal reference (a default sort naming a real column, an overview widget naming a real layer) — so a template defect like the two above fails the build instead of reaching your screen as an empty page.
+
+### Layer dashboard editor
+
+- **A layer template that would break the layer is refused at publish instead of being stored.** Publishing a layer template — from the editor, or from a file you imported or hand-edited — is now structurally checked before anything reaches OAP, the same way overview dashboards and the 3D-map config already were. A template carrying an unknown field, an unknown component flag or dashboard scope, a widget the dashboard grid cannot run (unknown kind, or no expression at all), or a service- [...]
+
+- **New `tab` widget — a sized slot with named tab panels, each holding its own widgets, edited inline.** A layer dashboard widget can now be a `tab` container: a grid slot you size with span / row span, holding any number of **named tabs**, where each tab is its own little dashboard — its own set of widgets (card / line / top / record / table) in a sub-grid. Switching a tab swaps the whole set, and only the active tab's widgets are queried (lazy) — an unopened tab costs nothing, and a p [...]
+- **`+ Add widget` now lets you pick the kind.** The add button opens a grouped menu — `Tab group` first, then the five widget kinds (card, line, top, record, table) — each with a one-line description, instead of always dropping in a card you then have to retype. The editor header stays pinned so the button is always reachable, and selecting a widget reserves a right-hand column for the edit drawer (the canvas re-shrinks to fit) with the drawer pinned in view as you scroll.
+- **The widget editor pins in place beside the canvas and always opens complete.** On the Layer dashboards admin, clicking a widget — anywhere on the board, including the bottom rows — now opens the per-widget editor pinned next to the canvas and fully visible, without scrolling the page (a sticky panel used to get clipped past the bottom of a tall board, hiding the editor's top or its `Up` / `Down` / `Delete` row). The move / delete controls sit in a pinned footer, and the editor tucks [...]
+
+### Compare on a layer dashboard
+
+- **One-click exit from comparison.** The compare bar gains a **Clear all** button that drops every locked entity — including the current one, whose chip has no per-entity × — and returns the page to the single-entity view. Previously, when your only lock was the current entity, there was no way to leave compare from the bar.
diff --git a/docs/menu.yml b/docs/menu.yml
index 3f18680..6a4508a 100644
--- a/docs/menu.yml
+++ b/docs/menu.yml
@@ -225,3 +225,22 @@ catalog:
path: "/components/dashboard-widgets"
- name: "Charts"
path: "/components/charts"
+
+ - name: "Release Notes"
+ catalog:
+ - name: "1.0.0"
+ path: "/changelog/1.0.0"
+ - name: "0.7.0"
+ path: "/changelog/0.7.0"
+ - name: "0.6.0"
+ path: "/changelog/0.6.0"
+ - name: "0.5.0"
+ path: "/changelog/0.5.0"
+ - name: "0.4.0"
+ path: "/changelog/0.4.0"
+ - name: "0.3.0"
+ path: "/changelog/0.3.0"
+ - name: "0.2.0"
+ path: "/changelog/0.2.0"
+ - name: "0.1.0"
+ path: "/changelog/0.1.0"
diff --git a/docs/operate/infra-3d-map.md b/docs/operate/infra-3d-map.md
index 9f6c400..b54da54 100644
--- a/docs/operate/infra-3d-map.md
+++ b/docs/operate/infra-3d-map.md
@@ -200,6 +200,7 @@ The map's loading stages run in batches, several requests at once. How aggressiv
The defaults are tuned for a typical deployment; only revisit these if the loading timeline stalls on the Metrics, Topologies, or Templates step, or if OAP returns errors under the load.
Viewing the map needs read access (`infra-3d:read`, held by the built-in
-viewer role and above); editing and publishing the configuration needs
+viewer role and above). A role without it does not get the topbar entry to
+the map at all. Editing and publishing the configuration needs
`overview:write` (operators and admins by default). See
[Roles and Permissions](../access-control/rbac.md).
diff --git a/docs/operate/live-debugger.md b/docs/operate/live-debugger.md
index 7595081..b0143fc 100644
--- a/docs/operate/live-debugger.md
+++ b/docs/operate/live-debugger.md
@@ -91,8 +91,7 @@ A capture that was archived before its first poll returned data shows as having
| Permission | Grants |
|---|---|
-| `live-debug:read` | View the Live Debugger, the active-session list, cluster status, and capture history. |
-| `live-debug:write` | Start and stop capture sessions. |
-| `rule:debug` | Debug a rule — required alongside `live-debug:write` to actually run a capture. |
+| `live-debug:read` | View the Live Debugger, the active-session list, cluster status, and capture history. Nothing else is required to watch a capture. |
+| `live-debug:write` | Start and stop capture sessions. Nothing else is required to run one — no `rule:*` grant takes part, and holding every rule verb without `live-debug:*` gets you nothing here. |
-In the bundled roles, all three are held by **operator** (and **admin**). A read-only viewer can be granted `live-debug:read` to inspect existing sessions and history without being able to start new captures. See [Roles and Permissions](../access-control/rbac.md).
+In the bundled roles, both are held by **operator** (and **admin**). A read-only viewer can be granted `live-debug:read` on its own to inspect existing sessions and history without being able to start new captures. See [Roles and Permissions](../access-control/rbac.md).
diff --git a/docs/operate/runtime-rules.md b/docs/operate/runtime-rules.md
index 3bc5dbb..8eb7d8d 100644
--- a/docs/operate/runtime-rules.md
+++ b/docs/operate/runtime-rules.md
@@ -1,13 +1,13 @@
# Runtime Rules (DSL)
-Path: `/operate/dsl/<catalog>` — one page per catalog: `/operate/dsl/otel-rules`, `/operate/dsl/telegraf-rules`, `/operate/dsl/lal`, `/operate/dsl/log-mal-rules`. Verbs: `rule:read` to browse, `rule:write` / `rule:write:structural` / `rule:delete` to change (granted by maintainer, operator, admin).
+Path: `/operate/dsl/<catalog>` — one page per catalog: `/operate/dsl/otel-rules`, `/operate/dsl/telegraf-rules`, `/operate/dsl/lal`, `/operate/dsl/log-mal-rules`. Verbs: `rule:read` to browse, `rule:write` / `rule:write:structural` / `rule:delete` to change — all four granted by **operator** and **admin**. **maintainer** holds read-only permissions and none of the `rule:` family, so it cannot open these pages.
DSL Management lets you edit a connected OAP's analysis rules — MAL (`otel-rules`, `telegraf-rules`, `log-mal-rules`) and LAL (`lal`) — at runtime, without restarting the backend. You browse a catalog, open a rule in the editor, and save; OAP applies the change live across the cluster. Bundled rules shipped with OAP can be overridden, inactivated, or reverted to their bundled version.
Two companion pages, both `rule:read`-gated, sit alongside the editable catalogs:
- **OAL catalog** (`/operate/oal`) — a read-only browser of the connected OAP's OAL rules. OAL rules are **not** runtime-editable; this page lets you read them, but changes go through the OAP build, not the live editor.
-- **Dump & restore** (`/operate/dsl/dump`) — exports the connected OAP's runtime rules for backup and re-imports them onto another OAP.
+- **Runtime-rule dump** (`/operate/dsl/dump`) — downloads the connected OAP's runtime rules as an archive, either every catalog at once or one catalog at a time. It is an export only, for backup and offline review: the archive cannot be uploaded back, on this page or anywhere else in Horizon.
## The two kinds of edit
diff --git a/docs/setup/horizon-yaml.md b/docs/setup/horizon-yaml.md
index bfaf87b..e26ccbf 100644
--- a/docs/setup/horizon-yaml.md
+++ b/docs/setup/horizon-yaml.md
@@ -17,7 +17,7 @@ This page is the top-level map. Each subsection has its own detail page:
| `query` | Per-request query limits (layer-landing service cap, Overview top-N). | [below](#query-limits) |
| `sourceMaps` | In-memory source-map budgets + static mount for the Browser Logs tab. | [Browser Logs & Source Maps](../operate/browser-source-maps.md) |
| `ai` | AI assistant: provider, model, credentials, prompt overrides, history cap. | [AI Assistant](../operate/ai-assistant.md) |
-| `performance` | How hard the BFF fans queries out to OAP, plus render / per-request record caps. | [below](#performance-tuning) |
+| `performance` | How hard the BFF fans queries out to OAP, plus render caps and the largest page a list may display. | [below](#performance-tuning) |
| `layers` | Layers to hide from the sidebar. | [below](#excluded-layers) |
## Top-level shape
diff --git a/docs/setup/rbac.md b/docs/setup/rbac.md
index 6635b92..8a91edc 100644
--- a/docs/setup/rbac.md
+++ b/docs/setup/rbac.md
@@ -10,7 +10,7 @@ rbac:
roles:
viewer: [metrics:read, alarms:read, events:read, traces:read, logs:read, browser-errors:read, inspect:read, topology:read, profile:read, overview:read, infra-3d:read, ai:read]
maintainer: [metrics:read, alarms:read, events:read, traces:read, logs:read, browser-errors:read, topology:read, profile:read, overview:read, cluster:read, inspect:read, ttl:read, config:read, infra-3d:read, ai:read]
- operator: [metrics:read, ..., rule:*, live-debug:*, profile:enable, ai:read]
+ operator: [metrics:read, ..., rule:write:structural, live-debug:write, profile:enable, ai:read]
admin: ["*"]
landingByRole:
viewer: /
@@ -33,7 +33,7 @@ rbac:
|---|---|---|
| `viewer` | Read-only data catalog, inspect tools, public overviews, and the AI assistant. | `metrics:read`, `alarms:read`, `events:read`, `traces:read`, `logs:read`, `browser-errors:read`, `inspect:read`, `topology:read`, `profile:read`, `overview:read`, `infra-3d:read`, `ai:read`. Deliberately not `*:read` so the viewer cannot see rule definitions, live-debug sessions, setup screens, or cluster / TTL / config internals. |
| `maintainer` | Viewer + platform monitoring. | viewer baseline + `cluster:read`, `ttl:read`, `config:read`. |
-| `operator` | Configures observability. | maintainer baseline + `overview:write`, `source-map:write`, `setup:read`/`write`, `dashboard:read`/`write`, `alarm-setup:read`/`write`, `alarm-rule:read`/`write`, `rule:read`, `rule:write`, `rule:write:structural`, `rule:delete`, `rule:debug`, `live-debug:read`/`write`, `profile:enable`. |
+| `operator` | Configures observability. | maintainer baseline + `overview:write`, `source-map:write`, `setup:read`, `dashboard:read`/`write`, `alarm-setup:read`, `alarm-rule:read`, `rule:read`, `rule:write`, `rule:write:structural`, `rule:delete`, `live-debug:read`/`write`, `profile:enable`. There is no `setup:write` or `alarm-setup:write` verb — publishing on those pages rides `overview:write`, and alarm rules are read-only for every role. |
| `admin` | Unrestricted. | `*`. |
## Verb grammar
@@ -44,7 +44,7 @@ Grants are dot-namespaced strings. Four matching modes:
|---|---|
| `*` or `admin` | Matches any verb (admin). |
| `area:verb` | Exact match: e.g., `rule:write` grants exactly `rule:write`. |
-| `area:*` | Matches any verb in that area: `rule:*` grants `rule:read`, `rule:write`, `rule:write:structural`, `rule:delete`, `rule:debug`. |
+| `area:*` | Matches any verb in that area: `rule:*` grants `rule:read`, `rule:write`, `rule:write:structural`, `rule:delete`. |
| `*:read` | Matches the `read` action across any area. |
A user's effective verbs are the **union** of all grants from all their roles.
diff --git a/package.json b/package.json
index 317e016..f935618 100644
--- a/package.json
+++ b/package.json
@@ -15,9 +15,10 @@
"dev:bff": "pnpm --filter @skywalking-horizon-ui/bff dev",
"build": "pnpm -r run build",
"type-check": "pnpm -r run type-check",
- "lint": "pnpm -r run lint && node scripts/check-source-budget.mjs && node scripts/check-ui-i18n.mjs && pnpm --filter @skywalking-horizon-ui/bff run i18n:validate && pnpm --filter @skywalking-horizon-ui/bff run templates:validate",
+ "lint": "pnpm -r run lint && node scripts/check-source-budget.mjs && node scripts/check-ui-i18n.mjs && node scripts/check-ui-promises.mjs && pnpm --filter @skywalking-horizon-ui/bff run i18n:validate && pnpm --filter @skywalking-horizon-ui/bff run templates:validate",
"lint:budget": "node scripts/check-source-budget.mjs",
"lint:i18n": "node scripts/check-ui-i18n.mjs && pnpm --filter @skywalking-horizon-ui/bff run i18n:validate",
+ "lint:promises": "node scripts/check-ui-promises.mjs",
"lint:templates": "pnpm --filter @skywalking-horizon-ui/bff run templates:validate",
"test:unit": "pnpm -r run test:unit",
"license:check": "license-eye -c .licenserc.yaml header check",
diff --git a/scripts/changelog-release-notes.mjs b/scripts/changelog-release-notes.mjs
index 4bf5852..9317296 100644
--- a/scripts/changelog-release-notes.mjs
+++ b/scripts/changelog-release-notes.mjs
@@ -16,15 +16,22 @@
*/
/**
- * Extract one version's section from CHANGELOG.md and emit it as a GitHub
- * release body, with hard-wrapped paragraphs and list items UNWRAPPED.
+ * Emit one version's changelog as a GitHub release body, with hard-wrapped
+ * paragraphs and list items UNWRAPPED.
*
- * Usage: node scripts/changelog-release-notes.mjs <version> [changelog-path]
- * → prints the reflowed section to stdout (exits 1 if not found)
- * node scripts/changelog-release-notes.mjs --whole-file [changelog-path]
+ * Usage: node scripts/changelog-release-notes.mjs <version> [file]
+ * → reflows docs/changelog/<version>.md, minus its `# <version>`
+ * page title, to stdout (exits 1 if the file is not there)
+ * node scripts/changelog-release-notes.mjs --whole-file <file>
* → prints the WHOLE file reflowed (one line per paragraph / item);
- * pipe back over CHANGELOG.md to normalize the source to the
- * one-line house style.
+ * pipe it back over the file to normalize it to the one-line
+ * house style.
+ *
+ * Every version keeps its own file, so there is one place to read and nothing
+ * to search. Naming a file explicitly overrides the lookup —
+ * scripts/release-finalize.sh passes the copy it read out of the tag, so the
+ * published notes describe the bytes that were released rather than whatever
+ * the working tree holds now.
*
* Why this exists — the two markdown contexts render newlines differently:
* - A `.md` file viewed in a repo renders as CommonMark, where a single
@@ -35,32 +42,33 @@
* hard-line-breaks ON, where every single newline becomes a literal
* `<br>`. Feeding a hard-wrapped section straight in produces a ragged
* column of short lines with a sea of right-hand whitespace.
- * The committed CHANGELOG is therefore written one physical line per paragraph
+ * The committed changelog is therefore written one physical line per paragraph
* / list item, and this script is the backstop that joins any that were wrapped
* anyway, while preserving the block structure that DOES depend on newlines:
* blank lines, headings, list markers + indentation, code fences, blockquotes,
* tables, thematic breaks, and HTML blocks.
*/
-import { readFileSync } from 'node:fs';
+import { existsSync, readFileSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const PROJECT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const argv = process.argv.slice(2);
const wholeFile = argv[0] === '--whole-file';
const version = wholeFile ? null : argv[0];
-const changelogPath = (wholeFile ? argv[1] : argv[1]) ?? 'CHANGELOG.md';
-if (!wholeFile && !version) {
- process.stderr.write('usage: changelog-release-notes.mjs <version>|--whole-file [changelog-path]\n');
+const path = argv[1];
+if (wholeFile ? !path : !version) {
+ process.stderr.write('usage: changelog-release-notes.mjs <version> [file] | --whole-file <file>\n');
process.exit(2);
}
-/** Pull the lines strictly between `## <version>` and the next `## ` heading. */
-function extractSection(content, v) {
+/** Everything below the page title — the notes themselves. */
+function body(content) {
const lines = content.split('\n');
- const start = lines.findIndex((l) => l === `## ${v}`);
- if (start === -1) return null;
- let end = start + 1;
- while (end < lines.length && !/^## /.test(lines[end])) end++;
- return lines.slice(start + 1, end);
+ const title = lines.findIndex((l) => l.startsWith('# '));
+ return title === -1 ? lines : lines.slice(title + 1);
}
const isBlank = (l) => /^\s*$/.test(l);
@@ -130,18 +138,17 @@ function reflow(lines) {
return out;
}
-const content = readFileSync(changelogPath, 'utf8');
-
if (wholeFile) {
+ const content = readFileSync(path, 'utf8');
// split/join round-trips the trailing newline (a final '\n' yields a
// trailing '' element that reflow preserves), so the file ends as it began.
process.stdout.write(reflow(content.split('\n')).join('\n'));
process.exit(0);
}
-const section = extractSection(content, version);
-if (section === null) {
- process.stderr.write(`changelog-release-notes: no "## ${version}" section in ${changelogPath}\n`);
+const notes = path ?? resolve(PROJECT_DIR, `docs/changelog/${version}.md`);
+if (!existsSync(notes)) {
+ process.stderr.write(`changelog-release-notes: ${notes} does not exist — ${version} has no changelog file.\n`);
process.exit(1);
}
-process.stdout.write(reflow(section).join('\n') + '\n');
+process.stdout.write(reflow(body(readFileSync(notes, 'utf8'))).join('\n') + '\n');
diff --git a/scripts/changelog-version.mjs b/scripts/changelog-version.mjs
new file mode 100644
index 0000000..8679590
--- /dev/null
+++ b/scripts/changelog-version.mjs
@@ -0,0 +1,209 @@
+#!/usr/bin/env node
+/*
+ * 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.
+ */
+
+/**
+ * The per-version changelog files: docs/changelog/<version>.md, one per release,
+ * kept forever. Contributors write into the file whose version matches
+ * package.json; a release reads it and never moves it.
+ *
+ * Commands:
+ * check <version>
+ * The release gate. Fails unless docs/changelog/<version>.md exists and
+ * has been filled in — a missing file, or one that still carries the stub
+ * line `seed` writes, stops the release before the tag exists.
+ * seed <version>
+ * Open the next cycle: write docs/changelog/<version>.md (title + stub)
+ * and add its docs/menu.yml entry. Both halves are idempotent, so a
+ * resumed release re-runs cleanly. Run it by hand when starting a patch
+ * line, where there is no preceding release to seed the file.
+ *
+ * `--repo-root <dir>` points either command at a tree other than this checkout
+ * — scripts/release.sh runs them against the release clone, which is what gets
+ * tagged and shipped.
+ */
+
+import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
+import { dirname, join, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const DEFAULT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
+
+/** Written by `seed`, rejected by `check` — one definition, so they cannot drift. */
+const STUB = '(In development — fill in highlights here before cutting the release.)';
+
+const DOC_DIR = 'docs/changelog';
+const MENU_FILE = 'docs/menu.yml';
+const MENU_GROUP = 'Release Notes';
+
+const VERSION_RE = /^\d+\.\d+\.\d+$/;
+
+function fail(message) {
+ process.stderr.write(`changelog-version: ${message}\n`);
+ process.exit(1);
+}
+
+function cmpVersion(a, b) {
+ const pa = a.split('.').map(Number);
+ const pb = b.split('.').map(Number);
+ for (let i = 0; i < 3; i++) {
+ if (pa[i] !== pb[i]) return pa[i] - pb[i];
+ }
+ return 0;
+}
+
+const docPath = (version) => `${DOC_DIR}/${version}.md`;
+
+// ========================== check ==========================
+
+function cmdCheck(version, root) {
+ const relative = docPath(version);
+ const absolute = join(root, relative);
+ if (!existsSync(absolute)) {
+ fail(
+ `${relative} does not exist. Every release reads its notes from that file — ` +
+ `write it (or run: node scripts/changelog-version.mjs seed ${version}) and land it on the branch being released.`,
+ );
+ }
+ const lines = readFileSync(absolute, 'utf8').split('\n');
+ // Whole-line, not a substring: a changelog entry may legitimately quote the
+ // stub while describing this gate, and a substring match would read that
+ // prose as an unfilled release and abort.
+ if (lines.some((line) => line === STUB)) {
+ fail(`${relative} still carries the stub line — fill in the ${version} highlights before cutting the release.`);
+ }
+ // The title must BE the version AND lead the file. The release-notes
+ // extractor drops the first H1 and publishes what follows, so anything above
+ // it is silently discarded — and a file titled for another version would
+ // publish under this one, a copy-paste from the previous release being the
+ // obvious way in.
+ const firstIndex = lines.findIndex((line) => line.trim() !== '');
+ const title = firstIndex === -1 ? undefined : lines[firstIndex];
+ if (title !== `# ${version}`) {
+ fail(
+ `${relative} is titled ${JSON.stringify(title ?? '(no # heading)')} — it must be exactly "# ${version}", ` +
+ `since the release notes are published from this file under that version, ` +
+ `and anything above the title is discarded by the extractor.`,
+ );
+ }
+ // Headings alone are not notes. `## Highlights` under the title would satisfy
+ // a "has a non-title line" test while publishing an empty release.
+ const body = lines
+ .slice(firstIndex + 1)
+ .filter((line) => line.trim() !== '' && !line.trimStart().startsWith('#'));
+ if (body.length === 0) {
+ fail(
+ `${relative} has headings and nothing under them — write the ${version} highlights before cutting the release.`,
+ );
+ }
+ process.stdout.write(`changelog-version: ${relative} is present and filled in.\n`);
+}
+
+// ========================== seed ==========================
+
+/**
+ * Add the version's entry to the `Release Notes` group, positioned so the list
+ * stays newest-first even when a patch on an older line is added after a newer
+ * minor. Edited as text, not through a YAML round-trip, so the file keeps its
+ * license header, comments and formatting.
+ */
+function ensureMenuEntry(root, version) {
+ const menuPath = join(root, MENU_FILE);
+ const content = readFileSync(menuPath, 'utf8');
+ const sitePath = `/changelog/${version}`;
+ if (content.includes(`path: "${sitePath}"`)) return false;
+
+ const entry = [` - name: "${version}"`, ` path: "${sitePath}"`];
+ const lines = content.split('\n');
+ const groupAt = lines.findIndex((l) => l === ` - name: "${MENU_GROUP}"`);
+ if (groupAt === -1) {
+ const tail = ['', ` - name: "${MENU_GROUP}"`, ' catalog:', ...entry, ''];
+ writeFileSync(menuPath, `${content.replace(/\n*$/, '\n')}${tail.join('\n')}`);
+ return true;
+ }
+
+ const catalogAt = lines.findIndex((l, i) => i > groupAt && l === ' catalog:');
+ if (catalogAt === -1) fail(`${MENU_FILE}: the "${MENU_GROUP}" group has no catalog: list.`);
+ let groupEnd = catalogAt + 1;
+ while (groupEnd < lines.length && (lines[groupEnd].startsWith(' ') || lines[groupEnd].trim() === '')) groupEnd++;
+ while (groupEnd > catalogAt + 1 && lines[groupEnd - 1].trim() === '') groupEnd--;
+
+ // Sibling entries are `- name:` / `path:` pairs; each one's version is in its
+ // path, so the newer version goes above the first entry it outranks.
+ let insertAt = groupEnd;
+ for (let i = catalogAt + 1; i < groupEnd; i++) {
+ const match = lines[i].match(/^ {8}path: "\/changelog\/(\d+\.\d+\.\d+)"$/);
+ if (match && cmpVersion(version, match[1]) > 0) {
+ insertAt = i - 1;
+ break;
+ }
+ }
+ lines.splice(insertAt, 0, ...entry);
+ writeFileSync(menuPath, lines.join('\n'));
+ return true;
+}
+
+function cmdSeed(version, root) {
+ const relative = docPath(version);
+ const absolute = join(root, relative);
+ if (existsSync(absolute)) {
+ process.stdout.write(`changelog-version: ${relative} already exists — left as it is.\n`);
+ } else {
+ mkdirSync(join(root, DOC_DIR), { recursive: true });
+ writeFileSync(absolute, `# ${version}\n\n${STUB}\n`);
+ process.stdout.write(`changelog-version: wrote ${relative}\n`);
+ }
+ if (ensureMenuEntry(root, version)) {
+ process.stdout.write(`changelog-version: added the "${version}" entry to ${MENU_FILE}\n`);
+ }
+}
+
+// ========================== entry point ==========================
+
+const argv = process.argv.slice(2);
+const command = argv[0];
+const positional = [];
+let repoRoot = DEFAULT_ROOT;
+for (let i = 1; i < argv.length; i++) {
+ if (argv[i] === '--repo-root') {
+ const value = argv[++i];
+ if (value === undefined) fail('--repo-root needs a value.');
+ repoRoot = resolve(value);
+ } else if (argv[i].startsWith('--')) {
+ fail(`unknown option ${argv[i]}`);
+ } else {
+ positional.push(argv[i]);
+ }
+}
+if ((command === 'check' || command === 'seed') && !VERSION_RE.test(positional[0] ?? '')) {
+ fail(`usage: changelog-version.mjs ${command} <X.Y.Z> [--repo-root <dir>]`);
+}
+
+switch (command) {
+ case 'check':
+ cmdCheck(positional[0], repoRoot);
+ break;
+ case 'seed':
+ cmdSeed(positional[0], repoRoot);
+ break;
+ default:
+ process.stderr.write(
+ 'usage: changelog-version.mjs check <X.Y.Z> [--repo-root <dir>]\n' +
+ ' changelog-version.mjs seed <X.Y.Z> [--repo-root <dir>]\n',
+ );
+ process.exit(2);
+}
diff --git a/scripts/check-ui-i18n.mjs b/scripts/check-ui-i18n.mjs
index c6e45ea..823d2e4 100644
--- a/scripts/check-ui-i18n.mjs
+++ b/scripts/check-ui-i18n.mjs
@@ -57,8 +57,12 @@ const KEYPATH_RE = /keypath=(?:'([^']*)'|"([^"]*)")/g;
let failed = false;
const missing = new Map();
+/** Every source file concatenated — the orphan check below tests membership
+ * against this rather than re-parsing call shapes. */
+let allSource = '';
for (const file of walk(SRC)) {
const text = ' ' + readFileSync(file, 'utf8');
+ allSource += text;
for (const m of text.matchAll(CALL_RE)) {
const key = (m[1] ?? m[2]).replace(/\\'/g, "'").replace(/\\"/g, '"');
if (key.length === 0 || enKeys.has(key)) continue;
@@ -81,6 +85,24 @@ if (missing.size > 0) {
for (const [key, file] of missing) console.error(` ${JSON.stringify(key)} — ${file}`);
}
+// 3. A key left in en.json that no source file mentions. The parity check
+// below measures every other catalog AGAINST en.json, so an orphan in
+// en.json itself is invisible to it — it simply propagates: the key looks
+// "present everywhere" while nothing renders it. Membership is a plain
+// substring test over the source, not the t() patterns above, because a key
+// may be reached dynamically or split across lines; that keeps this a
+// dead-weight check and not a second, stricter usage rule.
+// REPORTS, does not fail: there is a standing backlog of ~80 such keys and
+// a substring test cannot see a key reached only from outside apps/ui/src
+// (a BFF-supplied label, a preset table). Blocking on it would stop CI on
+// work that did not cause it. Triage the list, then make this fail.
+const orphans = [...enKeys].filter((k) => k !== '_comment' && !allSource.includes(k));
+if (orphans.length > 0) {
+ console.warn(`⚠ ${orphans.length} key(s) in en.json that no file under apps/ui/src mentions (not failing):`);
+ for (const k of orphans.slice(0, 5)) console.warn(` orphan: ${JSON.stringify(k)}`);
+ if (orphans.length > 5) console.warn(` … and ${orphans.length - 5} more`);
+}
+
for (const f of readdirSync(LOCALES_DIR)) {
if (!f.endsWith('.json') || f === 'en.json') continue;
const keys = new Set(Object.keys(JSON.parse(readFileSync(join(LOCALES_DIR, f), 'utf8'))));
diff --git a/scripts/check-ui-promises.mjs b/scripts/check-ui-promises.mjs
new file mode 100644
index 0000000..2840d14
--- /dev/null
+++ b/scripts/check-ui-promises.mjs
@@ -0,0 +1,118 @@
+/*
+ * 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.
+ */
+
+// Shipped-promise gate. A disabled affordance labelled "later release" /
+// "coming soon" is a commitment the release cannot keep: it occupies UI
+// real estate, it survives into every locale, and it outlives whatever
+// note explained it. Ship the capability or ship nothing — never the
+// promise. (This is why the runtime-rule page's "restore · later
+// release" panel was deleted rather than reworded.)
+//
+// Two scan surfaces, chosen so an honest engineering note never trips
+// the gate:
+//
+// 1. en.json keys AND values. Every user-visible chrome string is a
+// catalog key (check-ui-i18n.mjs enforces that for literal t()),
+// so the source catalog is the one choke point every shipped
+// string passes through. Non-English catalogs are covered by
+// proxy: they are key-parity-gated against en.json, so a promise
+// cannot enter them without its English key existing here.
+// 2. The <template> block of each .vue, HTML comments stripped —
+// catching text hardcoded straight into markup, past t(). Script
+// blocks are deliberately NOT scanned: "not yet supported by OAP"
+// is a legitimate code comment, and a gate that fires on comments
+// gets disabled instead of obeyed.
+//
+// If a phrase below is genuinely the right thing to say about the
+// BACKEND rather than about Horizon's roadmap (e.g. an OAP capability
+// that does not exist), phrase it as a statement of fact about OAP
+// today — not as a promise about a Horizon release.
+
+import { readFileSync, readdirSync } from 'node:fs';
+import { join, relative } from 'node:path';
+
+const ROOT = new URL('..', import.meta.url).pathname;
+const SRC = join(ROOT, 'apps/ui/src');
+const EN = join(SRC, 'i18n/locales/en.json');
+
+const PROMISE_PATTERNS = [
+ /\blater release\b/i,
+ /\bfuture release\b/i,
+ /\bcoming soon\b/i,
+ /\bwill land\b/i,
+ /\bnot yet (?:implemented|supported|available)\b/i,
+ /\bstay tuned\b/i,
+ /\bwork in progress\b/i,
+];
+
+function firstMatch(text) {
+ for (const p of PROMISE_PATTERNS) {
+ const m = p.exec(text);
+ if (m) return m[0];
+ }
+ return null;
+}
+
+function* walk(dir) {
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
+ const p = join(dir, e.name);
+ if (e.isDirectory()) yield* walk(p);
+ else if (e.name.endsWith('.vue')) yield p;
+ }
+}
+
+/** The SFC root <template>, minus HTML comments. Nested `<template #slot>`
+ * tags sit inside it, so the outermost pair is the right span. */
+function rootTemplate(text) {
+ const open = text.indexOf('\n<template>');
+ const close = text.lastIndexOf('\n</template>');
+ if (open < 0 || close <= open) return '';
+ return text.slice(open, close).replace(/<!--[\s\S]*?-->/g, '');
+}
+
+const violations = [];
+
+const en = JSON.parse(readFileSync(EN, 'utf8'));
+for (const [key, value] of Object.entries(en)) {
+ if (key === '_comment') continue;
+ const hit = firstMatch(key) ?? firstMatch(String(value));
+ if (hit) violations.push([relative(ROOT, EN), hit, key]);
+}
+
+for (const file of walk(SRC)) {
+ const tpl = rootTemplate(readFileSync(file, 'utf8'));
+ if (!tpl) continue;
+ for (const line of tpl.split('\n')) {
+ const hit = firstMatch(line);
+ if (hit) violations.push([relative(ROOT, file), hit, line.trim()]);
+ }
+}
+
+if (violations.length > 0) {
+ console.error(`✗ ${violations.length} shipped "later release" promise(s) in the UI:`);
+ for (const [where, hit, context] of violations) {
+ console.error(` ${where}: ${JSON.stringify(hit)} — ${JSON.stringify(context.slice(0, 120))}`);
+ }
+ console.error(
+ ' Ship the capability or remove the affordance. Do not restate it as future work.',
+ );
+ process.exit(1);
+}
+
+console.log(
+ `✓ ui-promises OK: no "later release" affordance in ${Object.keys(en).length - 1} catalog keys or any .vue template`,
+);
diff --git a/scripts/release-finalize.sh b/scripts/release-finalize.sh
index 4f4ab9e..20090a1 100755
--- a/scripts/release-finalize.sh
+++ b/scripts/release-finalize.sh
@@ -49,8 +49,8 @@
#
# 4. Cut a GitHub release on tag v<v>, attaching the SAME voted bytes
# (src + bin tarballs + .asc + .sha512) that were just verified, with
-# the CHANGELOG section for <v> — read out of the tag, not the working
-# tree — as the body. On a release that already exists, every attached
+# the release's notes — docs/changelog/<v>.md read out of the tag, not out
+# of the working tree — as the body. On a release that already exists, every attached
# asset is compared against those verified bytes (size, then sha512) and
# only what is missing or different is (re-)uploaded: a matching FILENAME
# is not evidence that the bytes behind it are the voted ones.
@@ -402,21 +402,21 @@ SVN_AUTH_READY=false
# ========================== Step 7: GitHub release ==========================
note "Step 7 — GitHub release ${TAG}"
-# Extract the CHANGELOG section for this version as the release body, read out
-# of the TAG rather than the working tree — the body has to describe the bytes
-# being published, and the checkout this script runs from may have moved on
-# since the release commit. A GitHub Release body renders with GFM
-# hard-line-breaks, so every newline inside a paragraph becomes a literal <br>.
-# The committed CHANGELOG is written one physical line per paragraph / list item
-# for that reason; the helper joins any that were hard-wrapped anyway, as a
-# backstop — see scripts/changelog-release-notes.mjs.
+# The release body is this version's changelog file, read out of the TAG rather
+# than the working tree — the body has to describe the bytes being published,
+# and the checkout this script runs from may have moved on since the release
+# commit. A GitHub Release body renders with GFM hard-line-breaks, so every
+# newline inside a paragraph becomes a literal <br>. The committed changelog is
+# written one physical line per paragraph / list item for that reason; the
+# helper joins any that were hard-wrapped anyway, as a backstop — see
+# scripts/changelog-release-notes.mjs.
NOTES_FILE="${WORK_DIR}/release-notes.md"
-TAG_CHANGELOG="${WORK_DIR}/CHANGELOG.${TAG}.md"
-if ! git -C "${PROJECT_DIR}" show "${TAG}:CHANGELOG.md" > "${TAG_CHANGELOG}"; then
- err "Could not read CHANGELOG.md at tag ${TAG}."
+NOTES_SOURCE="${WORK_DIR}/changelog-${RELEASE_VERSION}.md"
+if ! git -C "${PROJECT_DIR}" show "${TAG}:docs/changelog/${RELEASE_VERSION}.md" > "${NOTES_SOURCE}" 2>/dev/null; then
+ err "Tag ${TAG} carries no docs/changelog/${RELEASE_VERSION}.md — cannot build the release notes."
exit 1
fi
-node "${SCRIPT_DIR}/changelog-release-notes.mjs" "${RELEASE_VERSION}" "${TAG_CHANGELOG}" > "${NOTES_FILE}"
+node "${SCRIPT_DIR}/changelog-release-notes.mjs" "${RELEASE_VERSION}" "${NOTES_SOURCE}" > "${NOTES_FILE}"
{
echo ""
echo "---"
@@ -493,7 +493,7 @@ if gh release view "${TAG}" --repo "${GH_REPO}" >/dev/null 2>&1; then
fi
fi
else
- echo "Release notes preview (CHANGELOG section for ${RELEASE_VERSION} at ${TAG}):"
+ echo "Release notes preview (docs/changelog/${RELEASE_VERSION}.md at ${TAG}):"
echo "------------------------------------------------------------"
cat "${NOTES_FILE}"
echo "------------------------------------------------------------"
diff --git a/scripts/release.sh b/scripts/release.sh
index 3571f88..cd701c2 100755
--- a/scripts/release.sh
+++ b/scripts/release.sh
@@ -46,11 +46,6 @@ REPO_BRANCH="${HORIZON_RELEASE_BRANCH:-main}"
SVN_DEV_URL="https://dist.apache.org/repos/dist/dev/skywalking/horizon-ui"
WORK_DIR="${SCRIPT_DIR}/.release-work"
CLONE_DIR="${WORK_DIR}/skywalking-horizon-ui"
-# The stub this script writes into the next version's section, and the exact
-# line the pre-tag gate rejects. One definition so the writer and the checker
-# cannot drift apart — a gate looking for a stub nobody writes anymore passes
-# every release silently.
-CHANGELOG_PLACEHOLDER='(In development — fill in highlights here before cutting the release.)'
# ========================== Helpers ==========================
@@ -137,10 +132,12 @@ if [ "${CURRENT_VERSION}" = "${RELEASE_VERSION}" ]; then
err "main should carry the dev-suffixed version between releases — bump it before running this script."
exit 1
fi
-MAJOR=$(echo "$RELEASE_VERSION" | cut -d. -f1)
-MINOR=$(echo "$RELEASE_VERSION" | cut -d. -f2)
-NEXT_MINOR=$((MINOR + 1))
-NEXT_RELEASE_VERSION="${MAJOR}.${NEXT_MINOR}.0"
+# Proposed next version: the next minor. Computed in a pipeline on purpose —
+# nothing derived from the version may survive as a variable across the prompt
+# below, where the operator can replace RELEASE_VERSION. Anything split out up
+# here (MAJOR / MINOR / …) would still describe the rejected version and be
+# read as current further down.
+NEXT_RELEASE_VERSION=$(echo "$RELEASE_VERSION" | awk -F. '{ printf "%d.%d.0", $1, $2 + 1 }')
echo "Current (in package.json): ${CURRENT_VERSION}"
echo "Release: ${RELEASE_VERSION}"
@@ -210,9 +207,9 @@ echo "Code markers all at ${CURRENT_VERSION}; container docs are release-check c
# ========================== Step 5: Release-file check ==========================
note "Step 5 — Release-file check"
-# The CHANGELOG is NOT checked here: what ships is the freshly cloned tree of
-# Step 7, not this one, so its section is gated in Step 8b against the exact
-# commit being tagged.
+# The changelog file is NOT checked here: what ships is the freshly cloned tree
+# of Step 7, not this one, so it is gated in Step 8b against the exact commit
+# being tagged.
# Make sure LICENSE / NOTICE exist at the repo root (they ship in src+bin tarballs).
for f in LICENSE NOTICE HEADER; do
@@ -296,36 +293,13 @@ fi
# main. So this is the only gate the release artifact ever gets.
note "Step 8b — Run the full gate battery on the release commit (pre-tag)"
-# The CHANGELOG section is part of the release artifact — it ships in both
+# The changelog file is part of the release artifact — it ships in both
# tarballs, the vote email links it at ${TAG}, and release-finalize.sh reads
# the GitHub release body out of the tag — so it is gated on the clone, on the
# commit about to be tagged. Checking the caller's tree instead can pass here
-# while the tagged tree carries no section at all, or still carries the stub.
+# while the tagged tree carries no notes at all, or still carries the stub.
# Cheapest gate in the battery, so it runs first.
-# -Fx, not a regex: the dots in a version are wildcards, so `## 1.0.0` as a
-# pattern also accepts a heading like `## 1x0x0`.
-if ! grep -Fxq "## ${RELEASE_VERSION}" "${CLONE_DIR}/CHANGELOG.md"; then
- err "CHANGELOG.md on the release commit has no '## ${RELEASE_VERSION}' section heading."
- err "Land that section on ${REPO_BRANCH} first — this clone is what gets tagged and shipped."
- exit 1
-fi
-# Reject the placeholder body. Operators MUST fill the section in before
-# casting a vote — a stub CHANGELOG line in a release tarball is a
-# review smell.
-#
-# Matched as a WHOLE LINE against the exact stub, not as a substring: the
-# CHANGELOG legitimately describes this very gate, quoting the stub inside a
-# prose bullet, and a substring match reads its own documentation as an
-# unfilled section and aborts the release.
-if awk -v v="${RELEASE_VERSION}" '
- $0 == "## " v { in_sec=1; next }
- in_sec && /^## / { in_sec=0 }
- in_sec { print }
-' "${CLONE_DIR}/CHANGELOG.md" | grep -Fxq "${CHANGELOG_PLACEHOLDER}"; then
- err "CHANGELOG.md ${RELEASE_VERSION} section on the release commit still contains the '(In development …)' placeholder. Fill it in on ${REPO_BRANCH}."
- exit 1
-fi
-echo "CHANGELOG.md on the release commit has a non-placeholder section for ${RELEASE_VERSION}."
+node "${CLONE_DIR}/scripts/changelog-version.mjs" check "${RELEASE_VERSION}" --repo-root "${CLONE_DIR}"
# Same reason, and `pnpm license:check` cannot stand in for it: HEADER is in
# the license-eye paths-ignore list, so nothing else looks at the copy that
@@ -371,6 +345,12 @@ git -C "${CLONE_DIR}" archive --format=tar --prefix="${SRC_STAGE_NAME}/" "${TAG}
| tar -C "${WORK_DIR}" -xf -
# Drop the CI publish workflow from the source release (ASF-infra specific).
rm -f "${WORK_DIR}/${SRC_STAGE_NAME}/.github/workflows/publish-image.yaml"
+# ASF convention puts a CHANGELOG at the ROOT of a release, where a reviewer
+# looks for it. The repo has no such file — notes live one file per version
+# under docs/changelog/ — so this release's file is copied there. A packaging
+# artifact only; it is never committed to git.
+cp "${WORK_DIR}/${SRC_STAGE_NAME}/docs/changelog/${RELEASE_VERSION}.md" \
+ "${WORK_DIR}/${SRC_STAGE_NAME}/CHANGELOG.md"
tar -C "${WORK_DIR}" -czf "${SRC_TAR}" "${SRC_STAGE_NAME}"
echo "Source tarball: ${SRC_TAR}"
@@ -394,7 +374,8 @@ node "${CLONE_DIR}/scripts/check-dist-licenses.mjs"
BIN_STAGE="${WORK_DIR}/${PRODUCT_NAME}-${RELEASE_VERSION}-bin"
rm -rf "${BIN_STAGE}"
cp -R "${CLONE_DIR}/dist" "${BIN_STAGE}"
-cp "${CLONE_DIR}/CHANGELOG.md" "${BIN_STAGE}/CHANGELOG.md"
+# Same packaging convention as the source tarball.
+cp "${CLONE_DIR}/docs/changelog/${RELEASE_VERSION}.md" "${BIN_STAGE}/CHANGELOG.md"
cp "${CLONE_DIR}/README.md" "${BIN_STAGE}/README.md"
# dist/LICENSE and dist/NOTICE were just generated by the collector and
# are the BINARY-flavored versions (Apache-2.0 + bundled-dep summary +
@@ -529,7 +510,7 @@ version ${RELEASE_VERSION}.
Release notes:
- * https://github.com/apache/skywalking-horizon-ui/blob/${TAG}/CHANGELOG.md
+ * https://github.com/apache/skywalking-horizon-ui/blob/${TAG}/docs/changelog/${RELEASE_VERSION}.md
Release Candidate:
@@ -616,18 +597,11 @@ rm apps/bff/src/server.ts.bak
# commit just bumped them). They stay there — docs always reference the
# last released tag, not the in-flight dev version.
-# Rotate CHANGELOG: insert a fresh placeholder at the top.
-node -e "
-const fs = require('fs');
-const path = 'CHANGELOG.md';
-const txt = fs.readFileSync(path, 'utf8');
-const insertion = '## ${NEXT_RELEASE_VERSION}\n\n${CHANGELOG_PLACEHOLDER}\n\n';
-// Insert above the first '## <prev>' heading.
-const out = txt.replace(/^## /m, insertion + '## ');
-fs.writeFileSync(path, out);
-"
+# Open the next cycle: an empty docs/changelog/${NEXT_RELEASE_VERSION}.md plus
+# its docs/menu.yml entry. The released version's file is not touched.
+node "${CLONE_DIR}/scripts/changelog-version.mjs" seed "${NEXT_RELEASE_VERSION}" --repo-root "${CLONE_DIR}"
-git add package.json packages/*/package.json apps/*/package.json apps/bff/src/server.ts CHANGELOG.md
+git add package.json packages/*/package.json apps/*/package.json apps/bff/src/server.ts docs/changelog docs/menu.yml
git commit -m "Prepare next release ${NEXT_DEV_VERSION}"
git push origin "${RELEASE_BRANCH_NAME}"
@@ -640,7 +614,8 @@ Release branch for ${RELEASE_VERSION}. Two commits:
\`${RELEASE_VERSION}\`. Tagged \`${TAG}\` (the release-candidate commit the
vote runs against).
2. \`Prepare next release ${NEXT_DEV_VERSION}\` — bumps every marker to
- \`${NEXT_DEV_VERSION}\` and rotates CHANGELOG for the next cycle.
+ \`${NEXT_DEV_VERSION}\` and seeds \`docs/changelog/${NEXT_RELEASE_VERSION}.md\`
+ (with its \`docs/menu.yml\` entry) for the next cycle.
Merge after the [VOTE] passes so \`${REPO_BRANCH}\` returns to a \`-dev\`
version with the release in its history. The \`${TAG}\` tag is immutable and