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
