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

Reply via email to