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 fc7ee98  release: 0.5.0 prep — admin/operate polish, dep security 
bumps, release-branch flow
fc7ee98 is described below

commit fc7ee98dd261f220e2edfa6514650f0f895243a5
Author: Wu Sheng <[email protected]>
AuthorDate: Thu May 21 08:08:39 2026 +0800

    release: 0.5.0 prep — admin/operate polish, dep security bumps, 
release-branch flow
    
    - Sidebar: operate layer dashboards order by menu placement (not landing
      priority); DSL Management ordered before Live debugger.
    - Admin template-diff modal: 80vw side-by-side with labelled bundled/OAP
      column headers + explanation; reset prompt on one line, red key, no page
      scroll (Modal gains width + fitBody). Force Monaco side-by-side.
    - Layers admin: in-page search of the layers rail.
    - Users admin: label "Active (24h)" / "Last seen" as per-node (seen-cache is
      process-local) and surface the serving node; align hint icon to first 
line.
    - Live debugger OAL: fix broken "OAL catalog" link route + style it; shorten
      the capturing session id in the state pill.
    - Deps: dompurify >=3.3.2 (override) and @fastify/static ^9.1.1 — clears all
      known prod advisories (pnpm audit --prod now clean).
    - release.sh: cut the version-strip + tag AND the next-dev bump on one
      release branch with a single PR, instead of pushing the release commit
      straight to main.
    - CHANGELOG: fill 0.5.0 operator-facing highlights.
---
 CHANGELOG.md                                       | 55 +++++++++++-
 apps/bff/package.json                              |  2 +-
 apps/bff/src/http/admin/users.ts                   |  8 ++
 apps/ui/src/api/scopes/admin-users.ts              |  4 +
 .../features/admin/_shared/TemplateDiffModal.vue   | 97 ++++++++++++++++++----
 .../admin/layer-templates/LayerDashboardsAdmin.vue | 52 +++++++++++-
 .../ui/src/features/admin/users/UsersAdminView.vue | 41 ++++++++-
 apps/ui/src/features/operate/_shared/Modal.vue     | 39 +++++++--
 .../ui/src/features/operate/_shared/MonacoDiff.vue |  5 ++
 .../src/features/operate/live-debug/DebugOal.vue   | 22 ++++-
 .../src/features/operate/live-debug/DebugView.vue  |  6 +-
 apps/ui/src/shell/AppSidebar.vue                   | 30 ++++---
 package.json                                       |  5 +-
 pnpm-lock.yaml                                     | 62 +++++---------
 scripts/release.sh                                 | 56 ++++++++++---
 15 files changed, 386 insertions(+), 98 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8c9f8af..67953ef 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,7 +16,60 @@ 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.
 
-Fill in screen-facing highlights here before tagging.
+### 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)
+  and highlights the selected frame across all four profilers.
+- 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.
+- The operate sidebar now leads with a single **Platform monitoring** group
+  (cluster status, data retention, OAP configuration) above the per-layer
+  self-observability dashboards.
+
+### 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).
+- 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
 
diff --git a/apps/bff/package.json b/apps/bff/package.json
index 6918656..a18b9ed 100644
--- a/apps/bff/package.json
+++ b/apps/bff/package.json
@@ -19,7 +19,7 @@
   },
   "dependencies": {
     "@fastify/cookie": "^11.0.1",
-    "@fastify/static": "^8.0.2",
+    "@fastify/static": "^9.1.1",
     "@skywalking-horizon-ui/api-client": "workspace:*",
     "argon2": "^0.41.1",
     "chokidar": "^4.0.1",
diff --git a/apps/bff/src/http/admin/users.ts b/apps/bff/src/http/admin/users.ts
index 2cd6c90..9e44299 100644
--- a/apps/bff/src/http/admin/users.ts
+++ b/apps/bff/src/http/admin/users.ts
@@ -28,6 +28,7 @@
  * comes from the seen cache when available; otherwise null ("never").
  */
 
+import { hostname } from 'node:os';
 import type { FastifyInstance } from 'fastify';
 import type { ConfigSource } from '../../config/loader.js';
 import type { UserSeenCache, SeenSource } from '../../user/seen-cache.js';
@@ -57,6 +58,12 @@ export interface AdminUserRow {
 export interface AdminUsersBody {
   generatedAt: number;
   backend: 'local' | 'ldap';
+  /** Host that served this request — pod name under k8s. The seen-cache
+   *  (last-seen + active-24h + the LDAP listing) is process-local, so
+   *  these numbers reflect only this node. In a multi-replica deploy the
+   *  UI surfaces this so operators read the counts as per-node, not
+   *  cluster-wide. */
+  node: string;
   rows: AdminUserRow[];
   counts: {
     total: number;
@@ -118,6 +125,7 @@ export function registerAdminUsersRoute(app: 
FastifyInstance, deps: UsersRouteDe
     const body: AdminUsersBody = {
       generatedAt: now,
       backend: cfg.auth.backend,
+      node: hostname(),
       rows,
       counts,
     };
diff --git a/apps/ui/src/api/scopes/admin-users.ts 
b/apps/ui/src/api/scopes/admin-users.ts
index f2fe464..c2e8c5f 100644
--- a/apps/ui/src/api/scopes/admin-users.ts
+++ b/apps/ui/src/api/scopes/admin-users.ts
@@ -30,6 +30,10 @@ export interface AdminUserRow {
 export interface AdminUsersResponse {
   generatedAt: number;
   backend: 'local' | 'ldap';
+  /** Host that served this request (pod name under k8s). The seen-cache
+   *  data (last-seen, active-24h, LDAP listing) is process-local, so the
+   *  UI labels those as reflecting this node only. */
+  node: string;
   rows: AdminUserRow[];
   counts: {
     total: number;
diff --git a/apps/ui/src/features/admin/_shared/TemplateDiffModal.vue 
b/apps/ui/src/features/admin/_shared/TemplateDiffModal.vue
index b390ed4..e87b5e2 100644
--- a/apps/ui/src/features/admin/_shared/TemplateDiffModal.vue
+++ b/apps/ui/src/features/admin/_shared/TemplateDiffModal.vue
@@ -126,13 +126,29 @@ async function onReset(): Promise<void> {
 </script>
 
 <template>
-  <Modal :open="open" :title="`Template diff — ${name}`" 
@close="emit('close')">
+  <Modal :open="open" :title="`Template diff — ${name}`" width="80vw" fit-body 
@close="emit('close')">
     <div v-if="loading" class="tdm__loading">Loading sync status…</div>
     <div v-else-if="loadError" class="tdm__err">{{ loadError }}</div>
     <template v-else-if="row">
-      <div class="tdm__legend">
-        <span class="tdm__legend-l">Left: <strong>bundled</strong> (the BFF's 
seed JSON)</span>
-        <span class="tdm__legend-r">Right: <strong>OAP-stored</strong> 
(operator-edited)</span>
+      <p class="tdm__about">
+        This is the dashboard definition for <code>{{ name }}</code>. It 
drives the rendered
+        page for this scope — the <strong>widget layout</strong>, each widget's
+        <strong>metric (MQE) expression</strong>, and which <strong>components 
/ tabs</strong>
+        appear. A difference means the template stored on OAP was edited (by 
an operator or
+        another UI) and no longer matches the JSON bundled in this build; the 
live UI follows
+        the right (OAP-stored) side until you reset.
+      </p>
+      <div class="tdm__cols">
+        <div class="tdm__col tdm__col--l">
+          <span class="tdm__col-side">◀ LEFT</span>
+          <span class="tdm__col-name">bundled</span>
+          <span class="tdm__col-note">the build's seed JSON (source of 
truth)</span>
+        </div>
+        <div class="tdm__col tdm__col--r">
+          <span class="tdm__col-side">RIGHT ▶</span>
+          <span class="tdm__col-name">OAP-stored</span>
+          <span class="tdm__col-note">what's live now (operator-edited)</span>
+        </div>
       </div>
       <div class="tdm__diff">
         <MonacoDiff :original="bundledPretty" :modified="remotePretty" 
language="json" />
@@ -146,7 +162,7 @@ async function onReset(): Promise<void> {
           considered the source of truth after this action.
         </p>
         <label class="tdm__reset-label">
-          Type <code>{{ confirmKey }}</code> to arm the Reset button:
+          <span>Type <code class="tdm__reset-key">{{ confirmKey }}</code> to 
arm the Reset button:</span>
           <input
             v-model="typed"
             type="text"
@@ -183,23 +199,61 @@ async function onReset(): Promise<void> {
 .tdm__err {
   color: var(--rr-danger, #c0392b);
 }
-.tdm__legend {
-  display: flex;
-  justify-content: space-between;
-  padding: 6px 8px;
-  font-size: 11px;
+.tdm__about {
+  margin: 0 0 10px;
+  padding: 8px 10px;
+  font-size: 12px;
+  line-height: 1.55;
   color: var(--rr-ink2);
-  border-bottom: 1px solid var(--rr-border, #2a2f38);
+  background: var(--rr-bg, rgba(255, 255, 255, 0.02));
+  border: 1px solid var(--rr-border, #2a2f38);
+  border-radius: var(--rr-radius, 6px);
+}
+.tdm__about code {
+  font-family: var(--rr-font-mono, ui-monospace, monospace);
+}
+/* Two column headers aligned 50/50 over the side-by-side diff panes, so
+ * it's unambiguous which side is bundled vs OAP-stored. */
+.tdm__cols {
+  display: flex;
+  border: 1px solid var(--rr-border, #2a2f38);
+  border-bottom: none;
+  border-top-left-radius: var(--rr-radius, 6px);
+  border-top-right-radius: var(--rr-radius, 6px);
+  overflow: hidden;
 }
-.tdm__legend-l { color: var(--sw-text-muted, #8a93a0); }
-.tdm__legend-r { color: var(--sw-warn, #b88500); }
+.tdm__col {
+  flex: 1 1 50%;
+  min-width: 0;
+  display: flex;
+  align-items: baseline;
+  gap: 8px;
+  padding: 7px 12px;
+  font-size: 11.5px;
+  background: var(--rr-bg3, rgba(255, 255, 255, 0.03));
+}
+.tdm__col--l { border-right: 1px solid var(--rr-border, #2a2f38); }
+.tdm__col--r { justify-content: flex-start; }
+.tdm__col-side {
+  font-family: var(--rr-font-mono, ui-monospace, monospace);
+  font-size: 10px;
+  letter-spacing: 0.08em;
+  color: var(--rr-dim, #6b7280);
+}
+.tdm__col--l .tdm__col-name { color: var(--sw-text-muted, #8a93a0); 
font-weight: 600; }
+.tdm__col--r .tdm__col-name { color: var(--sw-warn, #b88500); font-weight: 
600; }
+.tdm__col-name { font-family: var(--rr-font-mono, ui-monospace, monospace); }
+.tdm__col-note { color: var(--rr-ink2); font-size: 11px; }
 .tdm__diff {
-  height: 50vh;
-  min-height: 400px;
+  /* Absorb the leftover height inside the fit-mode modal body and scroll
+   * internally — keeps the popout itself free of a vertical scrollbar. */
+  flex: 1;
+  min-height: 0;
   border-bottom: 1px solid var(--rr-border, #2a2f38);
 }
 .tdm__reset {
-  padding: 14px 6px 4px;
+  flex: 0 0 auto;
+  padding: 12px 6px 2px;
 }
 .tdm__reset h4 {
   margin: 0 0 6px;
@@ -215,10 +269,17 @@ async function onReset(): Promise<void> {
 }
 .tdm__reset-label {
   display: flex;
-  flex-direction: column;
-  gap: 6px;
+  flex-direction: row;
+  align-items: center;
+  gap: 8px;
   font-size: 12px;
   color: var(--rr-ink2);
+  white-space: nowrap;
+}
+.tdm__reset-key {
+  color: var(--sw-danger, #c0392b);
+  font-family: var(--rr-font-mono, ui-monospace, monospace);
+  font-weight: 600;
 }
 .tdm__reset-input {
   font-family: var(--rr-font-mono, ui-monospace, monospace);
diff --git 
a/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue 
b/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
index 874842c..6e88db9 100644
--- a/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
+++ b/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
@@ -102,6 +102,15 @@ const saveMsg = ref<string | null>(null);
  *  editor can claim the full width. The toggle in the rail header
  *  flips this; layer switching still works via dot click. */
 const layerListOpen = ref(true);
+/** Free-text filter for the layers rail — matches alias or key. */
+const layerSearch = ref('');
+const filteredTemplates = computed<AdminLayerTemplate[]>(() => {
+  const q = layerSearch.value.trim().toLowerCase();
+  if (!q) return templates.value;
+  return templates.value.filter(
+    (t) => (t.alias ?? '').toLowerCase().includes(q) || 
t.key.toLowerCase().includes(q),
+  );
+});
 
 /** Working copy — reactively edited. Diffs against `templates` to drive
  *  the Save / Reset state. */
@@ -922,12 +931,24 @@ const namingTest = computed<NamingTestResult>(() => {
           </button>
           <h4 v-if="layerListOpen">Layers</h4>
           <span v-if="layerListOpen" class="sub">
-            {{ templates.length }} template{{ templates.length === 1 ? '' : 
's' }}
+            {{ layerSearch.trim()
+              ? `${filteredTemplates.length} / ${templates.length}`
+              : `${templates.length} template${templates.length === 1 ? '' : 
's'}` }}
           </span>
         </div>
         <template v-if="layerListOpen">
+          <div class="list-search">
+            <input
+              v-model="layerSearch"
+              type="text"
+              class="list-search-input"
+              placeholder="Search layers…"
+              autocomplete="off"
+              spellcheck="false"
+            />
+          </div>
           <button
-            v-for="t in templates"
+            v-for="t in filteredTemplates"
             :key="t.key"
             class="layer-row"
             :class="{ active: selectedKey === t.key }"
@@ -937,6 +958,9 @@ const namingTest = computed<NamingTestResult>(() => {
             <span class="name">{{ t.alias || t.key }}</span>
             <TemplateStatusBadge 
:status="sync.badgeFor(`horizon.layer.${t.key}`)" />
           </button>
+          <p v-if="filteredTemplates.length === 0" class="list-empty">
+            No layers match “{{ layerSearch }}”.
+          </p>
         </template>
         <!-- Collapsed mode shows just colored dots for navigation; click
              a dot to switch layers without expanding. -->
@@ -2084,6 +2108,30 @@ const namingTest = computed<NamingTestResult>(() => {
 .list-head .sub {
   font-size: 10px;
   color: var(--sw-fg-3);
+  margin-left: auto;
+}
+.list-search {
+  padding: 0 10px 8px;
+}
+.list-search-input {
+  width: 100%;
+  box-sizing: border-box;
+  background: var(--sw-bg-1);
+  border: 1px solid var(--sw-line-2);
+  border-radius: 5px;
+  color: var(--sw-fg-0);
+  font: inherit;
+  font-size: 11.5px;
+  padding: 5px 8px;
+}
+.list-search-input:focus {
+  outline: none;
+  border-color: var(--sw-accent);
+}
+.list-empty {
+  padding: 8px 10px;
+  font-size: 11px;
+  color: var(--sw-fg-3);
 }
 .layer-row {
   display: flex;
diff --git a/apps/ui/src/features/admin/users/UsersAdminView.vue 
b/apps/ui/src/features/admin/users/UsersAdminView.vue
index c3fe3e4..0172bf3 100644
--- a/apps/ui/src/features/admin/users/UsersAdminView.vue
+++ b/apps/ui/src/features/admin/users/UsersAdminView.vue
@@ -137,10 +137,19 @@ function rolePill(role: string): string {
           <div class="kpi-value">{{ data.counts.local }}</div>
         </div>
         <div class="kpi-card">
-          <div class="kpi-label">Active (24h)</div>
+          <div
+            class="kpi-label"
+            title="Last-seen activity is tracked in each BFF replica's own 
memory and is NOT shared across the cluster. This count reflects only the node 
that served this page."
+          >
+            Active (24h) <span class="kpi-scope">· this node</span>
+          </div>
           <div class="kpi-value ok">{{ data.counts.activeLast24h }}</div>
         </div>
       </div>
+      <p class="node-note">
+        Last-seen &amp; Active (24h) are tracked per BFF node (in-memory, not 
cluster-shared) —
+        served by <code>{{ data.node }}</code>. In a multi-replica deploy 
these reflect this node only.
+      </p>
 
       <!-- Users table -->
       <section class="sw-card">
@@ -171,7 +180,7 @@ function rolePill(role: string): string {
               <th>Username</th>
               <th>Source</th>
               <th>Roles</th>
-              <th>Last seen</th>
+              <th title="Per-node: tracked in this BFF replica's memory only, 
not shared across the cluster.">Last seen <span class="th-scope">· this 
node</span></th>
               <th>IP</th>
               <th>Note</th>
             </tr>
@@ -262,8 +271,11 @@ function rolePill(role: string): string {
   border-radius: 6px;
   font-size: 11.5px;
   margin-bottom: 14px;
-  align-items: start;
+  /* Align the leading icon to the first line of the body text (the two
+   * differ in size/weight, so `start` left them on different baselines). */
+  align-items: baseline;
 }
+.hint-body { line-height: 1.55; }
 .hint-info {
   background: rgba(56,189,248,0.06);
   border: 1px solid rgba(56,189,248,0.3);
@@ -291,6 +303,29 @@ function rolePill(role: string): string {
   letter-spacing: 0.06em;
   color: var(--sw-fg-3);
 }
+.kpi-scope {
+  text-transform: none;
+  letter-spacing: 0;
+  color: var(--sw-warn);
+  cursor: help;
+}
+.node-note {
+  margin: -4px 0 14px;
+  font-size: 11px;
+  line-height: 1.5;
+  color: var(--sw-fg-3);
+}
+.node-note code {
+  font-family: var(--sw-mono);
+  color: var(--sw-fg-1);
+}
+.th-scope {
+  text-transform: none;
+  letter-spacing: 0;
+  font-weight: 400;
+  color: var(--sw-warn);
+  cursor: help;
+}
 .kpi-value {
   font-size: 22px;
   font-weight: 700;
diff --git a/apps/ui/src/features/operate/_shared/Modal.vue 
b/apps/ui/src/features/operate/_shared/Modal.vue
index acec4cb..f6db00f 100644
--- a/apps/ui/src/features/operate/_shared/Modal.vue
+++ b/apps/ui/src/features/operate/_shared/Modal.vue
@@ -17,10 +17,21 @@
 <script setup lang="ts">
 import { onMounted, onBeforeUnmount } from 'vue';
 
-const props = defineProps<{
-  open: boolean;
-  title: string;
-}>();
+const props = withDefaults(
+  defineProps<{
+    open: boolean;
+    title: string;
+    /** Panel width — any CSS length. Defaults to the narrow form dialog;
+     *  wide surfaces (side-by-side diffs) pass e.g. `80vw`. */
+    width?: string;
+    /** When true the body becomes a flex column with no scroll of its
+     *  own — a child marked `flex: 1` (e.g. a diff editor) absorbs the
+     *  leftover height and scrolls internally, so the popout never grows
+     *  a vertical scrollbar. */
+    fitBody?: boolean;
+  }>(),
+  { width: '520px', fitBody: false },
+);
 
 const emit = defineEmits<{ close: [] }>();
 
@@ -35,7 +46,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', 
onKey));
 <template>
   <Teleport to="body">
     <div v-if="open" class="modal" role="dialog" aria-modal="true" 
@click.self="emit('close')">
-      <div class="modal__panel">
+      <div class="modal__panel" :class="{ 'modal__panel--fit': fitBody }" 
:style="{ width: props.width }">
         <header class="modal__header">
           <span class="modal__title">{{ title }}</span>
           <button
@@ -47,7 +58,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', 
onKey));
             ×
           </button>
         </header>
-        <div class="modal__body"><slot /></div>
+        <div class="modal__body" :class="{ 'modal__body--fit': fitBody 
}"><slot /></div>
         <footer v-if="$slots.footer" class="modal__footer"><slot name="footer" 
/></footer>
       </div>
     </div>
@@ -66,6 +77,8 @@ onBeforeUnmount(() => window.removeEventListener('keydown', 
onKey));
 }
 
 .modal__panel {
+  /* width comes from the `width` prop (inline style); this is the
+   * fallback for any consumer that doesn't pass one. */
   width: 520px;
   max-width: calc(100vw - 32px);
   background: var(--rr-bg2);
@@ -76,6 +89,11 @@ onBeforeUnmount(() => window.removeEventListener('keydown', 
onKey));
   max-height: calc(100vh - 64px);
   overflow: hidden;
 }
+/* Fit mode: take a concrete height so the flex body can fill it and the
+ * diff child can claim the slack. */
+.modal__panel--fit {
+  height: calc(100vh - 64px);
+}
 
 .modal__header {
   display: flex;
@@ -112,6 +130,15 @@ onBeforeUnmount(() => 
window.removeEventListener('keydown', onKey));
   font-size: 13px;
   color: var(--rr-ink);
 }
+/* Fit mode: fill the panel height, no own scroll — a `flex: 1` child
+ * (e.g. the diff editor) takes the slack and scrolls internally. */
+.modal__body--fit {
+  flex: 1;
+  min-height: 0;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+}
 
 .modal__footer {
   display: flex;
diff --git a/apps/ui/src/features/operate/_shared/MonacoDiff.vue 
b/apps/ui/src/features/operate/_shared/MonacoDiff.vue
index 678de0a..67f0993 100644
--- a/apps/ui/src/features/operate/_shared/MonacoDiff.vue
+++ b/apps/ui/src/features/operate/_shared/MonacoDiff.vue
@@ -52,6 +52,11 @@ onMounted(() => {
     fontSize: 13,
     minimap: { enabled: false },
     renderSideBySide: true,
+    // Monaco collapses to a single inline column when the editor is
+    // narrower than ~900px (default). That hides the "original" (left)
+    // side entirely. Keep side-by-side regardless of width so the
+    // bundled-vs-remote comparison always shows both columns.
+    useInlineViewWhenSpaceIsLimited: false,
     readOnly: true,
     originalEditable: false,
   });
diff --git a/apps/ui/src/features/operate/live-debug/DebugOal.vue 
b/apps/ui/src/features/operate/live-debug/DebugOal.vue
index e2f7ba9..3196d19 100644
--- a/apps/ui/src/features/operate/live-debug/DebugOal.vue
+++ b/apps/ui/src/features/operate/live-debug/DebugOal.vue
@@ -479,7 +479,7 @@ const allFolded = computed<boolean>(
         OAL source class<span v-if="sources.length !== 1">es</span> registered
         across {{ files.length }} <code>.oal</code> file<span 
v-if="files.length !== 1">s</span>.
         Browse them in
-        <router-link to="/oal" class="oal__link">OAL catalog</router-link>
+        <router-link to="/operate/oal" class="oal__link">OAL 
catalog</router-link>
         — every <code>metric = from(Source…)</code> line has a green ▶ that
         deep-links here with the picker pre-filled.
       </p>
@@ -688,6 +688,26 @@ const allFolded = computed<boolean>(
   font-size: 12px;
   margin: 0;
 }
+.oal__hint {
+  font-size: 12px;
+  line-height: 1.55;
+  color: var(--rr-ink2);
+  margin: 0;
+}
+.oal__hint code {
+  font-family: var(--rr-font-mono);
+  color: var(--rr-ink);
+}
+/* Inline accent link — was unstyled and fell back to the browser default
+ * link color, which clashes with the dark theme. */
+.oal__link {
+  color: var(--rr-accent, var(--sw-accent, #38bdf8));
+  text-decoration: none;
+  font-weight: 600;
+}
+.oal__link:hover {
+  text-decoration: underline;
+}
 
 .oal__empty {
   padding: 14px;
diff --git a/apps/ui/src/features/operate/live-debug/DebugView.vue 
b/apps/ui/src/features/operate/live-debug/DebugView.vue
index e47eb19..936bf47 100644
--- a/apps/ui/src/features/operate/live-debug/DebugView.vue
+++ b/apps/ui/src/features/operate/live-debug/DebugView.vue
@@ -147,7 +147,11 @@ function nodeStatusTone(status: NodeSlice['status']): 'ok' 
| 'info' | 'warn' | '
         >
           {{ dbg.state.value }}
         </Pill>
-        <code v-if="dbg.sessionId.value" class="dv__sid">{{ 
dbg.sessionId.value }}</code>
+        <code
+          v-if="dbg.sessionId.value"
+          class="dv__sid"
+          :title="dbg.sessionId.value"
+        >{{ dbg.sessionId.value.slice(0, 8) }}…</code>
       </span>
     </header>
 
diff --git a/apps/ui/src/shell/AppSidebar.vue b/apps/ui/src/shell/AppSidebar.vue
index 532ee38..884d3d8 100644
--- a/apps/ui/src/shell/AppSidebar.vue
+++ b/apps/ui/src/shell/AppSidebar.vue
@@ -115,11 +115,17 @@ function bucket(rows: SidebarLayer[]): SidebarEntry[] {
   }
   return out;
 }
+// Public layers mirror the Overview/landing order (landing.priority) so
+// the two surfaces stay in lockstep.
 const publicLayers = computed(() =>
   orderedLayers.value.filter((L) => L.visibility !== 'operate'),
 );
+// Operate (Platform monitoring) layers follow the sidebar/menu placement
+// — the BFF catalog order — NOT landing.priority. Landing priority is an
+// Overview-page concept the operator can edit; it must not reorder the
+// operate sidebar. `availableLayers` preserves the menu order as returned.
 const operateLayers = computed(() =>
-  orderedLayers.value.filter((L) => L.visibility === 'operate'),
+  availableLayers.value.filter((L) => L.visibility === 'operate'),
 );
 const sidebarEntries = computed<SidebarEntry[]>(() => 
bucket(publicLayers.value));
 
@@ -202,17 +208,6 @@ const sections: NavSection[] = [
     kicker: 'Operate',
     links: [
       { icon: 'alert', label: 'Alerting rules', to: '/operate/alerting-rules', 
verb: 'alarm-rule:read' },
-      {
-        icon: 'flame',
-        label: 'Live debugger',
-        to: '/operate/live-debug',
-        verb: 'live-debug:read',
-        // Match the tab variants only; the history sibling at
-        // /operate/live-debug/history must NOT highlight this row.
-        activeWhen: (p) => p === '/operate/live-debug' || 
/^\/operate\/live-debug\/(mal|lal|oal)(\/|$)/.test(p),
-      },
-      { icon: 'event', label: 'Capture history', to: 
'/operate/live-debug/history', verb: 'live-debug:read' },
-      { icon: 'metric', label: 'Metrics Inspect', to: '/operate/inspect', 
verb: 'inspect:read' },
       {
         icon: 'set',
         label: 'DSL Management',
@@ -230,6 +225,17 @@ const sections: NavSection[] = [
           { icon: 'download', label: 'Dump & restore', to: 
'/operate/dsl/dump', verb: 'rule:read' },
         ],
       },
+      {
+        icon: 'flame',
+        label: 'Live debugger',
+        to: '/operate/live-debug',
+        verb: 'live-debug:read',
+        // Match the tab variants only; the history sibling at
+        // /operate/live-debug/history must NOT highlight this row.
+        activeWhen: (p) => p === '/operate/live-debug' || 
/^\/operate\/live-debug\/(mal|lal|oal)(\/|$)/.test(p),
+      },
+      { icon: 'event', label: 'Capture history', to: 
'/operate/live-debug/history', verb: 'live-debug:read' },
+      { icon: 'metric', label: 'Metrics Inspect', to: '/operate/inspect', 
verb: 'inspect:read' },
     ],
   },
   {
diff --git a/package.json b/package.json
index 1a62408..52d0b00 100644
--- a/package.json
+++ b/package.json
@@ -26,6 +26,9 @@
     "typescript": "~5.6.3"
   },
   "pnpm": {
-    "onlyBuiltDependencies": ["argon2", "esbuild", "@parcel/watcher", 
"vue-demi"]
+    "onlyBuiltDependencies": ["argon2", "esbuild", "@parcel/watcher", 
"vue-demi"],
+    "overrides": {
+      "dompurify@<3.3.2": ">=3.3.2"
+    }
   }
 }
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0b39909..1a735e3 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -4,6 +4,9 @@ settings:
   autoInstallPeers: true
   excludeLinksFromLockfile: false
 
+overrides:
+  dompurify@<3.3.2: '>=3.3.2'
+
 importers:
 
   .:
@@ -18,8 +21,8 @@ importers:
         specifier: ^11.0.1
         version: 11.0.2
       '@fastify/static':
-        specifier: ^8.0.2
-        version: 8.3.0
+        specifier: ^9.1.1
+        version: 9.1.3
       '@skywalking-horizon-ui/api-client':
         specifier: workspace:*
         version: link:../../packages/api-client
@@ -874,8 +877,8 @@ packages:
   '@fastify/[email protected]':
     resolution: {integrity: 
sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==}
 
-  '@fastify/[email protected]':
-    resolution: {integrity: 
sha512-yKxviR5PH1OKNnisIzZKmgZSus0r2OZb8qCSbqmw34aolT4g3UlzYfeBRym+HJ1J471CR8e2ldNub4PubD1coA==}
+  '@fastify/[email protected]':
+    resolution: {integrity: 
sha512-aXrYtsiryLhRxRNaxNqsn7FUISeb7rB9q4eHUPIot5aeQBLNahnz1m6thzm7JWC1poSGXS9XrX8DvuMivp2hkQ==}
 
   '@floating-ui/[email protected]':
     resolution: {integrity: 
sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==}
@@ -1000,10 +1003,6 @@ packages:
     resolution: {integrity: 
sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
     engines: {node: '>=12'}
 
-  '@isaacs/[email protected]':
-    resolution: {integrity: 
sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==}
-    engines: {node: '>=18'}
-
   '@jridgewell/[email protected]':
     resolution: {integrity: 
sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
 
@@ -1872,9 +1871,9 @@ packages:
   [email protected]:
     resolution: {integrity: 
sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==}
 
-  [email protected]:
-    resolution: {integrity: 
sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
-    engines: {node: '>= 0.6'}
+  [email protected]:
+    resolution: {integrity: 
sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==}
+    engines: {node: '>=18'}
 
   [email protected]:
     resolution: {integrity: 
sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
@@ -2104,8 +2103,8 @@ packages:
     resolution: {integrity: 
sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
     engines: {node: '>=8'}
 
-  [email protected]:
-    resolution: {integrity: 
sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==}
+  [email protected]:
+    resolution: {integrity: 
sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==}
 
   [email protected]:
     resolution: {integrity: 
sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
@@ -2454,11 +2453,9 @@ packages:
     deprecated: Old versions of glob are not supported, and contain widely 
publicized security vulnerabilities, which have been fixed in the current 
version. Please update. Support for old versions may be purchased (at 
exorbitant rates) by contacting [email protected]
     hasBin: true
 
-  [email protected]:
-    resolution: {integrity: 
sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==}
-    engines: {node: 20 || >=22}
-    deprecated: Old versions of glob are not supported, and contain widely 
publicized security vulnerabilities, which have been fixed in the current 
version. Please update. Support for old versions may be purchased (at 
exorbitant rates) by contacting [email protected]
-    hasBin: true
+  [email protected]:
+    resolution: {integrity: 
sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==}
+    engines: {node: 18 || 20 || >=22}
 
   [email protected]:
     resolution: {integrity: 
sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==}
@@ -2708,10 +2705,6 @@ packages:
   [email protected]:
     resolution: {integrity: 
sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
 
-  [email protected]:
-    resolution: {integrity: 
sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==}
-    engines: {node: 20 || >=22}
-
   [email protected]:
     resolution: {integrity: 
sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
     engines: {node: '>=10'}
@@ -4267,14 +4260,14 @@ snapshots:
       http-errors: 2.0.1
       mime: 3.0.0
 
-  '@fastify/[email protected]':
+  '@fastify/[email protected]':
     dependencies:
       '@fastify/accept-negotiator': 2.0.1
       '@fastify/send': 4.1.0
-      content-disposition: 0.5.4
+      content-disposition: 1.1.0
       fastify-plugin: 5.1.0
       fastq: 1.20.1
-      glob: 11.1.0
+      glob: 13.0.6
 
   '@floating-ui/[email protected]':
     dependencies:
@@ -4430,8 +4423,6 @@ snapshots:
       wrap-ansi: 8.1.0
       wrap-ansi-cjs: [email protected]
 
-  '@isaacs/[email protected]': {}
-
   '@jridgewell/[email protected]':
     dependencies:
       '@jridgewell/sourcemap-codec': 1.5.5
@@ -5312,9 +5303,7 @@ snapshots:
       ini: 1.3.8
       proto-list: 1.2.4
 
-  [email protected]:
-    dependencies:
-      safe-buffer: 5.2.1
+  [email protected]: {}
 
   [email protected]: {}
 
@@ -5570,7 +5559,7 @@ snapshots:
   [email protected]:
     optional: true
 
-  [email protected]:
+  [email protected]:
     optionalDependencies:
       '@types/trusted-types': 2.0.7
 
@@ -6110,13 +6099,10 @@ snapshots:
       package-json-from-dist: 1.0.1
       path-scurry: 1.11.1
 
-  [email protected]:
+  [email protected]:
     dependencies:
-      foreground-child: 3.3.1
-      jackspeak: 4.2.3
       minimatch: 10.2.5
       minipass: 7.1.3
-      package-json-from-dist: 1.0.1
       path-scurry: 2.0.2
 
   [email protected]:
@@ -6354,10 +6340,6 @@ snapshots:
     optionalDependencies:
       '@pkgjs/parseargs': 0.11.0
 
-  [email protected]:
-    dependencies:
-      '@isaacs/cliui': 9.0.0
-
   [email protected]: {}
 
   [email protected]:
@@ -6521,7 +6503,7 @@ snapshots:
 
   [email protected]:
     dependencies:
-      dompurify: 3.2.7
+      dompurify: 3.4.5
       marked: 14.0.0
 
   [email protected]: {}
diff --git a/scripts/release.sh b/scripts/release.sh
index 7936b4c..d21f5a6 100755
--- a/scripts/release.sh
+++ b/scripts/release.sh
@@ -232,6 +232,14 @@ note "Step 8 — Prepare release commit + tag ${TAG}"
 
 cd "${CLONE_DIR}"
 
+# The release commit is NEVER pushed straight to ${REPO_BRANCH} — main is
+# protected, and ASF review wants the version change to land via PR. Cut a
+# dedicated release branch; the tag is created on the release commit here,
+# and the next-dev bump is added as a second commit on the SAME branch in
+# Step 15 so one PR carries both (version strip → tag → back to -dev).
+RELEASE_BRANCH_NAME="prepare-release-${RELEASE_VERSION}"
+git checkout -b "${RELEASE_BRANCH_NAME}"
+
 # Strip the -dev suffix on every code marker in the clone. The committed
 # release-tagged commit must carry the bare semver.
 node -e "
@@ -266,8 +274,11 @@ if git ls-remote --tags origin | grep -q 
"refs/tags/${TAG}$"; then
     exit 1
 fi
 git tag "${TAG}"
-git push origin HEAD:"${REPO_BRANCH}" "${TAG}"
-echo "Pushed release commit + tag ${TAG}."
+# Push the release commit on its own branch + the tag (the tag points at
+# the release commit). The branch is merged into ${REPO_BRANCH} via the PR
+# opened in Step 15 — after the next-dev bump is added on top.
+git push --set-upstream origin "${RELEASE_BRANCH_NAME}" "${TAG}"
+echo "Pushed release branch ${RELEASE_BRANCH_NAME} + tag ${TAG} (tag → release 
commit; not pushed to ${REPO_BRANCH})."
 
 # ========================== Step 9: Build source tarball 
==========================
 note "Step 9 — Build source tarball"
@@ -470,16 +481,20 @@ Voting will start now (${VOTE_DATE}) and will remain open 
for at least
 ========================================================================
 EOF
 
-# ========================== Step 15: Prepare next version 
==========================
-note "Step 15 — Prepare next-version (${NEXT_RELEASE_VERSION}) PR"
+# ========================== Step 15: Next-dev bump + release PR 
==========================
+note "Step 15 — Add next-dev bump (${NEXT_RELEASE_VERSION}-dev) + open release 
PR"
 
-if ! confirm "Push next-version PR (${NEXT_RELEASE_VERSION}) now?"; then
-    echo "Skipping next-version PR. Release artifacts are in ${WORK_DIR}/."
+if ! confirm "Add the next-dev bump on ${RELEASE_BRANCH_NAME} and open the 
release PR now?"; then
+    echo "Skipping the next-dev commit + PR. Release artifacts are in 
${WORK_DIR}/."
+    echo "Release branch ${RELEASE_BRANCH_NAME} + tag ${TAG} are already 
pushed; open the PR manually when ready."
     exit 0
 fi
 
 cd "${CLONE_DIR}"
-git checkout -b "prepare-next-${NEXT_RELEASE_VERSION}"
+# Stay on the release branch — the next-dev bump is a SECOND commit on top
+# of the tagged release commit, so one PR carries both: the version strip
+# (tagged) and the return to -dev for the next cycle.
+git checkout "${RELEASE_BRANCH_NAME}"
 
 # Bump every code marker to the next dev-suffixed version.
 NEXT_DEV_VERSION="${NEXT_RELEASE_VERSION}-dev"
@@ -521,15 +536,31 @@ fs.writeFileSync(path, out);
 
 git add package.json packages/*/package.json apps/*/package.json 
apps/bff/src/server.ts CHANGELOG.md
 git commit -m "Prepare next release ${NEXT_DEV_VERSION}"
-git push --set-upstream origin "prepare-next-${NEXT_RELEASE_VERSION}"
-
-gh pr create --title "Prepare next release ${NEXT_DEV_VERSION}" \
-    --body "Bump every package version to ${NEXT_DEV_VERSION} and rotate 
CHANGELOG for the next development cycle after ${RELEASE_VERSION}." \
+git push origin "${RELEASE_BRANCH_NAME}"
+
+gh pr create --title "Release ${RELEASE_VERSION}, bump to ${NEXT_DEV_VERSION}" 
\
+    --body "$(cat <<PRBODY
+Release branch for ${RELEASE_VERSION}. Two commits:
+
+1. \`Prepare release ${RELEASE_VERSION}\` — strips \`-dev\` from every package
+   marker + \`apps/bff/src/server.ts\`; advances container-image docs to
+   \`${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.
+
+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
+keeps pointing at commit 1 regardless of how this PR is merged.
+PRBODY
+)" \
+    --head "${RELEASE_BRANCH_NAME}" \
     --base "${REPO_BRANCH}"
 
 note "Done."
 echo "  Release version:    ${RELEASE_VERSION}"
 echo "  Next dev version:   ${NEXT_DEV_VERSION}"
+echo "  Release branch:     ${RELEASE_BRANCH_NAME} (PR open → ${REPO_BRANCH})"
 echo "  SVN dev staging:    ${SVN_DEV_URL}/${RELEASE_VERSION}"
 echo "  Release tag:        ${TAG}"
 echo ""
@@ -537,4 +568,5 @@ echo "Next steps:"
 echo "  1. Send the vote email above to [email protected]."
 echo "  2. After the vote passes, run:  svn mv 
${SVN_DEV_URL}/${RELEASE_VERSION} \\"
 echo "         
https://dist.apache.org/repos/dist/release/skywalking/horizon-ui/${RELEASE_VERSION}";
-echo "  3. Merge the next-version PR."
+echo "  3. Merge the release PR (${RELEASE_BRANCH_NAME} → ${REPO_BRANCH}): 
brings the"
+echo "     version strip + next-dev bump into ${REPO_BRANCH}; tag ${TAG} stays 
put."


Reply via email to