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 fb614baeeb87ee3fbf8c9fa338b0b96a1a2e3e05
Author: Wu Sheng <[email protected]>
AuthorDate: Fri Jun 26 09:59:27 2026 +0800

    fix(readonly): close alert-page write gap, yellow read-only banner, 
stronger env parity
    
    Three review findings + a UX fix:
    
    - HIGH: the alert page-setup (/api/alarms/config) was still writable in 
readonly.
      The readonly backstop only covered /api/admin/templates* and
      /api/admin/overview-templates*; /api/alarms/config writes the
      horizon.alert.page-setup template (file-backed) and slipped through. It's 
now
      in the config-surface write set — a direct POST returns 409 in readonly — 
and
      GET serves the bundled alert page-setup (like every other template) 
instead of
      the local store. Validated live: POST 409, GET serves the bundle.
    
    - The read-only SyncStatusBanner was muted gray ("white") and easy to miss 
next
      to the green per-row sync chips. It's now warning-yellow so it's 
unmistakable
      that the whole config surface is bundle-served and uneditable. (The 
per-row
      "synced" chip is correct as-is: in readonly the bundle IS the presented 
remote,
      so bundled == remote and the diff legitimately reads synced.)
    
    - The env parity guard only checked top-level sections + four hard-coded 
tokens.
      It now recursively walks every schema-default field and asserts each is 
covered
      by a ${HORIZON_…} token in horizon.example.yaml (a scalar token, or a 
JSON-env
      token for a whole subtree). It immediately caught a real gap — oap.mqe 
shipped
      untokenized — now fixed (HORIZON_OAP_MQE). A future nested field without 
env
      coverage will now fail the test.
    
    type-check / lint / license / 162 BFF + 116 UI tests / builds green.
---
 apps/bff/src/config/schema.test.ts                 | 36 ++++++++++++++++++++++
 apps/bff/src/http/config/alarms.ts                 |  7 ++++-
 apps/bff/src/rbac/route-policy.test.ts             |  6 ++--
 apps/bff/src/rbac/route-policy.ts                  | 16 +++++++---
 .../features/admin/_shared/SyncStatusBanner.vue    | 11 ++++---
 horizon.example.yaml                               |  3 ++
 6 files changed, 66 insertions(+), 13 deletions(-)

diff --git a/apps/bff/src/config/schema.test.ts 
b/apps/bff/src/config/schema.test.ts
index 9d4a7d0..74bc26d 100644
--- a/apps/bff/src/config/schema.test.ts
+++ b/apps/bff/src/config/schema.test.ts
@@ -73,4 +73,40 @@ describe('horizon.example.yaml — tokenized default + 
parity', () => {
     expect(raw).toContain('${HORIZON_TEMPLATES_MODE');
     expect(raw).toContain('${HORIZON_OAP_ADMIN_URL');
   });
+
+  // The real contract: EVERY schema-default field is env-overridable. Walk the
+  // fully-defaulted config and assert each node is covered in the example by a
+  // `${HORIZON_…}` token — either a scalar token at that path, or a JSON-env
+  // token standing in for a whole subtree (performance, rbac.roles, …). 
Catches
+  // a new nested field shipped without env coverage, which the 
top-level-section
+  // check above would miss.
+  it('every schema-default field is env-tokenized in the example (recursive)', 
() => {
+    const defaults = configSchema.parse({}) as Record<string, unknown>;
+    const example = (YAML.parse(raw) ?? {}) as Record<string, unknown>;
+    const TOKEN = /\$\{HORIZON_[A-Z0-9_]+(:[\s\S]*)?\}/;
+    const uncovered: string[] = [];
+    const walk = (schemaNode: unknown, exampleNode: unknown, path: string): 
void => {
+      // A token string covers this node (scalar token, or a JSON-env token
+      // collapsing a whole subtree to one var).
+      if (typeof exampleNode === 'string' && TOKEN.test(exampleNode)) return;
+      if (schemaNode !== null && typeof schemaNode === 'object' && 
!Array.isArray(schemaNode)) {
+        if (exampleNode === null || typeof exampleNode !== 'object' || 
Array.isArray(exampleNode)) {
+          uncovered.push(`${path} (section absent from example)`);
+          return;
+        }
+        for (const key of Object.keys(schemaNode)) {
+          if (path === '' && key === 'infra3d') continue; // deprecated 
passthrough, never tokenized
+          walk(
+            (schemaNode as Record<string, unknown>)[key],
+            (exampleNode as Record<string, unknown>)[key],
+            path ? `${path}.${key}` : key,
+          );
+        }
+        return;
+      }
+      uncovered.push(`${path} (leaf not tokenized)`);
+    };
+    walk(defaults, example, '');
+    expect(uncovered, `fields lacking an env token in horizon.example.yaml:\n  
${uncovered.join('\n  ')}`).toEqual([]);
+  });
 });
diff --git a/apps/bff/src/http/config/alarms.ts 
b/apps/bff/src/http/config/alarms.ts
index 1b1e5c3..467259d 100644
--- a/apps/bff/src/http/config/alarms.ts
+++ b/apps/bff/src/http/config/alarms.ts
@@ -30,6 +30,8 @@ import type { ConfigSource } from '../../config/loader.js';
 import type { SessionStore } from '../../user/sessions.js';
 import type { AuditLogger } from '../../audit/logger.js';
 import { requireAuth } from '../../user/middleware.js';
+import { isTemplateReadOnly } from '../../logic/templates/sync.js';
+import { loadBundledAlertPageSetup } from '../../logic/alarms/bundled.js';
 import type { ServiceLayerCatalog } from 
'../../logic/services/service-layer-catalog.js';
 import {
   ALARMS_WINDOW_OPTIONS,
@@ -77,7 +79,10 @@ export function registerAlarmsConfigRoutes(
     '/api/alarms/config',
     { preHandler: auth },
     async (_req: FastifyRequest, reply: FastifyReply) => {
-      const cfg = await deps.store.load();
+      // Readonly mode serves the alert page-setup from the bundle like every
+      // other template; the local store (a prior live session's edits) is not
+      // the source here. Writes are denied by the route-policy backstop.
+      const cfg = isTemplateReadOnly() ? loadBundledAlertPageSetup() : await 
deps.store.load();
       return reply.send(cfg);
     },
   );
diff --git a/apps/bff/src/rbac/route-policy.test.ts 
b/apps/bff/src/rbac/route-policy.test.ts
index 67f3242..750dc3a 100644
--- a/apps/bff/src/rbac/route-policy.test.ts
+++ b/apps/bff/src/rbac/route-policy.test.ts
@@ -27,12 +27,14 @@ describe('isTemplateWriteRoute — which routes the readonly 
backstop covers', (
     expect(isTemplateWriteRoute('POST', 
'/api/admin/templates/sync-all')).toBe(true);
     expect(isTemplateWriteRoute('POST', 
'/api/admin/overview-templates')).toBe(true);
     expect(isTemplateWriteRoute('DELETE', 
'/api/admin/overview-templates/x')).toBe(true);
+    // The alert page-setup (horizon.alert.page-setup) is config-surface too.
+    expect(isTemplateWriteRoute('POST', '/api/alarms/config')).toBe(true);
   });
-  it('does NOT match reads, nor non-template writes (runtime-rule / local 
state stay editable)', () => {
+  it('does NOT match reads, nor non-config writes (runtime-rule / live-debug 
stay editable)', () => {
     expect(isTemplateWriteRoute('GET', 
'/api/admin/templates/sync-status')).toBe(false);
     expect(isTemplateWriteRoute('HEAD', 
'/api/admin/templates/sync-status')).toBe(false);
+    expect(isTemplateWriteRoute('GET', '/api/alarms/config')).toBe(false); // 
read stays open
     expect(isTemplateWriteRoute('POST', '/api/rule')).toBe(false); // runtime 
rule
-    expect(isTemplateWriteRoute('POST', '/api/alarms/config')).toBe(false); // 
local state
     expect(isTemplateWriteRoute('POST', '/api/debug/session')).toBe(false); // 
live-debug
   });
 });
diff --git a/apps/bff/src/rbac/route-policy.ts 
b/apps/bff/src/rbac/route-policy.ts
index fb3d617..257a74b 100644
--- a/apps/bff/src/rbac/route-policy.ts
+++ b/apps/bff/src/rbac/route-policy.ts
@@ -33,13 +33,19 @@ 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). */
+/** A config-surface write. The template routes push to OAP's ui_template 
store;
+ *  `/api/alarms/config` writes the alert page-setup (the 
`horizon.alert.page-setup`
+ *  template, file-backed). In `templates.mode=readonly` the whole config 
surface
+ *  is served from the local bundle and read-only, 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). */
 export 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');
+  return (
+    url.startsWith('/api/admin/templates') ||
+    url.startsWith('/api/admin/overview-templates') ||
+    url.startsWith('/api/alarms/config')
+  );
 }
 export async function denyTemplateWriteWhenReadOnly(
   _req: FastifyRequest,
diff --git a/apps/ui/src/features/admin/_shared/SyncStatusBanner.vue 
b/apps/ui/src/features/admin/_shared/SyncStatusBanner.vue
index 69a52c6..8f84279 100644
--- a/apps/ui/src/features/admin/_shared/SyncStatusBanner.vue
+++ b/apps/ui/src/features/admin/_shared/SyncStatusBanner.vue
@@ -124,13 +124,14 @@ 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`. */
+/* Deliberate read-only (templates.mode=readonly) — not an error (no red), but
+ * warning-yellow so it's unmistakable that the whole surface is uneditable; a
+ * muted gray strip was too easy to miss next to the green per-row chips. */
 .sbb--readonly {
-  border-color: var(--sw-muted, #4a525c);
-  background: rgba(255, 255, 255, 0.03);
+  border-color: var(--sw-warn, #b88500);
+  background: rgba(184, 133, 0, 0.10);
 }
-.sbb--readonly .sbb__chip { background: var(--sw-muted, #4a525c); color: #fff; 
}
+.sbb--readonly .sbb__chip { background: var(--sw-warn, #b88500); color: 
#1a1a1a; }
 .sbb__row {
   display: flex;
   gap: 12px;
diff --git a/horizon.example.yaml b/horizon.example.yaml
index d3c045e..4da1e8f 100644
--- a/horizon.example.yaml
+++ b/horizon.example.yaml
@@ -58,6 +58,9 @@ oap:
   # 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 MQE host/port override (defaults to the query host). JSON env, 
e.g.
+  # HORIZON_OAP_MQE='{"host":"mqe.internal","port":12800}'.
+  mqe: ${HORIZON_OAP_MQE:null}
   # Optional basic-auth for outbound OAP calls. JSON env, e.g.
   # HORIZON_OAP_AUTH='{"username":"skywalking","password":"skywalking"}'.
   auth: ${HORIZON_OAP_AUTH:null}

Reply via email to