You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by wu...@apache.org on 2026/08/21 15:41:37 UTC
(skywalking-horizon-ui) branch fix/config-reload-correctness created (now 8391c81)
This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a change to branch fix/config-reload-correctness
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
at 8391c81 fix(config): make a hot reload change exactly what it says it changes
This branch includes the following new commits:
new 8391c81 fix(config): make a hot reload change exactly what it says it changes
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(skywalking-horizon-ui) 01/01: fix(config): make a hot reload change exactly what it says it changes
Posted by wu...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a commit to branch fix/config-reload-correctness
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
commit 8391c81c140a1aa5ce07caf3cab3a04cedb35d78
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Fri Aug 21 23:41:16 2026 +0800
fix(config): make a hot reload change exactly what it says it changes
Three things a config reload got wrong, and the documentation that described
none of them accurately.
A reload that found no file replaced the running configuration with schema
defaults. That is correct at startup — bootstrap validation then tells the
operator no users are configured — but on a reload it is silent and total,
because defaults are a perfectly valid config: every user, OAP URL, role and
signing key would be swapped for one, with nothing to say so. The reload path
now refuses a missing file and keeps serving what it has.
Metrics Inspect resolved its MQE target through a sixty-second cache that was
not keyed by the configuration it came from, so a reload that repointed OAP
left MQE querying the previous target for up to a minute while every other
call had already moved. It now keys on the OAP settings it resolved from, the
way the capability and time-window caches already did.
Two code comments asserted the opposite of the code. The performance block
says its values are clamped to a ceiling; zod `.max()` rejects, so an
above-ceiling number exits at boot or bounces the whole reload rather than
being pulled down, and the two topology caps have no ceiling at all. The
template-source flag says it is set at boot and on reload; only at boot.
On the docs side: capability probes were listed as needing a restart, when
they are cached for five minutes and keyed by the query URL, so an OAP upgrade
is picked up on its own; `server.staticDir` genuinely does need one and was
missing; and the source-map page led a note with "Restart-only knobs" before
saying those same knobs apply live. `debugLog`, `security.trustedLinkDomains`
and a caveat about enabling source maps in a live file were absent from the
list entirely, and `security`, `mcp.name` and `oauth.clientMetadataHosts` had
no row in the configuration reference at all.
The reload lifecycle had no tests. It has four now, driving the real watcher
against a real file — a valid edit applies and notifies, a malformed one is
rejected with the previous config still serving, a vanished file never yields
defaults, and a closed source stops watching. `loadConfig` takes an optional
write-settle threshold so a case costs milliseconds instead of chokidar's two
seconds, the same seam SessionStore exposes for its reaper.
Separately, the bundled dashboard docs are catalogued the way the sidebar is.
Forty-six pages sat in one flat alphabetical list; they are now grouped by the
`group` the layer templates already carry, so the docs cannot drift from what
an operator sees.
---
apps/bff/src/config/loader.test.ts | 153 ++++++++++++++++++++++++-
apps/bff/src/config/loader.ts | 34 ++++--
apps/bff/src/config/schema.ts | 7 +-
apps/bff/src/logic/templates/sync.ts | 3 +-
apps/bff/src/util/mqe-target.ts | 15 ++-
docs/changelog/1.0.0.md | 12 ++
docs/menu.yml | 209 ++++++++++++++++++++---------------
docs/operate/browser-source-maps.md | 2 +-
docs/setup/horizon-yaml.md | 13 ++-
9 files changed, 336 insertions(+), 112 deletions(-)
diff --git a/apps/bff/src/config/loader.test.ts b/apps/bff/src/config/loader.test.ts
index 952ff76..13f3245 100644
--- a/apps/bff/src/config/loader.test.ts
+++ b/apps/bff/src/config/loader.test.ts
@@ -15,13 +15,14 @@
* limitations under the License.
*/
-import { readFileSync } from 'node:fs';
+import { readFileSync, writeFileSync, rmSync, mkdtempSync, realpathSync } from 'node:fs';
+import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
-import { dirname, resolve } from 'node:path';
+import { dirname, join, resolve } from 'node:path';
import YAML from 'yaml';
-import { afterEach, describe, expect, it, vi } from 'vitest';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { configSchema } from './schema.js';
-import { interpolateEnv, stripNullish, isAuthConfigured, validateBootstrap } from './loader.js';
+import { interpolateEnv, stripNullish, isAuthConfigured, validateBootstrap, loadConfig } from './loader.js';
import { logger } from '../logger.js';
describe('interpolateEnv', () => {
@@ -414,3 +415,147 @@ describe('a local username that is an email collides with an SSO identity', () =
expect(cfg.auth.breakGlass).toBeUndefined();
});
});
+
+/**
+ * Reload lifecycle. These drive the real watcher against a real file, because
+ * the thing under test IS the file→config path — a mocked fs would prove the
+ * test's own wiring. `awaitWriteFinishMs` is shortened so a case costs
+ * milliseconds rather than chokidar's default two seconds.
+ *
+ * Both directions of a regression are damaging, which is why the rejection
+ * cases outnumber the happy one: a reload that fails to apply leaves an
+ * operator editing a file that does nothing, while a reload that applies the
+ * WRONG thing silently swaps live configuration — credentials, roles, OAP
+ * targets — for something nobody authored.
+ */
+describe('config reload', () => {
+ const WATCH_MS = 40;
+ let dir: string;
+ let file: string;
+ const open: Array<{ close: () => Promise<void> }> = [];
+
+ const yaml = (oapUrl: string): string =>
+ `oap:\n queryUrl: "${oapUrl}"\nauth:\n backend: local\n local:\n users: []\n`;
+
+ const load = (): ReturnType<typeof loadConfig> => {
+ const src = loadConfig(file, { awaitWriteFinishMs: WATCH_MS });
+ open.push(src);
+ return src;
+ };
+
+ /**
+ * Re-apply `write` until `check` passes, or give up.
+ *
+ * The retry is not politeness about slow machines — chokidar attaches its
+ * watcher asynchronously and `loadConfig` returns before that finishes, so a
+ * single write racing construction is simply missed. Re-writing until the
+ * change lands removes the race without a bare sleep, which would trade a
+ * flake for a slower flake.
+ */
+ const settledAfter = async (
+ write: () => void,
+ check: () => boolean,
+ ms = 4000,
+ ): Promise<boolean> => {
+ const deadline = Date.now() + ms;
+ while (Date.now() < deadline) {
+ write();
+ const inner = Date.now() + WATCH_MS * 4;
+ while (Date.now() < inner) {
+ if (check()) return true;
+ await new Promise((r) => setTimeout(r, 10));
+ }
+ }
+ return check();
+ };
+
+ beforeEach(() => {
+ // realpath, not the raw mkdtemp path: on macOS `tmpdir()` is a symlink
+ // (/var → /private/var) and the watcher resolves the path without
+ // following it, so events would arrive for a path it is not watching.
+ dir = realpathSync(mkdtempSync(join(tmpdir(), 'horizon-config-')));
+ file = join(dir, 'horizon.yaml');
+ writeFileSync(file, yaml('http://before:12800'));
+ });
+
+ afterEach(async () => {
+ for (const s of open.splice(0)) await s.close();
+ rmSync(dir, { recursive: true, force: true });
+ });
+
+ it('applies a valid edit without a restart, and notifies listeners', async () => {
+ const src = load();
+ expect(src.current.oap.queryUrl).toBe('http://before:12800');
+
+ const seen: string[] = [];
+ src.onChange((cfg) => seen.push(cfg.oap.queryUrl));
+
+ expect(
+ await settledAfter(
+ () => writeFileSync(file, yaml('http://after:12800')),
+ () => src.current.oap.queryUrl === 'http://after:12800',
+ ),
+ ).toBe(true);
+ // The listener sees the NEW config, and `current` is already swapped by
+ // the time it runs — a subscriber may read either and get the same answer.
+ expect(seen.at(-1)).toBe('http://after:12800');
+ });
+
+ it('rejects a malformed edit and keeps serving the previous config', async () => {
+ const src = load();
+ const errs = vi.spyOn(logger, 'error').mockImplementation(() => logger);
+
+ expect(
+ await settledAfter(
+ () => writeFileSync(file, 'oap: [this is not the shape\n'),
+ () => errs.mock.calls.length > 0,
+ ),
+ ).toBe(true);
+ // The whole point: a bad edit changes nothing that is serving.
+ expect(src.current.oap.queryUrl).toBe('http://before:12800');
+ errs.mockRestore();
+ });
+
+ /**
+ * An INVARIANT, not a reproduction — and the distinction matters to whoever
+ * reads this next.
+ *
+ * `parseFile` treats ENOENT as "run on defaults", which is right at BOOT:
+ * bootstrap validation rejects an empty config on first start. On a RELOAD it
+ * would be silent and catastrophic, because defaults are a VALID config —
+ * every configured user, OAP URL, role and key would be swapped for a default
+ * with nothing to say so. `loadConfig` therefore passes `allowMissing: false`
+ * on the reload path.
+ *
+ * This test could NOT be made to fail against the unfixed code, and that is a
+ * fact about the watcher rather than about the fix: only `change` is
+ * subscribed, so a deleted file never reaches the reload path. The guard is
+ * defensive — it closes the door on the narrow race where `change` fires and
+ * the file is gone by the time it is read, which is reachable in principle
+ * (a symlink swap mid-stabilisation) and not reproducible on demand here.
+ *
+ * So: assert the invariant, and do not claim regression coverage the case
+ * does not provide.
+ */
+ it('never serves schema defaults after the file goes missing', async () => {
+ const src = load();
+ const errs = vi.spyOn(logger, 'error').mockImplementation(() => logger);
+
+ rmSync(file);
+ await new Promise((r) => setTimeout(r, WATCH_MS * 10));
+
+ expect(src.current.oap.queryUrl).toBe('http://before:12800');
+ expect(src.current.oap.queryUrl).not.toBe(configSchema.parse({}).oap.queryUrl);
+ errs.mockRestore();
+ });
+
+ it('stops watching once closed', async () => {
+ const src = load();
+ await src.close();
+ open.length = 0;
+
+ writeFileSync(file, yaml('http://after-close:12800'));
+ await new Promise((r) => setTimeout(r, WATCH_MS * 10));
+ expect(src.current.oap.queryUrl).toBe('http://before:12800');
+ });
+});
diff --git a/apps/bff/src/config/loader.ts b/apps/bff/src/config/loader.ts
index 259ca5f..2d7ea7b 100644
--- a/apps/bff/src/config/loader.ts
+++ b/apps/bff/src/config/loader.ts
@@ -311,14 +311,19 @@ export function validateBootstrap(cfg: HorizonConfig): HorizonConfig {
return cfg;
}
-function parseFile(absPath: string): HorizonConfig {
+/**
+ * @param allowMissing at BOOT a missing file means "run on defaults" — the
+ * bootstrap validation that follows rejects it on first start anyway. On a
+ * RELOAD it must NOT: defaults are a valid config, so accepting them would
+ * silently swap every configured user, OAP URL, role and key for a default,
+ * which is the one outcome the reload path promises never to produce.
+ */
+function parseFile(absPath: string, allowMissing: boolean): HorizonConfig {
let raw = '';
try {
raw = readFileSync(absPath, 'utf8');
} catch (err) {
- if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
- // No config file → use full defaults. Bootstrap validation still
- // runs and will reject this (no users, no ldap) on first start.
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT' && allowMissing) {
return configSchema.parse({});
}
throw err;
@@ -328,17 +333,30 @@ function parseFile(absPath: string): HorizonConfig {
return configSchema.parse(stripNullish(parsed));
}
-export function loadConfig(configPath: string): ConfigSource {
+export interface LoadConfigOptions {
+ /** How long chokidar waits for writes to settle before emitting `change`.
+ * Defaults to its own 2s. Tests shorten it so a reload case does not cost
+ * two seconds of wall clock each — the same seam `SessionStore` exposes
+ * for its reaper interval. */
+ awaitWriteFinishMs?: number;
+}
+
+export function loadConfig(configPath: string, opts: LoadConfigOptions = {}): ConfigSource {
const absPath = resolve(configPath);
- let current = parseFile(absPath);
+ let current = parseFile(absPath, true);
validateBootstrap(current);
const listeners = new Set<(cfg: HorizonConfig) => void>();
- const watcher = chokidar.watch(absPath, { ignoreInitial: true, awaitWriteFinish: true });
+ const watcher = chokidar.watch(absPath, {
+ ignoreInitial: true,
+ awaitWriteFinish: opts.awaitWriteFinishMs
+ ? { stabilityThreshold: opts.awaitWriteFinishMs, pollInterval: 20 }
+ : true,
+ });
watcher.on('change', () => {
let next: HorizonConfig;
try {
- next = parseFile(absPath);
+ next = parseFile(absPath, false);
validateBootstrap(next);
} catch (err) {
// A malformed reload must not kill the watcher — the previous valid
diff --git a/apps/bff/src/config/schema.ts b/apps/bff/src/config/schema.ts
index 093c0ac..cccf1b8 100644
--- a/apps/bff/src/config/schema.ts
+++ b/apps/bff/src/config/schema.ts
@@ -776,8 +776,11 @@ const layersSchema = z
// OAP, plus the render / fetch caps that protect storage. OPERATIONAL,
// per-deployment, hot-reloaded — NOT dashboard content (those live in
// templates published to OAP). Defaults equal the built-in values, so
-// omitting this block changes nothing. Every value is clamped to a hard
-// ceiling (the `.max()` below) — config can lower, never exceed it.
+// omitting this block changes nothing. Most values carry a `.max()`, and it
+// REJECTS rather than clamps: a number above the ceiling fails validation, so
+// it exits at boot or bounces the whole reload — it is not pulled down to the
+// limit. `topologyMaxNodes` / `topologyMaxEdges` have no ceiling on purpose,
+// because a genuinely large graph is a reason to raise them.
const performanceSchema = z
.object({
bulk: z
diff --git a/apps/bff/src/logic/templates/sync.ts b/apps/bff/src/logic/templates/sync.ts
index 9b1cb12..0e9b4a7 100644
--- a/apps/bff/src/logic/templates/sync.ts
+++ b/apps/bff/src/logic/templates/sync.ts
@@ -189,7 +189,8 @@ 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. */
+ * to the disk bundle. Set once at BOOT by the server. A config reload does not change it —
+ * server.ts only warns that the edit needs a restart. */
let readOnlyMode = false;
export function setTemplateReadOnly(on: boolean): void {
readOnlyMode = on;
diff --git a/apps/bff/src/util/mqe-target.ts b/apps/bff/src/util/mqe-target.ts
index 7cf7158..c1b6602 100644
--- a/apps/bff/src/util/mqe-target.ts
+++ b/apps/bff/src/util/mqe-target.ts
@@ -65,6 +65,16 @@ const CACHE_TTL_MS = 60_000;
interface CacheEntry {
target: MqeTarget;
expiresAt: number;
+ /** The OAP settings this entry was resolved from. A config hot-reload that
+ * repoints OAP must MISS rather than serve the old target for the rest of
+ * the TTL — the same reason the capability and window caches key on the
+ * config they read (see `logic/oap/capabilities.ts`). */
+ key: string;
+}
+
+function cacheKey(cfg: HorizonConfig): string {
+ const { queryUrl, adminUrl, mqe } = cfg.oap;
+ return [queryUrl, adminUrl, mqe?.host ?? '', mqe?.port ?? ''].join('|');
}
export class MqeTargetCache {
@@ -76,9 +86,10 @@ export class MqeTargetCache {
async resolve(deps: ResolveDeps): Promise<MqeTarget> {
const now = Date.now();
- if (this.entry && this.entry.expiresAt > now) return this.entry.target;
+ const key = cacheKey(deps.config());
+ if (this.entry && this.entry.key === key && this.entry.expiresAt > now) return this.entry.target;
const target = await resolveMqeTarget(deps);
- this.entry = { target, expiresAt: now + CACHE_TTL_MS };
+ this.entry = { target, expiresAt: now + CACHE_TTL_MS, key };
return target;
}
}
diff --git a/docs/changelog/1.0.0.md b/docs/changelog/1.0.0.md
index b8eda28..7c4ca0c 100644
--- a/docs/changelog/1.0.0.md
+++ b/docs/changelog/1.0.0.md
@@ -366,6 +366,8 @@
### Bundled layer dashboards
+- **The dashboard docs are now grouped the way the sidebar is.** Forty-six pages sat in one flat alphabetical list, so ActiveMQ came before Airflow and the six database dashboards were scattered through it. They are now catalogued under the same groups the layer templates carry — Databases, Gateways, MQ, Kubernetes, Istio, AWS, Mobile, OS, Self-Observability, Virtual targets, Workflow Scheduler — plus Overviews for the two cross-layer dashboards and Application for the general-service an [...]
+
- **Single-value metrics now render as cards, not flat lines, on several layer dashboards.** Widgets whose expression collapses the window to one number (a `latest(...)` total) had been mis-ported as line charts — drawn as a lone dot that misreads as a time series and shares one axis with an unrelated average trend. Each is now split into a proper single-value **card** (the total) plus a trend **line** (the average), matching the metric's shape, the way booster-ui rendered them. Affects [...]
- **The Kubernetes Service dashboard tiles without a gap.** The Service and Pod views left an empty half-row where a run of same-width widgets didn't divide evenly into the grid, and the layout back-filled the hole by pulling a later chart up out of order. The CPU / Memory Resources and Pod HTTP RPM / Response Time widgets are re-sized so every row fills the grid in order — no gap, and the charts stay in reading order.
@@ -389,6 +391,16 @@
- **One-click exit from comparison.** The compare bar gains a **Clear all** button that drops every locked entity — including the current one, whose chip has no per-entity × — and returns the page to the single-entity view. Previously, when your only lock was the current entity, there was no way to leave compare from the bar.
+### Configuration and hot reload
+
+- **A config edit that repoints OAP now reaches MQE straight away.** Metrics Inspect resolved its MQE target through a 60-second cache that was not keyed by the configuration it came from, so after a hot reload it kept querying the previous OAP for up to a minute while every other call had already moved. The cache now keys on the OAP settings it resolved from, the way the capability and time-window caches already did.
+
+- **A config file that vanishes can no longer replace your settings with defaults.** The loader treats a missing file as "run on built-in defaults", which is right at startup — validation then tells you no users are configured. On a *reload* it would have been silent and total, because defaults are a perfectly valid config: every user, OAP URL, role and key would have been swapped for one. Reload now refuses a missing file and keeps serving what it has, which is what the page always promised.
+
+- **The hot-reload documentation matches what the code does.** Capability probes were listed as needing a restart; they are cached for five minutes and keyed by the OAP query URL, so an OAP upgrade is picked up on its own and repointing OAP re-probes at once. `server.staticDir` genuinely does need a restart and was missing. The source-map page led a note with "Restart-only knobs" and then said those same three knobs apply live. And `debugLog`, `security.trustedLinkDomains` and one caveat [...]
+
+- **`security`, `mcp.name` and `oauth.clientMetadataHosts` appear in the configuration reference.** All three were real, and two of them documented elsewhere, but none appeared in the section table or the top-level shape — so anyone reading the reference to find out what is configurable never learned they existed.
+
### Dashboard setup
- **Rearranging the menu keeps what you arranged.** The switch only makes the entries draggable — closing it ends dragging, it does not discard what you dragged. Moving an entry is what records the order; dragging everything back to where it started removes the record again, exactly as **Reset to built-in order** does, since the built-in order is what having no record means.
diff --git a/docs/menu.yml b/docs/menu.yml
index d100655..06cd048 100644
--- a/docs/menu.yml
+++ b/docs/menu.yml
@@ -94,99 +94,124 @@ catalog:
- name: "Bundled Dashboards"
catalog:
- - name: "Services Dashboard"
- path: "/dashboards/overview-services"
- - name: "Mesh Dashboard"
- path: "/dashboards/overview-mesh"
- - name: "ActiveMQ"
- path: "/dashboards/activemq"
- - name: "Airflow"
- path: "/dashboards/airflow"
- - name: "Alipay Mini Program"
- path: "/dashboards/alipay_mini_program"
- - name: "APISIX"
- path: "/dashboards/apisix"
- - name: "AWS API Gateway"
- path: "/dashboards/aws_gateway"
- - name: "AWS DynamoDB"
- path: "/dashboards/aws_dynamodb"
- - name: "AWS EKS"
- path: "/dashboards/aws_eks"
- - name: "AWS S3"
- path: "/dashboards/aws_s3"
- - name: "BanyanDB"
- path: "/dashboards/banyandb"
- - name: "BookKeeper"
- path: "/dashboards/bookkeeper"
- - name: "Browser"
- path: "/dashboards/browser"
- - name: "Cilium Service"
- path: "/dashboards/cilium_service"
- - name: "ClickHouse"
- path: "/dashboards/clickhouse"
- - name: "Elasticsearch"
- path: "/dashboards/elasticsearch"
- - name: "Envoy AI Gateway"
- path: "/dashboards/envoy_ai_gateway"
- - name: "Flink"
- path: "/dashboards/flink"
- - name: "General Service"
- path: "/dashboards/general"
- - name: "Go Agent (Self-Observability)"
- path: "/dashboards/so11y_go_agent"
- - name: "iOS"
- path: "/dashboards/ios"
- - name: "Istio Control Plane"
- path: "/dashboards/mesh_cp"
- - name: "Istio Data Plane"
- path: "/dashboards/mesh_dp"
- - name: "Istio Managed Services"
- path: "/dashboards/mesh"
- - name: "Java Agent (Self-Observability)"
- path: "/dashboards/so11y_java_agent"
- - name: "Kafka"
- path: "/dashboards/kafka"
- - name: "Kong"
- path: "/dashboards/kong"
+ - name: "Overviews"
+ catalog:
+ - name: "Services Dashboard"
+ path: "/dashboards/overview-services"
+ - name: "Mesh Dashboard"
+ path: "/dashboards/overview-mesh"
+ - name: "Application"
+ catalog:
+ - name: "General Service"
+ path: "/dashboards/general"
+ - name: "Browser"
+ path: "/dashboards/browser"
+ - name: "AWS"
+ catalog:
+ - name: "AWS DynamoDB"
+ path: "/dashboards/aws_dynamodb"
+ - name: "AWS EKS"
+ path: "/dashboards/aws_eks"
+ - name: "AWS API Gateway"
+ path: "/dashboards/aws_gateway"
+ - name: "AWS S3"
+ path: "/dashboards/aws_s3"
+ - name: "Databases"
+ catalog:
+ - name: "ClickHouse"
+ path: "/dashboards/clickhouse"
+ - name: "Elasticsearch"
+ path: "/dashboards/elasticsearch"
+ - name: "MongoDB"
+ path: "/dashboards/mongodb"
+ - name: "MySQL / MariaDB"
+ path: "/dashboards/mysql"
+ - name: "PostgreSQL"
+ path: "/dashboards/postgresql"
+ - name: "Redis"
+ path: "/dashboards/redis"
+ - name: "Gateways"
+ catalog:
+ - name: "APISIX"
+ path: "/dashboards/apisix"
+ - name: "Envoy AI Gateway"
+ path: "/dashboards/envoy_ai_gateway"
+ - name: "Kong"
+ path: "/dashboards/kong"
+ - name: "Nginx"
+ path: "/dashboards/nginx"
+ - name: "Istio"
+ catalog:
+ - name: "Istio Managed Services"
+ path: "/dashboards/mesh"
+ - name: "Istio Control Plane"
+ path: "/dashboards/mesh_cp"
+ - name: "Istio Data Plane"
+ path: "/dashboards/mesh_dp"
- name: "Kubernetes"
- path: "/dashboards/k8s"
- - name: "Kubernetes Services"
- path: "/dashboards/k8s_service"
- - name: "Linux"
- path: "/dashboards/os_linux"
- - name: "MongoDB"
- path: "/dashboards/mongodb"
- - name: "MySQL / MariaDB"
- path: "/dashboards/mysql"
- - name: "Nginx"
- path: "/dashboards/nginx"
- - name: "OAP (Self-Observability)"
- path: "/dashboards/so11y_oap"
- - name: "PostgreSQL"
- path: "/dashboards/postgresql"
- - name: "Pulsar"
- path: "/dashboards/pulsar"
- - name: "RabbitMQ"
- path: "/dashboards/rabbitmq"
- - name: "Redis"
- path: "/dashboards/redis"
- - name: "RocketMQ"
- path: "/dashboards/rocketmq"
- - name: "Satellite (Self-Observability)"
- path: "/dashboards/so11y_satellite"
- - name: "Virtual Cache"
- path: "/dashboards/virtual_cache"
- - name: "Virtual Database"
- path: "/dashboards/virtual_database"
- - name: "Virtual GenAI"
- path: "/dashboards/virtual_genai"
- - name: "Virtual MQ"
- path: "/dashboards/virtual_mq"
- - name: "WeChat Mini Program"
- path: "/dashboards/wechat_mini_program"
- - name: "Windows"
- path: "/dashboards/os_windows"
-
+ catalog:
+ - name: "Cilium Service"
+ path: "/dashboards/cilium_service"
+ - name: "Kubernetes"
+ path: "/dashboards/k8s"
+ - name: "Kubernetes Services"
+ path: "/dashboards/k8s_service"
+ - name: "MQ"
+ catalog:
+ - name: "ActiveMQ"
+ path: "/dashboards/activemq"
+ - name: "BookKeeper"
+ path: "/dashboards/bookkeeper"
+ - name: "Flink"
+ path: "/dashboards/flink"
+ - name: "Kafka"
+ path: "/dashboards/kafka"
+ - name: "Pulsar"
+ path: "/dashboards/pulsar"
+ - name: "RabbitMQ"
+ path: "/dashboards/rabbitmq"
+ - name: "RocketMQ"
+ path: "/dashboards/rocketmq"
+ - name: "Mobile"
+ catalog:
+ - name: "Alipay Mini Program"
+ path: "/dashboards/alipay_mini_program"
+ - name: "iOS"
+ path: "/dashboards/ios"
+ - name: "WeChat Mini Program"
+ path: "/dashboards/wechat_mini_program"
+ - name: "OS"
+ catalog:
+ - name: "Linux"
+ path: "/dashboards/os_linux"
+ - name: "Windows"
+ path: "/dashboards/os_windows"
+ - name: "Self-Observability"
+ catalog:
+ - name: "BanyanDB"
+ path: "/dashboards/banyandb"
+ - name: "Go Agent (Self-Observability)"
+ path: "/dashboards/so11y_go_agent"
+ - name: "Java Agent (Self-Observability)"
+ path: "/dashboards/so11y_java_agent"
+ - name: "OAP (Self-Observability)"
+ path: "/dashboards/so11y_oap"
+ - name: "Satellite (Self-Observability)"
+ path: "/dashboards/so11y_satellite"
+ - name: "Virtual targets"
+ catalog:
+ - name: "Virtual Cache"
+ path: "/dashboards/virtual_cache"
+ - name: "Virtual Database"
+ path: "/dashboards/virtual_database"
+ - name: "Virtual GenAI"
+ path: "/dashboards/virtual_genai"
+ - name: "Virtual MQ"
+ path: "/dashboards/virtual_mq"
+ - name: "Workflow Scheduler"
+ catalog:
+ - name: "Airflow"
+ path: "/dashboards/airflow"
- name: "Access Control"
catalog:
- name: "Local Users"
diff --git a/docs/operate/browser-source-maps.md b/docs/operate/browser-source-maps.md
index 26cc201..ef121af 100644
--- a/docs/operate/browser-source-maps.md
+++ b/docs/operate/browser-source-maps.md
@@ -117,7 +117,7 @@ against this budget. Size `maxTotalBytes` against the memory available to the
server — maps with `sourcesContent` are commonly tens of MiB each. When
`enabled` is `false`, the tab still lists errors but the map controls are hidden.
-> **Restart-only knobs.** `enabled`, `maxTotalBytes`, and `maxFileCount` apply
+> **Which knobs need a restart.** `enabled`, `maxTotalBytes`, and `maxFileCount` apply
> live on a config reload — lowering a budget trims the **uploaded** set on the
> next upload / resolve / list (least-recently-used first). Maps already loaded
> from the mount are fixed by the boot scan: lowering `maxFileCount` afterwards
diff --git a/docs/setup/horizon-yaml.md b/docs/setup/horizon-yaml.md
index cc9b377..52c6c59 100644
--- a/docs/setup/horizon-yaml.md
+++ b/docs/setup/horizon-yaml.md
@@ -8,6 +8,7 @@ This page is the top-level map. Each subsection has its own detail page:
|---|---|---|
| `server` | HTTP listener and static asset path. | [server](server.md) |
| `templates` | Template source mode: OAP-backed (`live`) or local read-only bundle (`readonly`). | [below](#template-source-mode) |
+| `security` | Which outbound link domains a dashboard template may point at. | [security](security.md) |
| `oap` | OAP query / admin / Zipkin URLs, timeouts, basic-auth, MQE override. | [oap](oap.md) |
| `auth` | Active backend (local or LDAP), local users, LDAP binding, break-glass, single sign-on, API tokens. | [auth](auth.md) |
| `rbac` | Role definitions, permission grants, landing route per role. | [rbac](rbac.md) |
@@ -28,6 +29,9 @@ server: { host, port, staticDir?, publicUrl? } # publicUrl = the PUBLIC base U
templates: { mode? } # live (default) | readonly
+security:
+ trustedLinkDomains?: string[] # outbound link allow-list for templates
+
oap:
queryUrl: string
adminUrl: string
@@ -70,6 +74,7 @@ ai:
mcp:
enabled?: boolean # ON by default
+ name?: string # what this deployment calls itself to an agent
oauth: # authorization server — OFF by default
enabled?: boolean
@@ -77,6 +82,7 @@ oauth: # authorization server — OFF by default
signingKey?: string # secret — env only, 32+ chars (openssl rand -base64 32)
accessTokenMinutes?: number
refreshTokenDays?: number # 0 disables refresh tokens
+ clientMetadataHosts?: [string, ...] # hosts whose client-metadata URLs are accepted
performance:
bulk:
@@ -145,13 +151,16 @@ Applied live:
- RBAC roles and policy (re-evaluated on next route call).
- OAP URLs and credentials (used on next outbound call).
- Session TTL (applies to every session immediately, already-signed-in ones included — see [Sessions](session.md#hot-reload)).
-- `sourceMaps.enabled`, `sourceMaps.maxTotalBytes`, `sourceMaps.maxFileCount` — applied on the next source-map upload / resolve / list. Lowering a budget trims the in-memory **uploaded** set then (least-recently-used first). It does **not** shrink maps already loaded from the static mount — see below.
+- `sourceMaps.enabled`, `sourceMaps.maxTotalBytes`, `sourceMaps.maxFileCount` — applied on the next source-map upload / resolve / list. Lowering a budget trims the in-memory **uploaded** set then (least-recently-used first). It does **not** shrink maps already loaded from the static mount — see below. One caveat on `enabled`: turning it on in a live file enables uploads and resolves, but the static mount is scanned only at startup, so a BFF that booted with `sourceMaps.enabled: false` ha [...]
+- `debugLog` in full — `enabled`, `file` and the redaction flags. Changing `file` rotates the wire log to the new path, finishing the write in flight on the old one.
+- `security.trustedLinkDomains` — applied the next time a template's outbound link is published or read back.
These changes require a process restart:
- `server.host`, `server.port` — the listener already bound.
+- `server.staticDir` — the static root is registered with the HTTP server at startup.
- `templates.mode` — the template source is chosen at boot (the OAP seed either ran or was skipped). Editing it in a live file logs a warning and keeps the boot-time mode until restart.
-- Capability probes — the OAP schema introspection cache is per-process.
+- Nothing about OAP capability detection. The schema-introspection and trace-API probes are cached for five minutes and keyed by `oap.queryUrl`, so an OAP upgrade is picked up within that window and repointing OAP re-probes at once — neither needs a restart.
- `sourceMaps.bootMountDir` — the static source-map directory is scanned once at startup, so a new directory (and newly-dropped `.map` files) needs a restart. The count of maps loaded from that mount is fixed by the startup scan as well: lowering `sourceMaps.maxFileCount` afterwards trims only the in-memory uploaded set, never the already-mounted maps — restart to re-scan a mount against a lower count.
- **Raising** `sourceMaps.maxFileBytes` — the multipart upload size limit is fixed at startup; lowering it applies live.