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 <[email protected]>
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

Reply via email to