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}
