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/06/26 03:12:10 UTC

(skywalking-horizon-ui) branch feat/template-modes-env-config created (now 3a1288a)

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

wu-sheng pushed a change to branch feat/template-modes-env-config
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git


      at 3a1288a  refactor(config): one committed env-driven horizon.yaml (drop the example/copy split)

This branch includes the following new commits:

     new 91abb21  feat(config): readonly template mode (run without ui_template API) + env-native config (mount-free image)
     new 23b13c8  fix(config): review pass 1 — readonly translations + env-native robustness
     new 9b8cb65  test(bff): readonly-mode sync — renders from disk, includes translation overlays, no OAP call
     new ea9f327  fix(config): review pass 2 — readonly UI gating, friendly config error, tests
     new 2e4b26b  feat(cluster): reachability-based admin preflight + ui_template unified as a feature
     new 032adb5  refactor(cluster): fold the Env var column into the Gates description
     new c1247c7  fix(cluster): badge no longer claims 'all reachable' when a feature is bundled
     new 411754f  fix(cluster): admin badge reads 'N/M reachable' in yellow when not all live
     new fb614ba  fix(readonly): close alert-page write gap, yellow read-only banner, stronger env parity
     new 05da4a0  fix(infra-3d): remove the silent disk-bundle fallback from the live render path
     new 3a1288a  refactor(config): one committed env-driven horizon.yaml (drop the example/copy split)

The 11 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(skywalking-horizon-ui) 06/11: refactor(cluster): fold the Env var column into the Gates description

Posted by wu...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a commit to branch feat/template-modes-env-config
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git

commit 032adb5fc54a9e90e192b9d1bea1c80a6bb260a0
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Fri Jun 26 09:04:56 2026 +0800

    refactor(cluster): fold the Env var column into the Gates description
    
    Drop the standalone Env var column; the selector now reads as an 'Enable on
    OAP: SW_X=default' line under each feature's Gates description. Four columns
    (Feature / State / Probe path / Gates) read cleaner now that reachability — not
    the selector — is the verdict.
---
 .../features/operate/cluster/ClusterStatusView.vue | 23 +++++++++++++++-------
 apps/ui/src/i18n/locales/de.json                   |  3 ++-
 apps/ui/src/i18n/locales/en.json                   |  3 ++-
 apps/ui/src/i18n/locales/es.json                   |  3 ++-
 apps/ui/src/i18n/locales/fr.json                   |  3 ++-
 apps/ui/src/i18n/locales/ja.json                   |  3 ++-
 apps/ui/src/i18n/locales/ko.json                   |  3 ++-
 apps/ui/src/i18n/locales/pt.json                   |  3 ++-
 apps/ui/src/i18n/locales/zh-CN.json                |  3 ++-
 9 files changed, 32 insertions(+), 15 deletions(-)

diff --git a/apps/ui/src/features/operate/cluster/ClusterStatusView.vue b/apps/ui/src/features/operate/cluster/ClusterStatusView.vue
index 45ed9ba..05431b2 100644
--- a/apps/ui/src/features/operate/cluster/ClusterStatusView.vue
+++ b/apps/ui/src/features/operate/cluster/ClusterStatusView.vue
@@ -233,7 +233,6 @@ function refreshAll(): void {
             <th>{{ t('Feature') }}</th>
             <th>{{ t('State') }}</th>
             <th>{{ t('Probe path') }}</th>
-            <th>{{ t('Env var') }}</th>
             <th>{{ t('Gates') }}</th>
           </tr>
         </thead>
@@ -252,8 +251,10 @@ function refreshAll(): void {
               </div>
             </td>
             <td class="modpath"><code>{{ m.probePath }}</code></td>
-            <td class="modenv"><code>{{ m.envVar }}</code></td>
-            <td class="modaffects">{{ t(m.affects) }}</td>
+            <td class="modaffects">
+              {{ t(m.affects) }}
+              <span class="env-ref">{{ t('Enable on OAP:') }} <code>{{ m.envVar }}=default</code></span>
+            </td>
           </tr>
         </tbody>
       </table>
@@ -520,16 +521,24 @@ function refreshAll(): void {
 .mod-table tbody tr:last-child td {
   border-bottom: none;
 }
-.mod-table tr.off .modname code,
-.mod-table tr.off .modenv code {
+.mod-table tr.off .modname code {
   color: var(--sw-fg-2);
 }
-.modname code,
-.modenv code {
+.modname code {
   font-family: var(--sw-mono);
   font-size: var(--sw-fs-sm);
   color: var(--sw-fg-0);
 }
+.env-ref {
+  display: block;
+  margin-top: 4px;
+  font-size: var(--sw-fs-xs);
+  color: var(--sw-fg-3);
+}
+.env-ref code {
+  font-family: var(--sw-mono);
+  color: var(--sw-fg-2);
+}
 .modaffects {
   line-height: var(--sw-lh-relaxed);
   color: var(--sw-fg-2);
diff --git a/apps/ui/src/i18n/locales/de.json b/apps/ui/src/i18n/locales/de.json
index 3da9721..fe09296 100644
--- a/apps/ui/src/i18n/locales/de.json
+++ b/apps/ui/src/i18n/locales/de.json
@@ -1547,5 +1547,6 @@
   "selector not detected": "Selektor nicht erkannt",
   "all reachable": "alle erreichbar",
   "{n} unreachable": "{n} nicht erreichbar",
-  "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is the live probe, not config-presence: a path that 404s (selector off, renamed, or absent in a fork) reads as unreachable. 'selector detected' below is only an upstream-release hint, not the verdict.": "Erreichbarkeit jeder Admin-Funktion — der BFF sendet ein GET an den relativen REST-Pfad, den die Funktion tatsächlich aufruft, und meldet, ob er  [...]
+  "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is the live probe, not config-presence: a path that 404s (selector off, renamed, or absent in a fork) reads as unreachable. 'selector detected' below is only an upstream-release hint, not the verdict.": "Erreichbarkeit jeder Admin-Funktion — der BFF sendet ein GET an den relativen REST-Pfad, den die Funktion tatsächlich aufruft, und meldet, ob er  [...]
+  "Enable on OAP:": "Auf OAP aktivieren:"
 }
diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json
index cdb5e69..63f9e62 100644
--- a/apps/ui/src/i18n/locales/en.json
+++ b/apps/ui/src/i18n/locales/en.json
@@ -1547,5 +1547,6 @@
   "selector not detected": "selector not detected",
   "all reachable": "all reachable",
   "{n} unreachable": "{n} unreachable",
-  "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is the live probe, not config-presence: a path that 404s (selector off, renamed, or absent in a fork) reads as unreachable. 'selector detected' below is only an upstream-release hint, not the verdict.": "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is th [...]
+  "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is the live probe, not config-presence: a path that 404s (selector off, renamed, or absent in a fork) reads as unreachable. 'selector detected' below is only an upstream-release hint, not the verdict.": "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is th [...]
+  "Enable on OAP:": "Enable on OAP:"
 }
diff --git a/apps/ui/src/i18n/locales/es.json b/apps/ui/src/i18n/locales/es.json
index 8ac4dfb..292b594 100644
--- a/apps/ui/src/i18n/locales/es.json
+++ b/apps/ui/src/i18n/locales/es.json
@@ -1547,5 +1547,6 @@
   "selector not detected": "selector no detectado",
   "all reachable": "todos accesibles",
   "{n} unreachable": "{n} inaccesible(s)",
-  "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is the live probe, not config-presence: a path that 404s (selector off, renamed, or absent in a fork) reads as unreachable. 'selector detected' below is only an upstream-release hint, not the verdict.": "Accesibilidad de cada función de administración: el BFF hace un GET a la ruta REST relativa que la función realmente llama e informa si responde. [...]
+  "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is the live probe, not config-presence: a path that 404s (selector off, renamed, or absent in a fork) reads as unreachable. 'selector detected' below is only an upstream-release hint, not the verdict.": "Accesibilidad de cada función de administración: el BFF hace un GET a la ruta REST relativa que la función realmente llama e informa si responde. [...]
+  "Enable on OAP:": "Activar en OAP:"
 }
diff --git a/apps/ui/src/i18n/locales/fr.json b/apps/ui/src/i18n/locales/fr.json
index 690208d..5a93b26 100644
--- a/apps/ui/src/i18n/locales/fr.json
+++ b/apps/ui/src/i18n/locales/fr.json
@@ -1547,5 +1547,6 @@
   "selector not detected": "sélecteur non détecté",
   "all reachable": "tous accessibles",
   "{n} unreachable": "{n} inaccessible(s)",
-  "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is the live probe, not config-presence: a path that 404s (selector off, renamed, or absent in a fork) reads as unreachable. 'selector detected' below is only an upstream-release hint, not the verdict.": "Accessibilité de chaque fonctionnalité d'administration — le BFF envoie un GET sur le chemin REST relatif que la fonctionnalité appelle réellemen [...]
+  "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is the live probe, not config-presence: a path that 404s (selector off, renamed, or absent in a fork) reads as unreachable. 'selector detected' below is only an upstream-release hint, not the verdict.": "Accessibilité de chaque fonctionnalité d'administration — le BFF envoie un GET sur le chemin REST relatif que la fonctionnalité appelle réellemen [...]
+  "Enable on OAP:": "Activer sur OAP :"
 }
diff --git a/apps/ui/src/i18n/locales/ja.json b/apps/ui/src/i18n/locales/ja.json
index 55ed2db..6dd4723 100644
--- a/apps/ui/src/i18n/locales/ja.json
+++ b/apps/ui/src/i18n/locales/ja.json
@@ -1547,5 +1547,6 @@
   "selector not detected": "selector を検出せず",
   "all reachable": "すべて到達可能",
   "{n} unreachable": "{n} 件が到達不可",
-  "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is the live probe, not config-presence: a path that 404s (selector off, renamed, or absent in a fork) reads as unreachable. 'selector detected' below is only an upstream-release hint, not the verdict.": "各管理機能の到達可能性 —— BFF は機能が実際に呼び出す相対 REST パスへ GET し、応答するかどうかを報告します。健全性は設定の有無ではなくライブプローブで判定されます。404 を返すパス(selector が無効、リネーム、またはフォークで欠落)は到達不可とみなされます。下の [...]
+  "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is the live probe, not config-presence: a path that 404s (selector off, renamed, or absent in a fork) reads as unreachable. 'selector detected' below is only an upstream-release hint, not the verdict.": "各管理機能の到達可能性 —— BFF は機能が実際に呼び出す相対 REST パスへ GET し、応答するかどうかを報告します。健全性は設定の有無ではなくライブプローブで判定されます。404 を返すパス(selector が無効、リネーム、またはフォークで欠落)は到達不可とみなされます。下の [...]
+  "Enable on OAP:": "OAP で有効化:"
 }
diff --git a/apps/ui/src/i18n/locales/ko.json b/apps/ui/src/i18n/locales/ko.json
index 3f6dd9a..7909e29 100644
--- a/apps/ui/src/i18n/locales/ko.json
+++ b/apps/ui/src/i18n/locales/ko.json
@@ -1547,5 +1547,6 @@
   "selector not detected": "selector 감지 안 됨",
   "all reachable": "모두 도달 가능",
   "{n} unreachable": "{n}개 도달 불가",
-  "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is the live probe, not config-presence: a path that 404s (selector off, renamed, or absent in a fork) reads as unreachable. 'selector detected' below is only an upstream-release hint, not the verdict.": "각 관리 기능의 도달 가능성 — BFF가 해당 기능이 실제로 호출하는 상대 REST 경로로 GET 요청을 보내 응답 여부를 보고합니다. 상태는 설정 존재 여부가 아니라 실시간 프로브로 판단합니다. 404를 반환하는 경로(selector 꺼짐, 이름 변경 또는  [...]
+  "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is the live probe, not config-presence: a path that 404s (selector off, renamed, or absent in a fork) reads as unreachable. 'selector detected' below is only an upstream-release hint, not the verdict.": "각 관리 기능의 도달 가능성 — BFF가 해당 기능이 실제로 호출하는 상대 REST 경로로 GET 요청을 보내 응답 여부를 보고합니다. 상태는 설정 존재 여부가 아니라 실시간 프로브로 판단합니다. 404를 반환하는 경로(selector 꺼짐, 이름 변경 또는  [...]
+  "Enable on OAP:": "OAP에서 활성화:"
 }
diff --git a/apps/ui/src/i18n/locales/pt.json b/apps/ui/src/i18n/locales/pt.json
index 5dc1edb..ad09cda 100644
--- a/apps/ui/src/i18n/locales/pt.json
+++ b/apps/ui/src/i18n/locales/pt.json
@@ -1547,5 +1547,6 @@
   "selector not detected": "selector não detectado",
   "all reachable": "todos acessíveis",
   "{n} unreachable": "{n} inacessível(is)",
-  "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is the live probe, not config-presence: a path that 404s (selector off, renamed, or absent in a fork) reads as unreachable. 'selector detected' below is only an upstream-release hint, not the verdict.": "Acessibilidade de cada recurso de administração: o BFF faz um GET no caminho REST relativo que o recurso realmente chama e informa se ele respond [...]
+  "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is the live probe, not config-presence: a path that 404s (selector off, renamed, or absent in a fork) reads as unreachable. 'selector detected' below is only an upstream-release hint, not the verdict.": "Acessibilidade de cada recurso de administração: o BFF faz um GET no caminho REST relativo que o recurso realmente chama e informa se ele respond [...]
+  "Enable on OAP:": "Ativar no OAP:"
 }
diff --git a/apps/ui/src/i18n/locales/zh-CN.json b/apps/ui/src/i18n/locales/zh-CN.json
index 03e6f6d..a884228 100644
--- a/apps/ui/src/i18n/locales/zh-CN.json
+++ b/apps/ui/src/i18n/locales/zh-CN.json
@@ -1547,5 +1547,6 @@
   "selector not detected": "未检测到 selector",
   "all reachable": "全部可达",
   "{n} unreachable": "{n} 个不可达",
-  "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is the live probe, not config-presence: a path that 404s (selector off, renamed, or absent in a fork) reads as unreachable. 'selector detected' below is only an upstream-release hint, not the verdict.": "每个管理功能的可达性 —— BFF 会向该功能实际调用的相对 REST 路径发起 GET,并报告其是否响应。健康状态取决于实时探测,而非配置存在性:返回 404 的路径(selector 关闭、被重命名或在 fork 版本中缺失)将被视为不可达。下方的“检测到 selector”仅为上游发 [...]
+  "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is the live probe, not config-presence: a path that 404s (selector off, renamed, or absent in a fork) reads as unreachable. 'selector detected' below is only an upstream-release hint, not the verdict.": "每个管理功能的可达性 —— BFF 会向该功能实际调用的相对 REST 路径发起 GET,并报告其是否响应。健康状态取决于实时探测,而非配置存在性:返回 404 的路径(selector 关闭、被重命名或在 fork 版本中缺失)将被视为不可达。下方的“检测到 selector”仅为上游发 [...]
+  "Enable on OAP:": "在 OAP 上启用:"
 }


(skywalking-horizon-ui) 09/11: fix(readonly): close alert-page write gap, yellow read-only banner, stronger env parity

Posted by wu...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a commit to branch feat/template-modes-env-config
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git

commit fb614baeeb87ee3fbf8c9fa338b0b96a1a2e3e05
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Fri Jun 26 09:59:27 2026 +0800

    fix(readonly): close alert-page write gap, yellow read-only banner, stronger env parity
    
    Three review findings + a UX fix:
    
    - HIGH: the alert page-setup (/api/alarms/config) was still writable in readonly.
      The readonly backstop only covered /api/admin/templates* and
      /api/admin/overview-templates*; /api/alarms/config writes the
      horizon.alert.page-setup template (file-backed) and slipped through. It's now
      in the config-surface write set — a direct POST returns 409 in readonly — and
      GET serves the bundled alert page-setup (like every other template) instead of
      the local store. Validated live: POST 409, GET serves the bundle.
    
    - The read-only SyncStatusBanner was muted gray ("white") and easy to miss next
      to the green per-row sync chips. It's now warning-yellow so it's unmistakable
      that the whole config surface is bundle-served and uneditable. (The per-row
      "synced" chip is correct as-is: in readonly the bundle IS the presented remote,
      so bundled == remote and the diff legitimately reads synced.)
    
    - The env parity guard only checked top-level sections + four hard-coded tokens.
      It now recursively walks every schema-default field and asserts each is covered
      by a ${HORIZON_…} token in horizon.example.yaml (a scalar token, or a JSON-env
      token for a whole subtree). It immediately caught a real gap — oap.mqe shipped
      untokenized — now fixed (HORIZON_OAP_MQE). A future nested field without env
      coverage will now fail the test.
    
    type-check / lint / license / 162 BFF + 116 UI tests / builds green.
---
 apps/bff/src/config/schema.test.ts                 | 36 ++++++++++++++++++++++
 apps/bff/src/http/config/alarms.ts                 |  7 ++++-
 apps/bff/src/rbac/route-policy.test.ts             |  6 ++--
 apps/bff/src/rbac/route-policy.ts                  | 16 +++++++---
 .../features/admin/_shared/SyncStatusBanner.vue    | 11 ++++---
 horizon.example.yaml                               |  3 ++
 6 files changed, 66 insertions(+), 13 deletions(-)

diff --git a/apps/bff/src/config/schema.test.ts b/apps/bff/src/config/schema.test.ts
index 9d4a7d0..74bc26d 100644
--- a/apps/bff/src/config/schema.test.ts
+++ b/apps/bff/src/config/schema.test.ts
@@ -73,4 +73,40 @@ describe('horizon.example.yaml — tokenized default + parity', () => {
     expect(raw).toContain('${HORIZON_TEMPLATES_MODE');
     expect(raw).toContain('${HORIZON_OAP_ADMIN_URL');
   });
+
+  // The real contract: EVERY schema-default field is env-overridable. Walk the
+  // fully-defaulted config and assert each node is covered in the example by a
+  // `${HORIZON_…}` token — either a scalar token at that path, or a JSON-env
+  // token standing in for a whole subtree (performance, rbac.roles, …). Catches
+  // a new nested field shipped without env coverage, which the top-level-section
+  // check above would miss.
+  it('every schema-default field is env-tokenized in the example (recursive)', () => {
+    const defaults = configSchema.parse({}) as Record<string, unknown>;
+    const example = (YAML.parse(raw) ?? {}) as Record<string, unknown>;
+    const TOKEN = /\$\{HORIZON_[A-Z0-9_]+(:[\s\S]*)?\}/;
+    const uncovered: string[] = [];
+    const walk = (schemaNode: unknown, exampleNode: unknown, path: string): void => {
+      // A token string covers this node (scalar token, or a JSON-env token
+      // collapsing a whole subtree to one var).
+      if (typeof exampleNode === 'string' && TOKEN.test(exampleNode)) return;
+      if (schemaNode !== null && typeof schemaNode === 'object' && !Array.isArray(schemaNode)) {
+        if (exampleNode === null || typeof exampleNode !== 'object' || Array.isArray(exampleNode)) {
+          uncovered.push(`${path} (section absent from example)`);
+          return;
+        }
+        for (const key of Object.keys(schemaNode)) {
+          if (path === '' && key === 'infra3d') continue; // deprecated passthrough, never tokenized
+          walk(
+            (schemaNode as Record<string, unknown>)[key],
+            (exampleNode as Record<string, unknown>)[key],
+            path ? `${path}.${key}` : key,
+          );
+        }
+        return;
+      }
+      uncovered.push(`${path} (leaf not tokenized)`);
+    };
+    walk(defaults, example, '');
+    expect(uncovered, `fields lacking an env token in horizon.example.yaml:\n  ${uncovered.join('\n  ')}`).toEqual([]);
+  });
 });
diff --git a/apps/bff/src/http/config/alarms.ts b/apps/bff/src/http/config/alarms.ts
index 1b1e5c3..467259d 100644
--- a/apps/bff/src/http/config/alarms.ts
+++ b/apps/bff/src/http/config/alarms.ts
@@ -30,6 +30,8 @@ import type { ConfigSource } from '../../config/loader.js';
 import type { SessionStore } from '../../user/sessions.js';
 import type { AuditLogger } from '../../audit/logger.js';
 import { requireAuth } from '../../user/middleware.js';
+import { isTemplateReadOnly } from '../../logic/templates/sync.js';
+import { loadBundledAlertPageSetup } from '../../logic/alarms/bundled.js';
 import type { ServiceLayerCatalog } from '../../logic/services/service-layer-catalog.js';
 import {
   ALARMS_WINDOW_OPTIONS,
@@ -77,7 +79,10 @@ export function registerAlarmsConfigRoutes(
     '/api/alarms/config',
     { preHandler: auth },
     async (_req: FastifyRequest, reply: FastifyReply) => {
-      const cfg = await deps.store.load();
+      // Readonly mode serves the alert page-setup from the bundle like every
+      // other template; the local store (a prior live session's edits) is not
+      // the source here. Writes are denied by the route-policy backstop.
+      const cfg = isTemplateReadOnly() ? loadBundledAlertPageSetup() : await deps.store.load();
       return reply.send(cfg);
     },
   );
diff --git a/apps/bff/src/rbac/route-policy.test.ts b/apps/bff/src/rbac/route-policy.test.ts
index 67f3242..750dc3a 100644
--- a/apps/bff/src/rbac/route-policy.test.ts
+++ b/apps/bff/src/rbac/route-policy.test.ts
@@ -27,12 +27,14 @@ describe('isTemplateWriteRoute — which routes the readonly backstop covers', (
     expect(isTemplateWriteRoute('POST', '/api/admin/templates/sync-all')).toBe(true);
     expect(isTemplateWriteRoute('POST', '/api/admin/overview-templates')).toBe(true);
     expect(isTemplateWriteRoute('DELETE', '/api/admin/overview-templates/x')).toBe(true);
+    // The alert page-setup (horizon.alert.page-setup) is config-surface too.
+    expect(isTemplateWriteRoute('POST', '/api/alarms/config')).toBe(true);
   });
-  it('does NOT match reads, nor non-template writes (runtime-rule / local state stay editable)', () => {
+  it('does NOT match reads, nor non-config writes (runtime-rule / live-debug stay editable)', () => {
     expect(isTemplateWriteRoute('GET', '/api/admin/templates/sync-status')).toBe(false);
     expect(isTemplateWriteRoute('HEAD', '/api/admin/templates/sync-status')).toBe(false);
+    expect(isTemplateWriteRoute('GET', '/api/alarms/config')).toBe(false); // read stays open
     expect(isTemplateWriteRoute('POST', '/api/rule')).toBe(false); // runtime rule
-    expect(isTemplateWriteRoute('POST', '/api/alarms/config')).toBe(false); // local state
     expect(isTemplateWriteRoute('POST', '/api/debug/session')).toBe(false); // live-debug
   });
 });
diff --git a/apps/bff/src/rbac/route-policy.ts b/apps/bff/src/rbac/route-policy.ts
index fb3d617..257a74b 100644
--- a/apps/bff/src/rbac/route-policy.ts
+++ b/apps/bff/src/rbac/route-policy.ts
@@ -33,13 +33,19 @@ import { logger } from '../logger.js';
 
 export type RoutePolicy = 'public' | 'auth' | string;
 
-/** A config-template write — it pushes to OAP's ui_template store. In
- *  `templates.mode=readonly` there is no store to write to, so these are denied
- *  at the edge regardless of the verb grant (the UI hides them too, but a direct
- *  request must still fail — the BFF is the authority). */
+/** A config-surface write. The template routes push to OAP's ui_template store;
+ *  `/api/alarms/config` writes the alert page-setup (the `horizon.alert.page-setup`
+ *  template, file-backed). In `templates.mode=readonly` the whole config surface
+ *  is served from the local bundle and read-only, so these are denied at the edge
+ *  regardless of the verb grant (the UI hides them too, but a direct request must
+ *  still fail — the BFF is the authority). */
 export function isTemplateWriteRoute(method: string, url: string): boolean {
   if (method === 'GET' || method === 'HEAD') return false;
-  return url.startsWith('/api/admin/templates') || url.startsWith('/api/admin/overview-templates');
+  return (
+    url.startsWith('/api/admin/templates') ||
+    url.startsWith('/api/admin/overview-templates') ||
+    url.startsWith('/api/alarms/config')
+  );
 }
 export async function denyTemplateWriteWhenReadOnly(
   _req: FastifyRequest,
diff --git a/apps/ui/src/features/admin/_shared/SyncStatusBanner.vue b/apps/ui/src/features/admin/_shared/SyncStatusBanner.vue
index 69a52c6..8f84279 100644
--- a/apps/ui/src/features/admin/_shared/SyncStatusBanner.vue
+++ b/apps/ui/src/features/admin/_shared/SyncStatusBanner.vue
@@ -124,13 +124,14 @@ export default { chipLabel };
   border-color: var(--sw-muted, #4a525c);
   background: var(--sw-bg-elev, #161a20);
 }
-/* Deliberate read-only (templates.mode=readonly) — informational, not an
- * error, so muted rather than the danger red of `unreachable`. */
+/* Deliberate read-only (templates.mode=readonly) — not an error (no red), but
+ * warning-yellow so it's unmistakable that the whole surface is uneditable; a
+ * muted gray strip was too easy to miss next to the green per-row chips. */
 .sbb--readonly {
-  border-color: var(--sw-muted, #4a525c);
-  background: rgba(255, 255, 255, 0.03);
+  border-color: var(--sw-warn, #b88500);
+  background: rgba(184, 133, 0, 0.10);
 }
-.sbb--readonly .sbb__chip { background: var(--sw-muted, #4a525c); color: #fff; }
+.sbb--readonly .sbb__chip { background: var(--sw-warn, #b88500); color: #1a1a1a; }
 .sbb__row {
   display: flex;
   gap: 12px;
diff --git a/horizon.example.yaml b/horizon.example.yaml
index d3c045e..4da1e8f 100644
--- a/horizon.example.yaml
+++ b/horizon.example.yaml
@@ -58,6 +58,9 @@ oap:
   # GraphQL port (typical for the demo / k8s deploys).
   zipkinUrl: "${HORIZON_OAP_ZIPKIN_URL:http://127.0.0.1:9412/zipkin}"
   timeoutMs: ${HORIZON_OAP_TIMEOUT_MS:15000}
+  # Optional MQE host/port override (defaults to the query host). JSON env, e.g.
+  # HORIZON_OAP_MQE='{"host":"mqe.internal","port":12800}'.
+  mqe: ${HORIZON_OAP_MQE:null}
   # Optional basic-auth for outbound OAP calls. JSON env, e.g.
   # HORIZON_OAP_AUTH='{"username":"skywalking","password":"skywalking"}'.
   auth: ${HORIZON_OAP_AUTH:null}


(skywalking-horizon-ui) 01/11: feat(config): readonly template mode (run without ui_template API) + env-native config (mount-free image)

Posted by wu...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a commit to branch feat/template-modes-env-config
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git

commit 91abb21f7acdd46b46b1c56333549547c0692cee
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Fri Jun 26 01:37:32 2026 +0800

    feat(config): readonly template mode (run without ui_template API) + env-native config (mount-free image)
    
    Goal A — `templates.mode: live | readonly` (HORIZON_TEMPLATES_MODE):
    - `readonly` renders every dashboard / overview / alert / 3D / translation from
      the local disk bundle, loaded into the same in-memory row shape live mode
      reads from OAP, so every render consumer resolves it unchanged. The
      ui_template store is never called (no seed, no readiness wait); OAP's query API
      is still used + boot-checked (config-only offline).
    - The config surface is read-only: admin pages still open + show the bundled
      config, but write controls are gone (the BFF rejects config-template writes at
      the route edge regardless — UI bypass still fails). Per-page + cluster-status
      surfaces show the mode; the global banner suppresses the ui_template-store
      strip in readonly (the query-unreachable strip still fires).
    - `live` (default) is unchanged: seed-to-OAP + read-from-OAP.
    
    Goal B — env-native config:
    - Every field in horizon.example.yaml is now a `${HORIZON_…:default}` token, so
      the config file is the complete, self-documenting env-var reference. Scalars
      take a value; lists / optional blocks (users, ldap, oap.auth, rbac.roles,
      performance, …) take a JSON-string env var; a `:null` token falls through to
      the schema default via a new `stripNullish` loader pass. Precedence env > file
      > default.
    - The image bakes the tokenized config AS /app/horizon.yaml, so `docker run`
      with env vars alone works — no mount, no repackage. A bind-mount still
      overrides it.
    
    Docs: container-image full env table + env-only / readonly run recipes.
    Tests: example parses to defaults (parity); env-merge (scalar / JSON / null /
    malformed); readonly status. type-check / lint / license / 263 unit tests / i18n
    green. Validated live (demo OAP): readonly bundled render + write-409 + query
    live; live-mode regression; mount-free env-only boot + login.
---
 CHANGELOG.md                                       |   5 +
 Dockerfile                                         |   5 +
 apps/bff/src/config/loader.test.ts                 |  59 +++-
 apps/bff/src/config/loader.ts                      |  23 +-
 apps/bff/src/config/schema.test.ts                 |  58 ++--
 apps/bff/src/config/schema.ts                      |  17 +
 apps/bff/src/http/config/bundle.ts                 |   5 +
 apps/bff/src/logic/templates/sync.ts               |  76 ++++-
 apps/bff/src/rbac/route-policy.ts                  |  25 ++
 apps/bff/src/server.ts                             |  23 +-
 apps/ui/src/api/scopes/configs.ts                  |   3 +
 apps/ui/src/controls/configBundle.ts               |   1 +
 .../features/admin/_shared/SyncStatusBanner.vue    |   9 +
 .../src/features/admin/_shared/useTemplateSync.ts  |  20 +-
 .../features/operate/cluster/ClusterStatusView.vue |  49 +++
 apps/ui/src/i18n/locales/de.json                   |   8 +-
 apps/ui/src/i18n/locales/en.json                   |   8 +-
 apps/ui/src/i18n/locales/es.json                   |   8 +-
 apps/ui/src/i18n/locales/fr.json                   |   8 +-
 apps/ui/src/i18n/locales/ja.json                   |   8 +-
 apps/ui/src/i18n/locales/ko.json                   |   8 +-
 apps/ui/src/i18n/locales/pt.json                   |   8 +-
 apps/ui/src/i18n/locales/zh-CN.json                |   8 +-
 docs/setup/container-image.md                      |  51 ++-
 horizon.example.yaml                               | 362 ++++++---------------
 25 files changed, 550 insertions(+), 305 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index b952ade..82983e4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,11 @@ The version line is shared by every package in the monorepo (apps + shared packa
 
 ## 1.1.0
 
+### Deployment & configuration
+
+- **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.** The image now bakes a **fully tokenized** `horizon.yaml` where every field is a `${HORIZON_…:default}` env var, 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 YAML-only. Lists and secrets (users, LDAP, OAP auth) are set as JSON-string env vars; preced [...]
+
 ### 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()`).
diff --git a/Dockerfile b/Dockerfile
index fe998dc..8e39e30 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -59,6 +59,11 @@ COPY --from=build /src/dist/package.json           ./package.json
 COPY --from=build /src/dist/node_modules           ./node_modules
 COPY --from=build /src/dist/static                 ./static
 COPY --from=build /src/dist/horizon.example.yaml   ./horizon.example.yaml
+# Bake the (fully tokenized) example AS the active config so the image runs
+# with NO mounted file: every field is a `${HORIZON_…:default}` token, so
+# `docker run -e HORIZON_OAP_QUERY_URL=… -e HORIZON_AUTH_LOCAL_USERS='[…]' …`
+# is enough. A bind-mount at /app/horizon.yaml still overrides it.
+COPY --from=build /src/dist/horizon.example.yaml   ./horizon.yaml
 
 COPY --from=build --chown=horizon:horizon /src/dist/bundled_templates  ./bundled_templates
 
diff --git a/apps/bff/src/config/loader.test.ts b/apps/bff/src/config/loader.test.ts
index b27245e..5890194 100644
--- a/apps/bff/src/config/loader.test.ts
+++ b/apps/bff/src/config/loader.test.ts
@@ -15,9 +15,13 @@
  * limitations under the License.
  */
 
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, resolve } from 'node:path';
+import YAML from 'yaml';
 import { describe, expect, it } from 'vitest';
 import { configSchema } from './schema.js';
-import { interpolateEnv, isAuthConfigured, validateBootstrap } from './loader.js';
+import { interpolateEnv, stripNullish, isAuthConfigured, validateBootstrap } from './loader.js';
 
 describe('interpolateEnv', () => {
   it('substitutes a defined variable', () => {
@@ -45,6 +49,59 @@ describe('interpolateEnv', () => {
     expect(interpolateEnv('${lowercase}', { lowercase: 'ok' })).toBe('ok');
     expect(interpolateEnv('${lowercase:fallback}', {})).toBe('fallback');
   });
+});
+
+describe('stripNullish', () => {
+  it('drops null-valued keys (a ${VAR:null} that resolved to null = use default)', () => {
+    expect(stripNullish({ a: 1, b: null, c: { d: null, e: 2 } })).toEqual({ a: 1, c: { e: 2 } });
+  });
+  it('keeps empty arrays + empty strings (those are real values, not "unset")', () => {
+    expect(stripNullish({ a: [], b: '', c: 0, d: false })).toEqual({ a: [], b: '', c: 0, d: false });
+  });
+});
+
+// The env-native contract: the tokenized horizon.example.yaml, interpolated +
+// stripped + parsed, must accept env overrides for every kind of field.
+describe('env-native config (horizon.example.yaml + env)', () => {
+  const here = dirname(fileURLToPath(import.meta.url));
+  const raw = readFileSync(resolve(here, '../../../../horizon.example.yaml'), 'utf8');
+  const load = (env: NodeJS.ProcessEnv): ReturnType<typeof configSchema.parse> =>
+    configSchema.parse(stripNullish(YAML.parse(interpolateEnv(raw, env)) ?? {}));
+
+  it('scalar env overrides (oap url, templates mode, boolean)', () => {
+    const cfg = load({
+      HORIZON_OAP_QUERY_URL: 'http://oap.prod:12800',
+      HORIZON_TEMPLATES_MODE: 'readonly',
+      HORIZON_RBAC_ENABLED: 'false',
+    });
+    expect(cfg.oap.queryUrl).toBe('http://oap.prod:12800');
+    expect(cfg.templates.mode).toBe('readonly');
+    expect(cfg.rbac.enabled).toBe(false);
+  });
+
+  it('JSON-array env override seeds local users (incl. an argon2 $ hash)', () => {
+    const hash = '$argon2id$v=19$m=65536,t=3,p=4$abc$def';
+    const cfg = load({
+      HORIZON_AUTH_LOCAL_USERS: JSON.stringify([{ username: 'admin', passwordHash: hash, roles: ['admin'] }]),
+    });
+    expect(cfg.auth.local.users).toEqual([{ username: 'admin', passwordHash: hash, roles: ['admin'] }]);
+  });
+
+  it('JSON-object env override sets the optional oap.auth block', () => {
+    const cfg = load({ HORIZON_OAP_AUTH: '{"username":"sw","password":"sw"}' });
+    expect(cfg.oap.auth).toEqual({ username: 'sw', password: 'sw' });
+  });
+
+  it('unset optional/structured blocks fall through to the schema default', () => {
+    const cfg = load({});
+    expect(cfg.oap.auth).toBeUndefined();
+    expect(cfg.performance.bulk.dashboard.bulkSize).toBe(6);
+    expect(cfg.layers.excluded.map((e) => e.key)).toEqual(['FAAS', 'VIRTUAL_GATEWAY']);
+  });
+
+  it('malformed JSON env throws at parse (fail loud, not silently default)', () => {
+    expect(() => load({ HORIZON_AUTH_LOCAL_USERS: '[{bad json' })).toThrow();
+  });
 
   it('survives newlines and YAML formatting', () => {
     const raw = `auth:\n  ldap:\n    bindPassword: "\${HORIZON_LDAP_BIND_PW:dev-only}"\n`;
diff --git a/apps/bff/src/config/loader.ts b/apps/bff/src/config/loader.ts
index bd1a3ad..20d9765 100644
--- a/apps/bff/src/config/loader.ts
+++ b/apps/bff/src/config/loader.ts
@@ -50,6 +50,27 @@ export function interpolateEnv(
   });
 }
 
+/**
+ * Recursively drop keys whose value is `null`. A `${VAR:null}` token (used for
+ * optional blocks + structured defaults like `oap.auth`, `auth.ldap`,
+ * `rbac.roles`, `performance`) resolves to `null` when the env var is unset,
+ * meaning "not provided — use the schema default", NOT an explicit null. No
+ * config field legitimately accepts null, so stripping them lets the strict
+ * schema fall through to its default instead of rejecting `key: null`.
+ */
+export function stripNullish(value: unknown): unknown {
+  if (Array.isArray(value)) return value.map(stripNullish);
+  if (value && typeof value === 'object') {
+    const out: Record<string, unknown> = {};
+    for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
+      if (v === null) continue;
+      out[k] = stripNullish(v);
+    }
+    return out;
+  }
+  return value;
+}
+
 /** Raised when the loaded config is structurally valid but operationally
  *  unusable in a way that cannot be deferred to runtime (reserved — no
  *  current callers; the auth-unconfigured cases boot and surface the
@@ -138,7 +159,7 @@ function parseFile(absPath: string): HorizonConfig {
   }
   const interpolated = interpolateEnv(raw);
   const parsed = YAML.parse(interpolated) ?? {};
-  return configSchema.parse(parsed);
+  return configSchema.parse(stripNullish(parsed));
 }
 
 export function loadConfig(configPath: string): ConfigSource {
diff --git a/apps/bff/src/config/schema.test.ts b/apps/bff/src/config/schema.test.ts
index f742523..24cd0f3 100644
--- a/apps/bff/src/config/schema.test.ts
+++ b/apps/bff/src/config/schema.test.ts
@@ -21,7 +21,7 @@ import { dirname, resolve } from 'node:path';
 import { describe, expect, it } from 'vitest';
 import YAML from 'yaml';
 import { configSchema } from './schema.js';
-import { interpolateEnv } from './loader.js';
+import { interpolateEnv, stripNullish } from './loader.js';
 
 describe('configSchema defaults', () => {
   it('parses an empty object — every non-optional field has a default', () => {
@@ -29,42 +29,36 @@ describe('configSchema defaults', () => {
   });
 });
 
-// Guard against horizon.example.yaml drifting from the schema defaults. The
-// example is "reference, not override" — every value it shows is meant to
-// equal what the BFF runs with when the block is omitted. If a default
-// changes (or someone edits the example to a non-default), this fails so the
-// two are reconciled before merge.
-describe('horizon.example.yaml matches schema defaults', () => {
+// horizon.example.yaml is the SHIPPED default + the env-var reference: every
+// field is a `${HORIZON_…:default}` token. Two contracts guarded here:
+//   1. With NO env set, the tokens' defaults parse to EXACTLY the schema
+//      defaults — so the file is a faithful "this is what you get" reference.
+//   2. Every top-level config section appears in the example, so a new
+//      section can't be added to the schema without an env-overridable token.
+describe('horizon.example.yaml — tokenized default + parity', () => {
   const here = dirname(fileURLToPath(import.meta.url));
   const examplePath = resolve(here, '../../../../horizon.example.yaml');
-  const example = YAML.parse(interpolateEnv(readFileSync(examplePath, 'utf8'))) ?? {};
-  const defaults = configSchema.parse({}) as Record<string, unknown>;
+  const raw = readFileSync(examplePath, 'utf8');
 
-  // YAML omits a value as null; the schema models the same absence as the
-  // empty string (interpolated `${VAR:}`). Treat the two as equal so an
-  // unset path doesn't read as drift.
-  const norm = (v: unknown): unknown => (v === null || v === undefined ? '' : v);
+  it('with NO env set, parses to exactly the schema defaults', () => {
+    const parsed = stripNullish(YAML.parse(interpolateEnv(raw, {})) ?? {});
+    expect(configSchema.parse(parsed)).toEqual(configSchema.parse({}));
+  });
 
-  // Walk only what the example actually declares; the example is allowed to
-  // omit fields (those fall back to defaults at runtime). Every scalar /
-  // array it DOES carry must match the parsed default at the same path.
-  const walk = (exVal: unknown, defVal: unknown, path: string): void => {
-    if (Array.isArray(exVal) || (exVal !== null && typeof exVal === 'object')) {
-      if (Array.isArray(exVal)) {
-        expect(defVal, `${path} should be an array in defaults`).toEqual(exVal);
-        return;
-      }
-      const exObj = exVal as Record<string, unknown>;
-      const defObj = (defVal ?? {}) as Record<string, unknown>;
-      for (const key of Object.keys(exObj)) {
-        walk(exObj[key], defObj[key], path ? `${path}.${key}` : key);
-      }
-      return;
+  it('every top-level config section has a token in the example', () => {
+    const sections = Object.keys(configSchema.parse({}) as Record<string, unknown>);
+    const exampleKeys = Object.keys((YAML.parse(raw) ?? {}) as Record<string, unknown>);
+    for (const s of sections) {
+      // `infra3d` is the deprecated/ignored passthrough — never tokenized.
+      if (s === 'infra3d') continue;
+      expect(exampleKeys, `config section "${s}" is missing from horizon.example.yaml`).toContain(s);
     }
-    expect(norm(exVal), `${path} drifted from schema default`).toEqual(norm(defVal));
-  };
+  });
 
-  it('every value present in the example equals the schema default', () => {
-    walk(example, defaults, '');
+  it('key fields are env tokens (not literals), so they are overridable', () => {
+    expect(raw).toContain('${HORIZON_OAP_QUERY_URL');
+    expect(raw).toContain('${HORIZON_AUTH_LOCAL_USERS');
+    expect(raw).toContain('${HORIZON_TEMPLATES_MODE');
+    expect(raw).toContain('${HORIZON_OAP_ADMIN_URL');
   });
 });
diff --git a/apps/bff/src/config/schema.ts b/apps/bff/src/config/schema.ts
index e00907b..e9e21a5 100644
--- a/apps/bff/src/config/schema.ts
+++ b/apps/bff/src/config/schema.ts
@@ -461,10 +461,26 @@ const performanceSchema = z
   .strict()
   .default({});
 
+// Template source mode. `live` (default) seeds bundled templates into OAP's
+// ui_template store at boot and reads/writes them via the ui_template API.
+// `readonly` renders templates from the local disk bundle only — the
+// ui_template API is never called and the config surface is read-only; OAP's
+// query API (metrics/traces/logs) is still used + boot-checked. Env-overridable
+// (`HORIZON_TEMPLATES_MODE`) so a file-less container can pick the mode.
+const templatesModeDefault: 'live' | 'readonly' =
+  process.env.HORIZON_TEMPLATES_MODE === 'readonly' ? 'readonly' : 'live';
+const templatesSchema = z
+  .object({
+    mode: z.enum(['live', 'readonly']).default(templatesModeDefault),
+  })
+  .strict()
+  .default({ mode: templatesModeDefault });
+
 export const configSchema = z
   .object({
     server: serverSchema.default({}),
     layers: layersSchema,
+    templates: templatesSchema,
     oap: oapSchema.default({}),
     auth: authSchema,
     rbac: rbacSchema,
@@ -485,6 +501,7 @@ export const configSchema = z
   .strict();
 
 export type HorizonConfig = z.infer<typeof configSchema>;
+export type TemplatesConfig = z.infer<typeof templatesSchema>;
 export type SourceMapsConfig = z.infer<typeof sourceMapsSchema>;
 export type LdapConfig = z.infer<typeof ldapSchema>;
 export type LocalUser = z.infer<typeof localUserSchema>;
diff --git a/apps/bff/src/http/config/bundle.ts b/apps/bff/src/http/config/bundle.ts
index eff0884..83cccc4 100644
--- a/apps/bff/src/http/config/bundle.ts
+++ b/apps/bff/src/http/config/bundle.ts
@@ -86,6 +86,10 @@ type ScopeMap = Partial<Record<'service' | 'instance' | 'endpoint', DashboardWid
  *  omitted here (they'd bloat the bundle 5x); the admin pages fetch
  *  them on demand from `/api/admin/templates/sync-status`. */
 export interface BundleSyncStatus {
+  /** `live` = templates read/written via OAP's ui_template store. `readonly` =
+   *  rendered from the local disk bundle; the store is not used and the config
+   *  surface is read-only. Drives the SPA's read-only chrome + banner. */
+  mode: 'live' | 'readonly';
   unreachable: boolean;
   lastSuccessfulSyncAt: number | null;
   generatedAt: number;
@@ -225,6 +229,7 @@ async function buildBundle(
   }
 
   const syncStatus: BundleSyncStatus = {
+    mode: sync.mode,
     unreachable: sync.unreachable,
     lastSuccessfulSyncAt: sync.lastSuccessfulSyncAt,
     generatedAt: sync.generatedAt,
diff --git a/apps/bff/src/logic/templates/sync.ts b/apps/bff/src/logic/templates/sync.ts
index 752a5e5..41a5bf8 100644
--- a/apps/bff/src/logic/templates/sync.ts
+++ b/apps/bff/src/logic/templates/sync.ts
@@ -94,9 +94,15 @@ export interface ConflictRow {
 }
 
 export interface SyncStatus {
+  /** Template source mode. `live` = read/write via OAP's ui_template store
+   *  (default). `readonly` = the store is never consulted; `rows` are the local
+   *  disk bundle loaded into the same in-memory shape and presented as the
+   *  effective content, and the config surface is read-only. */
+  mode: 'live' | 'readonly';
   /** When true, OAP admin was unreachable at the time this status was
    *  computed. `rows` will be a bundled-only view (every bundled row marked
-   *  `bundled-fallback`, no remote info). */
+   *  `bundled-fallback`, no remote info). Always false in `readonly` mode —
+   *  the store is deliberately not used, not unreachable. */
   unreachable: boolean;
   /** Epoch ms of the most-recent successful OAP probe. `null` when we
    *  have never reached OAP since process start. */
@@ -145,6 +151,18 @@ let cache: CacheEntry | null = null;
 let inFlight: Promise<SyncStatus> | null = null;
 let lastSuccessfulSyncAt: number | null = null;
 
+/** Boot-time template mode (`config.templates.mode`). In `readonly` the
+ *  orchestrator never touches the ui_template client — `runOnce` short-circuits
+ *  to the disk bundle. Set once at boot (and on config reload) by the server. */
+let readOnlyMode = false;
+export function setTemplateReadOnly(on: boolean): void {
+  readOnlyMode = on;
+  cache = null; // a mode flip must not serve a stale cross-mode status
+}
+export function isTemplateReadOnly(): boolean {
+  return readOnlyMode;
+}
+
 export function invalidateSyncCache(): void {
   cache = null;
 }
@@ -261,6 +279,23 @@ async function runOnce(deps: SyncDeps, opts: RunOptions): Promise<SyncStatus> {
   const now = (deps.now ?? Date.now)();
   const bundledRows = buildBundledRows(deps.bundled());
 
+  // readonly mode: the disk bundle IS the source. Never call the ui_template
+  // client (no list, no seed); present every bundled source + translation
+  // overlay as the effective content so all render consumers resolve them
+  // exactly as they would a live remote row.
+  if (readOnlyMode) {
+    lastSuccessfulSyncAt = now;
+    const overlays = deps.bundledOverlays ? [...deps.bundledOverlays()] : [];
+    return {
+      mode: 'readonly',
+      unreachable: false,
+      lastSuccessfulSyncAt,
+      generatedAt: now,
+      rows: readonlyRows(bundledRows, overlays),
+      conflicts: [],
+    };
+  }
+
   let oapRows;
   try {
     oapRows = await deps.client.list();
@@ -270,6 +305,7 @@ async function runOnce(deps: SyncDeps, opts: RunOptions): Promise<SyncStatus> {
       'OAP UI-template list failed — rendering bundled, admin read-only',
     );
     return {
+      mode: 'live',
       unreachable: true,
       lastSuccessfulSyncAt,
       generatedAt: now,
@@ -315,6 +351,7 @@ async function runOnce(deps: SyncDeps, opts: RunOptions): Promise<SyncStatus> {
 
   const rows = mergeRows(bundledRows, parsedRemote.byName);
   return {
+    mode: 'live',
     unreachable: false,
     lastSuccessfulSyncAt,
     generatedAt: now,
@@ -323,6 +360,43 @@ async function runOnce(deps: SyncDeps, opts: RunOptions): Promise<SyncStatus> {
   };
 }
 
+/** readonly-mode rows: every bundled source template + translation overlay,
+ *  presented with the disk content as the effective (`remote`) configuration so
+ *  every render consumer resolves them uniformly (the ui_template store is never
+ *  consulted in this mode). `status: 'synced'` because the rendered config is,
+ *  by construction, exactly the bundled source; the synthetic `bundled:` id is
+ *  never used for a write (writes are denied in readonly). */
+function readonlyRows(bundled: Map<string, BundledRow>, overlays: BundledOverlay[]): TemplateRow[] {
+  const out: TemplateRow[] = [];
+  for (const b of bundled.values()) {
+    out.push({
+      name: b.name,
+      kind: b.kind,
+      key: b.key,
+      status: 'synced',
+      effective: 'remote',
+      remote: { id: `bundled:${b.name}`, configuration: b.configuration, disabled: false },
+      bundled: { configuration: b.configuration },
+    });
+  }
+  for (const ov of overlays) {
+    const env = buildOverlayEnvelope(ov.kind, ov.key, ov.locale, ov.content);
+    const configuration = serializeEnvelope(env);
+    out.push({
+      name: env.name,
+      kind: ov.kind,
+      key: ov.key,
+      locale: ov.locale,
+      status: 'synced',
+      effective: 'remote',
+      remote: { id: `bundled:${env.name}`, configuration, disabled: false },
+      bundled: { configuration },
+    });
+  }
+  out.sort((a, b) => a.name.localeCompare(b.name));
+  return out;
+}
+
 /** Thrown when a write to OAP succeeded but the resulting row state
  *  didn't become visible to `list()` within the polling window. Routes
  *  catch this and return 504. */
diff --git a/apps/bff/src/rbac/route-policy.ts b/apps/bff/src/rbac/route-policy.ts
index 6e20dce..ff0abeb 100644
--- a/apps/bff/src/rbac/route-policy.ts
+++ b/apps/bff/src/rbac/route-policy.ts
@@ -28,10 +28,31 @@ import type { FastifyReply, FastifyRequest, RouteOptions } from 'fastify';
 import { sessionHasVerb } from './policy.js';
 import type { AuthDeps } from '../user/middleware.js';
 import { requireAuth } from '../user/middleware.js';
+import { isTemplateReadOnly } from '../logic/templates/sync.js';
 import { logger } from '../logger.js';
 
 export type RoutePolicy = 'public' | 'auth' | string;
 
+/** A config-template write — it pushes to OAP's ui_template store. In
+ *  `templates.mode=readonly` there is no store to write to, so these are denied
+ *  at the edge regardless of the verb grant (the UI hides them too, but a direct
+ *  request must still fail — the BFF is the authority). */
+function isTemplateWriteRoute(method: string, url: string): boolean {
+  if (method === 'GET' || method === 'HEAD') return false;
+  return url.startsWith('/api/admin/templates') || url.startsWith('/api/admin/overview-templates');
+}
+async function denyTemplateWriteWhenReadOnly(
+  _req: FastifyRequest,
+  reply: FastifyReply,
+): Promise<void> {
+  if (isTemplateReadOnly()) {
+    reply.code(409).send({
+      error: 'read_only',
+      reason: 'templates.mode=readonly — config templates are served from the local bundle and cannot be edited',
+    });
+  }
+}
+
 /**
  * Verb-only check. Assumes a prior pre-handler (`requireAuth`) has
  * already populated `req.session`; sends 401 if not.
@@ -293,6 +314,10 @@ export function makeRouteAuthHook(deps: AuthDeps) {
     const newHandlers = [];
     if (!hasAuth) newHandlers.push(requireAuth(deps));
     if (chosen !== 'auth') newHandlers.push(checkVerb(deps, chosen));
+    // readonly-mode backstop on the config-template write routes.
+    if (methods.some((m) => isTemplateWriteRoute(String(m).toUpperCase(), route.url))) {
+      newHandlers.push(denyTemplateWriteWhenReadOnly);
+    }
 
     route.preHandler = [...existing, ...newHandlers];
   };
diff --git a/apps/bff/src/server.ts b/apps/bff/src/server.ts
index 03be503..b418f40 100644
--- a/apps/bff/src/server.ts
+++ b/apps/bff/src/server.ts
@@ -66,7 +66,7 @@ import { registerOverviewRoutes } from './http/config/overview.js';
 import { registerConfigBundleRoute } from './http/config/bundle.js';
 import { registerTemplateSyncAdminRoutes } from './http/admin/template-sync.js';
 import { buildOapClients } from './client/index.js';
-import { bootSeed, waitForOapAdminReady } from './logic/templates/sync.js';
+import { bootSeed, waitForOapAdminReady, setTemplateReadOnly } from './logic/templates/sync.js';
 import { iterateBundledTemplates, iterateBundledOverlays } from './logic/templates/aggregator.js';
 // Admin (operational tools)
 import { registerDslCatalogRoutes } from './http/admin/dsl/catalog.js';
@@ -107,16 +107,25 @@ try {
   throw err;
 }
 logger.info(
-  { configPath: source.path, backend: source.current.auth.backend },
+  {
+    configPath: source.path,
+    backend: source.current.auth.backend,
+    templatesMode: source.current.templates.mode,
+  },
   'config loaded',
 );
+// Template source mode is a boot-time global the sync orchestrator reads.
+setTemplateReadOnly(source.current.templates.mode === 'readonly');
 if (source.current.auth.backend === 'ldap' && source.current.auth.local.users.length > 0) {
   logger.warn(
     { users: source.current.auth.local.users.length },
     'auth.local.users is populated but auth.backend is "ldap"; local users are ignored',
   );
 }
-source.onChange((cfg) => logger.info({ backend: cfg.auth.backend }, 'config reloaded'));
+source.onChange((cfg) => {
+  logger.info({ backend: cfg.auth.backend, templatesMode: cfg.templates.mode }, 'config reloaded');
+  setTemplateReadOnly(cfg.templates.mode === 'readonly');
+});
 
 const app = Fastify({ logger: loggerOptions });
 
@@ -356,6 +365,14 @@ app.listen({ host, port }).then(
     // admin action triggers a fresh sync. The seed itself is
     // absent-only (`seedMissing` skips templates already present), so
     // a successful previous boot leaves nothing to re-push.
+    // readonly mode renders from the disk bundle and never touches the
+    // ui_template store — skip the readiness wait + seed entirely (otherwise
+    // the backoff loop warn-spams forever against an absent/disabled admin
+    // surface). The OAP *query* reachability check is independent and stays.
+    if (source.current.templates.mode === 'readonly') {
+      logger.info('templates.mode=readonly — rendering bundled templates, ui_template store not used');
+      return;
+    }
     void (async (): Promise<void> => {
       const deps = {
         client: buildOapClients(source.current).uiTemplate(),
diff --git a/apps/ui/src/api/scopes/configs.ts b/apps/ui/src/api/scopes/configs.ts
index 8c2f0d9..e64aea7 100644
--- a/apps/ui/src/api/scopes/configs.ts
+++ b/apps/ui/src/api/scopes/configs.ts
@@ -76,6 +76,9 @@ export interface TemplateConflict {
 /** Bundle-level sync envelope. When `unreachable`, all rows fall back to
  *  bundled and the admin pages render the global read-only banner. */
 export interface BundleSyncStatus {
+  /** `live` = OAP ui_template store is the source. `readonly` = local bundle
+   *  only; the config surface is read-only. */
+  mode: 'live' | 'readonly';
   unreachable: boolean;
   lastSuccessfulSyncAt: number | null;
   generatedAt: number;
diff --git a/apps/ui/src/controls/configBundle.ts b/apps/ui/src/controls/configBundle.ts
index c9513b6..76df532 100644
--- a/apps/ui/src/controls/configBundle.ts
+++ b/apps/ui/src/controls/configBundle.ts
@@ -169,6 +169,7 @@ export function ensureConfigBundle(): Promise<void> {
           layers: {},
           overviews: [],
           syncStatus: {
+            mode: 'live',
             unreachable: true,
             lastSuccessfulSyncAt: null,
             generatedAt: now,
diff --git a/apps/ui/src/features/admin/_shared/SyncStatusBanner.vue b/apps/ui/src/features/admin/_shared/SyncStatusBanner.vue
index f036903..69a52c6 100644
--- a/apps/ui/src/features/admin/_shared/SyncStatusBanner.vue
+++ b/apps/ui/src/features/admin/_shared/SyncStatusBanner.vue
@@ -79,6 +79,8 @@ function chipLabel(s: SyncBanner['severity']): string {
   switch (s) {
     case 'unreachable':
       return 'READ-ONLY';
+    case 'readonly':
+      return 'READ-ONLY';
     case 'conflict':
       return 'CONFLICT';
     case 'diverged':
@@ -122,6 +124,13 @@ export default { chipLabel };
   border-color: var(--sw-muted, #4a525c);
   background: var(--sw-bg-elev, #161a20);
 }
+/* Deliberate read-only (templates.mode=readonly) — informational, not an
+ * error, so muted rather than the danger red of `unreachable`. */
+.sbb--readonly {
+  border-color: var(--sw-muted, #4a525c);
+  background: rgba(255, 255, 255, 0.03);
+}
+.sbb--readonly .sbb__chip { background: var(--sw-muted, #4a525c); color: #fff; }
 .sbb__row {
   display: flex;
   gap: 12px;
diff --git a/apps/ui/src/features/admin/_shared/useTemplateSync.ts b/apps/ui/src/features/admin/_shared/useTemplateSync.ts
index 0efff87..cebdcc3 100644
--- a/apps/ui/src/features/admin/_shared/useTemplateSync.ts
+++ b/apps/ui/src/features/admin/_shared/useTemplateSync.ts
@@ -46,7 +46,7 @@ import type {
   TemplateStatus,
 } from '@/api/scopes/configs';
 
-export type BannerSeverity = 'unreachable' | 'conflict' | 'diverged' | 'clean' | 'unknown';
+export type BannerSeverity = 'unreachable' | 'readonly' | 'conflict' | 'diverged' | 'clean' | 'unknown';
 
 export interface SyncBanner {
   severity: BannerSeverity;
@@ -101,7 +101,11 @@ export function useTemplateSync(opts: UseTemplateSyncOptions): UseTemplateSyncRe
     return (s.conflicts ?? []).filter((c) => c.kind === opts.kind);
   });
 
-  const readOnly = computed<boolean>(() => status.value?.unreachable === true);
+  // Read-only when OAP admin is unreachable (live mode, transient) OR the BFF
+  // is deliberately in readonly template mode (rendering the local bundle).
+  const readOnly = computed<boolean>(
+    () => status.value?.unreachable === true || status.value?.mode === 'readonly',
+  );
 
   // Shown on diverged + clean banners so the operator always knows what
   // the two axes mean.
@@ -128,6 +132,18 @@ export function useTemplateSync(opts: UseTemplateSyncOptions): UseTemplateSyncRe
     const counts: Partial<Record<TemplateStatus, number>> = {};
     for (const b of ownBadges.value) counts[b.status] = (counts[b.status] ?? 0) + 1;
 
+    if (s.mode === 'readonly') {
+      return {
+        severity: 'readonly',
+        message:
+          'Read-only mode — templates are served from the local bundle. Editing and publishing are disabled.',
+        detail:
+          'Set templates.mode=live (HORIZON_TEMPLATES_MODE=live) with OAP’s ui_template store reachable to edit.',
+        counts,
+        localCount: localCount.value,
+        conflicts: [],
+      };
+    }
     if (s.unreachable) {
       const last = s.lastSuccessfulSyncAt
         ? new Date(s.lastSuccessfulSyncAt).toLocaleString()
diff --git a/apps/ui/src/features/operate/cluster/ClusterStatusView.vue b/apps/ui/src/features/operate/cluster/ClusterStatusView.vue
index 75f1e8f..7c8fb73 100644
--- a/apps/ui/src/features/operate/cluster/ClusterStatusView.vue
+++ b/apps/ui/src/features/operate/cluster/ClusterStatusView.vue
@@ -19,6 +19,7 @@ import { computed } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { useOapInfo } from '@/shell/useOapInfo';
 import { useAdminFeatures } from '@/shell/useAdminFeatures';
+import { useConfigBundle } from '@/controls/configBundle';
 
 // Two-pane Cluster Status:
 //   - Pane A (graphql / :12800): version, server clock, timezone,
@@ -43,6 +44,15 @@ const {
   refetch: refetchInfo,
 } = useOapInfo();
 
+// Dashboard-template source mode (the ui_template store vs the local bundle)
+// rides on the config bundle the shell preloads.
+const { bundle } = useConfigBundle();
+const templateMode = computed<'live' | 'readonly'>(() => bundle.value?.syncStatus?.mode ?? 'live');
+// ui_template API availability — N/A (null) in readonly (the store isn't used).
+const uiTemplateAvailable = computed<boolean | null>(() =>
+  templateMode.value === 'readonly' ? null : bundle.value?.syncStatus?.unreachable === false,
+);
+
 const {
   result: preflight,
   adminUrl,
@@ -224,6 +234,25 @@ function refreshAll(): void {
           </tr>
         </tbody>
       </table>
+
+      <!-- Dashboard-template source: live (OAP ui_template) vs readonly (bundle). -->
+      <div class="tpl-source">
+        <span class="tpl-label">{{ t('Dashboard templates') }}</span>
+        <span class="sw-badge" :class="templateMode === 'readonly' ? 'is-warn' : 'is-ok'">
+          <span class="state-dot" />{{ templateMode === 'readonly' ? t('read-only · bundled') : t('live · OAP ui_template') }}
+        </span>
+        <span class="tpl-hint">
+          <template v-if="templateMode === 'readonly'">
+            {{ t('Rendering from the local bundle; the ui_template API is not used and editing is disabled.') }}
+          </template>
+          <template v-else-if="uiTemplateAvailable">
+            {{ t('ui_template store reachable — editing enabled.') }}
+          </template>
+          <template v-else>
+            {{ t('ui_template store unreachable — editing disabled.') }}
+          </template>
+        </span>
+      </div>
     </section>
 
     <!-- ── Pane C · Zipkin / OTLP trace endpoint ─────────────────── -->
@@ -441,6 +470,26 @@ function refreshAll(): void {
   border-radius: 6px;
 }
 
+.tpl-source {
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 10px;
+  margin-top: 12px;
+  padding: 10px 12px;
+  background: var(--sw-bg-1);
+  border: 1px solid var(--sw-line);
+  border-radius: 8px;
+  font-size: var(--sw-fs-base);
+}
+.tpl-label {
+  font-weight: var(--sw-fw-bold);
+  color: var(--sw-fg-1);
+}
+.tpl-hint {
+  color: var(--sw-fg-3);
+  font-size: var(--sw-fs-xs);
+}
 .mod-table {
   width: 100%;
   border-collapse: collapse;
diff --git a/apps/ui/src/i18n/locales/de.json b/apps/ui/src/i18n/locales/de.json
index da89ed0..f8d147b 100644
--- a/apps/ui/src/i18n/locales/de.json
+++ b/apps/ui/src/i18n/locales/de.json
@@ -1533,5 +1533,11 @@
   "No widgets visible in this tab.": "Keine Widgets in diesem Tab sichtbar.",
   "This tab has no widgets.": "Keine Widgets in diesem Tab.",
   "Clear all": "Alle löschen",
-  "Clear all and exit comparison": "Alle löschen und Vergleich beenden"
+  "Clear all and exit comparison": "Alle löschen und Vergleich beenden",
+  "Dashboard templates": "Dashboard-Vorlagen",
+  "read-only · bundled": "schreibgeschützt · gebündelt",
+  "live · OAP ui_template": "live · OAP ui_template",
+  "Rendering from the local bundle; the ui_template API is not used and editing is disabled.": "Rendering aus dem lokalen Bundle; die ui_template-API wird nicht verwendet und die Bearbeitung ist deaktiviert.",
+  "ui_template store reachable — editing enabled.": "ui_template-Speicher erreichbar — Bearbeitung aktiviert.",
+  "ui_template store unreachable — editing disabled.": "ui_template-Speicher nicht erreichbar — Bearbeitung deaktiviert."
 }
diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json
index cb27a5b..0a9ba6a 100644
--- a/apps/ui/src/i18n/locales/en.json
+++ b/apps/ui/src/i18n/locales/en.json
@@ -1533,5 +1533,11 @@
   "No widgets visible in this tab.": "No widgets visible in this tab.",
   "This tab has no widgets.": "This tab has no widgets.",
   "Clear all": "Clear all",
-  "Clear all and exit comparison": "Clear all and exit comparison"
+  "Clear all and exit comparison": "Clear all and exit comparison",
+  "Dashboard templates": "Dashboard templates",
+  "read-only · bundled": "read-only · bundled",
+  "live · OAP ui_template": "live · OAP ui_template",
+  "Rendering from the local bundle; the ui_template API is not used and editing is disabled.": "Rendering from the local bundle; the ui_template API is not used and editing is disabled.",
+  "ui_template store reachable — editing enabled.": "ui_template store reachable — editing enabled.",
+  "ui_template store unreachable — editing disabled.": "ui_template store unreachable — editing disabled."
 }
diff --git a/apps/ui/src/i18n/locales/es.json b/apps/ui/src/i18n/locales/es.json
index eb106de..bff51fd 100644
--- a/apps/ui/src/i18n/locales/es.json
+++ b/apps/ui/src/i18n/locales/es.json
@@ -1533,5 +1533,11 @@
   "No widgets visible in this tab.": "No hay widgets visibles en esta pestaña.",
   "This tab has no widgets.": "Esta pestaña no contiene widgets.",
   "Clear all": "Borrar todo",
-  "Clear all and exit comparison": "Borrar todo y salir de la comparación"
+  "Clear all and exit comparison": "Borrar todo y salir de la comparación",
+  "Dashboard templates": "Plantillas de panel",
+  "read-only · bundled": "solo lectura · incluido",
+  "live · OAP ui_template": "en vivo · OAP ui_template",
+  "Rendering from the local bundle; the ui_template API is not used and editing is disabled.": "Renderizado desde el paquete local; la API ui_template no se usa y la edición está deshabilitada.",
+  "ui_template store reachable — editing enabled.": "Almacén ui_template accesible — edición habilitada.",
+  "ui_template store unreachable — editing disabled.": "Almacén ui_template inaccesible — edición deshabilitada."
 }
diff --git a/apps/ui/src/i18n/locales/fr.json b/apps/ui/src/i18n/locales/fr.json
index 5818403..08363a3 100644
--- a/apps/ui/src/i18n/locales/fr.json
+++ b/apps/ui/src/i18n/locales/fr.json
@@ -1533,5 +1533,11 @@
   "No widgets visible in this tab.": "Aucun widget visible dans cet onglet.",
   "This tab has no widgets.": "Aucun widget dans cet onglet.",
   "Clear all": "Tout effacer",
-  "Clear all and exit comparison": "Tout effacer et quitter la comparaison"
+  "Clear all and exit comparison": "Tout effacer et quitter la comparaison",
+  "Dashboard templates": "Modèles de tableau de bord",
+  "read-only · bundled": "lecture seule · intégré",
+  "live · OAP ui_template": "en direct · OAP ui_template",
+  "Rendering from the local bundle; the ui_template API is not used and editing is disabled.": "Rendu à partir du paquet local ; l’API ui_template n’est pas utilisée et l’édition est désactivée.",
+  "ui_template store reachable — editing enabled.": "Magasin ui_template accessible — édition activée.",
+  "ui_template store unreachable — editing disabled.": "Magasin ui_template inaccessible — édition désactivée."
 }
diff --git a/apps/ui/src/i18n/locales/ja.json b/apps/ui/src/i18n/locales/ja.json
index 25b67b6..6a51ac0 100644
--- a/apps/ui/src/i18n/locales/ja.json
+++ b/apps/ui/src/i18n/locales/ja.json
@@ -1533,5 +1533,11 @@
   "No widgets visible in this tab.": "このタブに表示できるウィジェットはありません。",
   "This tab has no widgets.": "このタブにはウィジェットがありません。",
   "Clear all": "すべてクリア",
-  "Clear all and exit comparison": "すべてクリアして比較を終了"
+  "Clear all and exit comparison": "すべてクリアして比較を終了",
+  "Dashboard templates": "ダッシュボードテンプレート",
+  "read-only · bundled": "読み取り専用 · バンドル",
+  "live · OAP ui_template": "ライブ · OAP ui_template",
+  "Rendering from the local bundle; the ui_template API is not used and editing is disabled.": "ローカルバンドルからレンダリングしています。ui_template API は使用されず、編集は無効です。",
+  "ui_template store reachable — editing enabled.": "ui_template ストアに到達可能 —— 編集が有効です。",
+  "ui_template store unreachable — editing disabled.": "ui_template ストアに到達不可 —— 編集が無効です。"
 }
diff --git a/apps/ui/src/i18n/locales/ko.json b/apps/ui/src/i18n/locales/ko.json
index aead661..6bf74f0 100644
--- a/apps/ui/src/i18n/locales/ko.json
+++ b/apps/ui/src/i18n/locales/ko.json
@@ -1533,5 +1533,11 @@
   "No widgets visible in this tab.": "이 탭에 표시할 위젯이 없습니다.",
   "This tab has no widgets.": "이 탭에는 위젯이 없습니다.",
   "Clear all": "모두 지우기",
-  "Clear all and exit comparison": "모두 지우고 비교 종료"
+  "Clear all and exit comparison": "모두 지우고 비교 종료",
+  "Dashboard templates": "대시보드 템플릿",
+  "read-only · bundled": "읽기 전용 · 번들",
+  "live · OAP ui_template": "라이브 · OAP ui_template",
+  "Rendering from the local bundle; the ui_template API is not used and editing is disabled.": "로컬 번들에서 렌더링합니다. ui_template API는 사용되지 않으며 편집이 비활성화됩니다.",
+  "ui_template store reachable — editing enabled.": "ui_template 저장소에 연결됨 — 편집이 활성화됩니다.",
+  "ui_template store unreachable — editing disabled.": "ui_template 저장소에 연결할 수 없음 — 편집이 비활성화됩니다."
 }
diff --git a/apps/ui/src/i18n/locales/pt.json b/apps/ui/src/i18n/locales/pt.json
index 27d3113..5e8ab13 100644
--- a/apps/ui/src/i18n/locales/pt.json
+++ b/apps/ui/src/i18n/locales/pt.json
@@ -1533,5 +1533,11 @@
   "No widgets visible in this tab.": "Nenhum widget visível nesta aba.",
   "This tab has no widgets.": "Nenhum widget nesta aba.",
   "Clear all": "Limpar tudo",
-  "Clear all and exit comparison": "Limpar tudo e sair da comparação"
+  "Clear all and exit comparison": "Limpar tudo e sair da comparação",
+  "Dashboard templates": "Modelos de painel",
+  "read-only · bundled": "somente leitura · incluído",
+  "live · OAP ui_template": "ao vivo · OAP ui_template",
+  "Rendering from the local bundle; the ui_template API is not used and editing is disabled.": "Renderizando a partir do pacote local; a API ui_template não é usada e a edição está desabilitada.",
+  "ui_template store reachable — editing enabled.": "Armazenamento ui_template acessível — edição habilitada.",
+  "ui_template store unreachable — editing disabled.": "Armazenamento ui_template inacessível — edição desabilitada."
 }
diff --git a/apps/ui/src/i18n/locales/zh-CN.json b/apps/ui/src/i18n/locales/zh-CN.json
index ea6d75e..dba1e96 100644
--- a/apps/ui/src/i18n/locales/zh-CN.json
+++ b/apps/ui/src/i18n/locales/zh-CN.json
@@ -1533,5 +1533,11 @@
   "No widgets visible in this tab.": "此标签页中没有可见的组件。",
   "This tab has no widgets.": "此标签页中暂无组件。",
   "Clear all": "全部清除",
-  "Clear all and exit comparison": "清除全部并退出对比"
+  "Clear all and exit comparison": "清除全部并退出对比",
+  "Dashboard templates": "仪表板模板",
+  "read-only · bundled": "只读 · 内置",
+  "live · OAP ui_template": "在线 · OAP ui_template",
+  "Rendering from the local bundle; the ui_template API is not used and editing is disabled.": "从本地内置模板渲染;不使用 ui_template API,编辑已禁用。",
+  "ui_template store reachable — editing enabled.": "ui_template 存储可达 —— 编辑已启用。",
+  "ui_template store unreachable — editing disabled.": "ui_template 存储不可达 —— 编辑已禁用。"
 }
diff --git a/docs/setup/container-image.md b/docs/setup/container-image.md
index 7053553..75c12de 100644
--- a/docs/setup/container-image.md
+++ b/docs/setup/container-image.md
@@ -29,7 +29,7 @@ The full commit SHA is the canonical, immutable identifier. Moving tags are conv
 | `/app/node_modules/` | root | no | Production npm dependencies. |
 | `/app/static/` | root | no | Built UI assets (Vite `dist/`). |
 | `/app/horizon.example.yaml` | root | no | Example config — **read-only reference**, copy from it. |
-| `/app/horizon.yaml` | n/a | n/a | Where the BFF expects the **active** config. **Not present in the image** — provide via mount or `COPY` (see below). |
+| `/app/horizon.yaml` | root | no | The **active** config — a **baked, fully tokenized default** (every field is a `${HORIZON_…:default}` env token). The image runs with no mounted file; override any field via env (see [Run with env vars only](#run-with-env-vars-only-no-mounted-file)), or bind-mount your own to replace it. |
 | `/app/bundled_templates/` | **horizon** | **yes** | Layer + overview JSON templates. Owned by `horizon` because the admin **Layer-Templates** and **Overview-Templates** editors write into per-key files here. |
 | `/data/` | **horizon** | **yes** | Declared `VOLUME`. Default destination for the audit log, setup state, alarm state, and wire debug log. Mount a PVC / named volume / host bind here for durable storage. |
 | `/app/sourcemaps/` | **horizon** | (read) | Static source maps for the **Browser Logs** tab. Bind-mount or copy `.map` files here and they're loaded at boot — durable across restarts. Optional; runtime uploads work without it. See [Browser Logs & Source Maps](../operate/browser-source-maps.md). |
@@ -54,6 +54,55 @@ The four `HORIZON_*_FILE` env vars seed the **defaults** the config schema uses
 
 `server.host` and `server.port` come from the YAML when present. If they are omitted, the image supplies defaults via `HORIZON_SERVER_HOST=0.0.0.0` and `HORIZON_SERVER_PORT=8081`. The image sets `EXPOSE 8081`; if you change `server.port`, also publish the new port.
 
+## Run with env vars only (no mounted file)
+
+The baked `/app/horizon.yaml` is **fully tokenized** — every config field is a `${HORIZON_…:default}` env var — so you can run the published image with **no mounted config** and set only the vars you need. Precedence is **env var > the baked file's default > built-in default**. The config file itself is the complete, self-documenting list; the table below mirrors it.
+
+Scalar vars take a plain value; **list / object vars take a JSON string** (injected into the YAML and parsed). A `null`/`[]` default means "use the built-in default".
+
+| Variable | Default | Type | Sets |
+|---|---|---|---|
+| `HORIZON_TEMPLATES_MODE` | `live` | `live` \| `readonly` | Template source: OAP ui_template store (`live`) vs. the local bundle, read-only (`readonly`). |
+| `HORIZON_OAP_QUERY_URL` | `http://127.0.0.1:12800` | url | OAP GraphQL / query host. |
+| `HORIZON_OAP_ADMIN_URL` | `http://127.0.0.1:17128` | url | OAP admin host (runtime-rule / inspect / status). |
+| `HORIZON_OAP_ZIPKIN_URL` | `http://127.0.0.1:9412/zipkin` | url | OAP Zipkin v2 host. |
+| `HORIZON_OAP_TIMEOUT_MS` | `15000` | int | Outbound OAP request timeout. |
+| `HORIZON_OAP_AUTH` | (none) | JSON | OAP basic-auth, e.g. `{"username":"sw","password":"sw"}`. |
+| `HORIZON_AUTH_BACKEND` | `local` | `local` \| `ldap` | Auth backend. |
+| `HORIZON_AUTH_LOCAL_USERS` | `[]` | JSON | Local users: `[{"username":"admin","passwordHash":"$argon2id$…","roles":["admin"]}]` (hash via `pnpm --filter bff cli:hash`). |
+| `HORIZON_AUTH_LDAP` | (none) | JSON | LDAP block: `{"url":"ldaps://…","userBaseDn":"…","groupMappings":[{"group":"*","role":"viewer"}]}`. |
+| `HORIZON_AUTH_BREAK_GLASS` | (none) | JSON | Break-glass admin (honored only when `ldap` + LDAP probe failing). |
+| `HORIZON_RBAC_ENABLED` | `true` | bool | When `false`, every session gets `*`. |
+| `HORIZON_RBAC_ROLES` | (built-in) | JSON | Role → verb-grants map. |
+| `HORIZON_RBAC_LANDING_BY_ROLE` | (built-in) | JSON | Post-login landing route per role. |
+| `HORIZON_LAYERS_EXCLUDED` | `FAAS`, `VIRTUAL_GATEWAY` | JSON | Layers hidden from the sidebar; `[]` shows all. |
+| `HORIZON_SESSION_TTL_MINUTES` | `60` | int | Session lifetime. |
+| `HORIZON_SESSION_COOKIE_NAME` | `horizon_sid` | string | Session cookie name. |
+| `HORIZON_SESSION_COOKIE_SECURE` | `false` | bool | Set `true` behind HTTPS. |
+| `HORIZON_QUERY_LANDING_SERVICE_CAP` | `100` | int | Max services a layer landing fetches metrics for per request. |
+| `HORIZON_SOURCEMAPS_ENABLED` | `true` | bool | Source-map upload / resolve capability. |
+| `HORIZON_SOURCEMAPS_MAX_FILE_BYTES` | `67108864` | int | Reject a `.map` larger than this (64 MiB). |
+| `HORIZON_SOURCEMAPS_MAX_TOTAL_BYTES` | `536870912` | int | In-memory map budget (512 MiB, LRU-evicted). |
+| `HORIZON_SOURCEMAPS_MAX_FILE_COUNT` | `128` | int | Max hosted maps. |
+| `HORIZON_DEBUG_LOG_ENABLED` | `false` | bool | OAP wire debug log. |
+| `HORIZON_DEBUG_LOG_MAX_BODY_CHARS` | `8192` | int | Wire-log body truncation. |
+| `HORIZON_DEBUG_LOG_REDACT_AUTH` | `true` | bool | Redact auth headers in the wire log. |
+| `HORIZON_PERFORMANCE` | (built-in) | JSON | BFF→OAP fan-out + caps, e.g. `{"bulk":{"dashboard":{"bulkSize":8}}}`. |
+
+Server bind, static dir, the `HORIZON_*_FILE` state paths, and `HORIZON_SOURCEMAPS_DIR` are in the table above this section (the image already sets them to container-appropriate values).
+
+A minimal env-only run against a real OAP with one admin user:
+
+```bash
+docker run --rm -p 8081:8081 \
+  -e HORIZON_OAP_QUERY_URL=http://oap:12800 \
+  -e HORIZON_OAP_ADMIN_URL=http://oap:17128 \
+  -e HORIZON_AUTH_LOCAL_USERS='[{"username":"admin","passwordHash":"'"$(…cli:hash…)"'","roles":["admin"]}]' \
+  ghcr.io/apache/skywalking-horizon-ui:<version>
+```
+
+To run standalone on the bundled templates (no ui_template admin API), add `-e HORIZON_TEMPLATES_MODE=readonly` — dashboards render from the local bundle and the config surface is read-only (the OAP query host is still required for metrics / traces / logs).
+
 ## Memory & sizing
 
 The BFF holds its **source-map cache in the Node heap** — uploaded Browser-Logs maps live in process memory, not in OAP — so the container's memory limit and Node's heap limit must be sized together with the source-map budget.
diff --git a/horizon.example.yaml b/horizon.example.yaml
index 922b140..341204a 100644
--- a/horizon.example.yaml
+++ b/horizon.example.yaml
@@ -13,274 +13,124 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-# Copy this file to `horizon.yaml` and edit. Hot-reload picks up changes.
+# ─────────────────────────────────────────────────────────────────────
+# This is the SHIPPED default config. EVERY field below is an env var:
+# `${HORIZON_X:default}` expands BEFORE YAML parsing. So you can run the
+# container with NO mounted file and set only the env vars you care about
+# (image-native), OR copy this to `horizon.yaml`, edit, and mount it.
 #
-# Env-var interpolation: `${VAR}` or `${VAR:default}` is expanded BEFORE
-# YAML parsing. Use the `:default` form for non-secret values; for
-# secrets prefer the bare `${VAR}` form so a missing env var fails loud
-# instead of silently falling back to a placeholder.
+#   • Scalars:        set `HORIZON_X` to override the `:default`.
+#   • Lists / objects (users, ldap, roles, excluded, performance, oap.auth):
+#     set the matching env var to a JSON STRING — it's injected inline and
+#     parsed. A `:null` default means "fall through to the built-in default".
+#   • Precedence: env var > this file's `:default` > built-in schema default.
+#   • Secrets (password hashes, ldap bind pw) are env-only — never bake them.
+#
+# Hot-reload picks up file edits; env vars are fixed at process start.
+# ─────────────────────────────────────────────────────────────────────
 
 server:
-  host: 127.0.0.1
-  port: 8081
-
-# Layers hidden from the sidebar even when OAP reports them in listLayers.
-# Config-driven — there is no hard-coded hide list. Clear `excluded` to
-# surface every reported layer, or add keys for internal-only layers you
-# don't want on the menu. `reason` is a note for whoever reads this file;
-# it is NOT shown in the UI (an excluded layer simply doesn't appear).
-# Keys are OAP layer keys (UPPER_SNAKE), matched case-insensitively.
-# The defaults below are applied when this block is omitted entirely.
-layers:
-  excluded:
-    - key: FAAS
-      reason: Deprecated.
-    - key: VIRTUAL_GATEWAY
-      reason: Not planned to set up.
+  # Bind host/port. The image sets HORIZON_SERVER_HOST=0.0.0.0 so the BFF
+  # is reachable from outside the container (the YAML default 127.0.0.1
+  # would bind container-loopback only).
+  host: ${HORIZON_SERVER_HOST:127.0.0.1}
+  port: ${HORIZON_SERVER_PORT:8081}
+
+templates:
+  # `live` (default): seed bundled dashboard templates into OAP's
+  # ui_template store at boot, then read/write them via that API.
+  # `readonly`: render templates from the local bundle only — the
+  # ui_template API is never called and the whole config surface is
+  # read-only. OAP's query API (metrics/traces/logs) is still used either
+  # way. Use `readonly` to run standalone against an OAP whose ui_template
+  # admin API is absent or disabled.
+  mode: ${HORIZON_TEMPLATES_MODE:live}
 
 oap:
-  # OAP query host (port 12800 by default; GraphQL + /status/*). One URL
-  # because query traffic is load-balanceable — any OAP node can answer.
-  queryUrl: http://127.0.0.1:12800
-  # OAP admin host (port 17128 by default; runtime-rule / dsl-debugging
-  # / inspect / status live here). Single URL — point it at a DNS name
-  # or VIP fronting your OAP cluster. Runtime-rule lists hit it once
-  # (OAP routes cluster-internal); per-node live-debug status uses a
-  # DNS lookup on the hostname to discover all node IPs and probes each.
-  adminUrl: http://127.0.0.1:17128
-  # OAP's Zipkin v2 REST host. Defaults to a standalone port (9412 +
-  # /zipkin) per the upstream Armeria binding. When OAP is configured
-  # to share the GraphQL port (typical for the demo / k8s deploys),
-  # use `<queryUrl>/zipkin` instead.
-  zipkinUrl: http://127.0.0.1:9412/zipkin
-  timeoutMs: 15000
-  # Optional basic-auth credentials for outbound OAP calls.
-  # auth:
-  #   username: skywalking
-  #   password: skywalking
-
-# ────────────────────────────────────────────────────────────────────
-# Authentication
-#
-# Pick ONE backend: `local` or `ldap`. The other block can stay
-# populated for break-glass / future use, but it's IGNORED. If you set
-# `backend: ldap` and leave `auth.local.users` populated, a warning is
-# logged at startup.
-#
-# Bootstrap rule: if `backend: local` and `auth.local.users` is empty,
-# OR `backend: ldap` and `auth.ldap` is missing / `groupMappings` empty,
-# the BFF boots but no login succeeds until auth is configured. There is
-# no "default admin/admin" password.
-# Generate hashes with:   pnpm --filter bff cli:hash
-# ────────────────────────────────────────────────────────────────────
+  # OAP query host (GraphQL + /status/*; default port 12800).
+  queryUrl: ${HORIZON_OAP_QUERY_URL:http://127.0.0.1:12800}
+  # OAP admin host (runtime-rule / dsl-debugging / inspect / status;
+  # default port 17128). Point at a DNS name / VIP fronting the cluster.
+  adminUrl: ${HORIZON_OAP_ADMIN_URL:http://127.0.0.1:17128}
+  # OAP Zipkin v2 REST host. Use `<queryUrl>/zipkin` when OAP shares the
+  # GraphQL port (typical for the demo / k8s deploys).
+  zipkinUrl: ${HORIZON_OAP_ZIPKIN_URL:http://127.0.0.1:9412/zipkin}
+  timeoutMs: ${HORIZON_OAP_TIMEOUT_MS:15000}
+  # Optional basic-auth for outbound OAP calls. JSON env, e.g.
+  # HORIZON_OAP_AUTH='{"username":"skywalking","password":"skywalking"}'.
+  auth: ${HORIZON_OAP_AUTH:null}
+
+# Layers hidden from the sidebar even when OAP reports them. JSON array of
+# {key, reason}; `:null` keeps the built-in default (FAAS, VIRTUAL_GATEWAY).
+# HORIZON_LAYERS_EXCLUDED='[]' surfaces every reported layer.
+layers:
+  excluded: ${HORIZON_LAYERS_EXCLUDED:null}
+
+# ─────────────────────────────────────────────────────────────────────
+# Authentication. Pick ONE backend: `local` or `ldap`. With `local` and no
+# users (or `ldap` with no group mappings) the BFF boots but no login
+# succeeds until configured — there is no default admin/admin.
+# Generate password hashes with:  pnpm --filter bff cli:hash
+# ─────────────────────────────────────────────────────────────────────
 auth:
-  backend: local
-
+  backend: ${HORIZON_AUTH_BACKEND:local}
+  # Local users — JSON array, e.g.
+  # HORIZON_AUTH_LOCAL_USERS='[{"username":"admin","passwordHash":"$argon2id$v=19$...","roles":["admin"]}]'
   local:
-    users: []
-    # - username: admin
-    #   passwordHash: "$argon2id$v=19$..."
-    #   roles: [admin]
-
-  # ── LDAP ──────────────────────────────────────────────────────────
-  # Required when `backend: ldap`. Passwords are never stored or read
-  # from the directory — login binds AS the user with their typed
-  # password, and group membership maps to a Horizon role via
-  # `groupMappings` (first match wins, multiple matches union).
-  # ldap:
-  #   url: ldaps://ldap.corp:636
-  #   bindDn: "cn=horizon,ou=services,dc=corp"
-  #   # `${VAR}` (required) or `${VAR:default}` (optional). Leave empty
-  #   # for anonymous service-bind (only works if the directory permits
-  #   # anonymous searches).
-  #   bindPassword: "${HORIZON_LDAP_BIND_PW}"
-  #   userBaseDn: "ou=people,dc=corp"
-  #   # `{username}` is substituted with the typed username (RFC 4515
-  #   # escaped). Use `(sAMAccountName={username})` for AD.
-  #   userFilter: "(uid={username})"
-  #   displayNameAttr: cn
-  #   # `memberOf` reads the attr off the user entry (AD-style);
-  #   # `search` searches `groupBaseDn` for the user's DN (OpenLDAP-style).
-  #   groupStrategy: memberOf
-  #   groupBaseDn: ""        # only used when groupStrategy: search
-  #   memberAttr: member     # only used when groupStrategy: search
-  #   timeoutMs: 5000
-  #   tlsInsecure: false     # set true only for dev with self-signed certs
-  #   groupMappings:
-  #     - group: "cn=horizon-admin,ou=groups,dc=corp"
-  #       role: admin
-  #     - group: "cn=sre,ou=groups,dc=corp"
-  #       role: operator
-  #     - group: "cn=platform,ou=groups,dc=corp"
-  #       role: maintainer
-  #     - group: "*"           # fallback for every authenticated user
-  #       role: viewer
-
-  # ── Break-glass ───────────────────────────────────────────────────
-  # Optional local admin credential honored ONLY when:
-  #   - backend: ldap, AND
-  #   - the LDAP probe is currently failing.
-  # Every successful break-glass login is audited at WARN level. Leave
-  # commented out to disable.
-  # breakGlass:
-  #   username: emergency-admin
-  #   passwordHash: "${HORIZON_BREAK_GLASS_HASH}"
-  #   roles: [admin]
-
-# ────────────────────────────────────────────────────────────────────
-# RBAC: four built-in roles. Verbs are dot-namespaced; `*:read` grants
-# every read across all areas, `area:*` grants every action in one area,
-# `*` grants everything. Routes are gated by a single policy table on
-# the BFF — see `apps/bff/src/rbac/route-policy.ts`.
-# ────────────────────────────────────────────────────────────────────
+    users: ${HORIZON_AUTH_LOCAL_USERS:[]}
+  # LDAP block (required when backend=ldap) — JSON object, e.g.
+  # HORIZON_AUTH_LDAP='{"url":"ldaps://ldap.corp:636","userBaseDn":"ou=people,dc=corp","groupMappings":[{"group":"*","role":"viewer"}]}'
+  ldap: ${HORIZON_AUTH_LDAP:null}
+  # Break-glass admin, honored ONLY when backend=ldap AND the LDAP probe is
+  # failing; every use is audited at WARN. JSON object or null.
+  breakGlass: ${HORIZON_AUTH_BREAK_GLASS:null}
+
+# RBAC. `roles` maps a role → its verb grants; `landingByRole` is the
+# post-login landing route per role. `:null` keeps the built-in defaults
+# (viewer/maintainer/operator/admin). Override with a JSON object env var.
 rbac:
-  enabled: true
-  roles:
-    # Data catalog + read-only overview dashboards. Deliberately NOT
-    # `*:read` so a viewer can't accidentally see rule definitions,
-    # live-debug sessions, setup screens, or cluster / TTL / config internals.
-    viewer:
-      - metrics:read
-      - alarms:read
-      - traces:read
-      - logs:read
-      - browser-errors:read
-      - inspect:read
-      - topology:read
-      - profile:read
-      - overview:read
-      - infra-3d:read
-
-    # Viewer + platform monitoring (OAP cluster + module inspector).
-    maintainer:
-      - metrics:read
-      - alarms: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
-
-    # Configures observability: dashboards, alarm rules, DSL/OAL,
-    # diagnostics, profiling. Inherits viewer + platform reads so the
-    # operator can verify their edits against live data.
-    operator:
-      - metrics:read
-      - alarms:read
-      - traces:read
-      - logs:read
-      - browser-errors:read
-      - source-map:write
-      - topology:read
-      - profile:read
-      - cluster:read
-      - inspect:read
-      - ttl:read
-      - config:read
-      - overview:read
-      - overview:write
-      - setup:read
-      - setup:write
-      - dashboard:read
-      - dashboard:write
-      - alarm-setup:read
-      - alarm-setup:write
-      - 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
-
-    admin:
-      - "*"
-  # Where each role lands after login. First role on the user wins.
-  # Cluster status lives under /operate/cluster (it's an operator tool
-  # against OAP, not an admin / RBAC surface); the prior `/admin/cluster`
-  # values 404'd because no route by that name exists.
-  landingByRole:
-    viewer: /
-    maintainer: /operate/cluster
-    operator: /
-    admin: /operate/cluster
+  enabled: ${HORIZON_RBAC_ENABLED:true}
+  roles: ${HORIZON_RBAC_ROLES:null}
+  landingByRole: ${HORIZON_RBAC_LANDING_BY_ROLE:null}
 
 session:
-  ttlMinutes: 60
-  cookieName: horizon_sid
-  cookieSecure: false           # set true behind HTTPS
-
-# State files (audit log, setup state, alarm state, wire debug log).
-# Leave the `file:` paths unset and the right location is chosen for you:
-#   • published container image → /data/*    (its writable volume)
-#   • local binary run          → ./horizon-* (relative to the working dir)
-# Set an explicit `file:` under each block ONLY if you need a specific
-# location — and make sure the running process can write it (in the image
-# that means a path under /data, since the non-root `horizon` user cannot
-# write /app).
+  ttlMinutes: ${HORIZON_SESSION_TTL_MINUTES:60}
+  cookieName: ${HORIZON_SESSION_COOKIE_NAME:horizon_sid}
+  cookieSecure: ${HORIZON_SESSION_COOKIE_SECURE:false}   # set true behind HTTPS
+
+# State files. The image sets HORIZON_*_FILE=/data/* (its writable volume);
+# a local run defaults to ./horizon-* in the working dir. Set an explicit
+# path only if you need one the running process can write.
+audit:
+  file: ${HORIZON_AUDIT_FILE:./horizon-audit.jsonl}
+setup:
+  file: ${HORIZON_SETUP_FILE:./horizon-setup.json}
+alarms:
+  file: ${HORIZON_ALARMS_FILE:./horizon-alarms.json}
 
 debugLog:
-  enabled: false
-  maxBodyChars: 8192
-  redactAuthHeaders: true
-
-# JS source maps for the BROWSER-layer "Browser Errors" tab (#6784).
-# Maps de-obfuscate minified error stacks back to original source. They
-# are held in the BFF process memory ONLY — there is no OAP-side storage —
-# so this cache is per-instance and intentionally temporary: uploaded maps
-# are lost on restart and the least-recently-used are evicted once the
-# budget is hit. For durable provisioning, drop .map files into the static
-# `bootMountDir` (Docker/k8s mount) — those are reloaded on demand and
-# survive restarts.
+  enabled: ${HORIZON_DEBUG_LOG_ENABLED:false}
+  file: ${HORIZON_WIRE_LOG_FILE:./horizon-wire.jsonl}
+  maxBodyChars: ${HORIZON_DEBUG_LOG_MAX_BODY_CHARS:8192}
+  redactAuthHeaders: ${HORIZON_DEBUG_LOG_REDACT_AUTH:true}
+
+query:
+  # Max services a layer landing runs metric MQE for, per request.
+  landingServiceCap: ${HORIZON_QUERY_LANDING_SERVICE_CAP:100}
+
+# JS source maps for the BROWSER-layer "Browser Errors" tab (#6784). Held
+# in BFF process memory only (no OAP storage), per-instance + ephemeral.
+# Drop durable .map files into `bootMountDir` (the image sets it to
+# /app/sourcemaps).
 sourceMaps:
-  enabled: true
-  maxFileBytes: 67108864      # 64 MiB — reject any single .map larger
-  maxTotalBytes: 536870912    # 512 MiB — total in-memory budget (LRU-evicted)
-  maxFileCount: 128           # cap on number of hosted maps
-  # Directory scanned at boot for statically-provisioned .map files. The
-  # published image sets HORIZON_SOURCEMAPS_DIR=/app/sourcemaps; leave empty
-  # to disable the static mount.
+  enabled: ${HORIZON_SOURCEMAPS_ENABLED:true}
+  maxFileBytes: ${HORIZON_SOURCEMAPS_MAX_FILE_BYTES:67108864}      # 64 MiB
+  maxTotalBytes: ${HORIZON_SOURCEMAPS_MAX_TOTAL_BYTES:536870912}   # 512 MiB
+  maxFileCount: ${HORIZON_SOURCEMAPS_MAX_FILE_COUNT:128}
   bootMountDir: ${HORIZON_SOURCEMAPS_DIR:}
 
-# ────────────────────────────────────────────────────────────────────
-# Performance / behavior tuning — how hard the BFF fans queries out to
-# OAP, and the caps that protect storage. OPERATIONAL (per-deployment,
-# hot-reloaded, never published to OAP), unlike dashboard content, which
-# lives in templates. The whole block is optional — defaults equal the
-# built-in values, shown here for reference. Every value is clamped to a
-# hard ceiling; config can lower it, never raise it past that.
-#
-# Node heap: the BFF holds the source-map cache (above) in process memory,
-# so size the container memory limit and `NODE_OPTIONS=--max-old-space-size`
-# to your sourceMaps budget. (--max-old-space-size is a process flag, not a
-# config field — V8 reads it before this file loads.)
-performance:
-  bulk:
-    # Service-map family (topology / instance-topology / deployment /
-    # endpoint-dependency): bulkSize = aliased MQE fragments per OAP
-    # request; concurrency = parallel requests.
-    topology:   { nodeBulkSize: 150, edgeBulkSize: 200, concurrency: 4 }
-    # 3D infrastructure-map metric fan-out (was the 3D template `pipeline`).
-    infra3d:    { metricBulkSize: 6, metricConcurrency: 4, topologyConcurrency: 4, templateConcurrency: 8 }
-    # Per-layer landing metric-column batches.
-    landing:    { bulkSize: 6, concurrency: 8 }
-    # Dashboard widget metric fan-out.
-    dashboard:  { bulkSize: 6 }
-  limits:
-    # Service-map render valve — a larger graph is rejected with a
-    # "narrow the scope" notice rather than drawn unreadably.
-    topologyMaxNodes: 5000
-    topologyMaxEdges: 15000
-    # Max RECORDS per request for each event list (the OAP storage LIMIT)
-    # — not a page count; the UI picker maxes at the same value.
-    maxPageSize: { traces: 100, logs: 100, browserLogs: 100 }
+# Performance / behavior tuning — BFF→OAP fan-out + storage-protective caps.
+# Operational, hot-reloaded. `:null` keeps the built-in defaults (shown in
+# the docs); override the whole tree with a JSON object env var, e.g.
+# HORIZON_PERFORMANCE='{"bulk":{"dashboard":{"bulkSize":8}}}'.
+performance: ${HORIZON_PERFORMANCE:null}


(skywalking-horizon-ui) 11/11: refactor(config): one committed env-driven horizon.yaml (drop the example/copy split)

Posted by wu...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a commit to branch feat/template-modes-env-config
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git

commit 3a1288a65278bc213d716a2dddbc8b1cd30e5bee
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Fri Jun 26 10:39:44 2026 +0800

    refactor(config): one committed env-driven horizon.yaml (drop the example/copy split)
    
    Collapse `horizon.example.yaml` + the gitignored local copy into a single
    committed `horizon.yaml` — the same fully tokenized file the image ships and the
    default `pnpm start` reads. Every field is a `${HORIZON_…:default}` token, so all
    config is supplied via environment variables; nothing is edited in the file.
    
    - `git mv horizon.example.yaml horizon.yaml`; un-ignore it in .gitignore +
      .dockerignore (now a tracked build input, not a local secret-bearing file).
    - Dockerfile bakes `dist/horizon.yaml` directly as `/app/horizon.yaml` — no more
      separate `/app/horizon.example.yaml` reference copy, no rename-on-copy.
    - package.mjs copies the committed `horizon.yaml` into dist/; release.sh + the
      parity tests (schema/loader) read `horizon.yaml`.
    - Docs: README, setup/overview, container-image, rbac, local-backend, ports no
      longer reference an example file; local-backend clarifies the committed file
      holds only `${HORIZON_…}` tokens (real hashes via HORIZON_AUTH_LOCAL_USERS).
    - local-boot skill: boots the repo `horizon.yaml` with OAP target + dev users
      (`dev-users.json`) / LDAP (`dev-ldap.json`) injected via HORIZON_* env vars;
      the three per-scenario yaml configs are removed. Documents the single-line-JSON
      constraint for env-injected lists/objects.
    
    Validated live: env-native boot (root horizon.yaml + env vars only) against the
    demo OAP — auth configured, login admin/admin → 200, OAP admin URL injected.
    type-check / lint / license / 162 BFF + 116 UI tests green.
---
 .claude/skills/local-boot/SKILL.md           | 354 +++++++++++----------------
 .claude/skills/local-boot/dev-ldap.json      |   1 +
 .claude/skills/local-boot/dev-users.json     |   1 +
 .claude/skills/local-boot/horizon.demo.yaml  | 119 ---------
 .claude/skills/local-boot/horizon.ldap.yaml  | 125 ----------
 .claude/skills/local-boot/horizon.local.yaml | 122 ---------
 .dockerignore                                |  14 +-
 .gitignore                                   |   4 +-
 CHANGELOG.md                                 |   2 +-
 Dockerfile                                   |   7 +-
 README.md                                    |   2 +-
 apps/bff/src/config/loader.test.ts           |   6 +-
 apps/bff/src/config/schema.test.ts           |  10 +-
 docs/access-control/local-backend.md         |   4 +-
 docs/compatibility/ports.md                  |   2 +-
 docs/setup/container-image.md                |   1 -
 docs/setup/overview.md                       |   5 +-
 docs/setup/rbac.md                           |   2 +-
 horizon.example.yaml => horizon.yaml         |   0
 scripts/package.mjs                          |   8 +-
 scripts/release.sh                           |   2 +-
 21 files changed, 184 insertions(+), 607 deletions(-)

diff --git a/.claude/skills/local-boot/SKILL.md b/.claude/skills/local-boot/SKILL.md
index e623e65..a7993e7 100644
--- a/.claude/skills/local-boot/SKILL.md
+++ b/.claude/skills/local-boot/SKILL.md
@@ -1,81 +1,71 @@
 ---
 name: local-boot
-description: Boot the Horizon UI dev env (BFF + UI) against a local OAP or the public Apache demo OAP, using the static configs bundled with this skill. Handles the apps/bff cwd / HORIZON_CONFIG gotcha and the demo OAP password (kept out of git via ${OAP_PASSWORD}).
+description: Boot the Horizon UI dev env (BFF + UI) against a local OAP or the public Apache demo OAP. Uses the repo's committed, env-driven horizon.yaml — the same config the image ships — and injects the OAP target + dev users purely via HORIZON_* environment variables. Handles the apps/bff cwd / HORIZON_CONFIG gotcha and the demo OAP password (kept out of git via the cached oap-password.local).
 user-invocable: true
 ---
 
 # Boot the Horizon UI local dev env
 
-Two bundled static configs live next to this file:
+There is **one config file**: the repo's committed `horizon.yaml` at the
+repo root. Every field in it is a `${HORIZON_…:default}` token, so dev boots
+use the SAME file the Docker image ships and override only what they need via
+**environment variables** — there are no per-scenario config files anymore.
 
-- `horizon.local.yaml` — no-auth OAP. Defaults to `127.0.0.1:12800`, but the OAP URLs are env-overridable (see "Custom OAP target" below) so the same file boots against any no-auth OAP (remote dev cluster, deliberately-unreachable port for the landing-block preview, etc.).
-- `horizon.demo.yaml` — public Apache demo OAP, OAP password read from `${OAP_PASSWORD}`.
+Two committed helpers live next to this file (throwaway dev values, safe to
+commit):
 
-Both define the same throwaway Horizon login users (password == username):
-`viewer`, `maintainer`, `operator`, `admin`.
+- `dev-users.json` — the four local login users (`viewer` / `maintainer` /
+  `operator` / `admin`, **password == username**), as a **single-line** JSON
+  array for `HORIZON_AUTH_LOCAL_USERS`.
+- `dev-ldap.json` — the test-OpenLDAP config for `HORIZON_AUTH_LDAP` (single line).
 
-The stack: **BFF** (Fastify) on `:8081`, **UI** (Vite) on `:9091` proxying `/api` → `:8081`. Open **`http://127.0.0.1:9091`** (use the IPv4 literal, not `localhost` — see the proxy/IPv4 section).
+The stack: **BFF** (Fastify) on `:8081`, **UI** (Vite) on `:9091` proxying
+`/api` → `:8081`. Open **`http://127.0.0.1:9091`** (use the IPv4 literal, not
+`localhost` — see the proxy/IPv4 section). Both ports are overridable — see
+"Custom ports".
 
-Both ports are overridable — see "Custom ports" below. The defaults are what the examples use throughout this doc.
+> **JSON env values must be SINGLE-LINE.** `HORIZON_AUTH_LOCAL_USERS`,
+> `HORIZON_OAP_AUTH`, `HORIZON_AUTH_LDAP`, etc. are spliced into the YAML before
+> parsing; a multi-line JSON value breaks the parse (`YAMLParseError: Flow
+> sequence … must be sufficiently indented`). The `dev-*.json` files are already
+> single-line — keep them that way.
 
 ## Environment variables (the dev contract)
 
-Local dev runs as **two processes / two ports**: the BFF (Fastify, owns
-`/api`) and Vite (serves the UI, proxies `/api` → BFF). They coordinate
-through env vars below. The BFF reads them via the YAML loader's
-`${VAR:default}` interpolation; Vite reads `BFF_PORT` + `UI_DEV_PORT`
-directly in `apps/ui/vite.config.ts`. Set them on **both** processes
-when overriding (or the proxy points at the wrong BFF).
+Local dev runs as **two processes / two ports**: the BFF (Fastify, owns `/api`)
+and Vite (serves the UI, proxies `/api` → BFF). The BFF reads `HORIZON_*` via the
+config loader's `${VAR:default}` interpolation; Vite reads `BFF_PORT` +
+`UI_DEV_PORT` directly in `apps/ui/vite.config.ts`.
 
-| Variable          | Default                          | Used by  | Purpose |
+| Variable                    | Default                    | Used by | Purpose |
 |---|---|---|---|
-| `HORIZON_CONFIG`  | _(none — required)_              | BFF      | **Absolute** path to the yaml config. A bare `./horizon.yaml` resolves under `apps/bff/` and silently boots with zero users — see "The one gotcha" below. |
-| `BFF_PORT`        | `8081`                           | BFF + UI | BFF listen port (yaml `server.port`) AND Vite's proxy target. Set the same value on both. Prod uses this as the single port (BFF serves the built UI). |
-| `UI_DEV_PORT`     | `9091`                           | UI       | Vite listen port. Dev-only — meaningless in prod. |
-| `OAP_QUERY_URL`   | `http://localhost:12800`         | BFF      | OAP GraphQL endpoint (applies to `horizon.local.yaml`). |
-| `OAP_ADMIN_URL`   | `http://localhost:12800`         | BFF      | OAP admin REST endpoint. Often differs from GraphQL on real deployments (e.g. demo splits on `:17128`) — if BFF logs `UITemplate 404`, override this. |
-| `OAP_ZIPKIN_URL`  | `http://localhost:9412/zipkin`   | BFF      | Zipkin query endpoint (Zipkin trace layer). |
-| `OAP_TIMEOUT_MS`  | `15000`                          | BFF      | OAP request timeout. Lower it (`5000`) when previewing the "OAP unreachable" landing block so errors surface fast. |
-| `OAP_PASSWORD`    | _(none)_                         | BFF      | Demo OAP basic-auth password (`horizon.demo.yaml` only). Cached at `oap-password.local` (git-ignored). |
-| `LDAP_BIND_PW`    | `admin` _(test value)_           | BFF      | LDAP service-bind password (`horizon.ldap.yaml` only). Set a real value before pointing at a real directory. |
-
-Minimal local dev (defaults, no overrides):
-
-```bash
-REPO="$(git rev-parse --show-toplevel)"
-HORIZON_CONFIG="$REPO/.claude/skills/local-boot/horizon.local.yaml" \
-  pnpm --filter @skywalking-horizon-ui/bff run dev &           # → :8081
-( cd "$REPO/apps/ui" && node_modules/.bin/vite --host 127.0.0.1 & ) # → :9091
-```
-
-Custom ports for a parallel env (e.g. coexist with booster-ui on 8080):
-
-```bash
-BFF_PORT=10081 UI_DEV_PORT=10091 \
-  HORIZON_CONFIG="$REPO/.claude/skills/local-boot/horizon.local.yaml" \
-  pnpm --filter @skywalking-horizon-ui/bff run dev &
-( cd "$REPO/apps/ui" && BFF_PORT=10081 UI_DEV_PORT=10091 \
-    node_modules/.bin/vite --host 127.0.0.1 & )
-```
-
-Remote OAP (no auth) via env override:
-
-```bash
-OAP_QUERY_URL=http://oap.dev.example:12800 \
-OAP_ADMIN_URL=http://oap.dev.example:17128 \
-HORIZON_CONFIG="$REPO/.claude/skills/local-boot/horizon.local.yaml" \
-  pnpm --filter @skywalking-horizon-ui/bff run dev &
-```
-
-The longer "Custom ports" and "Custom OAP target" sections below cover
-the prod single-port model, the GraphQL/admin port split, and the
-unreachable-OAP preview.
+| `HORIZON_CONFIG`            | _(none — required)_        | BFF     | **Absolute** path to the config. Always `"$REPO/horizon.yaml"`. A bare `./horizon.yaml` resolves under `apps/bff/` — see "The one gotcha". |
+| `HORIZON_SERVER_PORT`       | `8081`                     | BFF     | BFF listen port (`server.port`). For a custom port set this AND `BFF_PORT` to the same value. |
+| `BFF_PORT`                  | `8081`                     | UI      | Vite's `/api` proxy target. Must match `HORIZON_SERVER_PORT`. |
+| `UI_DEV_PORT`               | `9091`                     | UI      | Vite listen port. Dev-only. |
+| `HORIZON_OAP_QUERY_URL`     | `http://127.0.0.1:12800`   | BFF     | OAP GraphQL endpoint. |
+| `HORIZON_OAP_ADMIN_URL`     | `http://127.0.0.1:17128`   | BFF     | OAP admin REST endpoint. Often a different port from GraphQL (the demo splits on `:17128`) — if the BFF logs `UITemplate 404`, this is wrong. |
+| `HORIZON_OAP_ZIPKIN_URL`    | `http://127.0.0.1:9412/zipkin` | BFF | Zipkin query endpoint (Zipkin trace layer). |
+| `HORIZON_OAP_TIMEOUT_MS`    | `15000`                    | BFF     | OAP request timeout. Lower it (`4000`) when previewing the "OAP unreachable" landing block so errors surface fast. |
+| `HORIZON_OAP_AUTH`          | _(none)_                   | BFF     | OAP basic-auth as JSON, e.g. `{"username":"admin","password":"…"}`. The demo needs it (password from `oap-password.local`). |
+| `HORIZON_AUTH_LOCAL_USERS`  | `[]`                       | BFF     | Local login users (JSON array, single-line). Use `$(cat dev-users.json)`. |
+| `HORIZON_AUTH_BACKEND`      | `local`                    | BFF     | `local` or `ldap`. |
+| `HORIZON_AUTH_LDAP`         | _(none)_                   | BFF     | LDAP config (JSON, single-line) when backend=ldap. Use `$(cat dev-ldap.json)`. |
+| `HORIZON_TEMPLATES_MODE`    | `live`                     | BFF     | `live` (seed + read ui_template) or `readonly` (render the bundle, config read-only). |
+
+RBAC is left to the built-in role defaults (the `horizon.yaml` token
+`roles: ${HORIZON_RBAC_ROLES:null}` falls through to them), so no roles env var
+is needed for dev.
 
 ## The one gotcha that bites every time
 
-The BFF dev script is `tsx watch src/server.ts` and pnpm runs it with **cwd = `apps/bff`**. The config path is `process.env.HORIZON_CONFIG ?? './horizon.yaml'`, resolved relative to cwd — so a bare `./horizon.yaml` points at the non-existent `apps/bff/horizon.yaml`, and the loader silently falls back to **defaults with zero users** (every login then fails with "invalid credentials", and the boot log warns `auth.local.users is empty`).
+The BFF dev script is `tsx watch src/server.ts` and pnpm runs it with **cwd =
+`apps/bff`**. The config path defaults to `./horizon.yaml`, resolved relative to
+cwd — so a bare `./horizon.yaml` points at the non-existent `apps/bff/horizon.yaml`
+(NOT the repo-root one), and the loader silently falls back to schema defaults
+with **zero users** (every login then fails "invalid credentials").
 
-**Always pass `HORIZON_CONFIG` as an ABSOLUTE path.** Use `"$REPO/.claude/skills/local-boot/<file>"`.
+**Always pass `HORIZON_CONFIG` as an ABSOLUTE path:** `"$REPO/horizon.yaml"`.
 
 ## Proxy + IPv4 (the second gotcha)
 
@@ -91,119 +81,23 @@ env | grep -iE '^(http_proxy|https_proxy|all_proxy)=' && echo "local proxy detec
 
 The browser uses its OWN proxy settings (not the shell's), so the developer must also let `127.0.0.1` / `localhost` go direct (ClashX "bypass localhost" / system proxy no-proxy list). The env-level bypass below only fixes CLI tools and Vite's own fetches.
 
-## Custom ports (two parallel envs, prod single-port)
-
-The defaults are BFF `:8081` and UI `:9091`. To run a second Horizon
-side-by-side (e.g. comparing two OAPs, or coexisting with the legacy
-booster-ui on 8080) override with two env vars **at boot time**:
-
-- `BFF_PORT` — the Fastify listen port. Read by the BFF yaml as
-  `port: ${BFF_PORT:8081}` (interpolated by the loader before parse) AND
-  by Vite's dev server when building the `/api` proxy target. **Set the
-  same value on both processes** or the proxy points at the wrong BFF.
-- `UI_DEV_PORT` — the Vite listen port. Dev-only, read by
-  `apps/ui/vite.config.ts`.
-
-```bash
-# parallel env on 10081/10091 (e.g. against a second OAP):
-BFF_PORT=10081 UI_DEV_PORT=10091 \
-  HORIZON_CONFIG="$REPO/.claude/skills/local-boot/horizon.<file>.yaml" \
-  pnpm --filter @skywalking-horizon-ui/bff run dev &
-( cd "$REPO/apps/ui" && BFF_PORT=10081 UI_DEV_PORT=10091 \
-    env -u http_proxy -u https_proxy -u all_proxy ... \
-        node_modules/.bin/vite --host 127.0.0.1 & )
-```
-
-The recipes below all use `${BFF_PORT:-8081}` / `${UI_DEV_PORT:-9091}`
-shell expansions, so they pick up an override that's already exported
-(or just use the defaults if not).
-
-**Prod is single-port.** The BFF serves the built UI as static files via
-`@fastify/static`, so `UI_DEV_PORT` is meaningless outside Vite. In prod
-only `server.port` (i.e. `BFF_PORT`) matters.
-
-## Custom OAP target (point horizon.local.yaml at any no-auth OAP)
-
-`horizon.local.yaml` is the canonical no-auth config — there are no
-separate `horizon.remote.yaml` / `horizon.unreachable.yaml` files
-anymore. The four OAP fields all accept env-var overrides via the
-loader's `${VAR:default}` syntax, so the same config boots against:
-
-- **A LOCAL OAP** (default). No env vars needed.
-- **A REMOTE OAP** — `OAP_QUERY_URL` + `OAP_ADMIN_URL` + `OAP_ZIPKIN_URL`.
-- **A deliberately-unreachable port** — to preview the "OAP query host
-  unreachable" landing block. Pair with a short `OAP_TIMEOUT_MS` so
-  errors surface fast.
-
-Heads-up: many real deployments split the GraphQL surface (12800) and
-the admin REST surface (often 17128 — same split as the public demo).
-If the BFF startup log warns `UITemplate 404` on `/ui-management/...`,
-the admin URL is wrong — override `OAP_ADMIN_URL` separately.
-
-```bash
-# Remote OAP (GraphQL 12800, admin REST split on 17128):
-OAP_QUERY_URL=http://oap.dev.example:12800 \
-OAP_ADMIN_URL=http://oap.dev.example:17128 \
-HORIZON_CONFIG="$REPO/.claude/skills/local-boot/horizon.local.yaml" \
-  pnpm --filter @skywalking-horizon-ui/bff run dev &
-
-# Unreachable-OAP landing-block preview:
-OAP_QUERY_URL=http://127.0.0.1:12801 \
-OAP_ADMIN_URL=http://127.0.0.1:12801 \
-OAP_TIMEOUT_MS=5000 \
-HORIZON_CONFIG="$REPO/.claude/skills/local-boot/horizon.local.yaml" \
-  pnpm --filter @skywalking-horizon-ui/bff run dev &
-```
-
 ## The stale-process trap (why a config switch "didn't take")
 
-`tsx watch` keeps the old BFF alive, so a freshly launched BFF with a NEW config silently dies on `EADDRINUSE: 127.0.0.1:${BFF_PORT:-8081}` while the OLD process keeps serving the OLD OAP. Symptom: you switch local↔demo, everything looks fine, but the UI still shows the previous OAP's data.
+`tsx watch` keeps the old BFF alive, so a freshly launched BFF silently dies on `EADDRINUSE: 127.0.0.1:8081` while the OLD process keeps serving the OLD env. Symptom: you switch demo↔local, everything looks fine, but the UI still shows the previous OAP's data.
 
-Killing matters in two ways:
-- `pkill -f "tsx watch src/server.ts"` may miss the actual listener — also `pkill -f "tsx/dist/cli.mjs watch"`, and **verify the port is actually free with `lsof` before relaunching** (loop until free; the watcher can respawn a child).
-- After boot, **confirm which OAP the LIVE process is using** — don't trust that your new process won the port. The authoritative check:
+- `pkill -f "tsx watch src/server.ts"` may miss the actual listener — also `pkill -f "tsx/dist/cli.mjs watch"`, and **verify the port is free with `lsof` before relaunching** (loop until free; the watcher respawns a child).
+- After boot, **confirm which OAP the LIVE process uses** — don't trust that your new process won the port:
   ```bash
-  curl -s --noproxy '*' -b /tmp/sw.cookies "http://127.0.0.1:${BFF_PORT:-8081}/api/oap/config" | grep -oE '"adminUrl":"[^"]*"'
-  # local => http://localhost:12800 ; demo => https://demo.skywalking.apache.org:17128
+  curl -s --noproxy '*' -b /tmp/sw.cookies "http://127.0.0.1:8081/api/oap/config" | grep -oE '"adminUrl":"[^"]*"'
+  # local => http://127.0.0.1:17128 ; demo => https://demo.skywalking.apache.org:17128
   ```
-  Also grep the boot log for `EADDRINUSE` and the `configPath` line. If the adminUrl is wrong, a stale BFF is still bound — kill it, confirm the port is free, relaunch.
-
-## Boot against the local OAP
-
-```bash
-REPO="$(git rev-parse --show-toplevel)"
-# 1. Kill prior dev servers AND confirm the BFF port is actually free —
-#    a stale BFF holding it makes the new one die on EADDRINUSE while
-#    the old config keeps serving (see "stale-process trap"). For a
-#    parallel env on custom ports, export BFF_PORT / UI_DEV_PORT first.
-pkill -f "tsx watch src/server.ts" 2>/dev/null
-pkill -f "tsx/dist/cli.mjs watch" 2>/dev/null; pkill -f vite 2>/dev/null
-until ! lsof -nP -iTCP:"${BFF_PORT:-8081}" -sTCP:LISTEN >/dev/null 2>&1; do sleep 1; done
-
-# 2. BFF — absolute config path is mandatory (see gotcha above). The
-#    yaml resolves ${BFF_PORT:8081} at load time, so just exporting
-#    BFF_PORT before this command is enough:
-HORIZON_CONFIG="$REPO/.claude/skills/local-boot/horizon.local.yaml" \
-  pnpm --filter @skywalking-horizon-ui/bff run dev &
-
-# 3. UI — IPv4 host + loopback proxy bypass (run the binary directly so
-#    --host actually applies). vite.config.ts reads BFF_PORT and
-#    UI_DEV_PORT from this env:
-( cd "$REPO/apps/ui" && \
-  env -u http_proxy -u https_proxy -u all_proxy -u HTTP_PROXY -u HTTPS_PROXY -u ALL_PROXY \
-      no_proxy="localhost,127.0.0.1,::1" NO_PROXY="localhost,127.0.0.1,::1" \
-      node_modules/.bin/vite --host 127.0.0.1 & )
-```
-
-Then open **`http://127.0.0.1:${UI_DEV_PORT:-9091}`** and log in as `admin` / `admin`.
 
 ## Boot against the public demo OAP
 
-The demo OAP needs basic-auth (network username `admin`). The password is
-NOT committed — it lives in `oap-password.local` next to this file, which
-is git-ignored via the repo-wide `*.local` rule. Source it before booting;
-if the file is missing, ask the developer and recreate it (one line, the
-password only):
+The demo OAP needs basic-auth (network username `admin`). The password is NOT
+committed — it lives in `oap-password.local` next to this file (git-ignored via
+the repo-wide `*.local` rule). Source it; if missing, ask the developer and
+recreate it (one line, the password only).
 
 ```bash
 REPO="$(git rev-parse --show-toplevel)"
@@ -215,83 +109,129 @@ else
   printf '%s\n' "$OAP_PASSWORD" > "$SECRET" && chmod 600 "$SECRET"  # cache for next boot
 fi
 
+# Kill prior dev servers + confirm the port is free (see stale-process trap).
 pkill -f "tsx watch src/server.ts" 2>/dev/null
 pkill -f "tsx/dist/cli.mjs watch" 2>/dev/null; pkill -f vite 2>/dev/null
-until ! lsof -nP -iTCP:"${BFF_PORT:-8081}" -sTCP:LISTEN >/dev/null 2>&1; do sleep 1; done
-HORIZON_CONFIG="$REPO/.claude/skills/local-boot/horizon.demo.yaml" \
+until ! lsof -nP -iTCP:8081 -sTCP:LISTEN >/dev/null 2>&1; do sleep 1; done
+
+# BFF — repo horizon.yaml + everything via env. JSON values are single-line.
+SK="$REPO/.claude/skills/local-boot"
+HORIZON_CONFIG="$REPO/horizon.yaml" \
+HORIZON_OAP_QUERY_URL=https://demo.skywalking.apache.org:12800 \
+HORIZON_OAP_ADMIN_URL=https://demo.skywalking.apache.org:17128 \
+HORIZON_OAP_ZIPKIN_URL=https://demo.skywalking.apache.org:9412/zipkin \
+HORIZON_OAP_AUTH="{\"username\":\"admin\",\"password\":\"$OAP_PASSWORD\"}" \
+HORIZON_AUTH_LOCAL_USERS="$(cat "$SK/dev-users.json")" \
   pnpm --filter @skywalking-horizon-ui/bff run dev &
+
+# UI — IPv4 host + loopback proxy bypass (run the binary directly so --host applies).
 ( cd "$REPO/apps/ui" && \
   env -u http_proxy -u https_proxy -u all_proxy -u HTTP_PROXY -u HTTPS_PROXY -u ALL_PROXY \
       no_proxy="localhost,127.0.0.1,::1" NO_PROXY="localhost,127.0.0.1,::1" \
       node_modules/.bin/vite --host 127.0.0.1 & )
 ```
 
-The cached `oap-password.local` is git-ignored, so it is safe to keep on
-disk between boots. If it is absent and `OAP_PASSWORD` is unset, ask the
-developer rather than guessing. The OAP network username is fixed as
-`admin` in `horizon.demo.yaml`.
+Then open **`http://127.0.0.1:9091`** and log in as `admin` / `admin`. To boot in
+read-only template mode, add `HORIZON_TEMPLATES_MODE=readonly` to the BFF line.
+
+## Boot against a local / remote no-auth OAP
+
+Same recipe, different OAP env vars (no `HORIZON_OAP_AUTH` for a no-auth OAP):
+
+```bash
+REPO="$(git rev-parse --show-toplevel)"; SK="$REPO/.claude/skills/local-boot"
+pkill -f "tsx watch src/server.ts" 2>/dev/null; pkill -f "tsx/dist/cli.mjs watch" 2>/dev/null; pkill -f vite 2>/dev/null
+until ! lsof -nP -iTCP:8081 -sTCP:LISTEN >/dev/null 2>&1; do sleep 1; done
+
+# LOCAL OAP (defaults already point at 127.0.0.1, so only users are needed):
+HORIZON_CONFIG="$REPO/horizon.yaml" \
+HORIZON_AUTH_LOCAL_USERS="$(cat "$SK/dev-users.json")" \
+  pnpm --filter @skywalking-horizon-ui/bff run dev &
+
+# REMOTE no-auth OAP (GraphQL 12800, admin split on 17128):
+HORIZON_CONFIG="$REPO/horizon.yaml" \
+HORIZON_OAP_QUERY_URL=http://oap.dev.example:12800 \
+HORIZON_OAP_ADMIN_URL=http://oap.dev.example:17128 \
+HORIZON_AUTH_LOCAL_USERS="$(cat "$SK/dev-users.json")" \
+  pnpm --filter @skywalking-horizon-ui/bff run dev &
+
+# UNREACHABLE-OAP landing-block preview (short timeout so errors surface fast):
+HORIZON_CONFIG="$REPO/horizon.yaml" \
+HORIZON_OAP_QUERY_URL=http://127.0.0.1:12801 HORIZON_OAP_ADMIN_URL=http://127.0.0.1:12801 \
+HORIZON_OAP_TIMEOUT_MS=4000 \
+HORIZON_AUTH_LOCAL_USERS="$(cat "$SK/dev-users.json")" \
+  pnpm --filter @skywalking-horizon-ui/bff run dev &
+```
+
+## Custom ports (two parallel envs)
+
+Defaults are BFF `:8081`, UI `:9091`. To run a second Horizon side-by-side, set
+the BFF port via **`HORIZON_SERVER_PORT`** (the config's `server.port` token) AND
+**`BFF_PORT`** (Vite's proxy target) to the same value, plus `UI_DEV_PORT`:
+
+```bash
+HORIZON_SERVER_PORT=10081 BFF_PORT=10081 UI_DEV_PORT=10091 \
+  HORIZON_CONFIG="$REPO/horizon.yaml" \
+  HORIZON_AUTH_LOCAL_USERS="$(cat "$SK/dev-users.json")" \
+  pnpm --filter @skywalking-horizon-ui/bff run dev &
+( cd "$REPO/apps/ui" && BFF_PORT=10081 UI_DEV_PORT=10091 \
+    env -u http_proxy -u https_proxy -u all_proxy \
+        node_modules/.bin/vite --host 127.0.0.1 & )
+```
+
+**Prod is single-port.** The BFF serves the built UI as static files, so
+`UI_DEV_PORT` is meaningless outside Vite; only `HORIZON_SERVER_PORT` matters.
 
 ## Boot against an LDAP directory (test)
 
-`horizon.ldap.yaml` points the BFF at a throwaway OpenLDAP seeded from
-`ldap-seed.ldif`. Stand the directory up, seed it, then boot:
+Stand up a throwaway OpenLDAP, seed it from `ldap-seed.ldif`, then boot with
+`HORIZON_AUTH_BACKEND=ldap` and the LDAP config from `dev-ldap.json`. Test logins
+mirror the local set (password == username): `admin` → admin, `operator` →
+operator, `maintainer` → maintainer, `viewer` → viewer (`*` fallback). The bind
+account is `cn=admin,dc=horizon,dc=test` / `admin`.
 
 ```bash
-REPO="$(git rev-parse --show-toplevel)"
+REPO="$(git rev-parse --show-toplevel)"; SK="$REPO/.claude/skills/local-boot"
 docker rm -f horizon-ldap 2>/dev/null
 docker run -d --name horizon-ldap -p 389:389 -p 636:636 \
   --env LDAP_ORGANISATION="Horizon Test" --env LDAP_DOMAIN="horizon.test" \
   --env LDAP_ADMIN_PASSWORD="admin" osixia/openldap:1.5.0
-# wait for slapd, then seed:
 until docker exec horizon-ldap ldapwhoami -x -H ldap://localhost \
   -D "cn=admin,dc=horizon,dc=test" -w admin >/dev/null 2>&1; do sleep 1; done
-docker cp "$REPO/.claude/skills/local-boot/ldap-seed.ldif" horizon-ldap:/tmp/seed.ldif
+docker cp "$SK/ldap-seed.ldif" horizon-ldap:/tmp/seed.ldif
 docker exec horizon-ldap ldapadd -x -H ldap://localhost \
   -D "cn=admin,dc=horizon,dc=test" -w admin -f /tmp/seed.ldif
 
 pkill -f "tsx watch src/server.ts" 2>/dev/null; pkill -f "tsx/dist/cli.mjs watch" 2>/dev/null
-until ! lsof -nP -iTCP:"${BFF_PORT:-8081}" -sTCP:LISTEN >/dev/null 2>&1; do sleep 1; done
-HORIZON_CONFIG="$REPO/.claude/skills/local-boot/horizon.ldap.yaml" \
+until ! lsof -nP -iTCP:8081 -sTCP:LISTEN >/dev/null 2>&1; do sleep 1; done
+HORIZON_CONFIG="$REPO/horizon.yaml" \
+HORIZON_AUTH_BACKEND=ldap \
+HORIZON_AUTH_LDAP="$(cat "$SK/dev-ldap.json")" \
   pnpm --filter @skywalking-horizon-ui/bff run dev &
 ```
 
-Test accounts (seeded by `ldap-seed.ldif`). Login users are named after
-their role and **password == username**, mirroring `horizon.local.yaml`:
-
-| Login | Group | Role |
-|---|---|---|
-| `admin` | cn=horizon-admin | admin |
-| `operator` | cn=sre | operator |
-| `maintainer` | cn=platform | maintainer |
-| `viewer` | (none) | viewer (`*` fallback) |
-
-Directory bind account: `cn=admin,dc=horizon,dc=test` / `admin` (override via `LDAP_BIND_PW`).
-
-```bash
-# verify a login resolves the expected role:
-curl -s --noproxy '*' -H 'Content-Type: application/json' -X POST \
-  "http://127.0.0.1:${BFF_PORT:-8081}/api/auth/login" -d '{"username":"admin","password":"admin"}'
-```
-
-Note: group resolution runs on the **service bind**, not the user's
-credentials (regular users usually can't read the group subtree).
+`dev-ldap.json` reads the bind password as the test value `admin`; for a real
+directory, edit it (or build the JSON with your own bind password) and never
+commit a real one. Group resolution runs on the **service bind**, not the user's
+credentials.
 
 ## Verify the BFF is healthy (no browser)
 
 ```bash
 # --noproxy so a local proxy (ClashX etc.) doesn't 502 the loopback call.
-until curl -s --noproxy '*' -m2 -o /dev/null "http://127.0.0.1:${BFF_PORT:-8081}/api/auth/health"; do sleep 1; done
+until curl -s --noproxy '*' -m2 -o /dev/null "http://127.0.0.1:8081/api/auth/health"; do sleep 1; done
 curl -s --noproxy '*' -c /tmp/sw.cookies -H 'Content-Type: application/json' -X POST \
-  "http://127.0.0.1:${BFF_PORT:-8081}/api/auth/login" -d '{"username":"admin","password":"admin"}'
-# Expect 200 with {username, roles, verbs, landingRoute}. A 401
-# "invalid credentials" almost always means the wrong config loaded —
-# re-check the absolute HORIZON_CONFIG path and that no stale BFF holds the BFF port.
+  "http://127.0.0.1:8081/api/auth/login" -d '{"username":"admin","password":"admin"}'
+# Expect 200 with {username, roles, verbs, landingRoute}. A 401 "invalid
+# credentials" usually means HORIZON_AUTH_LOCAL_USERS wasn't passed (or the
+# wrong HORIZON_CONFIG path / a stale BFF holds the port).
 ```
 
-## Editing the configs
+## Editing the dev values
 
-These files are the source of truth for dev boot. Keep secrets out:
-local-user hashes (password == username) are fine to commit; real OAP
-passwords must stay in `${OAP_PASSWORD}` (the loader's `interpolateEnv`
-expands `${VAR}` / `${VAR:default}` in the raw YAML before parsing).
-To mint a new local-user hash: `pnpm --filter @skywalking-horizon-ui/bff cli:hash`.
+`horizon.yaml` (repo root) is the committed, env-driven config — leave it as-is
+and override via `HORIZON_*` env vars. The dev users / LDAP config live in
+`dev-users.json` / `dev-ldap.json` here (throwaway, single-line JSON). Real OAP
+or LDAP passwords stay out of git: the demo OAP password in `oap-password.local`
+(git-ignored), real bind passwords supplied at boot. To mint a new local-user
+hash: `pnpm --filter @skywalking-horizon-ui/bff cli:hash`.
diff --git a/.claude/skills/local-boot/dev-ldap.json b/.claude/skills/local-boot/dev-ldap.json
new file mode 100644
index 0000000..f52c039
--- /dev/null
+++ b/.claude/skills/local-boot/dev-ldap.json
@@ -0,0 +1 @@
+{"url":"ldap://localhost:389","bindDn":"cn=admin,dc=horizon,dc=test","bindPassword":"admin","userBaseDn":"ou=people,dc=horizon,dc=test","userFilter":"(uid={username})","displayNameAttr":"cn","groupStrategy":"search","groupBaseDn":"ou=groups,dc=horizon,dc=test","memberAttr":"member","timeoutMs":5000,"tlsInsecure":false,"groupMappings":[{"group":"cn=horizon-admin,ou=groups,dc=horizon,dc=test","role":"admin"},{"group":"cn=sre,ou=groups,dc=horizon,dc=test","role":"operator"},{"group":"cn=pla [...]
diff --git a/.claude/skills/local-boot/dev-users.json b/.claude/skills/local-boot/dev-users.json
new file mode 100644
index 0000000..a0f7972
--- /dev/null
+++ b/.claude/skills/local-boot/dev-users.json
@@ -0,0 +1 @@
+[{"username":"viewer","passwordHash":"$argon2id$v=19$m=65536,t=3,p=4$Gp175hqr+EF2iZ7v1fndvw$w6w9hDI59/UA+CRARChDoGRlR1TkVt6kqzApa021K+0","roles":["viewer"]},{"username":"maintainer","passwordHash":"$argon2id$v=19$m=65536,t=3,p=4$w7ULwB3/jzH9FxVoHJ238A$y+qGoX6IPeOoGywLQCpfpAN5VJXcaevoWeJQhaybvQU","roles":["maintainer"]},{"username":"operator","passwordHash":"$argon2id$v=19$m=65536,t=3,p=4$nzoI4RqiobprtzX/mJqe5Q$FY2Hi7mKep0DPHoaE++r/KD++WLUwTgRUFLde87j2Wg","roles":["operator"]},{"username" [...]
diff --git a/.claude/skills/local-boot/horizon.demo.yaml b/.claude/skills/local-boot/horizon.demo.yaml
deleted file mode 100644
index f68f222..0000000
--- a/.claude/skills/local-boot/horizon.demo.yaml
+++ /dev/null
@@ -1,119 +0,0 @@
-# Static demo-boot config for the Horizon UI BFF — points at the public
-# Apache demo OAP (demo.skywalking.apache.org). The OAP network basic-auth
-# password is NOT committed: it is read from ${OAP_PASSWORD} at boot.
-# Ask the developer for it before booting and export it, e.g.
-#   read -rs OAP_PASSWORD && export OAP_PASSWORD
-# Boot via the `local-boot` skill (see SKILL.md), which passes this file
-# through HORIZON_CONFIG as an ABSOLUTE path. Horizon login users below
-# are throwaway dev credentials (password == username).
-
-server:
-  host: 127.0.0.1
-  port: ${BFF_PORT:8081}
-
-oap:
-  queryUrl: https://demo.skywalking.apache.org:12800
-  adminUrl: https://demo.skywalking.apache.org:17128
-  zipkinUrl: https://demo.skywalking.apache.org:9412/zipkin
-  timeoutMs: 15000
-  auth:
-    username: admin
-    # Supplied via the environment so the demo password stays out of git.
-    # interpolateEnv() in apps/bff/src/config/loader.ts expands ${OAP_PASSWORD}.
-    password: "${OAP_PASSWORD}"
-
-auth:
-  backend: local
-  local:
-    users:
-      - username: viewer
-        # password: viewer
-        passwordHash: "$argon2id$v=19$m=65536,t=3,p=4$Gp175hqr+EF2iZ7v1fndvw$w6w9hDI59/UA+CRARChDoGRlR1TkVt6kqzApa021K+0"
-        roles: [viewer]
-      - username: maintainer
-        # password: maintainer
-        passwordHash: "$argon2id$v=19$m=65536,t=3,p=4$w7ULwB3/jzH9FxVoHJ238A$y+qGoX6IPeOoGywLQCpfpAN5VJXcaevoWeJQhaybvQU"
-        roles: [maintainer]
-      - username: operator
-        # password: operator
-        passwordHash: "$argon2id$v=19$m=65536,t=3,p=4$nzoI4RqiobprtzX/mJqe5Q$FY2Hi7mKep0DPHoaE++r/KD++WLUwTgRUFLde87j2Wg"
-        roles: [operator]
-      - username: admin
-        # password: admin
-        passwordHash: "$argon2id$v=19$m=65536,t=3,p=4$joV9AVlyLS3pqq4mLrYokQ$pJLkTKrz9/LzEH6YaFljdz9k8dyBiryjwSB26Diiz9U"
-        roles: [admin]
-
-rbac:
-  enabled: true
-  roles:
-    viewer:
-      - "metrics:read"
-      - "alarms:read"
-      - "traces:read"
-      - "logs:read"
-      - "browser-errors:read"
-      - "topology:read"
-      - "profile:read"
-      - "overview:read"
-    maintainer:
-      - "metrics:read"
-      - "alarms:read"
-      - "traces:read"
-      - "logs:read"
-      - "browser-errors:read"
-      - "topology:read"
-      - "profile:read"
-      - "overview:read"
-      - "cluster:read"
-      - "inspect:read"
-      - "ttl:read"
-      - "config:read"
-    operator:
-      - "metrics:read"
-      - "alarms:read"
-      - "traces:read"
-      - "logs:read"
-      - "browser-errors:read"
-      - "source-map:write"
-      - "topology:read"
-      - "profile:read"
-      - "cluster:read"
-      - "inspect:read"
-      - "ttl:read"
-      - "config:read"
-      - "overview:read"
-      - "overview:write"
-      - "setup:read"
-      - "setup:write"
-      - "dashboard:read"
-      - "dashboard:write"
-      - "alarm-setup:read"
-      - "alarm-setup:write"
-      - "alarm-rule:read"
-      - "alarm-rule:write"
-      - "rule:read"
-      - "rule:write"
-      - "rule:write:structural"
-      - "rule:delete"
-      - "rule:debug"
-      - "live-debug:read"
-      - "live-debug:write"
-      - "profile:enable"
-    admin:
-      - "*"
-  landingByRole:
-    viewer: /
-    maintainer: /operate/cluster
-    operator: /
-    admin: /
-
-session:
-  ttlMinutes: 60
-  cookieName: horizon_sid
-  cookieSecure: false
-
-audit:    { file: ./horizon-audit.jsonl }
-setup:    { file: ./horizon-setup.json }
-alarms:   { file: ./horizon-alarms.json }
-debugLog: { enabled: false, file: ./horizon-wire.jsonl, maxBodyChars: 8192, redactAuthHeaders: true }
-sourceMaps: { enabled: true, maxFileBytes: 67108864, maxTotalBytes: 536870912, maxFileCount: 128, bootMountDir: "" }
diff --git a/.claude/skills/local-boot/horizon.ldap.yaml b/.claude/skills/local-boot/horizon.ldap.yaml
deleted file mode 100644
index e324a59..0000000
--- a/.claude/skills/local-boot/horizon.ldap.yaml
+++ /dev/null
@@ -1,125 +0,0 @@
-# LDAP-backend boot config for the Horizon UI BFF. Authenticates against
-# an external directory; Horizon stores no passwords. This file targets
-# the throwaway test OpenLDAP described in SKILL.md ("Test the LDAP
-# backend"): base dc=horizon,dc=test, users under ou=people, groups
-# (groupOfNames) under ou=groups, seeded from ldap-seed.ldif.
-#
-# Test directory accounts (all seeded by ldap-seed.ldif). Login users
-# are named after their role and password == username, mirroring
-# horizon.local.yaml:
-#   directory admin (bindDn):  cn=admin,dc=horizon,dc=test  /  admin
-#   login users (password == username):
-#     admin       -> cn=horizon-admin   -> role admin
-#     operator    -> cn=sre             -> role operator
-#     maintainer  -> cn=platform        -> role maintainer
-#     viewer      -> (no group)         -> role viewer (the "*" fallback)
-#
-# The directory bind password is read from ${LDAP_BIND_PW} (defaults to
-# the test value `admin`); for a real directory, export LDAP_BIND_PW and
-# never commit it.
-#
-# OAP still points at a local OAP so the rest of the app works. Boot via
-# the local-boot skill with an ABSOLUTE HORIZON_CONFIG path.
-
-server:
-  host: 127.0.0.1
-  port: ${BFF_PORT:8081}
-
-oap:
-  queryUrl: http://localhost:12800
-  adminUrl: http://localhost:12800
-  zipkinUrl: http://localhost:9412/zipkin
-  timeoutMs: 15000
-
-auth:
-  backend: ldap
-  ldap:
-    url: ldap://localhost:389
-    bindDn: "cn=admin,dc=horizon,dc=test"
-    bindPassword: "${LDAP_BIND_PW:admin}"
-    userBaseDn: "ou=people,dc=horizon,dc=test"
-    userFilter: "(uid={username})"
-    displayNameAttr: cn
-    # This test directory uses groupOfNames groups without a memberOf
-    # overlay, so resolve membership by searching ou=groups for the
-    # user's DN. Switch to `memberOf` against directories that populate it.
-    groupStrategy: search
-    groupBaseDn: "ou=groups,dc=horizon,dc=test"
-    memberAttr: member
-    timeoutMs: 5000
-    tlsInsecure: false
-    groupMappings:
-      - { group: "cn=horizon-admin,ou=groups,dc=horizon,dc=test", role: admin }
-      - { group: "cn=sre,ou=groups,dc=horizon,dc=test", role: operator }
-      - { group: "cn=platform,ou=groups,dc=horizon,dc=test", role: maintainer }
-      - { group: "*", role: viewer }
-
-rbac:
-  enabled: true
-  roles:
-    viewer:
-      - "metrics:read"
-      - "alarms:read"
-      - "traces:read"
-      - "logs:read"
-      - "topology:read"
-      - "profile:read"
-      - "overview:read"
-    maintainer:
-      - "metrics:read"
-      - "alarms:read"
-      - "traces:read"
-      - "logs:read"
-      - "topology:read"
-      - "profile:read"
-      - "overview:read"
-      - "cluster:read"
-      - "inspect:read"
-      - "ttl:read"
-      - "config:read"
-    operator:
-      - "metrics:read"
-      - "alarms:read"
-      - "traces:read"
-      - "logs:read"
-      - "topology:read"
-      - "profile:read"
-      - "cluster:read"
-      - "inspect:read"
-      - "ttl:read"
-      - "config:read"
-      - "overview:read"
-      - "overview:write"
-      - "setup:read"
-      - "setup:write"
-      - "dashboard:read"
-      - "dashboard:write"
-      - "alarm-setup:read"
-      - "alarm-setup:write"
-      - "alarm-rule:read"
-      - "alarm-rule:write"
-      - "rule:read"
-      - "rule:write"
-      - "rule:write:structural"
-      - "rule:delete"
-      - "rule:debug"
-      - "live-debug:read"
-      - "live-debug:write"
-      - "profile:enable"
-    admin:
-      - "*"
-  landingByRole:
-    viewer: /
-    maintainer: /operate/cluster
-    operator: /
-    admin: /
-
-session:
-  ttlMinutes: 60
-  cookieName: horizon_sid
-  cookieSecure: false
-
-audit:    { file: ./horizon-audit.jsonl }
-setup:    { file: ./horizon-setup.json }
-alarms:   { file: ./horizon-alarms.json }
-debugLog: { enabled: false, file: ./horizon-wire.jsonl, maxBodyChars: 8192, redactAuthHeaders: true }
diff --git a/.claude/skills/local-boot/horizon.local.yaml b/.claude/skills/local-boot/horizon.local.yaml
deleted file mode 100644
index c21c19b..0000000
--- a/.claude/skills/local-boot/horizon.local.yaml
+++ /dev/null
@@ -1,122 +0,0 @@
-# Static local-boot config for the Horizon UI BFF. Defaults to a LOCAL
-# OAP on 127.0.0.1 with NO network auth, but the four OAP fields below
-# accept env-var overrides so the same file boots against a remote OAP
-# (any URL), a deliberately-unreachable port (for the "OAP unreachable"
-# landing-block preview), or any other no-auth OAP. For demo-OAP access
-# use horizon.demo.yaml instead, which keeps the password out of git
-# via ${OAP_PASSWORD}.
-#
-# Boot via the `local-boot` skill (see SKILL.md) which passes this
-# file through HORIZON_CONFIG as an ABSOLUTE path. The local-user
-# password hashes below are throwaway dev credentials (password ==
-# username) — safe to commit; do NOT add real secrets here.
-
-server:
-  host: 127.0.0.1
-  port: ${BFF_PORT:8081}
-
-oap:
-  # On the default LOCAL OAP, GraphQL and the admin REST surface share
-  # the same port (12800). On the public demo and some k8s deployments
-  # they split (12800 / 17128) — override OAP_ADMIN_URL accordingly.
-  # Zipkin (9412) may not be exposed locally.
-  queryUrl: ${OAP_QUERY_URL:http://localhost:12800}
-  adminUrl: ${OAP_ADMIN_URL:http://localhost:12800}
-  zipkinUrl: ${OAP_ZIPKIN_URL:http://localhost:9412/zipkin}
-  timeoutMs: ${OAP_TIMEOUT_MS:15000}
-
-auth:
-  backend: local
-  local:
-    # One account per role for manual menu-RBAC checks. Password == username.
-    users:
-      - username: viewer
-        # password: viewer
-        passwordHash: "$argon2id$v=19$m=65536,t=3,p=4$Gp175hqr+EF2iZ7v1fndvw$w6w9hDI59/UA+CRARChDoGRlR1TkVt6kqzApa021K+0"
-        roles: [viewer]
-      - username: maintainer
-        # password: maintainer
-        passwordHash: "$argon2id$v=19$m=65536,t=3,p=4$w7ULwB3/jzH9FxVoHJ238A$y+qGoX6IPeOoGywLQCpfpAN5VJXcaevoWeJQhaybvQU"
-        roles: [maintainer]
-      - username: operator
-        # password: operator
-        passwordHash: "$argon2id$v=19$m=65536,t=3,p=4$nzoI4RqiobprtzX/mJqe5Q$FY2Hi7mKep0DPHoaE++r/KD++WLUwTgRUFLde87j2Wg"
-        roles: [operator]
-      - username: admin
-        # password: admin
-        passwordHash: "$argon2id$v=19$m=65536,t=3,p=4$joV9AVlyLS3pqq4mLrYokQ$pJLkTKrz9/LzEH6YaFljdz9k8dyBiryjwSB26Diiz9U"
-        roles: [admin]
-
-rbac:
-  enabled: true
-  roles:
-    viewer:
-      - "metrics:read"
-      - "alarms:read"
-      - "traces:read"
-      - "logs:read"
-      - "browser-errors:read"
-      - "topology:read"
-      - "profile:read"
-      - "overview:read"
-    maintainer:
-      - "metrics:read"
-      - "alarms:read"
-      - "traces:read"
-      - "logs:read"
-      - "browser-errors:read"
-      - "topology:read"
-      - "profile:read"
-      - "overview:read"
-      - "cluster:read"
-      - "inspect:read"
-      - "ttl:read"
-      - "config:read"
-    operator:
-      - "metrics:read"
-      - "alarms:read"
-      - "traces:read"
-      - "logs:read"
-      - "browser-errors:read"
-      - "source-map:write"
-      - "topology:read"
-      - "profile:read"
-      - "cluster:read"
-      - "inspect:read"
-      - "ttl:read"
-      - "config:read"
-      - "overview:read"
-      - "overview:write"
-      - "setup:read"
-      - "setup:write"
-      - "dashboard:read"
-      - "dashboard:write"
-      - "alarm-setup:read"
-      - "alarm-setup:write"
-      - "alarm-rule:read"
-      - "alarm-rule:write"
-      - "rule:read"
-      - "rule:write"
-      - "rule:write:structural"
-      - "rule:delete"
-      - "rule:debug"
-      - "live-debug:read"
-      - "live-debug:write"
-      - "profile:enable"
-    admin:
-      - "*"
-  landingByRole:
-    viewer: /
-    maintainer: /operate/cluster
-    operator: /
-    admin: /
-
-session:
-  ttlMinutes: 60
-  cookieName: horizon_sid
-  cookieSecure: false
-
-audit:    { file: ./horizon-audit.jsonl }
-setup:    { file: ./horizon-setup.json }
-alarms:   { file: ./horizon-alarms.json }
-debugLog: { enabled: false, file: ./horizon-wire.jsonl, maxBodyChars: 8192, redactAuthHeaders: true }
diff --git a/.dockerignore b/.dockerignore
index d3ea2be..4690a97 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -16,8 +16,8 @@
 # Denylist style: the image builds FROM SOURCE (see Dockerfile), so the
 # context is the source tree. Exclude only what must NOT enter the build —
 # host-built artifacts (arch-specific node_modules + dist), VCS, CI config,
-# local secrets, and scratch dirs. Everything else (source, lockfile,
-# horizon.example.yaml) is needed by `pnpm install` + `pnpm package`.
+# local secrets, and scratch dirs. Everything else (source, lockfile, the
+# committed horizon.yaml) is needed by `pnpm install` + `pnpm package`.
 
 **/node_modules
 **/dist
@@ -29,6 +29,10 @@ scripts/.release-work
 scripts/.finalize-work
 .claude
 
-# Local runtime config may carry secrets — never ship it. The committed
-# horizon.example.yaml stays (package.mjs reads it into dist/).
-horizon.yaml
+# horizon.yaml is the committed, env-driven config (package.mjs reads it into
+# dist/, the Dockerfile bakes it as /app/horizon.yaml) — keep it in the context.
+# Runtime state files carry local data; never ship them.
+horizon-audit.jsonl
+horizon-wire.jsonl
+horizon-setup.json
+horizon-alarms.json
diff --git a/.gitignore b/.gitignore
index e06b6d8..c4ae196 100644
--- a/.gitignore
+++ b/.gitignore
@@ -40,11 +40,11 @@ coverage
 # OS
 Thumbs.db
 
-# BFF runtime files
-horizon.yaml
+# BFF runtime files (horizon.yaml itself is committed — the env-driven config)
 horizon-audit.jsonl
 horizon-wire.jsonl
 horizon-setup.json
+horizon-alarms.json
 
 # Release
 scripts/.release-work
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fedb72b..916148a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,7 +9,7 @@ The version line is shared by every package in the monorepo (apps + shared packa
 ### Deployment & configuration
 
 - **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.** The image now bakes a **fully tokenized** `horizon.yaml` where every field is a `${HORIZON_…:default}` env var, 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 YAML-only. Lists and secrets (users, LDAP, OAP auth) are set as JSON-string env vars; preced [...]
+- **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 [...]
 - **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  [...]
 
 ### General Service — PHP runtime (PHM)
diff --git a/Dockerfile b/Dockerfile
index 8e39e30..f5c0318 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -58,12 +58,11 @@ COPY --from=build /src/dist/server.js              ./server.js
 COPY --from=build /src/dist/package.json           ./package.json
 COPY --from=build /src/dist/node_modules           ./node_modules
 COPY --from=build /src/dist/static                 ./static
-COPY --from=build /src/dist/horizon.example.yaml   ./horizon.example.yaml
-# Bake the (fully tokenized) example AS the active config so the image runs
-# with NO mounted file: every field is a `${HORIZON_…:default}` token, so
+# The fully tokenized horizon.yaml is the active config — every field is a
+# `${HORIZON_…:default}` token, so the image runs with NO mounted file:
 # `docker run -e HORIZON_OAP_QUERY_URL=… -e HORIZON_AUTH_LOCAL_USERS='[…]' …`
 # is enough. A bind-mount at /app/horizon.yaml still overrides it.
-COPY --from=build /src/dist/horizon.example.yaml   ./horizon.yaml
+COPY --from=build /src/dist/horizon.yaml           ./horizon.yaml
 
 COPY --from=build --chown=horizon:horizon /src/dist/bundled_templates  ./bundled_templates
 
diff --git a/README.md b/README.md
index b25be5d..f24e853 100644
--- a/README.md
+++ b/README.md
@@ -127,7 +127,7 @@ See [`docs/setup/container-image.md`](docs/setup/container-image.md) for image t
 
 ## Configuration
 
-Horizon UI is configured by a single `horizon.yaml` (hot-reloaded, with `${VAR}` environment-variable interpolation) — see `horizon.example.yaml`. Key sections:
+Horizon UI is configured by a single `horizon.yaml` (hot-reloaded, with `${VAR}` environment-variable interpolation) — see `horizon.yaml`. Key sections:
 
 - `server` — host / port.
 - `oap` — `queryUrl`, `adminUrl`, `zipkinUrl`, `timeoutMs`, and optional outbound basic-auth.
diff --git a/apps/bff/src/config/loader.test.ts b/apps/bff/src/config/loader.test.ts
index aa629f8..bae60ca 100644
--- a/apps/bff/src/config/loader.test.ts
+++ b/apps/bff/src/config/loader.test.ts
@@ -60,11 +60,11 @@ describe('stripNullish', () => {
   });
 });
 
-// The env-native contract: the tokenized horizon.example.yaml, interpolated +
+// The env-native contract: the tokenized horizon.yaml, interpolated +
 // stripped + parsed, must accept env overrides for every kind of field.
-describe('env-native config (horizon.example.yaml + env)', () => {
+describe('env-native config (horizon.yaml + env)', () => {
   const here = dirname(fileURLToPath(import.meta.url));
-  const raw = readFileSync(resolve(here, '../../../../horizon.example.yaml'), 'utf8');
+  const raw = readFileSync(resolve(here, '../../../../horizon.yaml'), 'utf8');
   const load = (env: NodeJS.ProcessEnv): ReturnType<typeof configSchema.parse> =>
     configSchema.parse(stripNullish(YAML.parse(interpolateEnv(raw, env)) ?? {}));
 
diff --git a/apps/bff/src/config/schema.test.ts b/apps/bff/src/config/schema.test.ts
index 74bc26d..ae16cd0 100644
--- a/apps/bff/src/config/schema.test.ts
+++ b/apps/bff/src/config/schema.test.ts
@@ -29,15 +29,15 @@ describe('configSchema defaults', () => {
   });
 });
 
-// horizon.example.yaml is the SHIPPED default + the env-var reference: every
+// horizon.yaml is the SHIPPED default + the env-var reference: every
 // field is a `${HORIZON_…:default}` token. Two contracts guarded here:
 //   1. With NO env set, the tokens' defaults parse to EXACTLY the schema
 //      defaults — so the file is a faithful "this is what you get" reference.
 //   2. Every top-level config section appears in the example, so a new
 //      section can't be added to the schema without an env-overridable token.
-describe('horizon.example.yaml — tokenized default + parity', () => {
+describe('horizon.yaml — tokenized default + parity', () => {
   const here = dirname(fileURLToPath(import.meta.url));
-  const examplePath = resolve(here, '../../../../horizon.example.yaml');
+  const examplePath = resolve(here, '../../../../horizon.yaml');
   const raw = readFileSync(examplePath, 'utf8');
 
   it('parses to exactly the schema defaults (token defaults match the schema)', () => {
@@ -63,7 +63,7 @@ describe('horizon.example.yaml — tokenized default + parity', () => {
     for (const s of sections) {
       // `infra3d` is the deprecated/ignored passthrough — never tokenized.
       if (s === 'infra3d') continue;
-      expect(exampleKeys, `config section "${s}" is missing from horizon.example.yaml`).toContain(s);
+      expect(exampleKeys, `config section "${s}" is missing from horizon.yaml`).toContain(s);
     }
   });
 
@@ -107,6 +107,6 @@ describe('horizon.example.yaml — tokenized default + parity', () => {
       uncovered.push(`${path} (leaf not tokenized)`);
     };
     walk(defaults, example, '');
-    expect(uncovered, `fields lacking an env token in horizon.example.yaml:\n  ${uncovered.join('\n  ')}`).toEqual([]);
+    expect(uncovered, `fields lacking an env token in horizon.yaml:\n  ${uncovered.join('\n  ')}`).toEqual([]);
   });
 });
diff --git a/docs/access-control/local-backend.md b/docs/access-control/local-backend.md
index dc01ce7..6ff3d86 100644
--- a/docs/access-control/local-backend.md
+++ b/docs/access-control/local-backend.md
@@ -60,10 +60,10 @@ The session captures the role list at authentication time, not on every request.
 
 ## File permissions
 
-`horizon.yaml` contains password hashes. Treat it as a secret-bearing file:
+The `horizon.yaml` shipped in the repo and image is **env-driven and holds no secrets** — `local.users` defaults to the `HORIZON_AUTH_LOCAL_USERS` variable. Supply your users (with their Argon2id hashes) through that environment variable so hashes never land in a version-controlled file. If you instead write hashes directly into a `horizon.yaml` you deploy, treat that copy as a secret-bearing file:
 
 - Filesystem perms `0600` (BFF user only).
-- Not in version control. The repo's `.gitignore` excludes `horizon.yaml`; only `horizon.example.yaml` is committed.
+- Keep it out of version control — the committed `horizon.yaml` holds only `${HORIZON_…}` tokens, never real hashes.
 - If you store the file in configuration management (Ansible, Helm secret, etc.), encrypt at rest.
 
 ## Mixing with LDAP
diff --git a/docs/compatibility/ports.md b/docs/compatibility/ports.md
index 6707bb5..0a3d51b 100644
--- a/docs/compatibility/ports.md
+++ b/docs/compatibility/ports.md
@@ -38,7 +38,7 @@ The OAP defaults. Each module binds its own port:
 - `:17128` for admin
 - `:9412` for Zipkin
 
-This is what `horizon.example.yaml` shows.
+This is what `horizon.yaml` shows.
 
 ### Shared port (Docker / Kubernetes presets)
 
diff --git a/docs/setup/container-image.md b/docs/setup/container-image.md
index 75c12de..0e89006 100644
--- a/docs/setup/container-image.md
+++ b/docs/setup/container-image.md
@@ -28,7 +28,6 @@ The full commit SHA is the canonical, immutable identifier. Moving tags are conv
 | `/app/server.js` | root | no | Compiled BFF entry point. `CMD` runs `node server.js`. |
 | `/app/node_modules/` | root | no | Production npm dependencies. |
 | `/app/static/` | root | no | Built UI assets (Vite `dist/`). |
-| `/app/horizon.example.yaml` | root | no | Example config — **read-only reference**, copy from it. |
 | `/app/horizon.yaml` | root | no | The **active** config — a **baked, fully tokenized default** (every field is a `${HORIZON_…:default}` env token). The image runs with no mounted file; override any field via env (see [Run with env vars only](#run-with-env-vars-only-no-mounted-file)), or bind-mount your own to replace it. |
 | `/app/bundled_templates/` | **horizon** | **yes** | Layer + overview JSON templates. Owned by `horizon` because the admin **Layer-Templates** and **Overview-Templates** editors write into per-key files here. |
 | `/data/` | **horizon** | **yes** | Declared `VOLUME`. Default destination for the audit log, setup state, alarm state, and wire debug log. Mount a PVC / named volume / host bind here for durable storage. |
diff --git a/docs/setup/overview.md b/docs/setup/overview.md
index 143122f..ea218d7 100644
--- a/docs/setup/overview.md
+++ b/docs/setup/overview.md
@@ -12,15 +12,14 @@ This page is the shortest path from "no Horizon" to "Horizon in front of a runni
 
 ### 1. Unpack Horizon
 
-Unpack the binary tarball (substitute the release version you downloaded for `<version>`) and copy the example config:
+Unpack the binary tarball (substitute the release version you downloaded for `<version>`):
 
 ```sh
 tar -xzf apache-skywalking-horizon-ui-<version>-bin.tar.gz
 cd apache-skywalking-horizon-ui-<version>-bin
-cp horizon.example.yaml horizon.yaml
 ```
 
-The binary is self-contained: `server.js`, `node_modules/`, `static/`, and bundled templates are already present. There is no `pnpm install` step.
+The binary is self-contained: `server.js`, `node_modules/`, `static/`, bundled templates, and the config `horizon.yaml` are already present. There is no `pnpm install` step. `horizon.yaml` is **env-driven** — every field is a `${HORIZON_…:default}` variable, so you can leave the file as-is and set only the environment variables you need (starting with your OAP address), or edit the file directly.
 
 ### 2. Point Horizon at OAP
 
diff --git a/docs/setup/rbac.md b/docs/setup/rbac.md
index 31b195a..346c119 100644
--- a/docs/setup/rbac.md
+++ b/docs/setup/rbac.md
@@ -24,7 +24,7 @@ rbac:
 | Field | Type | Default | Required | Notes |
 |---|---|---|---|---|
 | `enabled` | boolean | `true` | no | When `false`, every authenticated session is granted `*` (full access). Useful for dev. **Set `true` in production.** |
-| `roles` | object | the four built-ins | no | Custom role definitions. Keys are role names; values are arrays of permission strings. **Omitting this block uses the four built-ins** (`viewer`, `maintainer`, `operator`, `admin`) — see `horizon.example.yaml` for the full grants. Defining the block at all overrides the built-ins entirely; redefine all four if you want to keep them. |
+| `roles` | object | the four built-ins | no | Custom role definitions. Keys are role names; values are arrays of permission strings. **Omitting this block uses the four built-ins** (`viewer`, `maintainer`, `operator`, `admin`) — see `horizon.yaml` for the full grants. Defining the block at all overrides the built-ins entirely; redefine all four if you want to keep them. |
 | `landingByRole` | object | see below | no | Post-login redirect route per role. First role on the user wins. |
 
 ## Built-in roles (used when `roles` is not set)
diff --git a/horizon.example.yaml b/horizon.yaml
similarity index 100%
rename from horizon.example.yaml
rename to horizon.yaml
diff --git a/scripts/package.mjs b/scripts/package.mjs
index 7355445..b750d47 100644
--- a/scripts/package.mjs
+++ b/scripts/package.mjs
@@ -30,7 +30,7 @@
  *       node_modules/          — production deps (npm + workspace dists)
  *       bundled_templates/     — layer + overview JSON templates
  *       static/                — built UI (Vite dist)
- *       horizon.example.yaml   — copy-and-edit starting point
+ *       horizon.yaml           — env-driven config (override fields via HORIZON_… env vars)
  *
  * The build:
  *   1. Builds each workspace package under `packages/*` (tsc → dist/).
@@ -102,7 +102,7 @@ cpSync(
   { recursive: true },
 );
 cpSync(resolve(root, 'apps/ui/dist'), resolve(dist, 'static'), { recursive: true });
-cpSync(resolve(root, 'horizon.example.yaml'), resolve(dist, 'horizon.example.yaml'));
+cpSync(resolve(root, 'horizon.yaml'), resolve(dist, 'horizon.yaml'));
 
 step('Done');
 console.log(`
@@ -110,8 +110,8 @@ Target binary: ${resolve(dist, 'server.js')}
 
 Boot it:
 
-    cp horizon.example.yaml horizon.yaml         # configure once
-    HORIZON_CONFIG=./horizon.yaml \\
+    HORIZON_CONFIG=./horizon.yaml \\             # every field is a \${HORIZON_…} env var
+      HORIZON_OAP_QUERY_URL=http://oap:12800 \\  # override only what you need
       HORIZON_STATIC_DIR=./dist/static \\
       node dist/server.js
 
diff --git a/scripts/release.sh b/scripts/release.sh
index 9320107..7b2cf10 100755
--- a/scripts/release.sh
+++ b/scripts/release.sh
@@ -504,7 +504,7 @@ Guide to build the release from source:
  * cd into the extracted directory
  * pnpm install --frozen-lockfile
  * pnpm package
- * node dist/server.js (after copying horizon.example.yaml → horizon.yaml)
+ * node dist/server.js (after copying horizon.yaml → horizon.yaml)
 
 Voting will start now (${VOTE_DATE}) and will remain open for at least
 72 hours. PMC members, please cast your vote.


(skywalking-horizon-ui) 07/11: fix(cluster): badge no longer claims 'all reachable' when a feature is bundled

Posted by wu...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a commit to branch feat/template-modes-env-config
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git

commit c1247c7a0a139f785591628babdd57f32c77977e
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Fri Jun 26 09:28:38 2026 +0800

    fix(cluster): badge no longer claims 'all reachable' when a feature is bundled
    
    reachable===null (ui_template in readonly: not probed, serving bundled
    templates) was slipping past the reachable===false filter, so the summary read
    'all reachable' while a row showed 'readonly · bundled'. Count the null/bundled
    features separately — the badge now reads '4 reachable · 1 bundled'.
---
 apps/ui/src/features/operate/cluster/ClusterStatusView.vue | 13 ++++++++++---
 apps/ui/src/i18n/locales/de.json                           |  3 ++-
 apps/ui/src/i18n/locales/en.json                           |  3 ++-
 apps/ui/src/i18n/locales/es.json                           |  3 ++-
 apps/ui/src/i18n/locales/fr.json                           |  3 ++-
 apps/ui/src/i18n/locales/ja.json                           |  3 ++-
 apps/ui/src/i18n/locales/ko.json                           |  3 ++-
 apps/ui/src/i18n/locales/pt.json                           |  3 ++-
 apps/ui/src/i18n/locales/zh-CN.json                        |  3 ++-
 9 files changed, 26 insertions(+), 11 deletions(-)

diff --git a/apps/ui/src/features/operate/cluster/ClusterStatusView.vue b/apps/ui/src/features/operate/cluster/ClusterStatusView.vue
index 05431b2..792c842 100644
--- a/apps/ui/src/features/operate/cluster/ClusterStatusView.vue
+++ b/apps/ui/src/features/operate/cluster/ClusterStatusView.vue
@@ -114,9 +114,16 @@ const adminBadgeState = computed<'ok' | 'warn' | 'err' | 'unknown'>(() => {
 const adminBadgeLabel = computed<string>(() => {
   if (!preflight.value) return t('loading…');
   if (!adminReachable.value) return t('unreachable');
-  const down = preflight.value.modules.filter((m) => m.required && m.reachable === false);
-  if (down.length === 0) return t('all reachable');
-  return t('{n} unreachable', { n: down.length });
+  const mods = preflight.value.modules;
+  const down = mods.filter((m) => m.reachable === false).length;
+  if (down > 0) return t('{n} unreachable', { n: down });
+  // reachable === null = not probed (ui_template in readonly: bundled). It's
+  // healthy, but it isn't "reachable" — don't fold it into "all reachable".
+  const bundled = mods.filter((m) => m.reachable === null).length;
+  if (bundled > 0) {
+    return t('{n} reachable · {b} bundled', { n: mods.length - bundled, b: bundled });
+  }
+  return t('all reachable');
 });
 
 const adminGeneratedAt = computed<string>(() => agoLabel(preflight.value?.generatedAt));
diff --git a/apps/ui/src/i18n/locales/de.json b/apps/ui/src/i18n/locales/de.json
index fe09296..d034f8a 100644
--- a/apps/ui/src/i18n/locales/de.json
+++ b/apps/ui/src/i18n/locales/de.json
@@ -1548,5 +1548,6 @@
   "all reachable": "alle erreichbar",
   "{n} unreachable": "{n} nicht erreichbar",
   "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is the live probe, not config-presence: a path that 404s (selector off, renamed, or absent in a fork) reads as unreachable. 'selector detected' below is only an upstream-release hint, not the verdict.": "Erreichbarkeit jeder Admin-Funktion — der BFF sendet ein GET an den relativen REST-Pfad, den die Funktion tatsächlich aufruft, und meldet, ob er  [...]
-  "Enable on OAP:": "Auf OAP aktivieren:"
+  "Enable on OAP:": "Auf OAP aktivieren:",
+  "{n} reachable · {b} bundled": "{n} erreichbar · {b} gebündelt"
 }
diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json
index 63f9e62..9be9b57 100644
--- a/apps/ui/src/i18n/locales/en.json
+++ b/apps/ui/src/i18n/locales/en.json
@@ -1548,5 +1548,6 @@
   "all reachable": "all reachable",
   "{n} unreachable": "{n} unreachable",
   "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is the live probe, not config-presence: a path that 404s (selector off, renamed, or absent in a fork) reads as unreachable. 'selector detected' below is only an upstream-release hint, not the verdict.": "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is th [...]
-  "Enable on OAP:": "Enable on OAP:"
+  "Enable on OAP:": "Enable on OAP:",
+  "{n} reachable · {b} bundled": "{n} reachable · {b} bundled"
 }
diff --git a/apps/ui/src/i18n/locales/es.json b/apps/ui/src/i18n/locales/es.json
index 292b594..23778bb 100644
--- a/apps/ui/src/i18n/locales/es.json
+++ b/apps/ui/src/i18n/locales/es.json
@@ -1548,5 +1548,6 @@
   "all reachable": "todos accesibles",
   "{n} unreachable": "{n} inaccesible(s)",
   "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is the live probe, not config-presence: a path that 404s (selector off, renamed, or absent in a fork) reads as unreachable. 'selector detected' below is only an upstream-release hint, not the verdict.": "Accesibilidad de cada función de administración: el BFF hace un GET a la ruta REST relativa que la función realmente llama e informa si responde. [...]
-  "Enable on OAP:": "Activar en OAP:"
+  "Enable on OAP:": "Activar en OAP:",
+  "{n} reachable · {b} bundled": "{n} accesible(s) · {b} incluido(s)"
 }
diff --git a/apps/ui/src/i18n/locales/fr.json b/apps/ui/src/i18n/locales/fr.json
index 5a93b26..5ab140e 100644
--- a/apps/ui/src/i18n/locales/fr.json
+++ b/apps/ui/src/i18n/locales/fr.json
@@ -1548,5 +1548,6 @@
   "all reachable": "tous accessibles",
   "{n} unreachable": "{n} inaccessible(s)",
   "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is the live probe, not config-presence: a path that 404s (selector off, renamed, or absent in a fork) reads as unreachable. 'selector detected' below is only an upstream-release hint, not the verdict.": "Accessibilité de chaque fonctionnalité d'administration — le BFF envoie un GET sur le chemin REST relatif que la fonctionnalité appelle réellemen [...]
-  "Enable on OAP:": "Activer sur OAP :"
+  "Enable on OAP:": "Activer sur OAP :",
+  "{n} reachable · {b} bundled": "{n} accessible(s) · {b} intégré(s)"
 }
diff --git a/apps/ui/src/i18n/locales/ja.json b/apps/ui/src/i18n/locales/ja.json
index 6dd4723..71d57f7 100644
--- a/apps/ui/src/i18n/locales/ja.json
+++ b/apps/ui/src/i18n/locales/ja.json
@@ -1548,5 +1548,6 @@
   "all reachable": "すべて到達可能",
   "{n} unreachable": "{n} 件が到達不可",
   "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is the live probe, not config-presence: a path that 404s (selector off, renamed, or absent in a fork) reads as unreachable. 'selector detected' below is only an upstream-release hint, not the verdict.": "各管理機能の到達可能性 —— BFF は機能が実際に呼び出す相対 REST パスへ GET し、応答するかどうかを報告します。健全性は設定の有無ではなくライブプローブで判定されます。404 を返すパス(selector が無効、リネーム、またはフォークで欠落)は到達不可とみなされます。下の [...]
-  "Enable on OAP:": "OAP で有効化:"
+  "Enable on OAP:": "OAP で有効化:",
+  "{n} reachable · {b} bundled": "{n} 件が到達可能 · {b} 件がバンドル"
 }
diff --git a/apps/ui/src/i18n/locales/ko.json b/apps/ui/src/i18n/locales/ko.json
index 7909e29..40dc171 100644
--- a/apps/ui/src/i18n/locales/ko.json
+++ b/apps/ui/src/i18n/locales/ko.json
@@ -1548,5 +1548,6 @@
   "all reachable": "모두 도달 가능",
   "{n} unreachable": "{n}개 도달 불가",
   "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is the live probe, not config-presence: a path that 404s (selector off, renamed, or absent in a fork) reads as unreachable. 'selector detected' below is only an upstream-release hint, not the verdict.": "각 관리 기능의 도달 가능성 — BFF가 해당 기능이 실제로 호출하는 상대 REST 경로로 GET 요청을 보내 응답 여부를 보고합니다. 상태는 설정 존재 여부가 아니라 실시간 프로브로 판단합니다. 404를 반환하는 경로(selector 꺼짐, 이름 변경 또는  [...]
-  "Enable on OAP:": "OAP에서 활성화:"
+  "Enable on OAP:": "OAP에서 활성화:",
+  "{n} reachable · {b} bundled": "{n}개 도달 가능 · {b}개 번들"
 }
diff --git a/apps/ui/src/i18n/locales/pt.json b/apps/ui/src/i18n/locales/pt.json
index ad09cda..8b65797 100644
--- a/apps/ui/src/i18n/locales/pt.json
+++ b/apps/ui/src/i18n/locales/pt.json
@@ -1548,5 +1548,6 @@
   "all reachable": "todos acessíveis",
   "{n} unreachable": "{n} inacessível(is)",
   "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is the live probe, not config-presence: a path that 404s (selector off, renamed, or absent in a fork) reads as unreachable. 'selector detected' below is only an upstream-release hint, not the verdict.": "Acessibilidade de cada recurso de administração: o BFF faz um GET no caminho REST relativo que o recurso realmente chama e informa se ele respond [...]
-  "Enable on OAP:": "Ativar no OAP:"
+  "Enable on OAP:": "Ativar no OAP:",
+  "{n} reachable · {b} bundled": "{n} acessível(is) · {b} incluído(s)"
 }
diff --git a/apps/ui/src/i18n/locales/zh-CN.json b/apps/ui/src/i18n/locales/zh-CN.json
index a884228..76f2c14 100644
--- a/apps/ui/src/i18n/locales/zh-CN.json
+++ b/apps/ui/src/i18n/locales/zh-CN.json
@@ -1548,5 +1548,6 @@
   "all reachable": "全部可达",
   "{n} unreachable": "{n} 个不可达",
   "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is the live probe, not config-presence: a path that 404s (selector off, renamed, or absent in a fork) reads as unreachable. 'selector detected' below is only an upstream-release hint, not the verdict.": "每个管理功能的可达性 —— BFF 会向该功能实际调用的相对 REST 路径发起 GET,并报告其是否响应。健康状态取决于实时探测,而非配置存在性:返回 404 的路径(selector 关闭、被重命名或在 fork 版本中缺失)将被视为不可达。下方的“检测到 selector”仅为上游发 [...]
-  "Enable on OAP:": "在 OAP 上启用:"
+  "Enable on OAP:": "在 OAP 上启用:",
+  "{n} reachable · {b} bundled": "{n} 个可达 · {b} 个内置"
 }


(skywalking-horizon-ui) 10/11: fix(infra-3d): remove the silent disk-bundle fallback from the live render path

Posted by wu...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a commit to branch feat/template-modes-env-config
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git

commit 05da4a0a636c3fd3e7bc7c3f00869dedaa7bdb53
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Fri Jun 26 10:14:47 2026 +0800

    fix(infra-3d): remove the silent disk-bundle fallback from the live render path
    
    The 3D map was the lone surface that silently served the disk bundle when its
    remote couldn't be resolved — in live mode an unreachable ui_template store (or
    a missing / invalid remote row) returned the bundled config instead of blocking.
    Every other surface (bundle.ts) renders ONLY from remote rows and treats an
    unsynced / disabled / unreachable template as simply absent.
    
    resolveEffectiveConfig now does the same: render only from the remote row — a
    real OAP row in live mode, the synthetic bundled row in readonly — and return
    null otherwise. The route 503s on null, so the map blocks with its connectivity
    state (like layer/overview/alert) instead of masking an outage with a stale
    bundle. The bundle still reaches OAP via boot-seed and is served in readonly
    through the fake-remote row; it is never a live runtime fallback.
    
    Validated live: readonly 200 (bundle via fake-remote), live+reachable 200
    (remote), live+ui_template-unreachable 503 (blocks). 162 BFF + 116 UI tests green.
---
 apps/bff/src/http/config/infra-3d.ts | 51 +++++++++++++++++++++++++-----------
 1 file changed, 35 insertions(+), 16 deletions(-)

diff --git a/apps/bff/src/http/config/infra-3d.ts b/apps/bff/src/http/config/infra-3d.ts
index 84ac1f4..f6c87e5 100644
--- a/apps/bff/src/http/config/infra-3d.ts
+++ b/apps/bff/src/http/config/infra-3d.ts
@@ -18,12 +18,13 @@
 /**
  * `GET /api/infra-3d/config` — the EFFECTIVE 3D Infrastructure Map config
  * the `/3d/map` view consumes. The config is a first-class template kind
- * (`horizon.infra-3d.config`), so it follows the same bundled → remote
- * policy as every other template: when OAP holds a non-disabled remote
- * row, that wins; otherwise the bundled default is served. Writes do NOT
- * happen here — the admin editor publishes through the generic template
- * sync surface (`POST /api/admin/templates/save`), which validates the
- * 3D-map content before it reaches OAP.
+ * (`horizon.infra-3d.config`) and is read ONLY from the remote row, exactly
+ * like the layer/overview surfaces — never the disk bundle as a runtime
+ * fallback. The bundle reaches OAP via boot-seed; readonly mode renders it
+ * through the synthetic bundled row. When the ui_template store is unreachable
+ * (or the row is absent / invalid) this 503s so the map blocks, rather than
+ * silently serving a stale bundle. Writes do NOT happen here — the admin editor
+ * publishes through the generic template sync surface (`POST /api/admin/templates/save`).
  */
 
 import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
@@ -38,7 +39,6 @@ import {
   parseEnvelope,
   INFRA3D_CONFIG_KEY,
 } from '../../logic/templates/names.js';
-import { loadBundledInfra3dConfig } from '../../logic/infra-3d/bundled.js';
 import { validateInfra3dConfig } from '../../logic/infra-3d/validate.js';
 import type { Infra3dConfig } from '../../logic/infra-3d/types.js';
 import { logger } from '../../logger.js';
@@ -60,6 +60,16 @@ export function registerInfra3dConfigRoutes(
     { preHandler: auth },
     async (_req: FastifyRequest, reply: FastifyReply) => {
       const cfg = await resolveEffectiveConfig(deps);
+      // Live + ui_template store unreachable → block, exactly like every other
+      // surface (the UI renders its connectivity-empty state). The 3D map used
+      // to be the lone exception that silently served the bundle here.
+      if (!cfg) {
+        return reply.code(503).send({
+          error: 'template_store_unreachable',
+          reason:
+            "OAP's ui_template store is unreachable; in live mode the 3D map renders only from OAP (no bundled fallback). It recovers when the store is reachable, or run templates.mode=readonly to serve the bundle.",
+        });
+      }
       // The metric fan-out budget is OPERATIONAL (per-deployment, hot-
       // reloaded), so it lives in horizon.yaml — NOT the published template.
       // Inject it server-side so the UI keeps reading `cfg.pipeline.*`; this
@@ -79,18 +89,26 @@ export function registerInfra3dConfigRoutes(
   );
 }
 
-/** Remote-wins resolution, mirroring `pickLayerContent` in the config
- *  bundle. The remote envelope is re-validated defensively — a row
- *  hand-edited on OAP into an invalid shape falls back to bundled rather
- *  than breaking the map. */
-async function resolveEffectiveConfig(deps: Infra3dConfigRouteDeps): Promise<Infra3dConfig> {
-  const bundled = loadBundledInfra3dConfig();
+/**
+ * Render ONLY from the remote row — never the disk bundle as a runtime
+ * fallback. This matches the layer/overview surfaces (bundle.ts): the bundle
+ * reaches OAP via boot-seed, then is read back as a remote row. `getSyncStatus`
+ * supplies that row — a real OAP row in live mode, the synthetic bundled row in
+ * readonly — so readonly renders the bundle through the SAME remote path.
+ *
+ * Returns null (→ the route blocks with 503) when there is no usable remote
+ * row: the store is unreachable, the row is absent / not-yet-seeded / disabled,
+ * or the remote envelope is invalid. A live ui_template outage must surface as
+ * a block like every other surface, not a silent stale bundle.
+ */
+async function resolveEffectiveConfig(deps: Infra3dConfigRouteDeps): Promise<Infra3dConfig | null> {
   try {
     const sync = await getSyncStatus({
       client: deps.uiTemplateClient(),
       bundled: () => iterateBundledTemplates(),
       logger,
     });
+    if (sync.unreachable) return null;
     const name = formatName('infra-3d', INFRA3D_CONFIG_KEY);
     const row = sync.rows.find((r) => r.name === name);
     if (row && row.status !== 'disabled' && row.effective === 'remote' && row.remote) {
@@ -98,11 +116,12 @@ async function resolveEffectiveConfig(deps: Infra3dConfigRouteDeps): Promise<Inf
       if (env) {
         const v = validateInfra3dConfig(env.content);
         if (v.ok) return v.value;
-        logger.warn({ issues: v.issues }, 'remote infra-3d config invalid; using bundled');
+        logger.warn({ issues: v.issues }, 'remote infra-3d config invalid — blocking, not falling back');
       }
     }
+    return null;
   } catch (err) {
-    logger.warn({ err }, 'infra-3d effective resolve failed; using bundled');
+    logger.warn({ err }, 'infra-3d effective resolve failed — blocking, not falling back');
+    return null;
   }
-  return bundled;
 }


(skywalking-horizon-ui) 04/11: fix(config): review pass 2 — readonly UI gating, friendly config error, tests

Posted by wu...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a commit to branch feat/template-modes-env-config
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git

commit ea9f3276022714afc87b7cadcb1060f420894b41
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Fri Jun 26 02:14:10 2026 +0800

    fix(config): review pass 2 — readonly UI gating, friendly config error, tests
    
    - Translations page is now read-only in readonly mode like its siblings: the
      widget editor never opens (openPanel early-returns) and pushToOap guards on
      readOnly. Previously the operator could translate→stage→push and was only
      stopped by the server 409 at the end.
    - Invalid config (e.g. a bool env var set to no/off/0, a non-numeric port, or
      malformed JSON env) now fails boot with a field-path-annotated fatal message
      instead of a raw ZodError dump (the crash itself is correct — fail loud).
    - Tests: the readonly write-deny route hook (isTemplateWriteRoute matches the
      config-template writes, not runtime-rule / local-state; deny → 409 in
      readonly, no-op in live); quoted string scalars survive YAML-metachar values;
      the example-parity test interpolates only the env vars the schema reads inline
      (a stray HORIZON_* no longer reads as drift).
    
    type-check / lint / license / 155 BFF + 116 UI unit tests green.
---
 apps/bff/src/config/loader.test.ts                 |  8 +++
 apps/bff/src/config/schema.test.ts                 | 18 +++++--
 apps/bff/src/rbac/route-policy.test.ts             | 62 ++++++++++++++++++++++
 apps/bff/src/rbac/route-policy.ts                  |  4 +-
 apps/bff/src/server.ts                             | 12 +++++
 .../admin/translations/TranslationsView.vue        |  6 ++-
 6 files changed, 102 insertions(+), 8 deletions(-)

diff --git a/apps/bff/src/config/loader.test.ts b/apps/bff/src/config/loader.test.ts
index 5890194..aa629f8 100644
--- a/apps/bff/src/config/loader.test.ts
+++ b/apps/bff/src/config/loader.test.ts
@@ -103,6 +103,14 @@ describe('env-native config (horizon.example.yaml + env)', () => {
     expect(() => load({ HORIZON_AUTH_LOCAL_USERS: '[{bad json' })).toThrow();
   });
 
+  it('quoted string scalars survive a value with YAML metacharacters (: and #)', () => {
+    // The string tokens are quoted (`"${X:default}"`), so a metachar value
+    // lands safely inside a YAML string instead of breaking the parse.
+    const cfg = load({ HORIZON_SESSION_COOKIE_NAME: 'weird: name #x', HORIZON_OAP_QUERY_URL: 'http://oap:12800' });
+    expect(cfg.session.cookieName).toBe('weird: name #x');
+    expect(cfg.oap.queryUrl).toBe('http://oap:12800');
+  });
+
   it('survives newlines and YAML formatting', () => {
     const raw = `auth:\n  ldap:\n    bindPassword: "\${HORIZON_LDAP_BIND_PW:dev-only}"\n`;
     const out = interpolateEnv(raw, {});
diff --git a/apps/bff/src/config/schema.test.ts b/apps/bff/src/config/schema.test.ts
index 9aaccc0..9d4a7d0 100644
--- a/apps/bff/src/config/schema.test.ts
+++ b/apps/bff/src/config/schema.test.ts
@@ -41,11 +41,19 @@ describe('horizon.example.yaml — tokenized default + parity', () => {
   const raw = readFileSync(examplePath, 'utf8');
 
   it('parses to exactly the schema defaults (token defaults match the schema)', () => {
-    // Use process.env on BOTH sides: the schema's inline env defaults
-    // (serverHostDefault, the *_FILE paths, sourcemaps dir, templatesMode) read
-    // process.env at module load, so the example's tokens must resolve against
-    // the same env or a stray HORIZON_* in CI would read as drift.
-    const parsed = stripNullish(YAML.parse(interpolateEnv(raw, process.env)) ?? {});
+    // configSchema.parse({}) reads a FIXED set of HORIZON_* env vars inline for
+    // its defaults (server host/port, the *_FILE paths, sourcemaps dir,
+    // templates mode). Interpolate the example with ONLY those — so the two
+    // sides agree on the env-derived defaults, while every OTHER stray HORIZON_*
+    // in a dev/CI env is ignored (it would otherwise read as drift, since the
+    // schema default for e.g. oap.queryUrl is a literal, not env-read).
+    const SCHEMA_ENV = [
+      'HORIZON_SERVER_HOST', 'HORIZON_SERVER_PORT', 'HORIZON_AUDIT_FILE', 'HORIZON_SETUP_FILE',
+      'HORIZON_ALARMS_FILE', 'HORIZON_WIRE_LOG_FILE', 'HORIZON_SOURCEMAPS_DIR', 'HORIZON_TEMPLATES_MODE',
+    ];
+    const env: NodeJS.ProcessEnv = {};
+    for (const k of SCHEMA_ENV) if (process.env[k] !== undefined) env[k] = process.env[k];
+    const parsed = stripNullish(YAML.parse(interpolateEnv(raw, env)) ?? {});
     expect(configSchema.parse(parsed)).toEqual(configSchema.parse({}));
   });
 
diff --git a/apps/bff/src/rbac/route-policy.test.ts b/apps/bff/src/rbac/route-policy.test.ts
new file mode 100644
index 0000000..67f3242
--- /dev/null
+++ b/apps/bff/src/rbac/route-policy.test.ts
@@ -0,0 +1,62 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { describe, it, expect, afterEach, vi } from 'vitest';
+import type { FastifyReply, FastifyRequest } from 'fastify';
+import { isTemplateWriteRoute, denyTemplateWriteWhenReadOnly } from './route-policy.js';
+import { setTemplateReadOnly } from '../logic/templates/sync.js';
+
+describe('isTemplateWriteRoute — which routes the readonly backstop covers', () => {
+  it('matches non-GET config-template write routes', () => {
+    expect(isTemplateWriteRoute('POST', '/api/admin/templates/save')).toBe(true);
+    expect(isTemplateWriteRoute('POST', '/api/admin/templates/save-translation')).toBe(true);
+    expect(isTemplateWriteRoute('POST', '/api/admin/templates/sync-all')).toBe(true);
+    expect(isTemplateWriteRoute('POST', '/api/admin/overview-templates')).toBe(true);
+    expect(isTemplateWriteRoute('DELETE', '/api/admin/overview-templates/x')).toBe(true);
+  });
+  it('does NOT match reads, nor non-template writes (runtime-rule / local state stay editable)', () => {
+    expect(isTemplateWriteRoute('GET', '/api/admin/templates/sync-status')).toBe(false);
+    expect(isTemplateWriteRoute('HEAD', '/api/admin/templates/sync-status')).toBe(false);
+    expect(isTemplateWriteRoute('POST', '/api/rule')).toBe(false); // runtime rule
+    expect(isTemplateWriteRoute('POST', '/api/alarms/config')).toBe(false); // local state
+    expect(isTemplateWriteRoute('POST', '/api/debug/session')).toBe(false); // live-debug
+  });
+});
+
+describe('denyTemplateWriteWhenReadOnly — the BFF backstop', () => {
+  afterEach(() => setTemplateReadOnly(false));
+
+  const fakeReply = (): { reply: FastifyReply; code: ReturnType<typeof vi.fn> } => {
+    const send = vi.fn();
+    const code = vi.fn(() => ({ send }));
+    return { reply: { code } as unknown as FastifyReply, code };
+  };
+
+  it('rejects with 409 in readonly mode', async () => {
+    setTemplateReadOnly(true);
+    const { reply, code } = fakeReply();
+    await denyTemplateWriteWhenReadOnly({} as FastifyRequest, reply);
+    expect(code).toHaveBeenCalledWith(409);
+  });
+
+  it('is a no-op in live mode (lets the write proceed)', async () => {
+    setTemplateReadOnly(false);
+    const { reply, code } = fakeReply();
+    await denyTemplateWriteWhenReadOnly({} as FastifyRequest, reply);
+    expect(code).not.toHaveBeenCalled();
+  });
+});
diff --git a/apps/bff/src/rbac/route-policy.ts b/apps/bff/src/rbac/route-policy.ts
index ff0abeb..fb3d617 100644
--- a/apps/bff/src/rbac/route-policy.ts
+++ b/apps/bff/src/rbac/route-policy.ts
@@ -37,11 +37,11 @@ export type RoutePolicy = 'public' | 'auth' | string;
  *  `templates.mode=readonly` there is no store to write to, so these are denied
  *  at the edge regardless of the verb grant (the UI hides them too, but a direct
  *  request must still fail — the BFF is the authority). */
-function isTemplateWriteRoute(method: string, url: string): boolean {
+export function isTemplateWriteRoute(method: string, url: string): boolean {
   if (method === 'GET' || method === 'HEAD') return false;
   return url.startsWith('/api/admin/templates') || url.startsWith('/api/admin/overview-templates');
 }
-async function denyTemplateWriteWhenReadOnly(
+export async function denyTemplateWriteWhenReadOnly(
   _req: FastifyRequest,
   reply: FastifyReply,
 ): Promise<void> {
diff --git a/apps/bff/src/server.ts b/apps/bff/src/server.ts
index 3a2b1a8..eba8ce9 100644
--- a/apps/bff/src/server.ts
+++ b/apps/bff/src/server.ts
@@ -17,6 +17,7 @@
 
 import { existsSync } from 'node:fs';
 import { resolve as resolvePath } from 'node:path';
+import { ZodError } from 'zod';
 import Fastify from 'fastify';
 import cookie from '@fastify/cookie';
 import fastifyStatic from '@fastify/static';
@@ -104,6 +105,17 @@ try {
     logger.fatal({ err: err.message, configPath }, 'BFF refusing to start: bootstrap validation failed');
     process.exit(1);
   }
+  if (err instanceof ZodError) {
+    // A bad value (often a HORIZON_* env override): surface the field path +
+    // reason instead of a raw zod dump. Booleans accept only true/false,
+    // numbers must be numeric, and JSON env vars must be valid JSON.
+    const issues = err.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`).join('; ');
+    logger.fatal(
+      { issues, configPath },
+      'BFF refusing to start: config validation failed — check the value (and any HORIZON_* env override) at each path. Booleans must be true/false; numbers must be numeric; JSON env vars must be valid JSON',
+    );
+    process.exit(1);
+  }
   throw err;
 }
 logger.info(
diff --git a/apps/ui/src/features/admin/translations/TranslationsView.vue b/apps/ui/src/features/admin/translations/TranslationsView.vue
index f21ccf9..333881e 100644
--- a/apps/ui/src/features/admin/translations/TranslationsView.vue
+++ b/apps/ui/src/features/admin/translations/TranslationsView.vue
@@ -398,6 +398,10 @@ interface PanelState {
 const panel = ref<PanelState>({ open: false, anchor: null, point: null, fields: [], label: '' });
 
 function openPanel(fields: TranslatableField[], label: string, el: HTMLElement, point: { x: number; y: number }): void {
+  // Read-only mode (templates.mode=readonly or admin unreachable): the canvas
+  // stays viewable but the editor never opens — no edit can start that could
+  // only end in a server 409. Sibling admin pages gate the same way.
+  if (readOnly.value) return;
   // Widgets with no translatable text (e.g. a topology widget that
   // only carries `layer`) shouldn't open an empty panel.
   if (fields.length === 0) return;
@@ -600,7 +604,7 @@ async function pushToOap(): Promise<void> {
   const name = selectedName.value;
   const loc = target.value;
   const overlay = draftOverlayForTarget.value;
-  if (!name || saving.value) return;
+  if (!name || saving.value || readOnly.value) return; // BFF denies it anyway
   saving.value = true;
   saveMsg.value = t('Saving to OAP…');
   let elapsed = 0;


(skywalking-horizon-ui) 03/11: test(bff): readonly-mode sync — renders from disk, includes translation overlays, no OAP call

Posted by wu...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a commit to branch feat/template-modes-env-config
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git

commit 9b8cb657e8fd44e23c29bc02d4647cf7004bbf2e
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Fri Jun 26 01:58:24 2026 +0800

    test(bff): readonly-mode sync — renders from disk, includes translation overlays, no OAP call
---
 apps/bff/src/logic/templates/sync.test.ts | 67 +++++++++++++++++++++++++++++++
 1 file changed, 67 insertions(+)

diff --git a/apps/bff/src/logic/templates/sync.test.ts b/apps/bff/src/logic/templates/sync.test.ts
new file mode 100644
index 0000000..b416292
--- /dev/null
+++ b/apps/bff/src/logic/templates/sync.test.ts
@@ -0,0 +1,67 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { describe, it, expect, afterEach } from 'vitest';
+import type { UITemplateClient } from '@skywalking-horizon-ui/api-client';
+import { getSyncStatus, setTemplateReadOnly, invalidateSyncCache } from './sync.js';
+import { iterateBundledTemplates } from './aggregator.js';
+import { logger } from '../../logger.js';
+
+// A client that THROWS on any call — proves readonly mode never touches the
+// OAP ui_template store (it short-circuits to the disk bundle before list()).
+const throwingClient = {
+  list: () => Promise.reject(new Error('ui_template store must not be called in readonly mode')),
+  create: () => Promise.reject(new Error('no')),
+  update: () => Promise.reject(new Error('no')),
+  disable: () => Promise.reject(new Error('no')),
+} as unknown as UITemplateClient;
+
+const deps = { client: throwingClient, bundled: () => iterateBundledTemplates(), logger };
+
+describe('sync — readonly mode renders from the disk bundle', () => {
+  afterEach(() => {
+    setTemplateReadOnly(false);
+    invalidateSyncCache();
+  });
+
+  it('returns mode=readonly + reachable, without calling the ui_template client', async () => {
+    setTemplateReadOnly(true);
+    const status = await getSyncStatus(deps);
+    expect(status.mode).toBe('readonly');
+    expect(status.unreachable).toBe(false);
+    expect(status.rows.length).toBeGreaterThan(0);
+    // Every row is presented as effective:'remote' so every render consumer
+    // resolves it exactly as it would a live remote row.
+    expect(status.rows.every((r) => r.effective === 'remote' && r.remote !== null)).toBe(true);
+  });
+
+  it('includes per-locale translation overlay rows so non-English locales render translated', async () => {
+    setTemplateReadOnly(true);
+    const status = await getSyncStatus(deps);
+    const overlays = status.rows.filter((r) => r.locale !== undefined);
+    expect(overlays.length).toBeGreaterThan(0);
+    expect(overlays.some((r) => r.locale === 'zh-CN')).toBe(true);
+  });
+
+  it('carries the bundled source rows (layer/overview/alert/...) as effective content', async () => {
+    setTemplateReadOnly(true);
+    const status = await getSyncStatus(deps);
+    const kinds = new Set(status.rows.filter((r) => r.locale === undefined).map((r) => r.kind));
+    expect(kinds.has('layer')).toBe(true);
+    expect(kinds.has('overview')).toBe(true);
+  });
+});


(skywalking-horizon-ui) 05/11: feat(cluster): reachability-based admin preflight + ui_template unified as a feature

Posted by wu...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a commit to branch feat/template-modes-env-config
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git

commit 2e4b26baa91c9dfc8c9d800b16087f1983d53586
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Fri Jun 26 08:56:41 2026 +0800

    feat(cluster): reachability-based admin preflight + ui_template unified as a feature
    
    Health on the Cluster Status admin pane is now the live probe, not config-
    presence. Each admin feature declares the relative REST path it actually calls
    on OAP; the BFF fires a safe GET at it and reports whether it responds:
    
      admin-server           /debugging/config/dump
      receiver-runtime-rule  /runtime/rule/list
      dsl-debugging          /dsl-debugging/status
      inspect                /inspect/metrics
      ui-management          /ui-management/templates
    
    `reachable` drives the green/red + the per-page warning gates. The config-dump
    prefix check (`enabled`) is demoted to an informational "selector detected"
    footnote — it only tells you the official upstream release advertises that
    selector, so a renamed / forked / on-but-broken module that 404s now reads as
    unreachable instead of a misleading green (validated live: inspect on the demo
    OAP advertises its selector but only /inspect/metrics responds — the catalog
    path 404s).
    
    ui_template joins the same table as a feature, mode-aware: in readonly it is
    never probed (`reachable: null` → "readonly · bundled"). Each row shows how long
    ago it was last checked (anchored to the BFF's generatedAt, so it reflects real
    probe + cache age), and a force re-check bypasses the cache. The 5 probes are
    single-flighted for 30s on the BFF so the UI's 60s poll across N sessions
    doesn't fan out on OAP.
    
    Validated live (demo OAP): live = all five reachable; readonly = ui_template
    null + others probed; admin-port down = all unreachable, no path probes.
    type-check / lint / license / 161 BFF + 116 UI tests / builds / 8-locale i18n green.
---
 CHANGELOG.md                                       |   1 +
 apps/bff/src/http/query/preflight.ts               |  14 +-
 apps/bff/src/logic/preflight/preflight.test.ts     | 145 +++++++++++++++++++
 apps/bff/src/logic/preflight/preflight.ts          | 159 ++++++++++++++++++---
 apps/ui/src/api/scopes/menu.ts                     |   4 +-
 .../features/operate/cluster/ClusterStatusView.vue | 123 ++++++++--------
 apps/ui/src/i18n/locales/de.json                   |  10 +-
 apps/ui/src/i18n/locales/en.json                   |  10 +-
 apps/ui/src/i18n/locales/es.json                   |  10 +-
 apps/ui/src/i18n/locales/fr.json                   |  10 +-
 apps/ui/src/i18n/locales/ja.json                   |  10 +-
 apps/ui/src/i18n/locales/ko.json                   |  10 +-
 apps/ui/src/i18n/locales/pt.json                   |  10 +-
 apps/ui/src/i18n/locales/zh-CN.json                |  10 +-
 apps/ui/src/shell/AdminFeatureWarning.vue          |  12 +-
 apps/ui/src/shell/useAdminFeatures.ts              |  40 +++---
 packages/api-client/src/preflight.ts               |  49 +++++--
 17 files changed, 490 insertions(+), 137 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 82983e4..fedb72b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@ The version line is shared by every package in the monorepo (apps + shared packa
 
 - **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.** The image now bakes a **fully tokenized** `horizon.yaml` where every field is a `${HORIZON_…:default}` env var, 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 YAML-only. Lists and secrets (users, LDAP, OAP auth) are set as JSON-string env vars; preced [...]
+- **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  [...]
 
 ### General Service — PHP runtime (PHM)
 
diff --git a/apps/bff/src/http/query/preflight.ts b/apps/bff/src/http/query/preflight.ts
index 99a2902..530e8ce 100644
--- a/apps/bff/src/http/query/preflight.ts
+++ b/apps/bff/src/http/query/preflight.ts
@@ -20,7 +20,7 @@ import type { FetchLike } from '@skywalking-horizon-ui/api-client';
 import type { ConfigSource } from '../../config/loader.js';
 import { requireAuth } from '../../user/middleware.js';
 import type { SessionStore } from '../../user/sessions.js';
-import { runPreflight } from '../../logic/preflight/preflight.js';
+import { getPreflight } from '../../logic/preflight/preflight.js';
 
 export interface PreflightRouteDeps {
   config: ConfigSource;
@@ -28,17 +28,19 @@ export interface PreflightRouteDeps {
   fetch?: FetchLike;
 }
 
-/** `GET /api/preflight` — interrogates OAP's config-dump and returns
- *  per-module enablement. Authenticated but ungated by verb — every
- *  logged-in user can see whether OAP is correctly set up. */
+/** `GET /api/preflight` — probes each admin feature's REST path and
+ *  returns per-feature reachability (30s single-flight cache; pass
+ *  `?refresh=1` to force a fresh round). Authenticated but ungated by
+ *  verb — every logged-in user can see whether OAP is correctly set up. */
 export function registerPreflightRoutes(app: FastifyInstance, deps: PreflightRouteDeps): void {
   const auth = requireAuth(deps);
   app.get(
     '/api/preflight',
     { preHandler: auth },
-    async (_req: FastifyRequest, reply: FastifyReply) => {
+    async (req: FastifyRequest, reply: FastifyReply) => {
       const fetchImpl = deps.fetch ?? globalThis.fetch.bind(globalThis);
-      const result = await runPreflight(deps.config.current, fetchImpl);
+      const force = (req.query as { refresh?: string }).refresh === '1';
+      const result = await getPreflight(deps.config.current, fetchImpl, { force });
       return reply.send(result);
     },
   );
diff --git a/apps/bff/src/logic/preflight/preflight.test.ts b/apps/bff/src/logic/preflight/preflight.test.ts
new file mode 100644
index 0000000..53adff4
--- /dev/null
+++ b/apps/bff/src/logic/preflight/preflight.test.ts
@@ -0,0 +1,145 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import type { FetchLike } from '@skywalking-horizon-ui/api-client';
+import type { HorizonConfig } from '../../config/schema.js';
+import { runPreflight, getPreflight, invalidatePreflightCache } from './preflight.js';
+
+const DUMP = '/debugging/config/dump';
+// The config dump advertises every module prefix (the "selector detected"
+// footnote) — so reachability, not config-presence, is what the test drives.
+const DUMP_BODY: Record<string, string> = {
+  'admin-server.provider': 'default',
+  'receiver-runtime-rule.provider': 'default',
+  'dsl-debugging.provider': 'default',
+  'inspect.provider': 'default',
+  'ui-management.provider': 'default',
+};
+
+function res(status: number, body: unknown = {}): Response {
+  return {
+    ok: status >= 200 && status < 300,
+    status,
+    text: async () => (typeof body === 'string' ? body : JSON.stringify(body)),
+    json: async () => body,
+  } as unknown as Response;
+}
+
+/** A fetch that answers the dump 200 and each probe path by a status map;
+ *  a path mapped to `'throw'` simulates a network error. Records every URL. */
+function fetchWith(statusByPath: Record<string, number | 'throw'>, calls: string[] = []): FetchLike {
+  return ((url: string) => {
+    calls.push(url);
+    if (url.endsWith(DUMP)) return Promise.resolve(res(200, DUMP_BODY));
+    for (const [path, st] of Object.entries(statusByPath)) {
+      if (url.endsWith(path)) {
+        if (st === 'throw') return Promise.reject(new Error('ECONNREFUSED'));
+        return Promise.resolve(res(st));
+      }
+    }
+    return Promise.resolve(res(404));
+  }) as unknown as FetchLike;
+}
+
+function cfg(mode: 'live' | 'readonly' = 'live'): HorizonConfig {
+  return {
+    oap: { adminUrl: 'http://oap:17128', timeoutMs: 0, queryUrl: '', zipkinUrl: '' },
+    templates: { mode },
+  } as unknown as HorizonConfig;
+}
+
+const ALL_OK = {
+  '/ui-management/templates': 200,
+  '/runtime/rule/list': 200,
+  '/dsl-debugging/status': 200,
+  '/inspect/metrics': 200,
+} as const;
+
+const byName = (r: Awaited<ReturnType<typeof runPreflight>>, name: string) =>
+  r.modules.find((m) => m.name === name)!;
+
+describe('runPreflight — reachability via path probes', () => {
+  beforeEach(() => invalidatePreflightCache());
+
+  it('probes every feature path; reachable comes from the real GET', async () => {
+    const r = await runPreflight(cfg('live'), fetchWith(ALL_OK));
+    expect(r.adminReachable).toBe(true);
+    expect(r.templatesMode).toBe('live');
+    // admin-server's probe IS the dump fetch — reachable without a 2nd call.
+    expect(byName(r, 'admin-server').reachable).toBe(true);
+    for (const n of ['receiver-runtime-rule', 'dsl-debugging', 'inspect', 'ui-management']) {
+      expect(byName(r, n).reachable).toBe(true);
+    }
+    // config-presence is a footnote, present here for every module.
+    expect(r.modules.every((m) => m.enabled)).toBe(true);
+  });
+
+  it('a 404 / 5xx / network error on a path reads as unreachable', async () => {
+    const r = await runPreflight(
+      cfg('live'),
+      fetchWith({ ...ALL_OK, '/inspect/metrics': 404, '/runtime/rule/list': 500, '/dsl-debugging/status': 'throw' }),
+    );
+    expect(byName(r, 'inspect').reachable).toBe(false); // 404 = route not registered
+    expect(byName(r, 'receiver-runtime-rule').reachable).toBe(false); // 5xx
+    expect(byName(r, 'dsl-debugging').reachable).toBe(false); // network error
+    expect(byName(r, 'ui-management').reachable).toBe(true);
+    // enabled stays true (the dump still advertises them) — proving reachable,
+    // not config-presence, drives health.
+    expect(byName(r, 'inspect').enabled).toBe(true);
+  });
+
+  it('a 4xx that is not 404 still counts as reachable (route exists)', async () => {
+    const r = await runPreflight(cfg('live'), fetchWith({ ...ALL_OK, '/runtime/rule/list': 400 }));
+    expect(byName(r, 'receiver-runtime-rule').reachable).toBe(true);
+  });
+
+  it('readonly mode does not probe ui_template — reachable is null', async () => {
+    const calls: string[] = [];
+    const r = await runPreflight(cfg('readonly'), fetchWith(ALL_OK, calls));
+    expect(byName(r, 'ui-management').reachable).toBeNull();
+    expect(calls.some((u) => u.endsWith('/ui-management/templates'))).toBe(false);
+    expect(byName(r, 'inspect').reachable).toBe(true); // others still probed
+  });
+
+  it('admin port down — every feature unreachable, no path probes', async () => {
+    const calls: string[] = [];
+    const downFetch = ((url: string) => {
+      calls.push(url);
+      return Promise.reject(new Error('ECONNREFUSED'));
+    }) as unknown as FetchLike;
+    const r = await runPreflight(cfg('live'), downFetch);
+    expect(r.adminReachable).toBe(false);
+    expect(r.modules.every((m) => m.reachable === false)).toBe(true);
+    expect(calls.every((u) => u.endsWith(DUMP))).toBe(true); // only the dump was tried
+  });
+});
+
+describe('getPreflight — single-flight cache', () => {
+  beforeEach(() => invalidatePreflightCache());
+
+  it('serves a cached round to concurrent / repeat reads, force bypasses it', async () => {
+    const calls: string[] = [];
+    const f = fetchWith(ALL_OK, calls);
+    await getPreflight(cfg('live'), f);
+    const dumpCalls1 = calls.filter((u) => u.endsWith(DUMP)).length;
+    await getPreflight(cfg('live'), f); // cached → no new probes
+    expect(calls.filter((u) => u.endsWith(DUMP)).length).toBe(dumpCalls1);
+    await getPreflight(cfg('live'), f, { force: true }); // bypass → fresh round
+    expect(calls.filter((u) => u.endsWith(DUMP)).length).toBe(dumpCalls1 + 1);
+  });
+});
diff --git a/apps/bff/src/logic/preflight/preflight.ts b/apps/bff/src/logic/preflight/preflight.ts
index 6026a81..5c551be 100644
--- a/apps/bff/src/logic/preflight/preflight.ts
+++ b/apps/bff/src/logic/preflight/preflight.ts
@@ -16,19 +16,25 @@
  */
 
 /**
- * Preflight check — interrogates `/debugging/config/dump` on OAP and
- * reports which of Studio's required OAP modules are enabled. The
- * SPA shows a one-time modal at login when any required selector is
- * missing, listing the env var and what UI breaks without it.
+ * Preflight — a REACHABILITY check of every OAP admin feature Horizon
+ * depends on. Each feature declares the relative admin REST path it
+ * actually calls; the BFF fires a safe GET at that path and reports
+ * whether it responds.
  *
- * The dump returns a `Map<String,String>` of dotted keys
- * `<module>.<provider>.<property>`. A module is enabled iff at least
- * one key with its prefix appears in the dump.
+ * Health = `reachable`, NOT `enabled`. The config-dump prefix check
+ * (`enabled`) only tells us the *official upstream release* advertises
+ * that selector — a fork / rename / different layout can be perfectly
+ * reachable yet show "disabled", or advertise a selector that 404s. So
+ * `enabled` is demoted to an informational footnote and the real GET
+ * decides green/red. A 404 = the route isn't there = unreachable.
  *
- * If admin-server itself is unreachable we return early with
- * `adminReachable: false` and every module marked `enabled: false`;
- * the operator's first move is "check OAP / admin port" rather than
- * "set selectors".
+ * Cost is bounded by a single-flight cache (`getPreflight`): concurrent
+ * / multi-user `/api/preflight` reads share one round of probes. It
+ * stays on-demand (only runs while a page is watching), not a timer.
+ *
+ * If the admin port itself is down (`/debugging/config/dump` fails) we
+ * return early with `adminReachable: false` and every feature
+ * `reachable: false` — fix the network / port first, not selectors.
  */
 
 import type {
@@ -44,6 +50,8 @@ interface ModuleDef {
   name: string;
   envVar: string;
   required: boolean;
+  /** Relative admin REST path the BFF GETs to test reachability. */
+  probePath: string;
   affects: string;
 }
 
@@ -52,13 +60,15 @@ const REQUIRED_MODULES: readonly ModuleDef[] = [
     name: 'admin-server',
     envVar: 'SW_ADMIN_SERVER',
     required: true,
+    probePath: '/debugging/config/dump',
     affects:
-      '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 modules fail at boot with ModuleNotFoundException.',
   },
   {
     name: 'receiver-runtime-rule',
     envVar: 'SW_RECEIVER_RUNTIME_RULE',
     required: true,
+    probePath: '/runtime/rule/list',
     affects:
       "DSL Management (Catalog, OAL catalog), Editor save/load, Cluster status rule matrix, Live debugger rule picker, and the Inspect drawer's source attribution.",
   },
@@ -66,6 +76,7 @@ const REQUIRED_MODULES: readonly ModuleDef[] = [
     name: 'dsl-debugging',
     envVar: 'SW_DSL_DEBUGGING',
     required: true,
+    probePath: '/dsl-debugging/status',
     affects:
       'Live debugger across MAL / LAL / OAL (start / poll / stop) and the DSL-debugging health pane on Cluster status.',
   },
@@ -73,9 +84,18 @@ const REQUIRED_MODULES: readonly ModuleDef[] = [
     name: 'inspect',
     envVar: 'SW_INSPECT',
     required: true,
+    probePath: '/inspect/metrics',
     affects:
       'The Inspect page — every /api/inspect/* call returns 404 inspect_not_enabled and the page shows a banner instead of the board.',
   },
+  {
+    name: 'ui-management',
+    envVar: 'SW_UI_MANAGEMENT',
+    required: true,
+    probePath: '/ui-management/templates',
+    affects:
+      'Dashboard templates — the layer / overview / alert / 3D template store the config surface reads and writes. Unreachable in live mode blocks the config surface; in readonly mode Horizon serves bundled templates and never calls it.',
+  },
 ];
 
 export async function runPreflight(
@@ -83,50 +103,143 @@ export async function runPreflight(
   fetch: FetchLike,
 ): Promise<PreflightResult> {
   const adminUrl = config.oap.adminUrl;
+  const templatesMode = config.templates.mode;
   const generatedAt = Date.now();
   const dump = await fetchConfigDump(adminUrl, fetch, config.oap.timeoutMs, config.oap.auth);
 
   if (!dump.ok) {
+    // Admin port down — nothing downstream is reachable. Probing each
+    // path would just repeat the same failure N times.
     return {
       adminUrl,
       adminReachable: false,
       adminError: dump.error,
+      templatesMode,
       modules: REQUIRED_MODULES.map((m) => ({
         name: m.name,
         envVar: m.envVar,
         required: m.required,
-        affects: m.affects,
+        probePath: m.probePath,
+        reachable: false,
         enabled: false,
+        affects: m.affects,
       })),
       dumpKeyCount: 0,
       generatedAt,
     };
   }
 
-  const keys = Object.keys(dump.body);
   const enabledPrefixes = new Set<string>();
-  for (const k of keys) {
+  for (const k of Object.keys(dump.body)) {
     const top = k.split('.', 1)[0];
     if (top) enabledPrefixes.add(top);
   }
 
-  const modules: PreflightModule[] = REQUIRED_MODULES.map((m) => ({
-    name: m.name,
-    envVar: m.envVar,
-    required: m.required,
-    affects: m.affects,
-    enabled: enabledPrefixes.has(m.name),
-  }));
+  const modules: PreflightModule[] = await Promise.all(
+    REQUIRED_MODULES.map(async (m): Promise<PreflightModule> => {
+      const base = {
+        name: m.name,
+        envVar: m.envVar,
+        required: m.required,
+        probePath: m.probePath,
+        enabled: enabledPrefixes.has(m.name),
+        affects: m.affects,
+      };
+      // admin-server's probe IS the dump fetch we just did.
+      if (m.name === 'admin-server') return { ...base, reachable: true };
+      // ui_template is not called in readonly mode — don't probe it.
+      if (m.name === 'ui-management' && templatesMode === 'readonly') {
+        return { ...base, reachable: null };
+      }
+      const reachable = await probeReachable(adminUrl, m.probePath, fetch, config.oap.timeoutMs, config.oap.auth);
+      return { ...base, reachable };
+    }),
+  );
 
   return {
     adminUrl,
     adminReachable: true,
+    templatesMode,
     modules,
-    dumpKeyCount: keys.length,
+    dumpKeyCount: Object.keys(dump.body).length,
     generatedAt,
   };
 }
 
+/**
+ * Cached `runPreflight`. A single-flight 30s cache bounds OAP load when
+ * many sessions poll `/api/preflight` (the UI polls every 60s). Pass
+ * `force` for the cluster page's manual "re-check now" after the
+ * operator fixes the network / a selector.
+ */
+let cache: { at: number; result: PreflightResult } | null = null;
+let inFlight: Promise<PreflightResult> | null = null;
+const CACHE_TTL_MS = 30_000;
+
+export async function getPreflight(
+  config: HorizonConfig,
+  fetch: FetchLike,
+  opts: { force?: boolean } = {},
+): Promise<PreflightResult> {
+  const now = Date.now();
+  if (!opts.force && cache && now - cache.at < CACHE_TTL_MS) return cache.result;
+  if (!opts.force && inFlight) return inFlight;
+  inFlight = runPreflight(config, fetch)
+    .then((result) => {
+      cache = { at: Date.now(), result };
+      inFlight = null;
+      return result;
+    })
+    .catch((err) => {
+      inFlight = null;
+      throw err;
+    });
+  return inFlight;
+}
+
+/** Test seam — drop the cached probe round. */
+export function invalidatePreflightCache(): void {
+  cache = null;
+  inFlight = null;
+}
+
+/**
+ * Reachability of one admin REST path. Reachable = the GET got an HTTP
+ * response that isn't 404 or 5xx; a 404 means the route isn't
+ * registered (selector off / module absent), a 5xx / network error
+ * means the feature can't serve. A 4xx like 400/401 still proves the
+ * route exists, so it counts as reachable.
+ */
+async function probeReachable(
+  adminUrl: string,
+  path: string,
+  fetch: FetchLike,
+  timeoutMs: number,
+  auth?: { username: string; password: string },
+): Promise<boolean> {
+  const url = `${adminUrl.replace(/\/$/, '')}${path}`;
+  const headers: Record<string, string> = { Accept: 'application/json' };
+  if (auth) {
+    const b64 = Buffer.from(`${auth.username}:${auth.password}`, 'utf8').toString('base64');
+    headers.authorization = `Basic ${b64}`;
+  }
+  let init: RequestInit = { method: 'GET', headers };
+  let timer: ReturnType<typeof setTimeout> | null = null;
+  if (timeoutMs > 0) {
+    const ctrl = new AbortController();
+    timer = setTimeout(() => ctrl.abort(), timeoutMs);
+    init = { ...init, signal: ctrl.signal };
+  }
+  try {
+    const res = await fetch(url, init);
+    return res.status !== 404 && res.status < 500;
+  } catch {
+    return false;
+  } finally {
+    if (timer) clearTimeout(timer);
+  }
+}
+
 interface DumpOk {
   ok: true;
   body: Record<string, string>;
diff --git a/apps/ui/src/api/scopes/menu.ts b/apps/ui/src/api/scopes/menu.ts
index 9a332d3..b08eac9 100644
--- a/apps/ui/src/api/scopes/menu.ts
+++ b/apps/ui/src/api/scopes/menu.ts
@@ -29,7 +29,7 @@ export class MenuApi {
   oapInfo(): Promise<OapInfo> {
     return this.bff.request<OapInfo>('GET', '/api/oap/info');
   }
-  preflight(): Promise<PreflightResult> {
-    return this.bff.request<PreflightResult>('GET', '/api/preflight');
+  preflight(force = false): Promise<PreflightResult> {
+    return this.bff.request<PreflightResult>('GET', force ? '/api/preflight?refresh=1' : '/api/preflight');
   }
 }
diff --git a/apps/ui/src/features/operate/cluster/ClusterStatusView.vue b/apps/ui/src/features/operate/cluster/ClusterStatusView.vue
index 7c8fb73..45ed9ba 100644
--- a/apps/ui/src/features/operate/cluster/ClusterStatusView.vue
+++ b/apps/ui/src/features/operate/cluster/ClusterStatusView.vue
@@ -15,11 +15,11 @@
   limitations under the License.
 -->
 <script setup lang="ts">
-import { computed } from 'vue';
+import { computed, ref, onMounted, onUnmounted } from 'vue';
 import { useI18n } from 'vue-i18n';
+import type { PreflightModule } from '@skywalking-horizon-ui/api-client';
 import { useOapInfo } from '@/shell/useOapInfo';
 import { useAdminFeatures } from '@/shell/useAdminFeatures';
-import { useConfigBundle } from '@/controls/configBundle';
 
 // Two-pane Cluster Status:
 //   - Pane A (graphql / :12800): version, server clock, timezone,
@@ -44,23 +44,42 @@ const {
   refetch: refetchInfo,
 } = useOapInfo();
 
-// Dashboard-template source mode (the ui_template store vs the local bundle)
-// rides on the config bundle the shell preloads.
-const { bundle } = useConfigBundle();
-const templateMode = computed<'live' | 'readonly'>(() => bundle.value?.syncStatus?.mode ?? 'live');
-// ui_template API availability — N/A (null) in readonly (the store isn't used).
-const uiTemplateAvailable = computed<boolean | null>(() =>
-  templateMode.value === 'readonly' ? null : bundle.value?.syncStatus?.unreachable === false,
-);
-
 const {
   result: preflight,
   adminUrl,
   adminReachable,
   adminError,
-  refetch: refetchPreflight,
+  recheck: refetchPreflight,
 } = useAdminFeatures();
 
+// "Checked Ns ago" advances against a slow ticker, anchored to the BFF's
+// generatedAt (so it reflects the real probe time incl. cache age, not the
+// render moment). 5s granularity is plenty for a 30s-cached / 60s-polled check.
+const now = ref(Date.now());
+let nowTimer: ReturnType<typeof setInterval> | null = null;
+onMounted(() => {
+  nowTimer = setInterval(() => (now.value = Date.now()), 5_000);
+});
+onUnmounted(() => {
+  if (nowTimer) clearInterval(nowTimer);
+});
+
+function agoLabel(ts: number | undefined): string {
+  if (!ts) return '—';
+  const sec = Math.max(0, Math.round((now.value - ts) / 1000));
+  if (sec < 60) return t('{n}s ago', { n: sec });
+  return t('{n}m ago', { n: Math.round(sec / 60) });
+}
+
+// Health = reachability of the feature's probed REST path, NOT config-presence.
+// reachable === null = not probed (ui_template in readonly: bundled, never called).
+function featureState(m: PreflightModule): { cls: string; label: string } {
+  if (m.reachable === null) return { cls: 'is-warn', label: t('readonly · bundled') };
+  return m.reachable
+    ? { cls: 'is-ok', label: t('reachable') }
+    : { cls: 'is-err', label: t('unreachable') };
+}
+
 const serverClockLocal = computed<string>(() => {
   const ts = info.value?.currentTimestamp;
   if (!ts) return '—';
@@ -87,24 +106,20 @@ const healthLabel = computed<string>(() => {
 const adminBadgeState = computed<'ok' | 'warn' | 'err' | 'unknown'>(() => {
   if (!preflight.value) return 'unknown';
   if (!adminReachable.value) return 'err';
-  // Admin port replied but some required selectors are off.
-  if (preflight.value.modules.some((m) => m.required && !m.enabled)) return 'warn';
+  // Admin port replied but a required feature's path doesn't respond.
+  if (preflight.value.modules.some((m) => m.required && m.reachable === false)) return 'warn';
   return 'ok';
 });
 
 const adminBadgeLabel = computed<string>(() => {
   if (!preflight.value) return t('loading…');
   if (!adminReachable.value) return t('unreachable');
-  const off = preflight.value.modules.filter((m) => m.required && !m.enabled);
-  if (off.length === 0) return t('all selectors on');
-  return t('{n} selectors off', { n: off.length });
+  const down = preflight.value.modules.filter((m) => m.required && m.reachable === false);
+  if (down.length === 0) return t('all reachable');
+  return t('{n} unreachable', { n: down.length });
 });
 
-const adminGeneratedAt = computed<string>(() => {
-  const ts = preflight.value?.generatedAt;
-  if (!ts) return '—';
-  return new Date(ts).toLocaleTimeString();
-});
+const adminGeneratedAt = computed<string>(() => agoLabel(preflight.value?.generatedAt));
 
 // Zipkin / OTLP trace endpoint. Probed on the same poll as Pane A but
 // independently — it only feeds the Zipkin/OTLP trace menu, so a red
@@ -199,7 +214,7 @@ function refreshAll(): void {
       </header>
 
       <p class="pane-lede">
-        {{ t("Per-module enablement on the admin port. Each row gates a slice of horizon's UI — flip the corresponding env var on OAP and restart to enable, or remove the corresponding page from your operator menu if you don't need it.") }}
+        {{ t("Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is the live probe, not config-presence: a path that 404s (selector off, renamed, or absent in a fork) reads as unreachable. 'selector detected' below is only an upstream-release hint, not the verdict.") }}
       </p>
 
       <div v-if="!preflight" class="empty">{{ t('loading preflight…') }}</div>
@@ -215,44 +230,33 @@ function refreshAll(): void {
       <table v-else class="mod-table">
         <thead>
           <tr>
-            <th>{{ t('Module') }}</th>
+            <th>{{ t('Feature') }}</th>
             <th>{{ t('State') }}</th>
+            <th>{{ t('Probe path') }}</th>
             <th>{{ t('Env var') }}</th>
             <th>{{ t('Gates') }}</th>
           </tr>
         </thead>
         <tbody>
-          <tr v-for="m in preflight.modules" :key="m.name" :class="{ off: !m.enabled }">
+          <tr v-for="m in preflight.modules" :key="m.name" :class="{ off: m.reachable === false }">
             <td class="modname"><code>{{ m.name }}</code></td>
             <td>
-              <span class="sw-badge" :class="m.enabled ? 'is-ok' : 'is-err'">
-                <span class="state-dot" />{{ m.enabled ? t('enabled') : t('missing') }}
+              <span class="sw-badge" :class="featureState(m).cls">
+                <span class="state-dot" />{{ featureState(m).label }}
               </span>
+              <div class="state-foot">
+                <span class="checked">{{ t('checked {at}', { at: agoLabel(preflight.generatedAt) }) }}</span>
+                <span class="sel" :class="{ 'sel-off': !m.enabled }">{{
+                  m.enabled ? t('selector detected') : t('selector not detected')
+                }}</span>
+              </div>
             </td>
+            <td class="modpath"><code>{{ m.probePath }}</code></td>
             <td class="modenv"><code>{{ m.envVar }}</code></td>
             <td class="modaffects">{{ t(m.affects) }}</td>
           </tr>
         </tbody>
       </table>
-
-      <!-- Dashboard-template source: live (OAP ui_template) vs readonly (bundle). -->
-      <div class="tpl-source">
-        <span class="tpl-label">{{ t('Dashboard templates') }}</span>
-        <span class="sw-badge" :class="templateMode === 'readonly' ? 'is-warn' : 'is-ok'">
-          <span class="state-dot" />{{ templateMode === 'readonly' ? t('read-only · bundled') : t('live · OAP ui_template') }}
-        </span>
-        <span class="tpl-hint">
-          <template v-if="templateMode === 'readonly'">
-            {{ t('Rendering from the local bundle; the ui_template API is not used and editing is disabled.') }}
-          </template>
-          <template v-else-if="uiTemplateAvailable">
-            {{ t('ui_template store reachable — editing enabled.') }}
-          </template>
-          <template v-else>
-            {{ t('ui_template store unreachable — editing disabled.') }}
-          </template>
-        </span>
-      </div>
     </section>
 
     <!-- ── Pane C · Zipkin / OTLP trace endpoint ─────────────────── -->
@@ -470,25 +474,22 @@ function refreshAll(): void {
   border-radius: 6px;
 }
 
-.tpl-source {
+.state-foot {
   display: flex;
-  align-items: center;
   flex-wrap: wrap;
-  gap: 10px;
-  margin-top: 12px;
-  padding: 10px 12px;
-  background: var(--sw-bg-1);
-  border: 1px solid var(--sw-line);
-  border-radius: 8px;
-  font-size: var(--sw-fs-base);
+  gap: 4px 10px;
+  margin-top: 4px;
+  font-size: var(--sw-fs-xs);
+  color: var(--sw-fg-3);
 }
-.tpl-label {
-  font-weight: var(--sw-fw-bold);
-  color: var(--sw-fg-1);
+.state-foot .sel-off {
+  color: var(--sw-fg-4);
+  text-decoration: line-through;
 }
-.tpl-hint {
-  color: var(--sw-fg-3);
-  font-size: var(--sw-fs-xs);
+.modpath code {
+  font-family: var(--sw-mono);
+  font-size: var(--sw-fs-sm);
+  color: var(--sw-fg-2);
 }
 .mod-table {
   width: 100%;
diff --git a/apps/ui/src/i18n/locales/de.json b/apps/ui/src/i18n/locales/de.json
index f8d147b..3da9721 100644
--- a/apps/ui/src/i18n/locales/de.json
+++ b/apps/ui/src/i18n/locales/de.json
@@ -1539,5 +1539,13 @@
   "live · OAP ui_template": "live · OAP ui_template",
   "Rendering from the local bundle; the ui_template API is not used and editing is disabled.": "Rendering aus dem lokalen Bundle; die ui_template-API wird nicht verwendet und die Bearbeitung ist deaktiviert.",
   "ui_template store reachable — editing enabled.": "ui_template-Speicher erreichbar — Bearbeitung aktiviert.",
-  "ui_template store unreachable — editing disabled.": "ui_template-Speicher nicht erreichbar — Bearbeitung deaktiviert."
+  "ui_template store unreachable — editing disabled.": "ui_template-Speicher nicht erreichbar — Bearbeitung deaktiviert.",
+  "Feature": "Funktion",
+  "Probe path": "Prüfpfad",
+  "readonly · bundled": "schreibgeschützt · gebündelt",
+  "selector detected": "Selektor erkannt",
+  "selector not detected": "Selektor nicht erkannt",
+  "all reachable": "alle erreichbar",
+  "{n} unreachable": "{n} nicht erreichbar",
+  "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is the live probe, not config-presence: a path that 404s (selector off, renamed, or absent in a fork) reads as unreachable. 'selector detected' below is only an upstream-release hint, not the verdict.": "Erreichbarkeit jeder Admin-Funktion — der BFF sendet ein GET an den relativen REST-Pfad, den die Funktion tatsächlich aufruft, und meldet, ob er  [...]
 }
diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json
index 0a9ba6a..cdb5e69 100644
--- a/apps/ui/src/i18n/locales/en.json
+++ b/apps/ui/src/i18n/locales/en.json
@@ -1539,5 +1539,13 @@
   "live · OAP ui_template": "live · OAP ui_template",
   "Rendering from the local bundle; the ui_template API is not used and editing is disabled.": "Rendering from the local bundle; the ui_template API is not used and editing is disabled.",
   "ui_template store reachable — editing enabled.": "ui_template store reachable — editing enabled.",
-  "ui_template store unreachable — editing disabled.": "ui_template store unreachable — editing disabled."
+  "ui_template store unreachable — editing disabled.": "ui_template store unreachable — editing disabled.",
+  "Feature": "Feature",
+  "Probe path": "Probe path",
+  "readonly · bundled": "readonly · bundled",
+  "selector detected": "selector detected",
+  "selector not detected": "selector not detected",
+  "all reachable": "all reachable",
+  "{n} unreachable": "{n} unreachable",
+  "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is the live probe, not config-presence: a path that 404s (selector off, renamed, or absent in a fork) reads as unreachable. 'selector detected' below is only an upstream-release hint, not the verdict.": "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is th [...]
 }
diff --git a/apps/ui/src/i18n/locales/es.json b/apps/ui/src/i18n/locales/es.json
index bff51fd..8ac4dfb 100644
--- a/apps/ui/src/i18n/locales/es.json
+++ b/apps/ui/src/i18n/locales/es.json
@@ -1539,5 +1539,13 @@
   "live · OAP ui_template": "en vivo · OAP ui_template",
   "Rendering from the local bundle; the ui_template API is not used and editing is disabled.": "Renderizado desde el paquete local; la API ui_template no se usa y la edición está deshabilitada.",
   "ui_template store reachable — editing enabled.": "Almacén ui_template accesible — edición habilitada.",
-  "ui_template store unreachable — editing disabled.": "Almacén ui_template inaccesible — edición deshabilitada."
+  "ui_template store unreachable — editing disabled.": "Almacén ui_template inaccesible — edición deshabilitada.",
+  "Feature": "Función",
+  "Probe path": "Ruta de sondeo",
+  "readonly · bundled": "solo lectura · incluido",
+  "selector detected": "selector detectado",
+  "selector not detected": "selector no detectado",
+  "all reachable": "todos accesibles",
+  "{n} unreachable": "{n} inaccesible(s)",
+  "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is the live probe, not config-presence: a path that 404s (selector off, renamed, or absent in a fork) reads as unreachable. 'selector detected' below is only an upstream-release hint, not the verdict.": "Accesibilidad de cada función de administración: el BFF hace un GET a la ruta REST relativa que la función realmente llama e informa si responde. [...]
 }
diff --git a/apps/ui/src/i18n/locales/fr.json b/apps/ui/src/i18n/locales/fr.json
index 08363a3..690208d 100644
--- a/apps/ui/src/i18n/locales/fr.json
+++ b/apps/ui/src/i18n/locales/fr.json
@@ -1539,5 +1539,13 @@
   "live · OAP ui_template": "en direct · OAP ui_template",
   "Rendering from the local bundle; the ui_template API is not used and editing is disabled.": "Rendu à partir du paquet local ; l’API ui_template n’est pas utilisée et l’édition est désactivée.",
   "ui_template store reachable — editing enabled.": "Magasin ui_template accessible — édition activée.",
-  "ui_template store unreachable — editing disabled.": "Magasin ui_template inaccessible — édition désactivée."
+  "ui_template store unreachable — editing disabled.": "Magasin ui_template inaccessible — édition désactivée.",
+  "Feature": "Fonctionnalité",
+  "Probe path": "Chemin de sonde",
+  "readonly · bundled": "lecture seule · intégré",
+  "selector detected": "sélecteur détecté",
+  "selector not detected": "sélecteur non détecté",
+  "all reachable": "tous accessibles",
+  "{n} unreachable": "{n} inaccessible(s)",
+  "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is the live probe, not config-presence: a path that 404s (selector off, renamed, or absent in a fork) reads as unreachable. 'selector detected' below is only an upstream-release hint, not the verdict.": "Accessibilité de chaque fonctionnalité d'administration — le BFF envoie un GET sur le chemin REST relatif que la fonctionnalité appelle réellemen [...]
 }
diff --git a/apps/ui/src/i18n/locales/ja.json b/apps/ui/src/i18n/locales/ja.json
index 6a51ac0..55ed2db 100644
--- a/apps/ui/src/i18n/locales/ja.json
+++ b/apps/ui/src/i18n/locales/ja.json
@@ -1539,5 +1539,13 @@
   "live · OAP ui_template": "ライブ · OAP ui_template",
   "Rendering from the local bundle; the ui_template API is not used and editing is disabled.": "ローカルバンドルからレンダリングしています。ui_template API は使用されず、編集は無効です。",
   "ui_template store reachable — editing enabled.": "ui_template ストアに到達可能 —— 編集が有効です。",
-  "ui_template store unreachable — editing disabled.": "ui_template ストアに到達不可 —— 編集が無効です。"
+  "ui_template store unreachable — editing disabled.": "ui_template ストアに到達不可 —— 編集が無効です。",
+  "Feature": "機能",
+  "Probe path": "プローブパス",
+  "readonly · bundled": "読み取り専用 · バンドル",
+  "selector detected": "selector を検出",
+  "selector not detected": "selector を検出せず",
+  "all reachable": "すべて到達可能",
+  "{n} unreachable": "{n} 件が到達不可",
+  "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is the live probe, not config-presence: a path that 404s (selector off, renamed, or absent in a fork) reads as unreachable. 'selector detected' below is only an upstream-release hint, not the verdict.": "各管理機能の到達可能性 —— BFF は機能が実際に呼び出す相対 REST パスへ GET し、応答するかどうかを報告します。健全性は設定の有無ではなくライブプローブで判定されます。404 を返すパス(selector が無効、リネーム、またはフォークで欠落)は到達不可とみなされます。下の [...]
 }
diff --git a/apps/ui/src/i18n/locales/ko.json b/apps/ui/src/i18n/locales/ko.json
index 6bf74f0..3f6dd9a 100644
--- a/apps/ui/src/i18n/locales/ko.json
+++ b/apps/ui/src/i18n/locales/ko.json
@@ -1539,5 +1539,13 @@
   "live · OAP ui_template": "라이브 · OAP ui_template",
   "Rendering from the local bundle; the ui_template API is not used and editing is disabled.": "로컬 번들에서 렌더링합니다. ui_template API는 사용되지 않으며 편집이 비활성화됩니다.",
   "ui_template store reachable — editing enabled.": "ui_template 저장소에 연결됨 — 편집이 활성화됩니다.",
-  "ui_template store unreachable — editing disabled.": "ui_template 저장소에 연결할 수 없음 — 편집이 비활성화됩니다."
+  "ui_template store unreachable — editing disabled.": "ui_template 저장소에 연결할 수 없음 — 편집이 비활성화됩니다.",
+  "Feature": "기능",
+  "Probe path": "프로브 경로",
+  "readonly · bundled": "읽기 전용 · 번들",
+  "selector detected": "selector 감지됨",
+  "selector not detected": "selector 감지 안 됨",
+  "all reachable": "모두 도달 가능",
+  "{n} unreachable": "{n}개 도달 불가",
+  "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is the live probe, not config-presence: a path that 404s (selector off, renamed, or absent in a fork) reads as unreachable. 'selector detected' below is only an upstream-release hint, not the verdict.": "각 관리 기능의 도달 가능성 — BFF가 해당 기능이 실제로 호출하는 상대 REST 경로로 GET 요청을 보내 응답 여부를 보고합니다. 상태는 설정 존재 여부가 아니라 실시간 프로브로 판단합니다. 404를 반환하는 경로(selector 꺼짐, 이름 변경 또는  [...]
 }
diff --git a/apps/ui/src/i18n/locales/pt.json b/apps/ui/src/i18n/locales/pt.json
index 5e8ab13..5dc1edb 100644
--- a/apps/ui/src/i18n/locales/pt.json
+++ b/apps/ui/src/i18n/locales/pt.json
@@ -1539,5 +1539,13 @@
   "live · OAP ui_template": "ao vivo · OAP ui_template",
   "Rendering from the local bundle; the ui_template API is not used and editing is disabled.": "Renderizando a partir do pacote local; a API ui_template não é usada e a edição está desabilitada.",
   "ui_template store reachable — editing enabled.": "Armazenamento ui_template acessível — edição habilitada.",
-  "ui_template store unreachable — editing disabled.": "Armazenamento ui_template inacessível — edição desabilitada."
+  "ui_template store unreachable — editing disabled.": "Armazenamento ui_template inacessível — edição desabilitada.",
+  "Feature": "Recurso",
+  "Probe path": "Caminho de sondagem",
+  "readonly · bundled": "somente leitura · incluído",
+  "selector detected": "selector detectado",
+  "selector not detected": "selector não detectado",
+  "all reachable": "todos acessíveis",
+  "{n} unreachable": "{n} inacessível(is)",
+  "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is the live probe, not config-presence: a path that 404s (selector off, renamed, or absent in a fork) reads as unreachable. 'selector detected' below is only an upstream-release hint, not the verdict.": "Acessibilidade de cada recurso de administração: o BFF faz um GET no caminho REST relativo que o recurso realmente chama e informa se ele respond [...]
 }
diff --git a/apps/ui/src/i18n/locales/zh-CN.json b/apps/ui/src/i18n/locales/zh-CN.json
index dba1e96..03e6f6d 100644
--- a/apps/ui/src/i18n/locales/zh-CN.json
+++ b/apps/ui/src/i18n/locales/zh-CN.json
@@ -1539,5 +1539,13 @@
   "live · OAP ui_template": "在线 · OAP ui_template",
   "Rendering from the local bundle; the ui_template API is not used and editing is disabled.": "从本地内置模板渲染;不使用 ui_template API,编辑已禁用。",
   "ui_template store reachable — editing enabled.": "ui_template 存储可达 —— 编辑已启用。",
-  "ui_template store unreachable — editing disabled.": "ui_template 存储不可达 —— 编辑已禁用。"
+  "ui_template store unreachable — editing disabled.": "ui_template 存储不可达 —— 编辑已禁用。",
+  "Feature": "功能",
+  "Probe path": "探测路径",
+  "readonly · bundled": "只读 · 内置",
+  "selector detected": "检测到 selector",
+  "selector not detected": "未检测到 selector",
+  "all reachable": "全部可达",
+  "{n} unreachable": "{n} 个不可达",
+  "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is the live probe, not config-presence: a path that 404s (selector off, renamed, or absent in a fork) reads as unreachable. 'selector detected' below is only an upstream-release hint, not the verdict.": "每个管理功能的可达性 —— BFF 会向该功能实际调用的相对 REST 路径发起 GET,并报告其是否响应。健康状态取决于实时探测,而非配置存在性:返回 404 的路径(selector 关闭、被重命名或在 fork 版本中缺失)将被视为不可达。下方的“检测到 selector”仅为上游发 [...]
 }
diff --git a/apps/ui/src/shell/AdminFeatureWarning.vue b/apps/ui/src/shell/AdminFeatureWarning.vue
index 628b069..8825b54 100644
--- a/apps/ui/src/shell/AdminFeatureWarning.vue
+++ b/apps/ui/src/shell/AdminFeatureWarning.vue
@@ -53,15 +53,17 @@ const { result, adminReachable, adminError, adminUrl, moduleByName, refetch } =
   useAdminFeatures();
 
 const mod = moduleByName(props.module);
-const moduleEnabled = computed<boolean>(() => mod.value?.enabled ?? false);
+// Health is the feature's path probe, not config-presence. `reachable`
+// is a boolean for these three (null is only ui_template/readonly).
+const moduleReachable = computed<boolean>(() => mod.value?.reachable !== false);
 
-/** Hide when everything is fine — admin reachable AND target module
- *  on. Loading state also hides (page renders normally; banner pops
- *  in if/when preflight returns a failure). */
+/** Hide when everything is fine — admin reachable AND this feature's
+ *  path responds. Loading state also hides (page renders normally;
+ *  banner pops in if/when the probe comes back unreachable). */
 const visible = computed<boolean>(() => {
   if (!result.value) return false;
   if (!adminReachable.value) return true;
-  return !moduleEnabled.value;
+  return !moduleReachable.value;
 });
 
 const kind = computed<'host' | 'module'>(() =>
diff --git a/apps/ui/src/shell/useAdminFeatures.ts b/apps/ui/src/shell/useAdminFeatures.ts
index a3cefa2..28c10fd 100644
--- a/apps/ui/src/shell/useAdminFeatures.ts
+++ b/apps/ui/src/shell/useAdminFeatures.ts
@@ -16,14 +16,15 @@
  */
 
 import { computed } from 'vue';
-import { useQuery } from '@tanstack/vue-query';
+import { useQuery, useQueryClient } from '@tanstack/vue-query';
 import type { PreflightResult } from '@skywalking-horizon-ui/api-client';
 import { bffClient } from '@/api/client';
 
 /**
- * Admin-port preflight — interrogates OAP's `/debugging/config/dump`
- * (port 17128 by default) and exposes per-module enablement plus a
- * shorthand `adminReachable` flag. Drives:
+ * Admin-port preflight — a REACHABILITY check of each OAP admin feature
+ * Horizon depends on (the BFF GETs the feature's real REST path).
+ * Exposes per-feature reachability plus a shorthand `adminReachable`.
+ * Drives:
  *   - the admin section of `/operate/cluster`
  *   - the per-page warning header on admin-host routes (DSL Mgmt,
  *     Live Debugger, Dump, OAL viewer, Inspect)
@@ -32,10 +33,13 @@ import { bffClient } from '@/api/client';
  * keeping the two checks split lets the cluster page show "graphql
  * fine, admin down" without false negatives on either side.
  *
- * Polled every 60s; the cluster page can call `refetch()` to poke
- * it on demand after the operator fixes the network / selectors.
+ * Polled every 60s (the BFF single-flights the underlying probes for
+ * 30s). `recheck()` forces a fresh round on the BFF — for the cluster
+ * page's "re-check now" after the operator fixes the network / a
+ * selector — and seeds it into the shared cache.
  */
 export function useAdminFeatures() {
+  const qc = useQueryClient();
   const q = useQuery({
     queryKey: ['oap-preflight'],
     queryFn: () => bffClient.menu.preflight(),
@@ -48,22 +52,13 @@ export function useAdminFeatures() {
   const adminReachable = computed<boolean>(() => result.value?.adminReachable ?? false);
   const adminUrl = computed<string | undefined>(() => result.value?.adminUrl);
   const adminError = computed<string | undefined>(() => result.value?.adminError);
+  const templatesMode = computed<'live' | 'readonly'>(() => result.value?.templatesMode ?? 'live');
 
   /** Look up a single module by OAP name (e.g. `receiver-runtime-rule`). */
   function moduleByName(name: string) {
     return computed(() => result.value?.modules.find((m) => m.name === name));
   }
 
-  const runtimeRuleEnabled = computed<boolean>(
-    () => moduleByName('receiver-runtime-rule').value?.enabled ?? false,
-  );
-  const dslDebuggingEnabled = computed<boolean>(
-    () => moduleByName('dsl-debugging').value?.enabled ?? false,
-  );
-  const inspectEnabled = computed<boolean>(
-    () => moduleByName('inspect').value?.enabled ?? false,
-  );
-
   /** Convenience for the per-page warning header — true when nothing
    *  on the admin port works (port itself down OR admin-server module
    *  off). Page UIs render the warning + still try to mount so the
@@ -72,6 +67,14 @@ export function useAdminFeatures() {
     () => !result.value || !adminReachable.value,
   );
 
+  /** Force a fresh probe round on the BFF (bypasses its 30s cache) and
+   *  push the result into the shared query cache. */
+  async function recheck(): Promise<PreflightResult> {
+    const fresh = await bffClient.menu.preflight(true);
+    qc.setQueryData(['oap-preflight'], fresh);
+    return fresh;
+  }
+
   return {
     isLoading: q.isLoading,
     result,
@@ -79,10 +82,9 @@ export function useAdminFeatures() {
     adminReachable,
     adminUnavailable,
     adminError,
-    runtimeRuleEnabled,
-    dslDebuggingEnabled,
-    inspectEnabled,
+    templatesMode,
     moduleByName,
     refetch: q.refetch,
+    recheck,
   };
 }
diff --git a/packages/api-client/src/preflight.ts b/packages/api-client/src/preflight.ts
index c4f61f7..9c471ad 100644
--- a/packages/api-client/src/preflight.ts
+++ b/packages/api-client/src/preflight.ts
@@ -16,17 +16,24 @@
  */
 
 /**
- * Wire shape for `GET /api/preflight` — interrogates OAP admin's
- * `/debugging/config/dump` and reports which OAP modules / SWIP-13
- * selectors are enabled. Drives the admin-port section of the
- * Cluster Status page and the per-page warning headers on admin-host
- * routes (DSL Management, Live Debugger, Dump, OAL viewer, Inspect).
+ * Wire shape for `GET /api/preflight` — a REACHABILITY check of each
+ * OAP admin feature Horizon depends on. Drives the admin-port section
+ * of the Cluster Status page and the per-page warning headers on
+ * admin-host routes (DSL Management, Live Debugger, Dump, OAL viewer,
+ * Inspect, dashboard templates).
+ *
+ * Health = `reachable`, NOT `enabled`. Each feature declares the
+ * relative admin REST path it actually calls; the BFF fires a safe GET
+ * at that path and reports whether the feature responds. A 404 (module
+ * selector off, renamed, or absent in a fork) reads as unreachable —
+ * an honest, release-agnostic signal. `enabled` (the config-dump
+ * prefix) is kept only as an informational "the upstream release
+ * advertises this selector" footnote, never as the health verdict.
  *
  * `adminReachable === false` means the admin host itself is down
- * (network, port not exposed, OAP not started); every module is
- * reported `enabled: false` regardless of selector config. The
- * operator's first move in that case is to fix the network / port
- * exposure, not to flip selectors on.
+ * (network, port not exposed, OAP not started); every feature is then
+ * reported `reachable: false`. The operator's first move is to fix the
+ * network / port exposure, not to flip selectors on.
  */
 
 export interface PreflightModule {
@@ -34,20 +41,36 @@ export interface PreflightModule {
   name: string;
   /** The env var that toggles this module's selector on OAP. */
   envVar: string;
-  /** True when horizon depends on this module being on. */
+  /** True when horizon depends on this module being reachable. */
   required: boolean;
-  /** True iff the dump carries at least one key with this module's prefix. */
+  /** The relative admin REST path the BFF GETs to test reachability. */
+  probePath: string;
+  /**
+   * THE health signal: true iff the safe GET on `probePath` responded
+   * (any status except 404 / 5xx / network error). `null` = not probed
+   * (e.g. ui_template in readonly mode — Horizon never calls it).
+   */
+  reachable: boolean | null;
+  /**
+   * Informational footnote only: the upstream config dump carries a key
+   * under this module's prefix. Release-specific; NOT a health verdict.
+   */
   enabled: boolean;
-  /** What part of horizon breaks when this module is off. */
+  /** What part of horizon breaks when this feature is unreachable. */
   affects: string;
 }
 
 export interface PreflightResult {
   adminUrl: string;
-  /** True iff `/debugging/config/dump` responded 2xx. */
+  /** True iff `/debugging/config/dump` responded 2xx (admin port up). */
   adminReachable: boolean;
   /** Short reason when `adminReachable` is false. */
   adminError?: string;
+  /**
+   * Active template mode. In `readonly` the ui_template feature is not
+   * probed (Horizon serves bundled templates) and its row reads "n/a".
+   */
+  templatesMode: 'live' | 'readonly';
   modules: PreflightModule[];
   /** Total keys in the dump. Diagnostic only. */
   dumpKeyCount: number;


(skywalking-horizon-ui) 02/11: fix(config): review pass 1 — readonly translations + env-native robustness

Posted by wu...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a commit to branch feat/template-modes-env-config
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git

commit 23b13c83031597b57951ded321505c09191ca399
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Fri Jun 26 01:55:58 2026 +0800

    fix(config): review pass 1 — readonly translations + env-native robustness
    
    - P1: readonly mode rendered every non-English locale in English. Translation
      overlays were sourced from `deps.bundledOverlays`, which only the (skipped-in-
      readonly) boot seed supplies; the render callers omit it. The readonly branch
      now sources overlays from the canonical `iterateBundledOverlays()` disk
      iterator. Validated live: zh-CN readonly bundle carries the bundled overlays
      (8551 CJK chars).
    - Scalar string env values are now injected into a quoted YAML position
      (`"${HORIZON_X:default}"`), so a value with YAML metacharacters can't break
      parsing; JSON list/object tokens + numbers/bools stay unquoted for typing.
    - `templates.mode` is fixed at boot (a hot-reload flip can't run the boot seed),
      warning if the file later changes it.
    - `setTemplateReadOnly` also clears the in-flight sync probe (no cross-mode
      backfill); the parity test resolves tokens against the same env the schema's
      inline defaults read; the admin `TemplateSyncStatus` UI type gains `mode`;
      config-bundle cache bumped v2→v3 so a stale cache can't show the wrong mode.
    
    type-check / lint / license / 263 unit tests green; readonly + env-native re-validated live.
---
 apps/bff/src/config/schema.test.ts      |  8 ++++++--
 apps/bff/src/logic/templates/sync.ts    | 13 ++++++++++--
 apps/bff/src/server.ts                  | 16 ++++++++++++---
 apps/ui/src/api/scopes/template-sync.ts |  2 ++
 apps/ui/src/controls/configBundle.ts    | 13 ++++++------
 horizon.example.yaml                    | 36 +++++++++++++++++----------------
 6 files changed, 58 insertions(+), 30 deletions(-)

diff --git a/apps/bff/src/config/schema.test.ts b/apps/bff/src/config/schema.test.ts
index 24cd0f3..9aaccc0 100644
--- a/apps/bff/src/config/schema.test.ts
+++ b/apps/bff/src/config/schema.test.ts
@@ -40,8 +40,12 @@ describe('horizon.example.yaml — tokenized default + parity', () => {
   const examplePath = resolve(here, '../../../../horizon.example.yaml');
   const raw = readFileSync(examplePath, 'utf8');
 
-  it('with NO env set, parses to exactly the schema defaults', () => {
-    const parsed = stripNullish(YAML.parse(interpolateEnv(raw, {})) ?? {});
+  it('parses to exactly the schema defaults (token defaults match the schema)', () => {
+    // Use process.env on BOTH sides: the schema's inline env defaults
+    // (serverHostDefault, the *_FILE paths, sourcemaps dir, templatesMode) read
+    // process.env at module load, so the example's tokens must resolve against
+    // the same env or a stray HORIZON_* in CI would read as drift.
+    const parsed = stripNullish(YAML.parse(interpolateEnv(raw, process.env)) ?? {});
     expect(configSchema.parse(parsed)).toEqual(configSchema.parse({}));
   });
 
diff --git a/apps/bff/src/logic/templates/sync.ts b/apps/bff/src/logic/templates/sync.ts
index 41a5bf8..e53372a 100644
--- a/apps/bff/src/logic/templates/sync.ts
+++ b/apps/bff/src/logic/templates/sync.ts
@@ -46,6 +46,7 @@ import {
   serializeEnvelope,
   type TemplateKind,
 } from './names.js';
+import { iterateBundledOverlays } from './aggregator.js';
 
 export interface BundledTemplate {
   kind: TemplateKind;
@@ -157,7 +158,11 @@ let lastSuccessfulSyncAt: number | null = null;
 let readOnlyMode = false;
 export function setTemplateReadOnly(on: boolean): void {
   readOnlyMode = on;
-  cache = null; // a mode flip must not serve a stale cross-mode status
+  // A mode flip must not serve a stale cross-mode status: drop the cache AND
+  // orphan any in-flight probe (it still resolves its awaiters, but won't
+  // backfill the cache with a result computed under the old mode).
+  cache = null;
+  inFlight = null;
 }
 export function isTemplateReadOnly(): boolean {
   return readOnlyMode;
@@ -285,7 +290,11 @@ async function runOnce(deps: SyncDeps, opts: RunOptions): Promise<SyncStatus> {
   // exactly as they would a live remote row.
   if (readOnlyMode) {
     lastSuccessfulSyncAt = now;
-    const overlays = deps.bundledOverlays ? [...deps.bundledOverlays()] : [];
+    // Source overlays from the canonical disk iterator — the on-demand render
+    // callers (bundle / menu / overlay / effective) don't pass `bundledOverlays`
+    // (only the boot seed does, and that's skipped in readonly), so without this
+    // every non-English locale would silently render in English.
+    const overlays = deps.bundledOverlays ? [...deps.bundledOverlays()] : [...iterateBundledOverlays()];
     return {
       mode: 'readonly',
       unreachable: false,
diff --git a/apps/bff/src/server.ts b/apps/bff/src/server.ts
index b418f40..3a2b1a8 100644
--- a/apps/bff/src/server.ts
+++ b/apps/bff/src/server.ts
@@ -114,8 +114,13 @@ logger.info(
   },
   'config loaded',
 );
-// Template source mode is a boot-time global the sync orchestrator reads.
-setTemplateReadOnly(source.current.templates.mode === 'readonly');
+// Template source mode is fixed at BOOT — it selects the boot-seed/source
+// path (live seeds + reads OAP; readonly skips the seed + renders bundled).
+// A hot-reload flip can't safely take effect (readonly→live would need the
+// boot seed that already ran/was-skipped), so we capture it once and only
+// warn if the file later changes it.
+const bootTemplatesMode = source.current.templates.mode;
+setTemplateReadOnly(bootTemplatesMode === 'readonly');
 if (source.current.auth.backend === 'ldap' && source.current.auth.local.users.length > 0) {
   logger.warn(
     { users: source.current.auth.local.users.length },
@@ -124,7 +129,12 @@ if (source.current.auth.backend === 'ldap' && source.current.auth.local.users.le
 }
 source.onChange((cfg) => {
   logger.info({ backend: cfg.auth.backend, templatesMode: cfg.templates.mode }, 'config reloaded');
-  setTemplateReadOnly(cfg.templates.mode === 'readonly');
+  if (cfg.templates.mode !== bootTemplatesMode) {
+    logger.warn(
+      { from: bootTemplatesMode, to: cfg.templates.mode },
+      'templates.mode change needs a BFF restart to take effect (boot-time seed + source selection); keeping the boot mode',
+    );
+  }
 });
 
 const app = Fastify({ logger: loggerOptions });
diff --git a/apps/ui/src/api/scopes/template-sync.ts b/apps/ui/src/api/scopes/template-sync.ts
index b7d0d9d..ac1cc12 100644
--- a/apps/ui/src/api/scopes/template-sync.ts
+++ b/apps/ui/src/api/scopes/template-sync.ts
@@ -41,6 +41,8 @@ export interface TemplateSyncRow {
 }
 
 export interface TemplateSyncStatus {
+  /** `live` = OAP ui_template store; `readonly` = local bundle, read-only. */
+  mode: 'live' | 'readonly';
   unreachable: boolean;
   lastSuccessfulSyncAt: number | null;
   generatedAt: number;
diff --git a/apps/ui/src/controls/configBundle.ts b/apps/ui/src/controls/configBundle.ts
index 76df532..e6e0b62 100644
--- a/apps/ui/src/controls/configBundle.ts
+++ b/apps/ui/src/controls/configBundle.ts
@@ -86,10 +86,11 @@ function preferParam(): 'local' | 'remote' {
   }
 }
 
-// Bumped to v2 in 2026-05 when the bundle gained `syncStatus` (OAP
-// UI-template overlay). v1 cached bundles lack the field; loading them
-// would crash the admin pages reading badges.
-const STORAGE_KEY = 'horizon:configBundle:v2';
+// v2 (2026-05) added `syncStatus`; v3 added `syncStatus.mode` (live/readonly).
+// A returning operator's stale cache lacking `mode` would read as live even
+// when the BFF is in readonly — bump the key so older shapes are discarded and
+// the next fetch repopulates.
+const STORAGE_KEY = 'horizon:configBundle:v3';
 const state = ref<ConfigBundle | null>(null);
 let loadPromise: Promise<void> | null = null;
 
@@ -99,9 +100,9 @@ function readStorage(): ConfigBundle | null {
     const raw = localStorage.getItem(STORAGE_KEY);
     if (!raw) return null;
     const parsed = JSON.parse(raw) as ConfigBundle;
-    // Strict shape check: a v2 bundle MUST carry syncStatus. Older v1
+    // Strict shape check: a v3 bundle MUST carry syncStatus with a mode. Older
     // shapes are silently discarded — the next bundle fetch repopulates.
-    if (!parsed?.etag || !parsed?.layers || !parsed?.syncStatus) return null;
+    if (!parsed?.etag || !parsed?.layers || !parsed?.syncStatus?.mode) return null;
     return parsed;
   } catch {
     return null;
diff --git a/horizon.example.yaml b/horizon.example.yaml
index 341204a..d3c045e 100644
--- a/horizon.example.yaml
+++ b/horizon.example.yaml
@@ -19,10 +19,12 @@
 # container with NO mounted file and set only the env vars you care about
 # (image-native), OR copy this to `horizon.yaml`, edit, and mount it.
 #
-#   • Scalars:        set `HORIZON_X` to override the `:default`.
-#   • Lists / objects (users, ldap, roles, excluded, performance, oap.auth):
-#     set the matching env var to a JSON STRING — it's injected inline and
-#     parsed. A `:null` default means "fall through to the built-in default".
+#   • String scalars are quoted (`"${X:default}"`) so a value with YAML
+#     metacharacters (`:`, `#`, …) can't break parsing.
+#   • Numbers / booleans are unquoted so they keep their type.
+#   • Lists / objects (users, ldap, roles, excluded, performance, oap.auth)
+#     are UNQUOTED and take a JSON STRING env var — injected inline + parsed.
+#     A `:null` default means "fall through to the built-in default".
 #   • Precedence: env var > this file's `:default` > built-in schema default.
 #   • Secrets (password hashes, ldap bind pw) are env-only — never bake them.
 #
@@ -33,7 +35,7 @@ server:
   # Bind host/port. The image sets HORIZON_SERVER_HOST=0.0.0.0 so the BFF
   # is reachable from outside the container (the YAML default 127.0.0.1
   # would bind container-loopback only).
-  host: ${HORIZON_SERVER_HOST:127.0.0.1}
+  host: "${HORIZON_SERVER_HOST:127.0.0.1}"
   port: ${HORIZON_SERVER_PORT:8081}
 
 templates:
@@ -43,18 +45,18 @@ templates:
   # ui_template API is never called and the whole config surface is
   # read-only. OAP's query API (metrics/traces/logs) is still used either
   # way. Use `readonly` to run standalone against an OAP whose ui_template
-  # admin API is absent or disabled.
-  mode: ${HORIZON_TEMPLATES_MODE:live}
+  # admin API is absent or disabled. (Change needs a BFF restart.)
+  mode: "${HORIZON_TEMPLATES_MODE:live}"
 
 oap:
   # OAP query host (GraphQL + /status/*; default port 12800).
-  queryUrl: ${HORIZON_OAP_QUERY_URL:http://127.0.0.1:12800}
+  queryUrl: "${HORIZON_OAP_QUERY_URL:http://127.0.0.1:12800}"
   # OAP admin host (runtime-rule / dsl-debugging / inspect / status;
   # default port 17128). Point at a DNS name / VIP fronting the cluster.
-  adminUrl: ${HORIZON_OAP_ADMIN_URL:http://127.0.0.1:17128}
+  adminUrl: "${HORIZON_OAP_ADMIN_URL:http://127.0.0.1:17128}"
   # OAP Zipkin v2 REST host. Use `<queryUrl>/zipkin` when OAP shares the
   # GraphQL port (typical for the demo / k8s deploys).
-  zipkinUrl: ${HORIZON_OAP_ZIPKIN_URL:http://127.0.0.1:9412/zipkin}
+  zipkinUrl: "${HORIZON_OAP_ZIPKIN_URL:http://127.0.0.1:9412/zipkin}"
   timeoutMs: ${HORIZON_OAP_TIMEOUT_MS:15000}
   # Optional basic-auth for outbound OAP calls. JSON env, e.g.
   # HORIZON_OAP_AUTH='{"username":"skywalking","password":"skywalking"}'.
@@ -73,7 +75,7 @@ layers:
 # Generate password hashes with:  pnpm --filter bff cli:hash
 # ─────────────────────────────────────────────────────────────────────
 auth:
-  backend: ${HORIZON_AUTH_BACKEND:local}
+  backend: "${HORIZON_AUTH_BACKEND:local}"
   # Local users — JSON array, e.g.
   # HORIZON_AUTH_LOCAL_USERS='[{"username":"admin","passwordHash":"$argon2id$v=19$...","roles":["admin"]}]'
   local:
@@ -95,22 +97,22 @@ rbac:
 
 session:
   ttlMinutes: ${HORIZON_SESSION_TTL_MINUTES:60}
-  cookieName: ${HORIZON_SESSION_COOKIE_NAME:horizon_sid}
+  cookieName: "${HORIZON_SESSION_COOKIE_NAME:horizon_sid}"
   cookieSecure: ${HORIZON_SESSION_COOKIE_SECURE:false}   # set true behind HTTPS
 
 # State files. The image sets HORIZON_*_FILE=/data/* (its writable volume);
 # a local run defaults to ./horizon-* in the working dir. Set an explicit
 # path only if you need one the running process can write.
 audit:
-  file: ${HORIZON_AUDIT_FILE:./horizon-audit.jsonl}
+  file: "${HORIZON_AUDIT_FILE:./horizon-audit.jsonl}"
 setup:
-  file: ${HORIZON_SETUP_FILE:./horizon-setup.json}
+  file: "${HORIZON_SETUP_FILE:./horizon-setup.json}"
 alarms:
-  file: ${HORIZON_ALARMS_FILE:./horizon-alarms.json}
+  file: "${HORIZON_ALARMS_FILE:./horizon-alarms.json}"
 
 debugLog:
   enabled: ${HORIZON_DEBUG_LOG_ENABLED:false}
-  file: ${HORIZON_WIRE_LOG_FILE:./horizon-wire.jsonl}
+  file: "${HORIZON_WIRE_LOG_FILE:./horizon-wire.jsonl}"
   maxBodyChars: ${HORIZON_DEBUG_LOG_MAX_BODY_CHARS:8192}
   redactAuthHeaders: ${HORIZON_DEBUG_LOG_REDACT_AUTH:true}
 
@@ -127,7 +129,7 @@ sourceMaps:
   maxFileBytes: ${HORIZON_SOURCEMAPS_MAX_FILE_BYTES:67108864}      # 64 MiB
   maxTotalBytes: ${HORIZON_SOURCEMAPS_MAX_TOTAL_BYTES:536870912}   # 512 MiB
   maxFileCount: ${HORIZON_SOURCEMAPS_MAX_FILE_COUNT:128}
-  bootMountDir: ${HORIZON_SOURCEMAPS_DIR:}
+  bootMountDir: "${HORIZON_SOURCEMAPS_DIR:}"
 
 # Performance / behavior tuning — BFF→OAP fan-out + storage-protective caps.
 # Operational, hot-reloaded. `:null` keeps the built-in defaults (shown in


(skywalking-horizon-ui) 08/11: fix(cluster): admin badge reads 'N/M reachable' in yellow when not all live

Posted by wu...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a commit to branch feat/template-modes-env-config
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git

commit 411754ff3cf1eace3107171fbae76b478142588d
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Fri Jun 26 09:44:43 2026 +0800

    fix(cluster): admin badge reads 'N/M reachable' in yellow when not all live
    
    '4 reachable · 1 bundled' wasn't a proper summary. The badge now shows a plain
    fraction — 'all reachable' (green) only when every feature probes live, else
    '{n}/{total} reachable' (yellow). Covers both a 404'd path and a bundled /
    not-probed feature (ui_template in readonly); the per-row chip says which.
---
 .../features/operate/cluster/ClusterStatusView.vue    | 19 ++++++++-----------
 apps/ui/src/i18n/locales/de.json                      |  2 +-
 apps/ui/src/i18n/locales/en.json                      |  2 +-
 apps/ui/src/i18n/locales/es.json                      |  2 +-
 apps/ui/src/i18n/locales/fr.json                      |  2 +-
 apps/ui/src/i18n/locales/ja.json                      |  2 +-
 apps/ui/src/i18n/locales/ko.json                      |  2 +-
 apps/ui/src/i18n/locales/pt.json                      |  2 +-
 apps/ui/src/i18n/locales/zh-CN.json                   |  2 +-
 9 files changed, 16 insertions(+), 19 deletions(-)

diff --git a/apps/ui/src/features/operate/cluster/ClusterStatusView.vue b/apps/ui/src/features/operate/cluster/ClusterStatusView.vue
index 792c842..7278d9b 100644
--- a/apps/ui/src/features/operate/cluster/ClusterStatusView.vue
+++ b/apps/ui/src/features/operate/cluster/ClusterStatusView.vue
@@ -106,8 +106,9 @@ const healthLabel = computed<string>(() => {
 const adminBadgeState = computed<'ok' | 'warn' | 'err' | 'unknown'>(() => {
   if (!preflight.value) return 'unknown';
   if (!adminReachable.value) return 'err';
-  // Admin port replied but a required feature's path doesn't respond.
-  if (preflight.value.modules.some((m) => m.required && m.reachable === false)) return 'warn';
+  // Anything not fully live-reachable — a path that 404s, or a bundled /
+  // not-probed feature (ui_template in readonly) — is a partial state.
+  if (preflight.value.modules.some((m) => m.reachable !== true)) return 'warn';
   return 'ok';
 });
 
@@ -115,15 +116,11 @@ const adminBadgeLabel = computed<string>(() => {
   if (!preflight.value) return t('loading…');
   if (!adminReachable.value) return t('unreachable');
   const mods = preflight.value.modules;
-  const down = mods.filter((m) => m.reachable === false).length;
-  if (down > 0) return t('{n} unreachable', { n: down });
-  // reachable === null = not probed (ui_template in readonly: bundled). It's
-  // healthy, but it isn't "reachable" — don't fold it into "all reachable".
-  const bundled = mods.filter((m) => m.reachable === null).length;
-  if (bundled > 0) {
-    return t('{n} reachable · {b} bundled', { n: mods.length - bundled, b: bundled });
-  }
-  return t('all reachable');
+  const reachable = mods.filter((m) => m.reachable === true).length;
+  if (reachable === mods.length) return t('all reachable');
+  // X/Y — covers both an unreachable path and a bundled (readonly) feature;
+  // the per-row chip says which.
+  return t('{n}/{total} reachable', { n: reachable, total: mods.length });
 });
 
 const adminGeneratedAt = computed<string>(() => agoLabel(preflight.value?.generatedAt));
diff --git a/apps/ui/src/i18n/locales/de.json b/apps/ui/src/i18n/locales/de.json
index d034f8a..d0c4389 100644
--- a/apps/ui/src/i18n/locales/de.json
+++ b/apps/ui/src/i18n/locales/de.json
@@ -1549,5 +1549,5 @@
   "{n} unreachable": "{n} nicht erreichbar",
   "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is the live probe, not config-presence: a path that 404s (selector off, renamed, or absent in a fork) reads as unreachable. 'selector detected' below is only an upstream-release hint, not the verdict.": "Erreichbarkeit jeder Admin-Funktion — der BFF sendet ein GET an den relativen REST-Pfad, den die Funktion tatsächlich aufruft, und meldet, ob er  [...]
   "Enable on OAP:": "Auf OAP aktivieren:",
-  "{n} reachable · {b} bundled": "{n} erreichbar · {b} gebündelt"
+  "{n}/{total} reachable": "{n}/{total} erreichbar"
 }
diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json
index 9be9b57..10d1945 100644
--- a/apps/ui/src/i18n/locales/en.json
+++ b/apps/ui/src/i18n/locales/en.json
@@ -1549,5 +1549,5 @@
   "{n} unreachable": "{n} unreachable",
   "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is the live probe, not config-presence: a path that 404s (selector off, renamed, or absent in a fork) reads as unreachable. 'selector detected' below is only an upstream-release hint, not the verdict.": "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is th [...]
   "Enable on OAP:": "Enable on OAP:",
-  "{n} reachable · {b} bundled": "{n} reachable · {b} bundled"
+  "{n}/{total} reachable": "{n}/{total} reachable"
 }
diff --git a/apps/ui/src/i18n/locales/es.json b/apps/ui/src/i18n/locales/es.json
index 23778bb..107a9f1 100644
--- a/apps/ui/src/i18n/locales/es.json
+++ b/apps/ui/src/i18n/locales/es.json
@@ -1549,5 +1549,5 @@
   "{n} unreachable": "{n} inaccesible(s)",
   "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is the live probe, not config-presence: a path that 404s (selector off, renamed, or absent in a fork) reads as unreachable. 'selector detected' below is only an upstream-release hint, not the verdict.": "Accesibilidad de cada función de administración: el BFF hace un GET a la ruta REST relativa que la función realmente llama e informa si responde. [...]
   "Enable on OAP:": "Activar en OAP:",
-  "{n} reachable · {b} bundled": "{n} accesible(s) · {b} incluido(s)"
+  "{n}/{total} reachable": "{n}/{total} accesibles"
 }
diff --git a/apps/ui/src/i18n/locales/fr.json b/apps/ui/src/i18n/locales/fr.json
index 5ab140e..518256d 100644
--- a/apps/ui/src/i18n/locales/fr.json
+++ b/apps/ui/src/i18n/locales/fr.json
@@ -1549,5 +1549,5 @@
   "{n} unreachable": "{n} inaccessible(s)",
   "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is the live probe, not config-presence: a path that 404s (selector off, renamed, or absent in a fork) reads as unreachable. 'selector detected' below is only an upstream-release hint, not the verdict.": "Accessibilité de chaque fonctionnalité d'administration — le BFF envoie un GET sur le chemin REST relatif que la fonctionnalité appelle réellemen [...]
   "Enable on OAP:": "Activer sur OAP :",
-  "{n} reachable · {b} bundled": "{n} accessible(s) · {b} intégré(s)"
+  "{n}/{total} reachable": "{n}/{total} accessibles"
 }
diff --git a/apps/ui/src/i18n/locales/ja.json b/apps/ui/src/i18n/locales/ja.json
index 71d57f7..bcb8878 100644
--- a/apps/ui/src/i18n/locales/ja.json
+++ b/apps/ui/src/i18n/locales/ja.json
@@ -1549,5 +1549,5 @@
   "{n} unreachable": "{n} 件が到達不可",
   "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is the live probe, not config-presence: a path that 404s (selector off, renamed, or absent in a fork) reads as unreachable. 'selector detected' below is only an upstream-release hint, not the verdict.": "各管理機能の到達可能性 —— BFF は機能が実際に呼び出す相対 REST パスへ GET し、応答するかどうかを報告します。健全性は設定の有無ではなくライブプローブで判定されます。404 を返すパス(selector が無効、リネーム、またはフォークで欠落)は到達不可とみなされます。下の [...]
   "Enable on OAP:": "OAP で有効化:",
-  "{n} reachable · {b} bundled": "{n} 件が到達可能 · {b} 件がバンドル"
+  "{n}/{total} reachable": "{n}/{total} 件が到達可能"
 }
diff --git a/apps/ui/src/i18n/locales/ko.json b/apps/ui/src/i18n/locales/ko.json
index 40dc171..680a3a9 100644
--- a/apps/ui/src/i18n/locales/ko.json
+++ b/apps/ui/src/i18n/locales/ko.json
@@ -1549,5 +1549,5 @@
   "{n} unreachable": "{n}개 도달 불가",
   "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is the live probe, not config-presence: a path that 404s (selector off, renamed, or absent in a fork) reads as unreachable. 'selector detected' below is only an upstream-release hint, not the verdict.": "각 관리 기능의 도달 가능성 — BFF가 해당 기능이 실제로 호출하는 상대 REST 경로로 GET 요청을 보내 응답 여부를 보고합니다. 상태는 설정 존재 여부가 아니라 실시간 프로브로 판단합니다. 404를 반환하는 경로(selector 꺼짐, 이름 변경 또는  [...]
   "Enable on OAP:": "OAP에서 활성화:",
-  "{n} reachable · {b} bundled": "{n}개 도달 가능 · {b}개 번들"
+  "{n}/{total} reachable": "{n}/{total}개 도달 가능"
 }
diff --git a/apps/ui/src/i18n/locales/pt.json b/apps/ui/src/i18n/locales/pt.json
index 8b65797..8305b7f 100644
--- a/apps/ui/src/i18n/locales/pt.json
+++ b/apps/ui/src/i18n/locales/pt.json
@@ -1549,5 +1549,5 @@
   "{n} unreachable": "{n} inacessível(is)",
   "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is the live probe, not config-presence: a path that 404s (selector off, renamed, or absent in a fork) reads as unreachable. 'selector detected' below is only an upstream-release hint, not the verdict.": "Acessibilidade de cada recurso de administração: o BFF faz um GET no caminho REST relativo que o recurso realmente chama e informa se ele respond [...]
   "Enable on OAP:": "Ativar no OAP:",
-  "{n} reachable · {b} bundled": "{n} acessível(is) · {b} incluído(s)"
+  "{n}/{total} reachable": "{n}/{total} acessíveis"
 }
diff --git a/apps/ui/src/i18n/locales/zh-CN.json b/apps/ui/src/i18n/locales/zh-CN.json
index 76f2c14..2eb38b0 100644
--- a/apps/ui/src/i18n/locales/zh-CN.json
+++ b/apps/ui/src/i18n/locales/zh-CN.json
@@ -1549,5 +1549,5 @@
   "{n} unreachable": "{n} 个不可达",
   "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is the live probe, not config-presence: a path that 404s (selector off, renamed, or absent in a fork) reads as unreachable. 'selector detected' below is only an upstream-release hint, not the verdict.": "每个管理功能的可达性 —— BFF 会向该功能实际调用的相对 REST 路径发起 GET,并报告其是否响应。健康状态取决于实时探测,而非配置存在性:返回 404 的路径(selector 关闭、被重命名或在 fork 版本中缺失)将被视为不可达。下方的“检测到 selector”仅为上游发 [...]
   "Enable on OAP:": "在 OAP 上启用:",
-  "{n} reachable · {b} bundled": "{n} 个可达 · {b} 个内置"
+  "{n}/{total} reachable": "{n}/{total} 个可达"
 }