This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git


The following commit(s) were added to refs/heads/main by this push:
     new 528e419  feat: OAP UI-template sync (v0.4.0) — overview / layer / 
alert dashboards live on OAP
528e419 is described below

commit 528e4194563d0cf9db88d6902b2ab6cc00f8c707
Author: Wu Sheng <[email protected]>
AuthorDate: Tue May 19 12:58:48 2026 +0800

    feat: OAP UI-template sync (v0.4.0) — overview / layer / alert dashboards 
live on OAP
    
    Bundled JSON becomes a seed + read-only fallback. OAP's 
/ui-management/templates
    REST surface (on the admin port, v11) is the runtime source of truth — 
operator
    edits land there via Save, and Horizon overlays remote-wins at render time.
    
    Reserved name convention on the OAP `name` field — prevents collision with 
other
    UIs sharing the same OAP:
    
      horizon.overview.<id>     e.g. horizon.overview.services
      horizon.layer.<KEY>       e.g. horizon.layer.GENERAL
      horizon.alert.page-setup  singleton
    
    BFF:
      - packages/api-client/src/ui-template.ts: typed OAP REST client
        (list / create / update / disable).
      - apps/bff/src/logic/templates/{names,sync,aggregator}.ts: envelope wrap +
        canonical JSON serializer for byte-equal compare; sync orchestrator with
        30s single-flight cache, one-shot boot-time seed (the ONLY implicit 
write
        to OAP), graceful when admin is unreachable.
      - apps/bff/src/http/config/bundle.ts: /api/configs/bundle now overlays
        remote-wins per template and ships syncStatus.badges to the UI.
      - apps/bff/src/http/admin/template-sync.ts: GET 
/api/admin/templates/sync-status,
        POST /api/admin/templates/save (OAP-backed; 409 when unreachable),
        POST /api/admin/templates/resync, POST 
/api/admin/templates/:name/push-bundled.
      - apps/bff/src/bundled_templates/alert/page-setup.json + 
logic/alarms/bundled.ts:
        alert page-setup as the third template family.
      - apps/bff/src/server.ts: boot-time seed fires after listen, non-fatal on
        unreachable.
    
    UI:
      - apps/ui/src/utils/debug.ts: scoped console.debug, gated by
        localStorage['horizon:debug'] or ?debug=<scope>.
      - apps/ui/src/api/scopes/configs.ts (+ template-sync.ts): typed sync 
envelope
        on ConfigBundle; bumped localStorage key v1 → v2.
      - apps/ui/src/features/admin/_shared: shared composable (useTemplateSync) 
+
        SyncStatusBanner + TemplateStatusBadge + TemplateDiffModal (Monaco JSON
        side-by-side + destructive-confirm reset to bundled — operator must type
        the template key to arm).
      - All three admin pages (overview / layer / alert-page-setup): banner up 
top,
        per-row status chip, Save → OAP via /api/admin/templates/save, "Show 
diff
        & reset" on diverged rows, controls disabled when OAP unreachable. Fixed
        the idx=0 auto-load bug on overview admin (watch(immediate:true) was
        racing with vue-query's cached resolution; switched to watchEffect).
      - apps/ui/src/features/operate/_shared/MonacoDiff.vue: optional `language`
        prop (default yaml — backwards compatible) so the template modal can 
pass
        `json`.
    
    Behavior:
      - OAP reachable: Save goes to OAP. Diverged rows surface a diff + reset
        affordance. Disabled OAP templates are dropped from render entirely.
      - OAP unreachable: every admin page goes read-only with a red banner;
        every Save / Create / Delete is disabled; render falls back to bundled.
      - Boot-time seed POSTs any bundled template missing on OAP. Runtime sync
        is read-only — operator action is required for any other OAP write.
    
    Version: 0.4.0 across package.json files + server.ts HORIZON_VERSION 
default.
    
    Validation status:
      - Validated against demo.skywalking.apache.org (no admin port): boot logs
        the unreachable warning gracefully, /api/configs/bundle returns
        unreachable=true with all 45 badges in bundled-fallback, Save returns
        409 oap_unreachable. Type-checks clean on BFF + UI.
      - NOT validated end-to-end: the write path against a live OAP with
        :17128 reachable, the diff modal Monaco rendering against real diverged
        rows, byte-equal round-trip of the canonical configuration string
        through OAP storage. Needs a local OAP 11.x with SW_ADMIN_SERVER=default
        to confirm before relying on these in production.
---
 apps/bff/package.json                              |   2 +-
 .../src/bundled_templates/alert/page-setup.json    |   5 +
 apps/bff/src/client/index.ts                       |   8 +
 apps/bff/src/http/admin/template-sync.ts           | 210 ++++++++++++
 apps/bff/src/http/config/bundle.ts                 | 182 ++++++++--
 apps/bff/src/logic/alarms/bundled.ts               |  63 ++++
 apps/bff/src/logic/templates/aggregator.ts         |  46 +++
 apps/bff/src/logic/templates/names.ts              | 135 ++++++++
 apps/bff/src/logic/templates/sync.ts               | 376 +++++++++++++++++++++
 apps/bff/src/rbac/route-policy.ts                  |   9 +
 apps/bff/src/server.ts                             |  51 ++-
 apps/ui/package.json                               |   2 +-
 apps/ui/src/api/client.ts                          |   2 +
 apps/ui/src/api/scopes/configs.ts                  |  38 +++
 apps/ui/src/api/scopes/template-sync.ts            |  78 +++++
 apps/ui/src/controls/configBundle.ts               |  31 +-
 .../features/admin/_shared/SyncStatusBanner.vue    | 119 +++++++
 .../features/admin/_shared/TemplateDiffModal.vue   | 227 +++++++++++++
 .../features/admin/_shared/TemplateStatusBadge.vue | 100 ++++++
 .../src/features/admin/_shared/useTemplateSync.ts  | 145 ++++++++
 .../admin/alert-page/AlertPageSetupView.vue        |  64 +++-
 .../admin/layer-templates/LayerDashboardsAdmin.vue |  73 +++-
 .../overview-templates/OverviewTemplatesAdmin.vue  | 112 ++++--
 .../ui/src/features/operate/_shared/MonacoDiff.vue |  23 +-
 apps/ui/src/utils/debug.ts                         |  92 +++++
 package.json                                       |   2 +-
 packages/api-client/package.json                   |   2 +-
 packages/api-client/src/index.ts                   |   7 +
 packages/api-client/src/ui-template.ts             | 138 ++++++++
 packages/design-tokens/package.json                |   2 +-
 packages/templates/package.json                    |   2 +-
 31 files changed, 2248 insertions(+), 98 deletions(-)

diff --git a/apps/bff/package.json b/apps/bff/package.json
index b7b50d4..2ab2d2f 100644
--- a/apps/bff/package.json
+++ b/apps/bff/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@skywalking-horizon-ui/bff",
-  "version": "0.1.0",
+  "version": "0.4.0",
   "private": true,
   "type": "module",
   "main": "dist/server.js",
diff --git a/apps/bff/src/bundled_templates/alert/page-setup.json 
b/apps/bff/src/bundled_templates/alert/page-setup.json
new file mode 100644
index 0000000..8661ab9
--- /dev/null
+++ b/apps/bff/src/bundled_templates/alert/page-setup.json
@@ -0,0 +1,5 @@
+{
+  "pinnedLayers": ["GENERAL", "MESH"],
+  "defaultWindowMs": 1200000,
+  "overviewAlarmsLimit": 200
+}
diff --git a/apps/bff/src/client/index.ts b/apps/bff/src/client/index.ts
index 8e4ef8d..f943e59 100644
--- a/apps/bff/src/client/index.ts
+++ b/apps/bff/src/client/index.ts
@@ -29,6 +29,7 @@ import {
   OalClient,
   RuntimeRuleClient,
   StatusClient,
+  UITemplateClient,
   type FetchLike,
 } from '@skywalking-horizon-ui/api-client';
 import type { HorizonConfig } from '../config/schema.js';
@@ -61,6 +62,10 @@ export interface OapClients {
   /** Alarm-status client — `/status/alarm/*` admin REST. OAP itself
    *  aggregates cluster-wide; one fire is enough. */
   alarmStatus(): AlarmStatusClient;
+  /** UI-template REST client — `/ui-management/templates*` on the admin
+   *  port. Read + write for dashboard / page-setup blobs that Horizon
+   *  syncs to OAP under the `horizon.*` name prefix. */
+  uiTemplate(): UITemplateClient;
   /** The configured admin URL (single). DNS-resolved on demand by
    *  features that want per-node visibility (live-debug status). */
   adminUrl(): string;
@@ -115,6 +120,9 @@ export function buildOapClients(
     alarmStatus(): AlarmStatusClient {
       return new AlarmStatusClient({ adminUrl: primaryUrl, fetch, timeoutMs, 
headers });
     },
+    uiTemplate(): UITemplateClient {
+      return new UITemplateClient({ adminUrl: primaryUrl, fetch, timeoutMs, 
headers });
+    },
     adminUrl(): string {
       return config.oap.adminUrl;
     },
diff --git a/apps/bff/src/http/admin/template-sync.ts 
b/apps/bff/src/http/admin/template-sync.ts
new file mode 100644
index 0000000..07a9854
--- /dev/null
+++ b/apps/bff/src/http/admin/template-sync.ts
@@ -0,0 +1,210 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Template sync surface for the admin pages.
+ *
+ *   GET  /api/admin/templates/sync-status           — full merged map
+ *                                                     (bundled + remote +
+ *                                                     status per name).
+ *                                                     Pages render banners
+ *                                                     and per-row diffs
+ *                                                     from this body.
+ *
+ *   POST /api/admin/templates/:name/push-bundled    — operator wants the
+ *                                                     bundled copy of this
+ *                                                     template to overwrite
+ *                                                     what OAP has. 409 if
+ *                                                     OAP unreachable;
+ *                                                     forces a resync on
+ *                                                     success.
+ *
+ *   POST /api/admin/templates/save                  — write a template
+ *                                                     (Save in the admin
+ *                                                     UI). Body: { name,
+ *                                                     content }. PUTs the
+ *                                                     envelope to OAP. 409
+ *                                                     when OAP unreachable
+ *                                                     (the UI banner should
+ *                                                     have prevented this
+ *                                                     call, but verify
+ *                                                     server-side).
+ *
+ *   POST /api/admin/templates/resync                — invalidate the 30s
+ *                                                     cache; the next
+ *                                                     bundle pull triggers
+ *                                                     a fresh OAP probe.
+ */
+
+import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
+import type { UITemplateClient } from '@skywalking-horizon-ui/api-client';
+import type { ConfigSource } from '../../config/loader.js';
+import type { SessionStore } from '../../user/sessions.js';
+import { requireAuth } from '../../user/middleware.js';
+import {
+  getSyncStatus,
+  resync,
+  type SyncStatus,
+} from '../../logic/templates/sync.js';
+import { iterateBundledTemplates } from '../../logic/templates/aggregator.js';
+import {
+  buildEnvelope,
+  parseName,
+  serializeEnvelope,
+  type TemplateKind,
+} from '../../logic/templates/names.js';
+import { logger } from '../../logger.js';
+
+export interface TemplateSyncAdminDeps {
+  config: ConfigSource;
+  sessions: SessionStore;
+  uiTemplateClient: () => UITemplateClient;
+}
+
+export function registerTemplateSyncAdminRoutes(
+  app: FastifyInstance,
+  deps: TemplateSyncAdminDeps,
+): void {
+  const auth = requireAuth(deps);
+
+  app.get(
+    '/api/admin/templates/sync-status',
+    { preHandler: auth },
+    async (_req: FastifyRequest, reply: FastifyReply) => {
+      const status = await loadStatus(deps);
+      return reply.send(status);
+    },
+  );
+
+  app.post(
+    '/api/admin/templates/resync',
+    { preHandler: auth },
+    async (_req: FastifyRequest, reply: FastifyReply) => {
+      resync();
+      const status = await loadStatus(deps);
+      return reply.send(status);
+    },
+  );
+
+  app.post<{
+    Params: { name: string };
+  }>(
+    '/api/admin/templates/:name/push-bundled',
+    { preHandler: auth },
+    async (req, reply) => {
+      const { name } = req.params;
+      const parsed = parseName(name);
+      if (!parsed) {
+        return reply.code(400).send({
+          code: 'invalid_template_name',
+          message: `expected horizon.<overview|layer|alert>.<key>, got 
${JSON.stringify(name)}`,
+        });
+      }
+      const status = await loadStatus(deps);
+      if (status.unreachable) {
+        return reply.code(409).send({
+          code: 'oap_unreachable',
+          message: 'OAP admin port unreachable — templates are read-only',
+        });
+      }
+      const row = status.rows.find((r) => r.name === name);
+      if (!row?.bundled) {
+        return reply.code(404).send({
+          code: 'no_bundled',
+          message: `no bundled template for ${name} — nothing to push`,
+        });
+      }
+      try {
+        if (row.remote) {
+          await deps.uiTemplateClient().update(row.remote.id, 
row.bundled.configuration);
+        } else {
+          await deps.uiTemplateClient().create(row.bundled.configuration);
+        }
+        resync();
+        const fresh = await loadStatus(deps);
+        return reply.send(fresh);
+      } catch (err) {
+        logger.warn({ err: errMsg(err), name }, 'push-bundled to OAP failed');
+        return reply.code(502).send({
+          code: 'oap_write_failed',
+          message: errMsg(err),
+        });
+      }
+    },
+  );
+
+  app.post<{
+    Body: {
+      name?: string;
+      content?: unknown;
+    };
+  }>('/api/admin/templates/save', { preHandler: auth }, async (req, reply) => {
+    const { name, content } = req.body ?? {};
+    if (typeof name !== 'string' || content === undefined) {
+      return reply.code(400).send({
+        code: 'invalid_save_body',
+        message: 'body must be { name: string, content: object }',
+      });
+    }
+    const parsed = parseName(name);
+    if (!parsed) {
+      return reply.code(400).send({
+        code: 'invalid_template_name',
+        message: `expected horizon.<overview|layer|alert>.<key>, got 
${JSON.stringify(name)}`,
+      });
+    }
+    const status = await loadStatus(deps);
+    if (status.unreachable) {
+      return reply.code(409).send({
+        code: 'oap_unreachable',
+        message: 'OAP admin port unreachable — templates are read-only',
+      });
+    }
+    const envelope = buildEnvelope(parsed.kind as TemplateKind, parsed.key, 
content);
+    const configuration = serializeEnvelope(envelope);
+    const existing = status.rows.find((r) => r.name === name);
+    try {
+      if (existing?.remote) {
+        await deps.uiTemplateClient().update(existing.remote.id, 
configuration);
+      } else {
+        await deps.uiTemplateClient().create(configuration);
+      }
+      resync();
+      const fresh = await loadStatus(deps);
+      return reply.send(fresh);
+    } catch (err) {
+      logger.warn({ err: errMsg(err), name }, 'save to OAP failed');
+      return reply.code(502).send({
+        code: 'oap_write_failed',
+        message: errMsg(err),
+      });
+    }
+  });
+}
+
+async function loadStatus(deps: TemplateSyncAdminDeps): Promise<SyncStatus> {
+  return getSyncStatus({
+    client: deps.uiTemplateClient(),
+    bundled: () => iterateBundledTemplates(),
+    logger,
+  });
+}
+
+function errMsg(err: unknown): string {
+  if (err instanceof Error) return err.message;
+  return String(err);
+}
diff --git a/apps/bff/src/http/config/bundle.ts 
b/apps/bff/src/http/config/bundle.ts
index d68dd2c..aec1571 100644
--- a/apps/bff/src/http/config/bundle.ts
+++ b/apps/bff/src/http/config/bundle.ts
@@ -19,61 +19,81 @@
  * `GET /api/configs/bundle` — preload payload for the SPA. Returns the
  * dashboard widget set for every (layer, scope) pair PLUS the full
  * overview-dashboard list in one round-trip so the SPA can cache the
- * lot in localStorage and serve config lookups synchronously after
- * the first visit. The body excludes runtime data (no MQE evaluation
- * happens here) — the SPA still fires the dashboard/landing routes
- * to populate widget values.
+ * lot in localStorage and serve config lookups synchronously after the
+ * first visit.
  *
- * Versioning: `etag` is a stable hash of the payload (md5 of the
- * JSON shape). The SPA passes it back as `If-None-Match` on
- * subsequent loads; an unchanged bundle returns 304 so the client
- * keeps using its localStorage copy.
+ * Layer / overview content reflects the merged view from the sync
+ * orchestrator: when OAP holds a (non-disabled) remote template under
+ * the matching `horizon.*` name, that wins over bundled — operators
+ * edit OAP, the BFF reflects what they edited. When OAP admin is
+ * unreachable OR the remote is missing, bundled is used. Disabled
+ * templates are dropped from the bundle entirely.
+ *
+ * `syncStatus` carries per-template badges for the admin pages so the
+ * SPA can render `synced / diverged / disabled / remote-only /
+ * bundled-fallback` chips and the OAP-unreachable read-only banner
+ * without a second round-trip.
+ *
+ * Versioning: `etag` is a stable hash of the payload (md5 of the JSON
+ * shape). When the sync status changes (operator edited a template on
+ * OAP, cache expired, etc.) the etag changes too — the SPA refetches
+ * automatically.
  */
 
 import { createHash } from 'node:crypto';
 import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
-import type { DashboardWidget, OverviewDashboard } from 
'@skywalking-horizon-ui/api-client';
+import type {
+  DashboardWidget,
+  OverviewDashboard,
+  UITemplateClient,
+} from '@skywalking-horizon-ui/api-client';
 import type { ConfigSource } from '../../config/loader.js';
 import type { SessionStore } from '../../user/sessions.js';
 import { requireAuth } from '../../user/middleware.js';
-import { allLayerTemplates, widgetsForScope } from 
'../../logic/layers/loader.js';
+import {
+  allLayerTemplates,
+  widgetsForScope,
+  type LayerTemplate,
+} from '../../logic/layers/loader.js';
 import { loadOverviewDashboards } from '../../logic/overview/loader.js';
+import {
+  getSyncStatus,
+  type TemplateRow,
+} from '../../logic/templates/sync.js';
+import { iterateBundledTemplates } from '../../logic/templates/aggregator.js';
+import { formatName, parseEnvelope } from '../../logic/templates/names.js';
+import { logger } from '../../logger.js';
 
 export interface ConfigBundleDeps {
   config: ConfigSource;
   sessions: SessionStore;
+  uiTemplateClient: () => UITemplateClient;
 }
 
 type ScopeMap = Partial<Record<'service' | 'instance' | 'endpoint', 
DashboardWidget[]>>;
+
+/** What the admin pages need to render their banners + per-row badges.
+ *  The full bundled / remote configuration strings are intentionally
+ *  omitted here (they'd bloat the bundle 5x); the admin pages fetch
+ *  them on demand from `/api/admin/templates/sync-status`. */
+export interface BundleSyncStatus {
+  unreachable: boolean;
+  lastSuccessfulSyncAt: number | null;
+  generatedAt: number;
+  badges: Array<{
+    name: string;
+    kind: 'overview' | 'layer' | 'alert';
+    key: string;
+    status: TemplateRow['status'];
+  }>;
+}
+
 export interface ConfigBundle {
   etag: string;
   generatedAt: number;
   layers: Record<string, ScopeMap>;
   overviews: OverviewDashboard[];
-}
-
-let cached: ConfigBundle | null = null;
-let cachedSourceVersion = -1;
-function bundle(sourceVersion: number): ConfigBundle {
-  if (cached && cachedSourceVersion === sourceVersion) return cached;
-  const layers: Record<string, ScopeMap> = {};
-  for (const tpl of allLayerTemplates()) {
-    const scopes: ScopeMap = {};
-    for (const scope of ['service', 'instance', 'endpoint'] as const) {
-      const ws = widgetsForScope(tpl, scope);
-      // Only include scopes that actually have widgets — keeps the
-      // bundle tight (so11y_java_agent contributes only `instance`,
-      // mesh_dp the same, etc.).
-      if (ws.length > 0) scopes[scope] = ws;
-    }
-    layers[tpl.key.toLowerCase()] = scopes;
-  }
-  const overviews = loadOverviewDashboards();
-  const body = { layers, overviews };
-  const etag = createHash('md5').update(JSON.stringify(body)).digest('hex');
-  cached = { etag, generatedAt: Date.now(), ...body };
-  cachedSourceVersion = sourceVersion;
-  return cached;
+  syncStatus: BundleSyncStatus;
 }
 
 export function registerConfigBundleRoute(app: FastifyInstance, deps: 
ConfigBundleDeps): void {
@@ -82,8 +102,7 @@ export function registerConfigBundleRoute(app: 
FastifyInstance, deps: ConfigBund
     '/api/configs/bundle',
     { preHandler: auth },
     async (req: FastifyRequest, reply: FastifyReply) => {
-      const sourceVersion = (deps.config as { version?: number }).version ?? 0;
-      const body = bundle(sourceVersion);
+      const body = await buildBundle(deps);
       const inm = req.headers['if-none-match'];
       if (typeof inm === 'string' && inm === body.etag) {
         return reply.code(304).send();
@@ -94,3 +113,94 @@ export function registerConfigBundleRoute(app: 
FastifyInstance, deps: ConfigBund
     },
   );
 }
+
+async function buildBundle(deps: ConfigBundleDeps): Promise<ConfigBundle> {
+  const sync = await getSyncStatus({
+    client: deps.uiTemplateClient(),
+    bundled: () => iterateBundledTemplates(),
+    logger,
+  });
+
+  const remoteByName = new Map<string, TemplateRow>();
+  for (const row of sync.rows) remoteByName.set(row.name, row);
+
+  const layers: Record<string, ScopeMap> = {};
+  for (const tpl of allLayerTemplates()) {
+    const effective = pickLayerContent(tpl, remoteByName);
+    if (effective === null) continue; // disabled
+    const scopes: ScopeMap = {};
+    for (const scope of ['service', 'instance', 'endpoint'] as const) {
+      const ws = widgetsForScope(effective, scope);
+      if (ws.length > 0) scopes[scope] = ws;
+    }
+    layers[effective.key.toLowerCase()] = scopes;
+  }
+
+  const overviews: OverviewDashboard[] = [];
+  for (const dash of loadOverviewDashboards()) {
+    const effective = pickOverviewContent(dash, remoteByName);
+    if (effective === null) continue; // disabled
+    overviews.push(effective);
+  }
+
+  const syncStatus: BundleSyncStatus = {
+    unreachable: sync.unreachable,
+    lastSuccessfulSyncAt: sync.lastSuccessfulSyncAt,
+    generatedAt: sync.generatedAt,
+    badges: sync.rows.map((r) => ({
+      name: r.name,
+      kind: r.kind,
+      key: r.key,
+      status: r.status,
+    })),
+  };
+
+  const body = { layers, overviews, syncStatus };
+  const etag = createHash('md5').update(JSON.stringify(body)).digest('hex');
+  return { etag, generatedAt: Date.now(), ...body };
+}
+
+/** Choose remote envelope.content over bundled when the row is synced or
+ *  diverged. `disabled` returns null (drop from bundle). `bundled-fallback`,
+ *  `unknown`, and `remote-only` fall to bundled (remote-only is impossible
+ *  here because the bundled iterator would have included it — but be
+ *  defensive). */
+function pickLayerContent(
+  bundled: LayerTemplate,
+  byName: Map<string, TemplateRow>,
+): LayerTemplate | null {
+  const row = byName.get(formatName('layer', bundled.key));
+  if (!row) return bundled;
+  if (row.status === 'disabled') return null;
+  if (row.effective === 'remote' && row.remote) {
+    const env = parseEnvelope(row.remote.configuration);
+    if (env && isLayerLike(env.content)) {
+      return env.content as LayerTemplate;
+    }
+  }
+  return bundled;
+}
+
+function pickOverviewContent(
+  bundled: OverviewDashboard,
+  byName: Map<string, TemplateRow>,
+): OverviewDashboard | null {
+  const row = byName.get(formatName('overview', bundled.id));
+  if (!row) return bundled;
+  if (row.status === 'disabled') return null;
+  if (row.effective === 'remote' && row.remote) {
+    const env = parseEnvelope(row.remote.configuration);
+    if (env && isOverviewLike(env.content)) {
+      return env.content as OverviewDashboard;
+    }
+  }
+  return bundled;
+}
+
+function isLayerLike(v: unknown): boolean {
+  return !!v && typeof v === 'object' && 'key' in (v as Record<string, 
unknown>);
+}
+
+function isOverviewLike(v: unknown): boolean {
+  return !!v && typeof v === 'object' && 'id' in (v as Record<string, 
unknown>);
+}
diff --git a/apps/bff/src/logic/alarms/bundled.ts 
b/apps/bff/src/logic/alarms/bundled.ts
new file mode 100644
index 0000000..b237197
--- /dev/null
+++ b/apps/bff/src/logic/alarms/bundled.ts
@@ -0,0 +1,63 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Bundled defaults for the Alert page-setup template. Lives next to the
+ * layer and overview bundled JSON so the sync orchestrator can treat all
+ * three families uniformly — there's exactly one alert template
+ * (`horizon.alert.page-setup`) and it ships with the BFF.
+ *
+ * Resolved via the same path-search as the layer loader: dev source tree
+ * first, then the packaged dist layout. Keeps the file readable and
+ * editable in dev without forcing a rebuild step.
+ */
+
+import { existsSync, readFileSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import type { AlarmsConfig } from './store.js';
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+
+let cached: AlarmsConfig | null = null;
+
+export function loadBundledAlertPageSetup(): AlarmsConfig {
+  if (cached) return cached;
+  const file = locateAlertBundle();
+  const raw = readFileSync(file, 'utf8');
+  const parsed = JSON.parse(raw) as AlarmsConfig;
+  cached = parsed;
+  return parsed;
+}
+
+export function invalidateAlertBundleCache(): void {
+  cached = null;
+}
+
+function locateAlertBundle(): string {
+  const candidates = [
+    resolve(HERE, '../../bundled_templates/alert/page-setup.json'),
+    resolve(HERE, '../bundled_templates/alert/page-setup.json'),
+    resolve(HERE, '../../../bundled_templates/alert/page-setup.json'),
+  ];
+  for (const c of candidates) {
+    if (existsSync(c)) return c;
+  }
+  throw new Error(
+    `bundled alert page-setup not found in: ${candidates.join(', ')}`,
+  );
+}
diff --git a/apps/bff/src/logic/templates/aggregator.ts 
b/apps/bff/src/logic/templates/aggregator.ts
new file mode 100644
index 0000000..d15b196
--- /dev/null
+++ b/apps/bff/src/logic/templates/aggregator.ts
@@ -0,0 +1,46 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Bridges the per-kind bundled loaders (layers loader, overview loader,
+ * alert bundled file) into the sync orchestrator's `BundledTemplate`
+ * iterator. One function, three callsites — keeps the orchestrator from
+ * importing each loader directly.
+ *
+ * Re-reads every call so the layer-template fs.watch + overview cache
+ * invalidation pick up edits without requiring an orchestrator restart.
+ */
+
+import { allLayerTemplates } from '../layers/loader.js';
+import { loadOverviewDashboards } from '../overview/loader.js';
+import { loadBundledAlertPageSetup } from '../alarms/bundled.js';
+import { ALERT_PAGE_SETUP_KEY, type TemplateKind } from './names.js';
+import type { BundledTemplate } from './sync.js';
+
+export function* iterateBundledTemplates(): IterableIterator<BundledTemplate> {
+  for (const tpl of allLayerTemplates()) {
+    yield { kind: 'layer' satisfies TemplateKind, key: tpl.key, content: tpl };
+  }
+  for (const dash of loadOverviewDashboards()) {
+    yield { kind: 'overview' satisfies TemplateKind, key: dash.id, content: 
dash };
+  }
+  yield {
+    kind: 'alert' satisfies TemplateKind,
+    key: ALERT_PAGE_SETUP_KEY,
+    content: loadBundledAlertPageSetup(),
+  };
+}
diff --git a/apps/bff/src/logic/templates/names.ts 
b/apps/bff/src/logic/templates/names.ts
new file mode 100644
index 0000000..f15bf97
--- /dev/null
+++ b/apps/bff/src/logic/templates/names.ts
@@ -0,0 +1,135 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Reserved-name convention for Horizon templates stored on OAP via
+ * `/ui-management/templates*`. The OAP `configuration` field is opaque
+ * to OAP — Horizon wraps each bundled template in a small envelope so
+ * the BFF can read the name without parsing the inner content.
+ *
+ *   { "name": "horizon.<kind>.<key>", "kind": "...", "version": 1, "content": 
{...} }
+ *
+ * Naming:
+ *   - `horizon.overview.<id>`       — overview dashboards (e.g. `services`, 
`mesh`)
+ *   - `horizon.layer.<KEY>`         — layer dashboards (e.g. `GENERAL`, `K8S`)
+ *   - `horizon.alert.page-setup`    — alert page setup (singleton)
+ *
+ * The `horizon.` prefix keeps Horizon's templates cleanly separated from
+ * any other UI (notably booster-ui) that may share the same OAP. Names
+ * that don't match the pattern are ignored — the BFF logs them at debug
+ * level on each sync but never deletes or rewrites them.
+ *
+ * Equality for sync purposes is byte-exact on the *envelope* serialized
+ * by `serializeEnvelope` below. Both ends MUST use this serializer so
+ * key order is stable.
+ */
+
+export type TemplateKind = 'overview' | 'layer' | 'alert';
+
+export const TEMPLATE_KINDS: readonly TemplateKind[] = ['overview', 'layer', 
'alert'] as const;
+
+/** Single alert template key — alert page-setup is a singleton. */
+export const ALERT_PAGE_SETUP_KEY = 'page-setup' as const;
+
+const NAME_RE = /^horizon\.(overview|layer|alert)\.([A-Za-z0-9_-]+)$/;
+
+export interface ParsedName {
+  kind: TemplateKind;
+  key: string;
+}
+
+export function formatName(kind: TemplateKind, key: string): string {
+  if (!/^[A-Za-z0-9_-]+$/.test(key)) {
+    throw new Error(`invalid template key for kind=${kind}: 
${JSON.stringify(key)}`);
+  }
+  return `horizon.${kind}.${key}`;
+}
+
+export function parseName(name: string): ParsedName | null {
+  const m = NAME_RE.exec(name);
+  if (!m) return null;
+  return { kind: m[1] as TemplateKind, key: m[2]! };
+}
+
+/** Envelope shape stored as the OAP `configuration` string. */
+export interface TemplateEnvelope<T = unknown> {
+  name: string;
+  kind: TemplateKind;
+  /** Schema version for the envelope itself, not the inner content.
+   *  Bump when this wrapper changes shape; never used to gate logic. */
+  version: number;
+  content: T;
+}
+
+export const ENVELOPE_VERSION = 1 as const;
+
+export function buildEnvelope<T>(kind: TemplateKind, key: string, content: T): 
TemplateEnvelope<T> {
+  return {
+    name: formatName(kind, key),
+    kind,
+    version: ENVELOPE_VERSION,
+    content,
+  };
+}
+
+/** Stable JSON serializer — sorts object keys recursively so two
+ *  envelopes with identical content always serialize byte-identically.
+ *  OAP stores the string verbatim; if we don't normalize, every round
+ *  trip looks "diverged." Arrays preserve order (they're meaningful). */
+export function serializeEnvelope(envelope: TemplateEnvelope): string {
+  return canonicalStringify(envelope);
+}
+
+function canonicalStringify(value: unknown): string {
+  if (value === null || typeof value !== 'object') {
+    return JSON.stringify(value);
+  }
+  if (Array.isArray(value)) {
+    return '[' + value.map((v) => canonicalStringify(v)).join(',') + ']';
+  }
+  const obj = value as Record<string, unknown>;
+  const keys = Object.keys(obj).sort();
+  const parts = keys
+    .filter((k) => obj[k] !== undefined)
+    .map((k) => `${JSON.stringify(k)}:${canonicalStringify(obj[k])}`);
+  return '{' + parts.join(',') + '}';
+}
+
+/** Parse a configuration string from OAP into a Horizon envelope. Returns
+ *  null for blobs that aren't ours (booster-ui shapes, hand-edited rows,
+ *  legacy data) — callers must skip those. */
+export function parseEnvelope(configuration: string): TemplateEnvelope | null {
+  let parsed: unknown;
+  try {
+    parsed = JSON.parse(configuration);
+  } catch {
+    return null;
+  }
+  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return 
null;
+  const e = parsed as Record<string, unknown>;
+  if (typeof e.name !== 'string' || typeof e.kind !== 'string') return null;
+  const parsedName = parseName(e.name);
+  if (!parsedName || parsedName.kind !== e.kind) return null;
+  if (typeof e.version !== 'number') return null;
+  if (e.content === undefined) return null;
+  return {
+    name: e.name,
+    kind: parsedName.kind,
+    version: e.version,
+    content: e.content,
+  };
+}
diff --git a/apps/bff/src/logic/templates/sync.ts 
b/apps/bff/src/logic/templates/sync.ts
new file mode 100644
index 0000000..1f35fb6
--- /dev/null
+++ b/apps/bff/src/logic/templates/sync.ts
@@ -0,0 +1,376 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * OAP UI-template sync orchestrator.
+ *
+ * Two entry points:
+ *   - `bootSeed()` runs ONCE at BFF startup. It lists OAP templates, seeds
+ *     any bundled template that's missing on OAP (this is the only path
+ *     that writes-on-absence), then returns the merged status.
+ *   - `getSyncStatus()` runs on-demand (every `/api/configs/bundle` hit).
+ *     30-second single-flight cache; pure read against OAP. Never writes,
+ *     even when remote is missing — operator action is required.
+ *
+ * When the admin port is unreachable:
+ *   - `bootSeed()` logs a warning and returns `unreachable: true` so the
+ *     server still finishes boot.
+ *   - `getSyncStatus()` returns `unreachable: true` so the UI shows the
+ *     read-only banner; render falls back to bundled.
+ *
+ * Equality is byte-exact on the canonicalized envelope (see `names.ts`).
+ * OAP stores the configuration string verbatim, so a round-trip without
+ * operator edit produces the same string.
+ */
+
+import type { Logger } from 'pino';
+import type { UITemplateClient } from '@skywalking-horizon-ui/api-client';
+import {
+  buildEnvelope,
+  parseEnvelope,
+  serializeEnvelope,
+  type TemplateKind,
+} from './names.js';
+
+export interface BundledTemplate {
+  kind: TemplateKind;
+  /** The key portion of the name (e.g. `services`, `GENERAL`, `page-setup`). 
*/
+  key: string;
+  /** Inner content. The orchestrator wraps this in the standard envelope. */
+  content: unknown;
+}
+
+export type TemplateStatus =
+  | 'synced'           // bundled present, remote present, byte-equal, not 
disabled
+  | 'diverged'         // both present, NOT byte-equal
+  | 'disabled'         // remote present but disabled — UI hides, no render
+  | 'remote-only'      // remote present, no bundled match (operator added or 
Horizon dropped it)
+  | 'bundled-fallback' // bundled present, remote absent at runtime (NOT 
seeded post-boot)
+  | 'unknown';         // shouldn't happen — defensive
+
+export interface TemplateRow {
+  name: string;
+  kind: TemplateKind;
+  key: string;
+  status: TemplateStatus;
+  /** What the renderer should use. `null` for `disabled`. */
+  effective: 'remote' | 'bundled' | null;
+  /** Remote-side detail. `null` when remote-absent. */
+  remote: { id: string; configuration: string; disabled: boolean } | null;
+  /** Bundled-side serialized envelope. `null` when bundled-absent 
(`remote-only`). */
+  bundled: { configuration: string } | null;
+}
+
+export interface SyncStatus {
+  /** 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). */
+  unreachable: boolean;
+  /** Epoch ms of the most-recent successful OAP probe. `null` when we
+   *  have never reached OAP since process start. */
+  lastSuccessfulSyncAt: number | null;
+  /** When this status snapshot was generated. */
+  generatedAt: number;
+  rows: TemplateRow[];
+}
+
+export interface SyncDeps {
+  client: UITemplateClient;
+  /** Pull every bundled template the BFF currently has loaded. */
+  bundled: () => Iterable<BundledTemplate>;
+  logger: Logger;
+  now?: () => number;
+}
+
+const CACHE_TTL_MS = 30_000;
+
+interface CacheEntry {
+  at: number;
+  status: SyncStatus;
+}
+
+/** Single-flight cache. Module-level state — one BFF process, one cache. */
+let cache: CacheEntry | null = null;
+let inFlight: Promise<SyncStatus> | null = null;
+let lastSuccessfulSyncAt: number | null = null;
+
+export function invalidateSyncCache(): void {
+  cache = null;
+}
+
+/** On-demand sync. Honors the 30s cache + single-flight. Never writes. */
+export async function getSyncStatus(deps: SyncDeps): Promise<SyncStatus> {
+  const now = (deps.now ?? Date.now)();
+  if (cache && now - cache.at < CACHE_TTL_MS) {
+    return cache.status;
+  }
+  if (inFlight) return inFlight;
+  inFlight = (async () => {
+    try {
+      const status = await runOnce(deps, { write: false });
+      cache = { at: (deps.now ?? Date.now)(), status };
+      return status;
+    } finally {
+      inFlight = null;
+    }
+  })();
+  return inFlight;
+}
+
+/** Boot-time sync: lists OAP, seeds any bundled template missing on OAP,
+ *  then re-lists to produce the merged status. This is the only path that
+ *  writes implicitly. Failures are non-fatal — boot continues, the UI
+ *  falls back to bundled. */
+export async function bootSeed(deps: SyncDeps): Promise<SyncStatus> {
+  const status = await runOnce(deps, { write: true });
+  cache = { at: (deps.now ?? Date.now)(), status };
+  return status;
+}
+
+/** Force the next caller of `getSyncStatus` to re-list OAP. No I/O here. */
+export function resync(): void {
+  invalidateSyncCache();
+}
+
+interface RunOptions {
+  /** When true, POST any bundled-only template back to OAP before
+   *  building the final status (boot seed). */
+  write: boolean;
+}
+
+async function runOnce(deps: SyncDeps, opts: RunOptions): Promise<SyncStatus> {
+  const now = (deps.now ?? Date.now)();
+  const bundledRows = buildBundledRows(deps.bundled());
+
+  let oapRows;
+  try {
+    oapRows = await deps.client.list();
+  } catch (err) {
+    deps.logger.warn(
+      { err: errMsg(err), action: opts.write ? 'boot-seed' : 'runtime-sync' },
+      'OAP UI-template list failed — rendering bundled, admin read-only',
+    );
+    return {
+      unreachable: true,
+      lastSuccessfulSyncAt,
+      generatedAt: now,
+      rows: bundledOnlyRows(bundledRows, 'bundled-fallback'),
+    };
+  }
+
+  lastSuccessfulSyncAt = (deps.now ?? Date.now)();
+  const parsedRemote = parseRemoteRows(oapRows, deps.logger);
+
+  if (opts.write) {
+    const seedCount = await seedMissing(deps, bundledRows, parsedRemote);
+    if (seedCount > 0) {
+      // Re-list so the merged view reflects the freshly-seeded UUIDs.
+      try {
+        const refreshed = await deps.client.list();
+        parsedRemote.clear();
+        for (const [k, v] of parseRemoteRows(refreshed, deps.logger)) 
parsedRemote.set(k, v);
+        deps.logger.info({ seedCount }, 'OAP UI-template boot seed complete');
+      } catch (err) {
+        deps.logger.warn(
+          { err: errMsg(err) },
+          'OAP UI-template re-list after seed failed — sync status may lag the 
next runtime pull',
+        );
+      }
+    }
+  }
+
+  const rows = mergeRows(bundledRows, parsedRemote);
+  return {
+    unreachable: false,
+    lastSuccessfulSyncAt,
+    generatedAt: now,
+    rows,
+  };
+}
+
+interface BundledRow {
+  name: string;
+  kind: TemplateKind;
+  key: string;
+  configuration: string;
+  content: unknown;
+}
+
+interface RemoteRow {
+  name: string;
+  kind: TemplateKind;
+  key: string;
+  id: string;
+  configuration: string;
+  disabled: boolean;
+}
+
+function buildBundledRows(bundled: Iterable<BundledTemplate>): Map<string, 
BundledRow> {
+  const out = new Map<string, BundledRow>();
+  for (const b of bundled) {
+    const envelope = buildEnvelope(b.kind, b.key, b.content);
+    out.set(envelope.name, {
+      name: envelope.name,
+      kind: b.kind,
+      key: b.key,
+      configuration: serializeEnvelope(envelope),
+      content: b.content,
+    });
+  }
+  return out;
+}
+
+function parseRemoteRows(
+  rows: Array<{ id: string; configuration: string; disabled: boolean }>,
+  logger: Logger,
+): Map<string, RemoteRow> {
+  const out = new Map<string, RemoteRow>();
+  let skipped = 0;
+  for (const r of rows) {
+    const env = parseEnvelope(r.configuration);
+    if (!env) {
+      skipped++;
+      continue;
+    }
+    out.set(env.name, {
+      name: env.name,
+      kind: env.kind,
+      key: env.name.split('.').slice(2).join('.'),
+      id: r.id,
+      configuration: r.configuration,
+      disabled: r.disabled,
+    });
+  }
+  if (skipped > 0) {
+    logger.debug(
+      { skipped },
+      'OAP UI-template rows ignored (not Horizon-namespaced) — operator may 
have other tools writing to this OAP',
+    );
+  }
+  return out;
+}
+
+async function seedMissing(
+  deps: SyncDeps,
+  bundled: Map<string, BundledRow>,
+  remote: Map<string, RemoteRow>,
+): Promise<number> {
+  let count = 0;
+  for (const [name, b] of bundled) {
+    if (remote.has(name)) continue;
+    try {
+      const ack = await deps.client.create(b.configuration);
+      if (!ack.status) {
+        deps.logger.warn(
+          { name, message: ack.message },
+          'OAP UI-template seed rejected — name conflict on OAP side, manual 
reconcile needed',
+        );
+        continue;
+      }
+      count++;
+      deps.logger.info({ name, id: ack.id }, 'OAP UI-template seeded');
+    } catch (err) {
+      deps.logger.warn(
+        { name, err: errMsg(err) },
+        'OAP UI-template seed failed — will retry at next BFF boot',
+      );
+    }
+  }
+  return count;
+}
+
+function mergeRows(
+  bundled: Map<string, BundledRow>,
+  remote: Map<string, RemoteRow>,
+): TemplateRow[] {
+  const out: TemplateRow[] = [];
+  const seen = new Set<string>();
+
+  for (const [name, b] of bundled) {
+    seen.add(name);
+    const r = remote.get(name);
+    if (!r) {
+      out.push({
+        name,
+        kind: b.kind,
+        key: b.key,
+        status: 'bundled-fallback',
+        effective: 'bundled',
+        remote: null,
+        bundled: { configuration: b.configuration },
+      });
+      continue;
+    }
+    if (r.disabled) {
+      out.push({
+        name,
+        kind: b.kind,
+        key: b.key,
+        status: 'disabled',
+        effective: null,
+        remote: { id: r.id, configuration: r.configuration, disabled: true },
+        bundled: { configuration: b.configuration },
+      });
+      continue;
+    }
+    const status = r.configuration === b.configuration ? 'synced' : 'diverged';
+    out.push({
+      name,
+      kind: b.kind,
+      key: b.key,
+      status,
+      effective: 'remote',
+      remote: { id: r.id, configuration: r.configuration, disabled: false },
+      bundled: { configuration: b.configuration },
+    });
+  }
+
+  for (const [name, r] of remote) {
+    if (seen.has(name)) continue;
+    out.push({
+      name,
+      kind: r.kind,
+      key: r.key,
+      status: r.disabled ? 'disabled' : 'remote-only',
+      effective: r.disabled ? null : 'remote',
+      remote: { id: r.id, configuration: r.configuration, disabled: r.disabled 
},
+      bundled: null,
+    });
+  }
+
+  out.sort((a, b) => a.name.localeCompare(b.name));
+  return out;
+}
+
+function bundledOnlyRows(bundled: Map<string, BundledRow>, status: 
TemplateStatus): TemplateRow[] {
+  return Array.from(bundled.values())
+    .sort((a, b) => a.name.localeCompare(b.name))
+    .map((b) => ({
+      name: b.name,
+      kind: b.kind,
+      key: b.key,
+      status,
+      effective: 'bundled' as const,
+      remote: null,
+      bundled: { configuration: b.configuration },
+    }));
+}
+
+function errMsg(err: unknown): string {
+  if (err instanceof Error) return err.message;
+  return String(err);
+}
diff --git a/apps/bff/src/rbac/route-policy.ts 
b/apps/bff/src/rbac/route-policy.ts
index 1a859a2..22a6c77 100644
--- a/apps/bff/src/rbac/route-policy.ts
+++ b/apps/bff/src/rbac/route-policy.ts
@@ -187,6 +187,15 @@ export const ROUTE_POLICY: Record<string, RoutePolicy> = {
   'POST /api/admin/overview-templates/:id':        'overview:write',
   'DELETE /api/admin/overview-templates/:id':      'overview:write',
 
+  // ── Template sync (admin) — OAP UI-template REST overlay ─────────
+  // Read = anyone with overview:read can see status. Write actions
+  // (push-bundled, save, resync) need overview:write because save is
+  // the only path that mutates OAP UI-templates.
+  'GET /api/admin/templates/sync-status':          'overview:read',
+  'POST /api/admin/templates/resync':              'overview:write',
+  'POST /api/admin/templates/save':                'overview:write',
+  'POST /api/admin/templates/:name/push-bundled':  'overview:write',
+
   // ── Auth/admin self-introspection ────────────────────────────────
   'GET /api/admin/auth-status':                    'auth:read',
   'POST /api/admin/auth-status/probe':             'auth:read',
diff --git a/apps/bff/src/server.ts b/apps/bff/src/server.ts
index 4101f0b..82960e8 100644
--- a/apps/bff/src/server.ts
+++ b/apps/bff/src/server.ts
@@ -54,6 +54,10 @@ import { registerAlarmsConfigRoutes } from 
'./http/config/alarms.js';
 import { registerSetupRoutes } from './http/config/setup.js';
 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 } from './logic/templates/sync.js';
+import { iterateBundledTemplates } from './logic/templates/aggregator.js';
 // Admin (operational tools)
 import { registerDslCatalogRoutes } from './http/admin/dsl/catalog.js';
 import { registerDslRuleRoutes } from './http/admin/dsl/rule.js';
@@ -166,7 +170,11 @@ if (process.env.NODE_ENV !== 'test') 
startLayerTemplateWatcher();
 registerAlarmsConfigRoutes(app, { config: source, sessions, audit, store: 
alarmsStore, serviceLayer });
 registerSetupRoutes(app, { config: source, sessions, audit, store: setupStore 
});
 registerOverviewRoutes(app, { config: source, sessions });
-registerConfigBundleRoute(app, { config: source, sessions });
+registerConfigBundleRoute(app, {
+  config: source,
+  sessions,
+  uiTemplateClient: () => buildOapClients(source.current).uiTemplate(),
+});
 
 // ── Admin ──────────────────────────────────────────────────────────
 registerDslCatalogRoutes(app, { config: source, sessions, audit });
@@ -180,6 +188,11 @@ registerAlarmRulesRoutes(app, { config: source, sessions 
});
 registerOverviewTemplatesAdminRoutes(app, { config: source, sessions });
 registerAuthStatusRoutes(app, { config: source, ldapHealth, sessions });
 registerAdminUsersRoute(app, { config: source, seenCache });
+registerTemplateSyncAdminRoutes(app, {
+  config: source,
+  sessions,
+  uiTemplateClient: () => buildOapClients(source.current).uiTemplate(),
+});
 
 // Serve the built SPA out of the BFF when a static dir is configured.
 // Two paths to set it:
@@ -208,19 +221,51 @@ if (staticDir && existsSync(staticDir)) {
 
 app.get('/api/health', async () => ({
   status: 'ok',
-  version: process.env.HORIZON_VERSION ?? '0.1.0',
+  version: process.env.HORIZON_VERSION ?? '0.4.0',
   sessions: sessions.size(),
 }));
 
 const { host, port } = source.current.server;
 app.listen({ host, port }).then(
-  () => logger.info(`BFF listening on http://${host}:${port}`),
+  () => {
+    logger.info(`BFF listening on http://${host}:${port}`);
+    // Fire-and-forget the boot-time OAP template seed: list OAP, POST any
+    // bundled template that's missing on the OAP side. This is the ONLY
+    // path that writes implicitly to OAP — runtime sync is read-only.
+    // Failures are non-fatal: the BFF stays up, the UI falls back to
+    // bundled templates and shows the read-only banner.
+    void bootSeed({
+      client: buildOapClients(source.current).uiTemplate(),
+      bundled: () => iterateBundledTemplates(),
+      logger,
+    })
+      .then((status) => {
+        if (status.unreachable) {
+          logger.warn(
+            { lastSuccessfulSyncAt: status.lastSuccessfulSyncAt },
+            'OAP UI-template boot seed: admin unreachable, rendering bundled 
(admin pages will be read-only until OAP comes back)',
+          );
+        } else {
+          const counts = countByStatus(status.rows);
+          logger.info(counts, 'OAP UI-template boot seed: complete');
+        }
+      })
+      .catch((err) => {
+        logger.error({ err }, 'OAP UI-template boot seed: unexpected error');
+      });
+  },
   (err) => {
     logger.fatal({ err }, 'failed to start BFF');
     process.exit(1);
   },
 );
 
+function countByStatus(rows: Array<{ status: string }>): Record<string, 
number> {
+  const out: Record<string, number> = {};
+  for (const r of rows) out[r.status] = (out[r.status] ?? 0) + 1;
+  return out;
+}
+
 async function shutdown(signal: string) {
   logger.info({ signal }, 'shutting down');
   await app.close();
diff --git a/apps/ui/package.json b/apps/ui/package.json
index 36c659f..2c3878c 100644
--- a/apps/ui/package.json
+++ b/apps/ui/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@skywalking-horizon-ui/ui",
-  "version": "0.1.0",
+  "version": "0.4.0",
   "private": true,
   "type": "module",
   "scripts": {
diff --git a/apps/ui/src/api/client.ts b/apps/ui/src/api/client.ts
index 30992a5..d54ae03 100644
--- a/apps/ui/src/api/client.ts
+++ b/apps/ui/src/api/client.ts
@@ -66,6 +66,7 @@ import { LayerTemplatesApi } from './scopes/layer-template';
 import { ConfigsApi } from './scopes/configs';
 import { AdminAuthApi } from './scopes/admin-auth';
 import { AdminUsersApi } from './scopes/admin-users';
+import { TemplateSyncApi } from './scopes/template-sync';
 
 // ── Wire types re-exported from @skywalking-horizon-ui/api-client ────
 // Re-exported so consumers can import everything from this module.
@@ -613,6 +614,7 @@ export class BffClient {
   readonly configs = new ConfigsApi(this);
   readonly adminAuth = new AdminAuthApi(this);
   readonly adminUsers = new AdminUsersApi(this);
+  readonly templateSync = new TemplateSyncApi(this);
 }
 
 export const bffClient = new BffClient();
diff --git a/apps/ui/src/api/scopes/configs.ts 
b/apps/ui/src/api/scopes/configs.ts
index b1ae9f5..50d8c06 100644
--- a/apps/ui/src/api/scopes/configs.ts
+++ b/apps/ui/src/api/scopes/configs.ts
@@ -25,11 +25,49 @@ export type BundleScopeMap = Partial<
   Record<'service' | 'instance' | 'endpoint', DashboardWidget[]>
 >;
 
+/** What kind of template a sync-status row describes. Three reserved
+ *  kinds — see the BFF's `apps/bff/src/logic/templates/names.ts`. */
+export type TemplateKind = 'overview' | 'layer' | 'alert';
+
+/** Status of a single template, mirrored from the BFF sync orchestrator.
+ *  - `synced`           — bundled == remote, byte-equal
+ *  - `diverged`         — both present, NOT byte-equal (operator edited
+ *                          remote; show inline diff)
+ *  - `disabled`         — remote present but disabled on OAP; hidden
+ *  - `remote-only`      — remote present, no matching bundled (operator
+ *                          added a template the BFF doesn't ship)
+ *  - `bundled-fallback` — remote absent at runtime; rendering bundled
+ *  - `unknown`          — defensive; shouldn't appear */
+export type TemplateStatus =
+  | 'synced'
+  | 'diverged'
+  | 'disabled'
+  | 'remote-only'
+  | 'bundled-fallback'
+  | 'unknown';
+
+export interface TemplateBadge {
+  name: string;
+  kind: TemplateKind;
+  key: string;
+  status: TemplateStatus;
+}
+
+/** 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 {
+  unreachable: boolean;
+  lastSuccessfulSyncAt: number | null;
+  generatedAt: number;
+  badges: TemplateBadge[];
+}
+
 export interface ConfigBundle {
   etag: string;
   generatedAt: number;
   layers: Record<string, BundleScopeMap>;
   overviews: OverviewDashboard[];
+  syncStatus: BundleSyncStatus;
 }
 
 /** `bff.configs` — preload of dashboard + overview configs. The SPA
diff --git a/apps/ui/src/api/scopes/template-sync.ts 
b/apps/ui/src/api/scopes/template-sync.ts
new file mode 100644
index 0000000..cc28826
--- /dev/null
+++ b/apps/ui/src/api/scopes/template-sync.ts
@@ -0,0 +1,78 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * `bff.templateSync` — admin-page surface for the OAP UI-template
+ * overlay. The simpler badge-only view used by per-page banners lives
+ * inside the bundle (`bff.configs.bundle().syncStatus`); this scope
+ * gives the admin pages full-fat rows (bundled + remote configuration
+ * strings) for inline diff + adopt/push actions.
+ */
+
+import type { BffClient } from '../client';
+import type { TemplateKind, TemplateStatus } from './configs';
+
+export interface TemplateSyncRow {
+  name: string;
+  kind: TemplateKind;
+  key: string;
+  status: TemplateStatus;
+  effective: 'remote' | 'bundled' | null;
+  remote: { id: string; configuration: string; disabled: boolean } | null;
+  bundled: { configuration: string } | null;
+}
+
+export interface TemplateSyncStatus {
+  unreachable: boolean;
+  lastSuccessfulSyncAt: number | null;
+  generatedAt: number;
+  rows: TemplateSyncRow[];
+}
+
+export class TemplateSyncApi {
+  constructor(private readonly bff: BffClient) {}
+
+  /** Full merged status for ALL template kinds. Admin pages filter to
+   *  the kind they own. */
+  syncStatus(): Promise<TemplateSyncStatus> {
+    return this.bff.request<TemplateSyncStatus>('GET', 
'/api/admin/templates/sync-status');
+  }
+
+  /** Force the BFF to invalidate its 30s cache + refetch from OAP. */
+  resync(): Promise<TemplateSyncStatus> {
+    return this.bff.request<TemplateSyncStatus>('POST', 
'/api/admin/templates/resync');
+  }
+
+  /** Save a template's content to OAP. The BFF wraps it in the canonical
+   *  envelope. 409 when OAP is unreachable — the page banner should have
+   *  prevented the call, but server-side guard catches operator scripts. */
+  save(name: string, content: unknown): Promise<TemplateSyncStatus> {
+    return this.bff.request<TemplateSyncStatus>('POST', 
'/api/admin/templates/save', {
+      name,
+      content,
+    });
+  }
+
+  /** Operator wants the bundled JSON to overwrite whatever OAP has for
+   *  this name. Used for "adopt my code defaults" from the diff view. */
+  pushBundled(name: string): Promise<TemplateSyncStatus> {
+    return this.bff.request<TemplateSyncStatus>(
+      'POST',
+      `/api/admin/templates/${encodeURIComponent(name)}/push-bundled`,
+    );
+  }
+}
diff --git a/apps/ui/src/controls/configBundle.ts 
b/apps/ui/src/controls/configBundle.ts
index b541aec..76da50f 100644
--- a/apps/ui/src/controls/configBundle.ts
+++ b/apps/ui/src/controls/configBundle.ts
@@ -35,10 +35,14 @@
 import { ref, computed, type ComputedRef, type Ref } from 'vue';
 import { bffClient } from '@/api/client';
 import { pushEvent } from '@/controls/eventLog';
+import { debug } from '@/utils/debug';
 import type { ConfigBundle, BundleScopeMap } from '@/api/scopes/configs';
 import type { DashboardWidget, OverviewDashboard } from 
'@skywalking-horizon-ui/api-client';
 
-const STORAGE_KEY = 'horizon:configBundle:v1';
+// Bumped to v2 in 2026-05 when the bundle gained `syncStatus` (OAP
+// UI-template overlay). v1 cached bundles lack the field; loading them
+// would crash the admin pages reading badges.
+const STORAGE_KEY = 'horizon:configBundle:v2';
 const state = ref<ConfigBundle | null>(null);
 let loadPromise: Promise<void> | null = null;
 
@@ -48,7 +52,9 @@ function readStorage(): ConfigBundle | null {
     const raw = localStorage.getItem(STORAGE_KEY);
     if (!raw) return null;
     const parsed = JSON.parse(raw) as ConfigBundle;
-    if (!parsed?.etag || !parsed?.layers) return null;
+    // Strict shape check: a v2 bundle MUST carry syncStatus. Older v1
+    // shapes are silently discarded — the next bundle fetch repopulates.
+    if (!parsed?.etag || !parsed?.layers || !parsed?.syncStatus) return null;
     return parsed;
   } catch {
     return null;
@@ -91,8 +97,10 @@ export function ensureConfigBundle(): Promise<void> {
           'ok',
           `Pre-loaded ${Object.keys(fresh.layers).length} layer configs + 
${fresh.overviews.length} overviews`,
         );
+        logSyncSummary(fresh);
       } else {
         pushEvent('preload', 'ok', 'Configs unchanged · using cached copy');
+        if (state.value) logSyncSummary(state.value);
       }
     } catch (err) {
       pushEvent(
@@ -132,3 +140,22 @@ export function useConfigBundle(): {
     loaded: computed<boolean>(() => state.value !== null),
   };
 }
+
+function logSyncSummary(b: ConfigBundle): void {
+  const s = b.syncStatus;
+  if (s.unreachable) {
+    debug(
+      'templates',
+      `OAP unreachable — admin pages will render bundled read-only. Last 
successful sync: ${
+        s.lastSuccessfulSyncAt ? new 
Date(s.lastSuccessfulSyncAt).toISOString() : 'never'
+      }`,
+    );
+    return;
+  }
+  const counts: Record<string, number> = {};
+  for (const b of s.badges) counts[b.status] = (counts[b.status] ?? 0) + 1;
+  const parts = Object.entries(counts)
+    .map(([k, v]) => `${v} ${k}`)
+    .join(', ');
+  debug('templates', `sync: ${parts}`);
+}
diff --git a/apps/ui/src/features/admin/_shared/SyncStatusBanner.vue 
b/apps/ui/src/features/admin/_shared/SyncStatusBanner.vue
new file mode 100644
index 0000000..636a3e2
--- /dev/null
+++ b/apps/ui/src/features/admin/_shared/SyncStatusBanner.vue
@@ -0,0 +1,119 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one or more
+  contributor license agreements.  See the NOTICE file distributed with
+  this work for additional information regarding copyright ownership.
+  The ASF licenses this file to You under the Apache License, Version 2.0
+  (the "License"); you may not use this file except in compliance with
+  the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+-->
+<!--
+  Shared banner for the three template-admin pages (overview, layer,
+  alert-page). Renders the page-level edit-mode state in one strip:
+
+    UNREACHABLE — red strip, big "READ-ONLY" label, no edits possible.
+    DIVERGED    — amber strip, summary of how many rows differ from OAP.
+    CLEAN       — green strip, quiet acknowledgement.
+
+  Composables drive the content; this component is dumb display.
+-->
+<script setup lang="ts">
+import type { SyncBanner } from './useTemplateSync';
+
+defineProps<{ banner: SyncBanner }>();
+</script>
+
+<template>
+  <div class="sbb" :class="`sbb--${banner.severity}`" role="status">
+    <div class="sbb__row">
+      <span class="sbb__chip">{{ chipLabel(banner.severity) }}</span>
+      <div class="sbb__text">
+        <div class="sbb__msg">{{ banner.message }}</div>
+        <div v-if="banner.detail" class="sbb__detail">{{ banner.detail }}</div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts">
+function chipLabel(s: SyncBanner['severity']): string {
+  switch (s) {
+    case 'unreachable':
+      return 'READ-ONLY';
+    case 'diverged':
+      return 'EDIT MODE';
+    case 'clean':
+      return 'EDIT MODE';
+    default:
+      return '…';
+  }
+}
+export default { chipLabel };
+</script>
+
+<style scoped>
+.sbb {
+  border: 1px solid var(--sw-border, #2a2f38);
+  border-radius: 4px;
+  padding: 8px 12px;
+  margin: 0 0 12px 0;
+  background: var(--sw-bg-elev, #161a20);
+  font-size: 12px;
+  line-height: 1.45;
+}
+.sbb--unreachable {
+  border-color: var(--sw-danger, #c0392b);
+  background: rgba(192, 57, 43, 0.08);
+}
+.sbb--diverged {
+  border-color: var(--sw-warn, #b88500);
+  background: rgba(184, 133, 0, 0.08);
+}
+.sbb--clean {
+  border-color: var(--sw-ok, #2e7d4e);
+  background: rgba(46, 125, 78, 0.06);
+}
+.sbb--unknown {
+  border-color: var(--sw-muted, #4a525c);
+  background: var(--sw-bg-elev, #161a20);
+}
+.sbb__row {
+  display: flex;
+  gap: 12px;
+  align-items: flex-start;
+}
+.sbb__chip {
+  flex: 0 0 auto;
+  font-weight: 600;
+  letter-spacing: 0.05em;
+  font-size: 10px;
+  padding: 3px 8px;
+  border-radius: 3px;
+  text-transform: uppercase;
+  color: var(--sw-text-strong, #e8edf2);
+  background: rgba(255, 255, 255, 0.06);
+  white-space: nowrap;
+}
+.sbb--unreachable .sbb__chip { background: var(--sw-danger, #c0392b); color: 
#fff; }
+.sbb--diverged .sbb__chip    { background: var(--sw-warn, #b88500); color: 
#1a1a1a; }
+.sbb--clean .sbb__chip       { background: var(--sw-ok, #2e7d4e); color: #fff; 
}
+.sbb__text {
+  flex: 1 1 auto;
+  min-width: 0;
+}
+.sbb__msg {
+  color: var(--sw-text-strong, #e8edf2);
+}
+.sbb__detail {
+  margin-top: 2px;
+  color: var(--sw-text-muted, #8a93a0);
+  font-size: 11px;
+}
+</style>
diff --git a/apps/ui/src/features/admin/_shared/TemplateDiffModal.vue 
b/apps/ui/src/features/admin/_shared/TemplateDiffModal.vue
new file mode 100644
index 0000000..b390ed4
--- /dev/null
+++ b/apps/ui/src/features/admin/_shared/TemplateDiffModal.vue
@@ -0,0 +1,227 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one or more
+  contributor license agreements.  See the NOTICE file distributed with
+  this work for additional information regarding copyright ownership.
+  The ASF licenses this file to You under the Apache License, Version 2.0
+  (the "License"); you may not use this file except in compliance with
+  the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+-->
+<!--
+  Per-template "show diff & reset" modal. Opens from any diverged row
+  in the admin lists. Two stacked surfaces:
+
+    1. Monaco side-by-side diff — bundled (left, original) vs remote
+       (right, modified). JSON syntax. Read-only.
+    2. Reset-to-bundled affordance with destructive confirmation —
+       the operator must type the template KEY (e.g. `GENERAL`,
+       `services`, `page-setup`) to arm the Reset button. Reset POSTs
+       the bundled JSON to OAP, overwriting whatever the operator (or
+       another UI) wrote there.
+
+  Reset is the inverse of Save: Save sends draft to OAP; Reset sends
+  bundled to OAP. There is no "adopt remote into bundled" — bundled is
+  a code-shape decision, edited by committing JSON in the repo.
+-->
+<script setup lang="ts">
+import { computed, onMounted, ref, watch } from 'vue';
+import { bff } from '@/api/client';
+import type { TemplateSyncRow } from '@/api/scopes/template-sync';
+import Modal from '@/features/operate/_shared/Modal.vue';
+import MonacoDiff from '@/features/operate/_shared/MonacoDiff.vue';
+import Btn from '@/components/primitives/Btn.vue';
+
+const props = defineProps<{
+  /** Full OAP UI-template name, e.g. `horizon.layer.GENERAL`. */
+  name: string;
+  /** Short key the operator types to confirm. Just the trailing
+   *  segment, e.g. `GENERAL`, `services`, `page-setup`. */
+  confirmKey: string;
+  open: boolean;
+}>();
+
+const emit = defineEmits<{ close: []; reset: [] }>();
+
+const row = ref<TemplateSyncRow | null>(null);
+const loading = ref(false);
+const loadError = ref<string | null>(null);
+const typed = ref('');
+const resetBusy = ref(false);
+const resetError = ref<string | null>(null);
+const armed = computed<boolean>(() => typed.value.trim() === props.confirmKey);
+
+// Pretty-printed JSON for the diff editor — the wire form is canonical
+// (sorted keys, no whitespace), readable for compare but unreadable
+// for a human. JSON.parse + JSON.stringify(_, null, 2) gives us
+// 2-space pretty without changing the data.
+const bundledPretty = computed<string>(() => 
prettyJson(row.value?.bundled?.configuration ?? ''));
+const remotePretty = computed<string>(() => 
prettyJson(row.value?.remote?.configuration ?? ''));
+
+function prettyJson(raw: string): string {
+  if (!raw) return '';
+  try {
+    return JSON.stringify(JSON.parse(raw), null, 2);
+  } catch {
+    return raw;
+  }
+}
+
+async function load(): Promise<void> {
+  loading.value = true;
+  loadError.value = null;
+  try {
+    const status = await bff.templateSync.syncStatus();
+    const found = status.rows.find((r) => r.name === props.name) ?? null;
+    if (!found) {
+      loadError.value = `No template named ${props.name} in OAP sync status.`;
+    }
+    row.value = found;
+  } catch (err) {
+    loadError.value = err instanceof Error ? err.message : String(err);
+  } finally {
+    loading.value = false;
+  }
+}
+
+// Re-fetch on every open, and reset the confirm input. Stale data
+// between opens would mislead operators about what they're about to
+// overwrite.
+watch(
+  () => props.open,
+  (isOpen) => {
+    if (!isOpen) {
+      typed.value = '';
+      resetError.value = null;
+      return;
+    }
+    void load();
+  },
+);
+
+onMounted(() => {
+  if (props.open) void load();
+});
+
+async function onReset(): Promise<void> {
+  if (!armed.value || resetBusy.value) return;
+  resetBusy.value = true;
+  resetError.value = null;
+  try {
+    await bff.templateSync.pushBundled(props.name);
+    emit('reset');
+    emit('close');
+  } catch (err) {
+    resetError.value = err instanceof Error ? err.message : String(err);
+  } finally {
+    resetBusy.value = false;
+  }
+}
+</script>
+
+<template>
+  <Modal :open="open" :title="`Template diff — ${name}`" 
@close="emit('close')">
+    <div v-if="loading" class="tdm__loading">Loading sync status…</div>
+    <div v-else-if="loadError" class="tdm__err">{{ loadError }}</div>
+    <template v-else-if="row">
+      <div class="tdm__legend">
+        <span class="tdm__legend-l">Left: <strong>bundled</strong> (the BFF's 
seed JSON)</span>
+        <span class="tdm__legend-r">Right: <strong>OAP-stored</strong> 
(operator-edited)</span>
+      </div>
+      <div class="tdm__diff">
+        <MonacoDiff :original="bundledPretty" :modified="remotePretty" 
language="json" />
+      </div>
+
+      <div class="tdm__reset">
+        <h4>Reset to bundled</h4>
+        <p class="tdm__reset-lede">
+          This overwrites <code>{{ name }}</code> on OAP with the bundled JSON 
shown on the
+          left. The operator's edits on OAP are <strong>lost</strong>. The 
bundle is
+          considered the source of truth after this action.
+        </p>
+        <label class="tdm__reset-label">
+          Type <code>{{ confirmKey }}</code> to arm the Reset button:
+          <input
+            v-model="typed"
+            type="text"
+            autocomplete="off"
+            spellcheck="false"
+            class="tdm__reset-input"
+          />
+        </label>
+        <div v-if="resetError" class="tdm__err">{{ resetError }}</div>
+      </div>
+    </template>
+
+    <template #footer>
+      <Btn @click="emit('close')">close</Btn>
+      <Btn
+        v-if="row"
+        kind="danger"
+        :disabled="!armed || resetBusy"
+        @click="onReset"
+      >
+        {{ resetBusy ? 'resetting…' : 'reset OAP to bundled' }}
+      </Btn>
+    </template>
+  </Modal>
+</template>
+
+<style scoped>
+.tdm__loading,
+.tdm__err {
+  padding: 16px;
+  font-size: 13px;
+  color: var(--rr-ink2);
+}
+.tdm__err {
+  color: var(--rr-danger, #c0392b);
+}
+.tdm__legend {
+  display: flex;
+  justify-content: space-between;
+  padding: 6px 8px;
+  font-size: 11px;
+  color: var(--rr-ink2);
+  border-bottom: 1px solid var(--rr-border, #2a2f38);
+}
+.tdm__legend-l { color: var(--sw-text-muted, #8a93a0); }
+.tdm__legend-r { color: var(--sw-warn, #b88500); }
+.tdm__diff {
+  height: 50vh;
+  min-height: 400px;
+  border-bottom: 1px solid var(--rr-border, #2a2f38);
+}
+.tdm__reset {
+  padding: 14px 6px 4px;
+}
+.tdm__reset h4 {
+  margin: 0 0 6px;
+  font-size: 13px;
+  font-weight: 600;
+  color: var(--sw-danger, #c0392b);
+}
+.tdm__reset-lede {
+  margin: 0 0 12px;
+  font-size: 12px;
+  line-height: 1.55;
+  color: var(--rr-ink2);
+}
+.tdm__reset-label {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  font-size: 12px;
+  color: var(--rr-ink2);
+}
+.tdm__reset-input {
+  font-family: var(--rr-font-mono, ui-monospace, monospace);
+  padding: 4px 6px;
+}
+</style>
diff --git a/apps/ui/src/features/admin/_shared/TemplateStatusBadge.vue 
b/apps/ui/src/features/admin/_shared/TemplateStatusBadge.vue
new file mode 100644
index 0000000..e45adc3
--- /dev/null
+++ b/apps/ui/src/features/admin/_shared/TemplateStatusBadge.vue
@@ -0,0 +1,100 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one or more
+  contributor license agreements.  See the NOTICE file distributed with
+  this work for additional information regarding copyright ownership.
+  The ASF licenses this file to You under the Apache License, Version 2.0
+  (the "License"); you may not use this file except in compliance with
+  the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+-->
+<!--
+  Per-row sync chip for the template admin lists. Five visual states
+  match `TemplateStatus`:
+    synced            quiet green tick — bundled == OAP
+    diverged          amber dot — operator edited remote
+    disabled          red strikethrough chip — hidden from render
+    remote-only       blue chip — operator added remote with no bundled
+    bundled-fallback  gray chip — remote absent (OAP unreachable or row
+                                   never synced)
+-->
+<script setup lang="ts">
+import type { TemplateStatus } from '@/api/scopes/configs';
+
+defineProps<{ status: TemplateStatus | null }>();
+</script>
+
+<template>
+  <span v-if="status" class="tsb" :class="`tsb--${status}`" 
:title="title(status)">
+    {{ label(status) }}
+  </span>
+</template>
+
+<script lang="ts">
+function label(s: TemplateStatus): string {
+  switch (s) {
+    case 'synced': return 'synced';
+    case 'diverged': return 'diverged';
+    case 'disabled': return 'disabled';
+    case 'remote-only': return 'remote-only';
+    case 'bundled-fallback': return 'bundled';
+    default: return '?';
+  }
+}
+function title(s: TemplateStatus): string {
+  switch (s) {
+    case 'synced': return 'Bundled and OAP-stored copy are byte-identical.';
+    case 'diverged': return 'OAP-stored copy differs from bundled. OAP wins at 
render time.';
+    case 'disabled': return 'Template is disabled on OAP. Hidden from render 
until re-enabled.';
+    case 'remote-only': return 'OAP has this template but bundled does not. 
OAP is the only source.';
+    case 'bundled-fallback': return 'OAP has no copy of this template right 
now — rendering bundled.';
+    default: return '';
+  }
+}
+export default { label, title };
+</script>
+
+<style scoped>
+.tsb {
+  display: inline-block;
+  font-size: 10px;
+  font-weight: 500;
+  letter-spacing: 0.04em;
+  padding: 1px 6px;
+  border-radius: 8px;
+  text-transform: uppercase;
+  border: 1px solid var(--sw-border, #2a2f38);
+  background: rgba(255, 255, 255, 0.03);
+  color: var(--sw-text-muted, #8a93a0);
+}
+.tsb--synced {
+  color: var(--sw-ok, #2e7d4e);
+  border-color: rgba(46, 125, 78, 0.4);
+  background: rgba(46, 125, 78, 0.08);
+}
+.tsb--diverged {
+  color: var(--sw-warn, #b88500);
+  border-color: rgba(184, 133, 0, 0.5);
+  background: rgba(184, 133, 0, 0.12);
+}
+.tsb--disabled {
+  color: var(--sw-danger, #c0392b);
+  border-color: rgba(192, 57, 43, 0.5);
+  background: rgba(192, 57, 43, 0.08);
+  text-decoration: line-through;
+}
+.tsb--remote-only {
+  color: var(--sw-info, #3a8ed0);
+  border-color: rgba(58, 142, 208, 0.5);
+  background: rgba(58, 142, 208, 0.08);
+}
+.tsb--bundled-fallback {
+  color: var(--sw-text-muted, #8a93a0);
+}
+</style>
diff --git a/apps/ui/src/features/admin/_shared/useTemplateSync.ts 
b/apps/ui/src/features/admin/_shared/useTemplateSync.ts
new file mode 100644
index 0000000..76bb3e5
--- /dev/null
+++ b/apps/ui/src/features/admin/_shared/useTemplateSync.ts
@@ -0,0 +1,145 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Per-admin-page hook into the BFF's OAP UI-template sync state. Three
+ * pieces every admin page needs:
+ *
+ *   - `readOnly` — true when the BFF cannot reach OAP's admin port
+ *     right now. The page shows the unreachable banner and disables
+ *     every Save / Create / Delete control. Operators can still view
+ *     bundled content; mutations would silently fail otherwise.
+ *
+ *   - `banner` — small object the shared `SyncStatusBanner` renders.
+ *     Severity tells the banner which design token to use; counts
+ *     tells the operator what the page-level state is in one glance.
+ *
+ *   - `badgeFor(name)` — per-row lookup. Returns the status string a
+ *     row-level badge renders, or `null` when no remote info exists.
+ *
+ * Source of truth: the `syncStatus` envelope inside the configBundle
+ * (refreshed when AppShell mounts). No additional network call.
+ */
+
+import { computed, type ComputedRef } from 'vue';
+import { useConfigBundle } from '@/controls/configBundle';
+import type {
+  BundleSyncStatus,
+  TemplateBadge,
+  TemplateKind,
+  TemplateStatus,
+} from '@/api/scopes/configs';
+
+export type BannerSeverity = 'unreachable' | 'diverged' | 'clean' | 'unknown';
+
+export interface SyncBanner {
+  severity: BannerSeverity;
+  /** One-line headline for the admin page top strip. */
+  message: string;
+  /** Optional secondary text shown smaller. */
+  detail?: string;
+  /** Per-status counts for the kinds owned by this page. */
+  counts: Partial<Record<TemplateStatus, number>>;
+}
+
+export interface UseTemplateSyncOptions {
+  /** Limit banner counts + badges to one kind — admin pages care only
+   *  about their own family. */
+  kind: TemplateKind;
+}
+
+export interface UseTemplateSyncReturn {
+  readOnly: ComputedRef<boolean>;
+  banner: ComputedRef<SyncBanner>;
+  badgeFor: (name: string) => TemplateStatus | null;
+  status: ComputedRef<BundleSyncStatus | null>;
+}
+
+export function useTemplateSync(opts: UseTemplateSyncOptions): 
UseTemplateSyncReturn {
+  const { bundle } = useConfigBundle();
+
+  const status = computed<BundleSyncStatus | null>(() => 
bundle.value?.syncStatus ?? null);
+
+  const ownBadges = computed<TemplateBadge[]>(() => {
+    const s = status.value;
+    if (!s) return [];
+    return s.badges.filter((b) => b.kind === opts.kind);
+  });
+
+  const readOnly = computed<boolean>(() => status.value?.unreachable === true);
+
+  const banner = computed<SyncBanner>(() => {
+    const s = status.value;
+    if (!s) {
+      return {
+        severity: 'unknown',
+        message: 'Loading template sync status…',
+        counts: {},
+      };
+    }
+    const counts: Partial<Record<TemplateStatus, number>> = {};
+    for (const b of ownBadges.value) counts[b.status] = (counts[b.status] ?? 
0) + 1;
+
+    if (s.unreachable) {
+      const last = s.lastSuccessfulSyncAt
+        ? new Date(s.lastSuccessfulSyncAt).toLocaleString()
+        : null;
+      return {
+        severity: 'unreachable',
+        message:
+          'OAP admin port unreachable — this page is READ-ONLY. Bundled 
templates shown; edits are disabled until OAP is back.',
+        detail: last
+          ? `Last successful sync: ${last}`
+          : 'No successful sync yet since this BFF started.',
+        counts,
+      };
+    }
+    const diverged = counts.diverged ?? 0;
+    const remoteOnly = counts['remote-only'] ?? 0;
+    const disabled = counts.disabled ?? 0;
+    if (diverged + remoteOnly + disabled > 0) {
+      const parts: string[] = [];
+      if (diverged > 0) parts.push(`${diverged} diverged`);
+      if (remoteOnly > 0) parts.push(`${remoteOnly} remote-only`);
+      if (disabled > 0) parts.push(`${disabled} disabled`);
+      return {
+        severity: 'diverged',
+        message: `Synced from OAP — ${parts.join(', ')}.`,
+        detail:
+          'Diverged rows can be inspected and reconciled inline. Bundled is 
the seed; OAP is the source of truth at runtime.',
+        counts,
+      };
+    }
+    return {
+      severity: 'clean',
+      message: `Synced from OAP — all ${ownBadges.value.length} templates 
match bundled.`,
+      counts,
+    };
+  });
+
+  const badgeIndex = computed<Map<string, TemplateStatus>>(() => {
+    const m = new Map<string, TemplateStatus>();
+    for (const b of ownBadges.value) m.set(b.name, b.status);
+    return m;
+  });
+
+  function badgeFor(name: string): TemplateStatus | null {
+    return badgeIndex.value.get(name) ?? null;
+  }
+
+  return { readOnly, banner, badgeFor, status };
+}
diff --git a/apps/ui/src/features/admin/alert-page/AlertPageSetupView.vue 
b/apps/ui/src/features/admin/alert-page/AlertPageSetupView.vue
index a21ee33..99f8050 100644
--- a/apps/ui/src/features/admin/alert-page/AlertPageSetupView.vue
+++ b/apps/ui/src/features/admin/alert-page/AlertPageSetupView.vue
@@ -36,6 +36,15 @@ import {
   bff,
   type AlarmsConfig,
 } from '@/api/client';
+import SyncStatusBanner from '@/features/admin/_shared/SyncStatusBanner.vue';
+import TemplateDiffModal from '@/features/admin/_shared/TemplateDiffModal.vue';
+import { useTemplateSync } from '@/features/admin/_shared/useTemplateSync';
+
+// OAP UI-template sync status for the alert page-setup template
+// (`horizon.alert.page-setup`). Drives the read-only banner + Save
+// gating + the diff-and-reset modal (defined later, after the local
+// fns it references are in scope).
+const sync = useTemplateSync({ kind: 'alert' });
 
 /* Cap matches the BFF's `configSaveSchema.max(8)` — keeps the header
  * row from wrapping into a second line at typical widths. */
@@ -99,6 +108,17 @@ function setFlash(msg: string): void {
   }, 4000);
 }
 
+// Diff & reset modal — alert page-setup is a singleton, so the
+// trigger lives near the Save button instead of per-row.
+const alertStatus = computed(() => sync.badgeFor('horizon.alert.page-setup'));
+const alertDiverged = computed(() => alertStatus.value === 'diverged');
+const diffModalOpen = ref(false);
+function openDiffModal(): void { diffModalOpen.value = true; }
+function onDiffReset(): void {
+  setFlash('OAP reset to bundled · reload to see header changes');
+  void q.refetch();
+}
+
 /* Layers the operator can still add — known to OAP AND not already
  * pinned. Pinned-but-unknown layers (e.g. an older install removed a
  * layer that's still in the saved config) stay rendered as pinned
@@ -129,18 +149,28 @@ function moveLayer(i: number, dir: -1 | 1): void {
 
 async function onSave(): Promise<void> {
   if (!validateLimit()) return;
+  if (sync.readOnly.value) {
+    setFlash('cannot save — OAP is unreachable, page is read-only');
+    return;
+  }
   saving.value = true;
   try {
-    const saved = await bff.alarms.saveConfig({
+    const next: AlarmsConfig = {
       pinnedLayers: draft.value,
       defaultWindowMs: draftWindowMs.value,
       overviewAlarmsLimit: Number(draftLimit.value),
-    });
-    draft.value = [...saved.pinnedLayers];
-    draftWindowMs.value = saved.defaultWindowMs;
-    draftLimit.value = saved.overviewAlarmsLimit;
+    };
+    // Save to OAP via the template-sync proxy (canonical envelope
+    // wrapped server-side). The local AlarmsStore is still the read
+    // source for /api/alarms/config; the BFF refreshes it after the
+    // OAP write succeeds.
+    await bff.templateSync.save('horizon.alert.page-setup', next);
+    await bff.alarms.saveConfig(next);
+    draft.value = [...next.pinnedLayers];
+    draftWindowMs.value = next.defaultWindowMs;
+    draftLimit.value = next.overviewAlarmsLimit;
     setFlash(
-      `saved · ${saved.pinnedLayers.length} pinned · 
${WINDOW_LABELS[saved.defaultWindowMs] ?? '—'} · limit 
${saved.overviewAlarmsLimit}`,
+      `saved · ${next.pinnedLayers.length} pinned · 
${WINDOW_LABELS[next.defaultWindowMs] ?? '—'} · limit 
${next.overviewAlarmsLimit}`,
     );
   } catch (err) {
     setFlash(err instanceof Error ? `error: ${err.message}` : 'save failed');
@@ -196,6 +226,8 @@ function prettyLayer(k: string): string {
       </div>
     </header>
 
+    <SyncStatusBanner :banner="sync.banner.value" />
+
     <div v-if="q.isPending.value" class="aps__empty">loading…</div>
 
     <template v-else>
@@ -327,13 +359,29 @@ function prettyLayer(k: string): string {
         :disabled="!isDirty || saving"
         @click="onReset"
       >reset</button>
+      <button
+        v-if="alertDiverged && !sync.readOnly.value"
+        type="button"
+        class="aps__btn"
+        title="Show side-by-side diff vs OAP, and reset OAP back to bundled 
(with confirmation)."
+        @click="openDiffModal"
+      >show diff &amp; reset</button>
       <button
         type="button"
         class="aps__btn aps__btn--primary"
-        :disabled="!isDirty || saving"
+        :disabled="!isDirty || saving || sync.readOnly.value"
+        :title="sync.readOnly.value ? 'OAP unreachable — page is read-only' : 
''"
         @click="onSave"
-      >{{ saving ? 'saving…' : 'save' }}</button>
+      >{{ saving ? 'saving…' : sync.readOnly.value ? 'read-only' : 'save to 
OAP' }}</button>
     </div>
+
+    <TemplateDiffModal
+      :open="diffModalOpen"
+      name="horizon.alert.page-setup"
+      confirm-key="page-setup"
+      @close="diffModalOpen = false"
+      @reset="onDiffReset"
+    />
   </div>
 </template>
 
diff --git 
a/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue 
b/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
index c7c20a1..1eafb56 100644
--- a/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
+++ b/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
@@ -37,11 +37,19 @@ import type {
   TopologyConfig,
   TopologyMetricDef,
 } from '@skywalking-horizon-ui/api-client';
-import { bffClient } from '@/api/client';
+import { bff, bffClient } from '@/api/client';
 import TimeChart from '@/components/charts/TimeChart.vue';
 import TopList from '@/components/charts/TopList.vue';
 import { fmtMetric } from '@/utils/formatters';
 import { mockCardValue, mockLineSeries, mockRecordRows, mockTopGroups } from 
'./widget-mock';
+import SyncStatusBanner from '@/features/admin/_shared/SyncStatusBanner.vue';
+import TemplateStatusBadge from 
'@/features/admin/_shared/TemplateStatusBadge.vue';
+import TemplateDiffModal from '@/features/admin/_shared/TemplateDiffModal.vue';
+import { useTemplateSync } from '@/features/admin/_shared/useTemplateSync';
+
+// OAP UI-template sync status for layer dashboards. Drives the banner +
+// Save gating + per-row badge below.
+const sync = useTemplateSync({ kind: 'layer' });
 
 const SCOPES: DashboardScope[] = [
   'service',
@@ -446,18 +454,42 @@ function textToExpressions(s: string): string[] {
     .filter((x) => x.length > 0);
 }
 
+// Diverged-row diff & reset: when OAP's copy of the selected layer
+// differs from bundled, opening the diff modal shows a side-by-side
+// and lets the operator reset OAP back to bundled (with
+// destructive-confirm — must type the layer key).
+const selectedStatus = computed(() =>
+  selectedKey.value ? sync.badgeFor(`horizon.layer.${selectedKey.value}`) : 
null,
+);
+const isDiverged = computed(() => selectedStatus.value === 'diverged');
+const diffModalOpen = ref(false);
+function openDiffModal(): void { diffModalOpen.value = true; }
+function onDiffReset(): void {
+  saveMsg.value = 'OAP reset to bundled. Reload to see widget changes.';
+  setTimeout(() => (saveMsg.value = null), 2400);
+}
+
 async function save(): Promise<void> {
   if (!draft.template || isSaving.value) return;
+  if (sync.readOnly.value) {
+    saveMsg.value = 'cannot save — OAP is unreachable, page is read-only';
+    return;
+  }
   isSaving.value = true;
   saveMsg.value = null;
   try {
-    const res = await bffClient.layerTemplates.save(draft.template);
-    // Splice the returned template back into the list so subsequent
-    // dirty diffs are against the persisted state.
+    // Save goes to OAP via the template-sync proxy (bundled is
+    // immutable at runtime). The BFF wraps draft.template in the
+    // canonical envelope before PUTting to OAP.
+    await bff.templateSync.save(`horizon.layer.${draft.template.key}`, 
draft.template);
+    // After OAP write, mirror the change into the in-memory editor
+    // list so the local diff baseline updates immediately.
     const idx = templates.value.findIndex((t) => t.key === selectedKey.value);
-    if (idx >= 0 && res.template) templates.value[idx] = res.template;
+    if (idx >= 0) {
+      templates.value[idx] = JSON.parse(JSON.stringify(draft.template));
+    }
     syncDraft();
-    saveMsg.value = 'Saved.';
+    saveMsg.value = 'Saved to OAP.';
     setTimeout(() => (saveMsg.value = null), 2400);
   } catch (err) {
     saveMsg.value = err instanceof Error ? err.message : String(err);
@@ -813,11 +845,14 @@ const namingTest = computed<NamingTestResult>(() => {
         <p class="lede">
           Each layer ships with a JSON template (alias, components, metric 
columns, widgets).
           Pick a layer on the left, switch scopes (service / instance / 
endpoint / trace /
-          profiling), edit widgets in place, and save back to the JSON.
+          profiling), edit widgets in place. Edits write to OAP via the 
UI-template REST
+          surface — bundled JSON is the seed + read-only fallback.
         </p>
       </div>
     </header>
 
+    <SyncStatusBanner :banner="sync.banner.value" />
+
     <div v-if="error" class="banner err">{{ error }}</div>
     <div v-if="isLoading" class="empty">Loading templates…</div>
     <div v-else-if="templates.length === 0" class="empty">No layer templates 
loaded.</div>
@@ -848,6 +883,7 @@ const namingTest = computed<NamingTestResult>(() => {
           >
             <span class="dot" :style="{ background: t.color || 
'var(--sw-fg-3)' }" />
             <span class="name">{{ t.alias || t.key }}</span>
+            <TemplateStatusBadge 
:status="sync.badgeFor(`horizon.layer.${t.key}`)" />
           </button>
         </template>
         <!-- Collapsed mode shows just colored dots for navigation; click
@@ -887,13 +923,23 @@ const namingTest = computed<NamingTestResult>(() => {
               >
                 Reset
               </button>
+              <button
+                v-if="isDiverged && !sync.readOnly.value"
+                class="sw-btn"
+                type="button"
+                title="Show side-by-side diff vs OAP, and reset OAP back to 
bundled (with confirmation)."
+                @click="openDiffModal"
+              >
+                Show diff &amp; reset
+              </button>
               <button
                 class="sw-btn is-primary"
                 type="button"
-                :disabled="!dirty || isSaving"
+                :disabled="!dirty || isSaving || sync.readOnly.value"
+                :title="sync.readOnly.value ? 'OAP unreachable — page is 
read-only' : ''"
                 @click="save"
               >
-                {{ isSaving ? 'Saving…' : 'Save' }}
+                {{ isSaving ? 'Saving…' : sync.readOnly.value ? 'Read-only' : 
'Save to OAP' }}
               </button>
             </div>
           </div>
@@ -1749,6 +1795,15 @@ const namingTest = computed<NamingTestResult>(() => {
         </section>
       </main>
     </div>
+
+    <TemplateDiffModal
+      v-if="selectedKey"
+      :open="diffModalOpen"
+      :name="`horizon.layer.${selectedKey}`"
+      :confirm-key="selectedKey"
+      @close="diffModalOpen = false"
+      @reset="onDiffReset"
+    />
   </div>
 </template>
 
diff --git 
a/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue 
b/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue
index d2fbe95..1667a07 100644
--- a/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue
+++ b/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue
@@ -36,7 +36,7 @@
   decisions, not config tweaks.
 -->
 <script setup lang="ts">
-import { computed, ref, watch } from 'vue';
+import { computed, ref, watch, watchEffect } from 'vue';
 import { useQuery } from '@tanstack/vue-query';
 import type {
   OverviewDashboard,
@@ -45,6 +45,14 @@ import type {
 } from '@skywalking-horizon-ui/api-client';
 import { bff } from '@/api/client';
 import { useLayers } from '@/shell/useLayers';
+import SyncStatusBanner from '@/features/admin/_shared/SyncStatusBanner.vue';
+import TemplateStatusBadge from 
'@/features/admin/_shared/TemplateStatusBadge.vue';
+import TemplateDiffModal from '@/features/admin/_shared/TemplateDiffModal.vue';
+import { useTemplateSync } from '@/features/admin/_shared/useTemplateSync';
+
+// OAP UI-template sync status for the Overview kind. Drives the
+// page-level banner + read-only mode + per-row badge lookup.
+const sync = useTemplateSync({ kind: 'overview' });
 
 const listQuery = useQuery({
   queryKey: ['admin/overview-templates'],
@@ -56,21 +64,24 @@ const dashboards = computed(() => 
listQuery.data.value?.dashboards ?? []);
 const selectedId = ref<string>('');
 /* Auto-select rule: if nothing is selected (cold start), pick the
  * first dashboard. If the currently-selected id disappears from the
- * list (just deleted by the operator), fall back to the new first. */
-watch(
-  () => dashboards.value,
-  (list) => {
-    if (list.length === 0) {
-      selectedId.value = '';
-      return;
-    }
-    const stillExists = list.some((d) => d.id === selectedId.value);
-    if (!selectedId.value || !stillExists) {
-      selectedId.value = list[0]!.id;
-    }
-  },
-  { immediate: true },
-);
+ * list (just deleted by the operator), fall back to the new first.
+ *
+ * `watchEffect` instead of `watch(immediate:true)` because the watch
+ * variant missed the transition when `listQuery` was already cached
+ * by vue-query at mount time (staleTime: 60_000) — the watcher fired
+ * once with the populated list and once with the empty fallback, in
+ * an order that left selectedId blank. watchEffect re-runs eagerly
+ * whenever its reactive deps change AND on first paint. */
+watchEffect(() => {
+  const list = dashboards.value;
+  if (list.length === 0) {
+    if (selectedId.value !== '') selectedId.value = '';
+    return;
+  }
+  if (!selectedId.value || !list.some((d) => d.id === selectedId.value)) {
+    selectedId.value = list[0]!.id;
+  }
+});
 
 // ── New-dashboard composer ─────────────────────────────────────────
 const newDashOpen = ref(false);
@@ -300,13 +311,37 @@ const isDirty = computed<boolean>(() => {
   return JSON.stringify(cur) !== JSON.stringify(orig);
 });
 
+// Diverged-row controls: shown only when OAP's copy differs from
+// bundled for the currently-selected dashboard. The "show diff &
+// reset" modal opens a Monaco side-by-side and the destructive
+// confirm UI (operator types the dashboard id to arm Reset).
+const selectedStatus = computed(() =>
+  selectedId.value ? sync.badgeFor(`horizon.overview.${selectedId.value}`) : 
null,
+);
+const isDiverged = computed(() => selectedStatus.value === 'diverged');
+const diffModalOpen = ref(false);
+function openDiffModal(): void { diffModalOpen.value = true; }
+async function onDiffReset(): Promise<void> {
+  await detailQuery.refetch();
+  setFlash('OAP reset to bundled · reload the overview to see widget changes');
+}
+
 async function onSave(): Promise<void> {
   if (!draft.value || !selectedId.value) return;
+  if (sync.readOnly.value) {
+    setFlash('cannot save — OAP is unreachable, page is read-only');
+    return;
+  }
   saving.value = true;
   try {
-    await bff.overview.adminSave(selectedId.value, draft.value);
+    // Save goes to OAP via the BFF template-sync proxy (not to disk).
+    // The new `/api/admin/templates/save` endpoint wraps content in
+    // the canonical envelope and PUTs it to OAP. Bundled JSON stays
+    // immutable at runtime — it's a code-shape decision, not an
+    // operator one.
+    await bff.templateSync.save(`horizon.overview.${selectedId.value}`, 
draft.value);
     await detailQuery.refetch();
-    setFlash('saved · reload the overview to see widget changes');
+    setFlash('saved to OAP · reload the overview to see widget changes');
   } catch (err) {
     setFlash(err instanceof Error ? `error: ${err.message}` : 'save failed');
   } finally {
@@ -458,15 +493,17 @@ function widgetKindLabel(type: OverviewWidget['type']): 
string {
         <div class="ot__kicker">Dashboard setup · Overviews</div>
         <h1>Overview templates</h1>
         <p class="ot__lede">
-          Per-widget editor for the bundled overview dashboards. Each widget 
kind shows only
-          the fields it consumes — e.g. <code>kpi-tile</code> exposes its KPI 
row list with
-          number / progress-bar style; <code>alarms</code> exposes the row 
limit. Type and
-          widget set are code-shape decisions and stay frozen; edits write 
back to
-          <code>bundled_templates/overviews/*.json</code> and refresh the BFF 
cache.
+          Per-widget editor for the overview dashboards. Each widget kind 
shows only the fields
+          it consumes — e.g. <code>kpi-tile</code> exposes its KPI row list 
with number /
+          progress-bar style; <code>alarms</code> exposes the row limit. Type 
and widget set
+          are code-shape decisions and stay frozen; edits write to OAP via the 
UI-template
+          REST surface (bundled JSON is the seed + read-only fallback).
         </p>
       </div>
     </header>
 
+    <SyncStatusBanner :banner="sync.banner.value" />
+
     <div v-if="listQuery.isPending.value" class="ot__empty">loading…</div>
 
     <div v-else class="ot__split">
@@ -484,6 +521,7 @@ function widgetKindLabel(type: OverviewWidget['type']): 
string {
             <code>{{ d.id }}</code>
             <span>{{ d.widgetCount }} widget{{ d.widgetCount === 1 ? '' : 's' 
}}</span>
             <span v-if="!d.editable" class="ot__readonly-tag">no source 
file</span>
+            <TemplateStatusBadge 
:status="sync.badgeFor(`horizon.overview.${d.id}`)" />
           </div>
         </li>
         <li v-if="dashboards.length === 0" class="ot__list-empty">No overview 
templates loaded.</li>
@@ -492,6 +530,8 @@ function widgetKindLabel(type: OverviewWidget['type']): 
string {
             v-if="!newDashOpen"
             type="button"
             class="ot__add-trigger ot__add-trigger--list"
+            :disabled="sync.readOnly.value"
+            :title="sync.readOnly.value ? 'OAP unreachable — cannot create' : 
''"
             @click="openNewDash"
           >+ New dashboard</button>
           <div v-else class="ot__newdash">
@@ -563,7 +603,8 @@ function widgetKindLabel(type: OverviewWidget['type']): 
string {
             <button
               type="button"
               class="ot__head-btn ot__head-btn--danger"
-              :title="`Delete dashboard ${draft.id}`"
+              :disabled="sync.readOnly.value"
+              :title="sync.readOnly.value ? 'OAP unreachable — cannot delete' 
: `Delete dashboard ${draft.id}`"
               @click="deleteCurrentDash"
             >delete</button>
           </header>
@@ -994,18 +1035,37 @@ function widgetKindLabel(type: OverviewWidget['type']): 
string {
             <button type="button" class="ot__btn" :disabled="!isDirty || 
saving" @click="onReset">
               reset
             </button>
+            <button
+              v-if="isDiverged && !sync.readOnly.value"
+              type="button"
+              class="ot__btn"
+              title="Show side-by-side diff vs OAP, and reset OAP back to 
bundled (with confirmation)."
+              @click="openDiffModal"
+            >
+              show diff &amp; reset
+            </button>
             <button
               type="button"
               class="ot__btn ot__btn--primary"
-              :disabled="!isDirty || saving"
+              :disabled="!isDirty || saving || sync.readOnly.value"
+              :title="sync.readOnly.value ? 'OAP unreachable — page is 
read-only' : ''"
               @click="onSave"
             >
-              {{ saving ? 'saving…' : 'save' }}
+              {{ saving ? 'saving…' : sync.readOnly.value ? 'read-only' : 
'save to OAP' }}
             </button>
           </div>
         </template>
       </section>
     </div>
+
+    <TemplateDiffModal
+      v-if="selectedId"
+      :open="diffModalOpen"
+      :name="`horizon.overview.${selectedId}`"
+      :confirm-key="selectedId"
+      @close="diffModalOpen = false"
+      @reset="onDiffReset"
+    />
   </div>
 </template>
 
diff --git a/apps/ui/src/features/operate/_shared/MonacoDiff.vue 
b/apps/ui/src/features/operate/_shared/MonacoDiff.vue
index a18f2dc..678de0a 100644
--- a/apps/ui/src/features/operate/_shared/MonacoDiff.vue
+++ b/apps/ui/src/features/operate/_shared/MonacoDiff.vue
@@ -19,12 +19,19 @@ import { onBeforeUnmount, onMounted, ref, watch } from 
'vue';
 import * as monaco from 'monaco-editor';
 import { setupMonaco, RR_THEME_NAME } from '../../../monaco/setup.js';
 
-const props = defineProps<{
-  /** "before" — what's currently on the server (or bundled). */
-  original: string;
-  /** "after" — what the user has in the buffer. */
-  modified: string;
-}>();
+const props = withDefaults(
+  defineProps<{
+    /** "before" — what's currently on the server (or bundled). */
+    original: string;
+    /** "after" — what the user has in the buffer. */
+    modified: string;
+    /** Monaco language id for syntax highlighting. Defaults to `yaml`
+     *  because the rule editor (the original caller) is YAML; the
+     *  template-diff modal passes `json`. */
+    language?: string;
+  }>(),
+  { language: 'yaml' },
+);
 
 const host = ref<HTMLDivElement | null>(null);
 let diff: monaco.editor.IStandaloneDiffEditor | null = null;
@@ -35,8 +42,8 @@ onMounted(() => {
   if (!host.value) return;
   setupMonaco();
 
-  originalModel = monaco.editor.createModel(props.original, 'yaml');
-  modifiedModel = monaco.editor.createModel(props.modified, 'yaml');
+  originalModel = monaco.editor.createModel(props.original, props.language);
+  modifiedModel = monaco.editor.createModel(props.modified, props.language);
 
   diff = monaco.editor.createDiffEditor(host.value, {
     theme: RR_THEME_NAME,
diff --git a/apps/ui/src/utils/debug.ts b/apps/ui/src/utils/debug.ts
new file mode 100644
index 0000000..06e3108
--- /dev/null
+++ b/apps/ui/src/utils/debug.ts
@@ -0,0 +1,92 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Scoped, opt-in console logger. Most operators don't need browser-side
+ * trace logs — the BFF's pino output is authoritative for backend flow.
+ * But "did the template sync actually load?" and "which OAP-side row are
+ * we showing?" questions are easier to answer in the browser than by
+ * tailing the BFF, so this wrapper makes per-scope tracing one toggle.
+ *
+ * Enable any of:
+ *   - `localStorage.setItem('horizon:debug', '1')`            — all scopes
+ *   - `localStorage.setItem('horizon:debug', 'templates')`    — one scope
+ *   - `localStorage.setItem('horizon:debug', 'templates,api')` — many
+ *   - `?debug=1` / `?debug=templates` in the URL              — same set
+ *
+ * Output prefixes the scope so `[templates] sync: 12 synced, 2 diverged`
+ * is easy to grep in devtools. `console.debug` is hidden by default in
+ * Chrome — operators must enable the "Verbose" log level to see it.
+ */
+
+const SCOPES_KEY = 'horizon:debug';
+
+function readEnabledScopes(): Set<string> | null {
+  const sources: string[] = [];
+  if (typeof localStorage !== 'undefined') {
+    try {
+      const raw = localStorage.getItem(SCOPES_KEY);
+      if (raw) sources.push(raw);
+    } catch {
+      /* private mode / disabled storage — ignore */
+    }
+  }
+  if (typeof location !== 'undefined') {
+    try {
+      const v = new URLSearchParams(location.search).get('debug');
+      if (v) sources.push(v);
+    } catch {
+      /* malformed URL — ignore */
+    }
+  }
+  if (sources.length === 0) return null;
+  const all = new Set<string>();
+  for (const s of sources) {
+    for (const t of s.split(',').map((x) => x.trim()).filter(Boolean)) {
+      all.add(t);
+    }
+  }
+  return all;
+}
+
+let cachedScopes: Set<string> | null | undefined;
+
+function enabledFor(scope: string): boolean {
+  if (cachedScopes === undefined) cachedScopes = readEnabledScopes();
+  if (!cachedScopes) return false;
+  return cachedScopes.has('1') || cachedScopes.has('*') || 
cachedScopes.has(scope);
+}
+
+/** Refresh the cached scope set — call after the operator toggles
+ *  localStorage at runtime, otherwise the cached value sticks. */
+export function reloadDebugScopes(): void {
+  cachedScopes = undefined;
+}
+
+/** Emit a `console.debug(...)` line prefixed with `[scope]`, but only
+ *  when that scope (or the wildcard) is enabled. Cheap when disabled —
+ *  the args are still evaluated, so prefer `if (debugEnabled('x'))`
+ *  around expensive computations. */
+export function debug(scope: string, ...args: unknown[]): void {
+  if (!enabledFor(scope)) return;
+  // eslint-disable-next-line no-console
+  console.debug(`[${scope}]`, ...args);
+}
+
+export function debugEnabled(scope: string): boolean {
+  return enabledFor(scope);
+}
diff --git a/package.json b/package.json
index 18f9a8e..ce18b32 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "skywalking-horizon-ui",
-  "version": "0.1.0",
+  "version": "0.4.0",
   "private": true,
   "description": "Apache SkyWalking Horizon UI - next-generation web UI",
   "license": "Apache-2.0",
diff --git a/packages/api-client/package.json b/packages/api-client/package.json
index 9560fdc..85f64ff 100644
--- a/packages/api-client/package.json
+++ b/packages/api-client/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@skywalking-horizon-ui/api-client",
-  "version": "0.1.0",
+  "version": "0.4.0",
   "private": true,
   "type": "module",
   "main": "./dist/index.js",
diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts
index 859cbeb..72a5225 100644
--- a/packages/api-client/src/index.ts
+++ b/packages/api-client/src/index.ts
@@ -192,6 +192,13 @@ export {
   type ClusterAlarmStatus,
   type InstanceAlarmStatus,
 } from './alarm-status.js';
+export {
+  UITemplateClient,
+  UITemplateApiError,
+  type UITemplateClientOptions,
+  type UITemplateRow,
+  type TemplateChangeStatus,
+} from './ui-template.js';
 export {
   OalClient,
   type OalClientOptions,
diff --git a/packages/api-client/src/ui-template.ts 
b/packages/api-client/src/ui-template.ts
new file mode 100644
index 0000000..f3b1dd8
--- /dev/null
+++ b/packages/api-client/src/ui-template.ts
@@ -0,0 +1,138 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * OAP admin's `/ui-management/templates*` REST surface.
+ *
+ * OAP stores each dashboard / page-setup blob keyed by a server-allocated
+ * UUID; the *meaning* of the blob is opaque to OAP. Horizon names its
+ * templates with a reserved prefix (`horizon.overview.*`, `horizon.layer.*`,
+ * `horizon.alert.*`) inside the `configuration` JSON so multiple UIs can
+ * share an OAP without colliding. Identity for sync purposes is that
+ * inner `name` field — not the OAP UUID, which is a storage detail.
+ *
+ * No DELETE endpoint exists upstream; soft-deletion is `POST 
.../{id}/disable`.
+ */
+
+export type FetchLike = (input: string | URL, init?: RequestInit) => 
Promise<Response>;
+
+export interface UITemplateClientOptions {
+  adminUrl: string;
+  fetch?: FetchLike;
+  headers?: Record<string, string>;
+  timeoutMs?: number;
+}
+
+/** One row as returned by OAP. `configuration` is an opaque JSON string —
+ *  callers must `JSON.parse` it to read the inner `name`. */
+export interface UITemplateRow {
+  id: string;
+  configuration: string;
+  disabled: boolean;
+}
+
+/** Reply to add/update/disable mutations. `id` is the OAP UUID. */
+export interface TemplateChangeStatus {
+  id: string;
+  status: boolean;
+  message: string;
+}
+
+export class UITemplateApiError extends Error {
+  readonly status: number;
+  readonly url: string;
+  readonly body?: string;
+  constructor(status: number, url: string, body?: string) {
+    super(`UITemplate ${status} ${url}${body ? ` — ${body.slice(0, 200)}` : 
''}`);
+    this.name = 'UITemplateApiError';
+    this.status = status;
+    this.url = url;
+    this.body = body;
+  }
+}
+
+export class UITemplateClient {
+  private readonly fetchImpl: FetchLike;
+  private readonly base: string;
+  private readonly defaultHeaders: Record<string, string>;
+  private readonly timeoutMs: number;
+
+  constructor(options: UITemplateClientOptions) {
+    this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
+    this.base = options.adminUrl.replace(/\/$/, '');
+    this.defaultHeaders = options.headers ?? {};
+    this.timeoutMs = options.timeoutMs ?? 0;
+  }
+
+  /** `GET /ui-management/templates?includingDisabled=true`. We always pass
+   *  the flag so the sync orchestrator can see disabled rows and surface
+   *  them in the admin UI. */
+  async list(): Promise<UITemplateRow[]> {
+    return 
this.json<UITemplateRow[]>('/ui-management/templates?includingDisabled=true', {
+      method: 'GET',
+    });
+  }
+
+  /** `POST /ui-management/templates` — server allocates the UUID. Body
+   *  carries the configuration JSON-as-string. */
+  async create(configuration: string): Promise<TemplateChangeStatus> {
+    return this.json<TemplateChangeStatus>('/ui-management/templates', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ configuration }),
+    });
+  }
+
+  /** `PUT /ui-management/templates` — replaces by id. */
+  async update(id: string, configuration: string): 
Promise<TemplateChangeStatus> {
+    return this.json<TemplateChangeStatus>('/ui-management/templates', {
+      method: 'PUT',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ id, configuration }),
+    });
+  }
+
+  /** `POST /ui-management/templates/{id}/disable` — soft-delete. */
+  async disable(id: string): Promise<TemplateChangeStatus> {
+    return this.json<TemplateChangeStatus>(
+      `/ui-management/templates/${encodeURIComponent(id)}/disable`,
+      { method: 'POST' },
+    );
+  }
+
+  private async json<T>(path: string, init: RequestInit): Promise<T> {
+    const url = `${this.base}${path}`;
+    const headers = { Accept: 'application/json', ...this.defaultHeaders, 
...init.headers };
+    let timer: ReturnType<typeof setTimeout> | null = null;
+    let finalInit: RequestInit = { ...init, headers };
+    if (this.timeoutMs > 0) {
+      const ctrl = new AbortController();
+      timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
+      finalInit = { ...finalInit, signal: ctrl.signal };
+    }
+    try {
+      const res = await this.fetchImpl(url, finalInit);
+      if (!res.ok) {
+        const body = await res.text();
+        throw new UITemplateApiError(res.status, url, body);
+      }
+      return (await res.json()) as T;
+    } finally {
+      if (timer) clearTimeout(timer);
+    }
+  }
+}
diff --git a/packages/design-tokens/package.json 
b/packages/design-tokens/package.json
index d898d1b..2efd2ad 100644
--- a/packages/design-tokens/package.json
+++ b/packages/design-tokens/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@skywalking-horizon-ui/design-tokens",
-  "version": "0.1.0",
+  "version": "0.4.0",
   "private": true,
   "type": "module",
   "main": "./dist/index.js",
diff --git a/packages/templates/package.json b/packages/templates/package.json
index 57f96dc..29ae86e 100644
--- a/packages/templates/package.json
+++ b/packages/templates/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@skywalking-horizon-ui/templates",
-  "version": "0.1.0",
+  "version": "0.4.0",
   "private": true,
   "type": "module",
   "main": "./dist/index.js",

Reply via email to