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 & 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 & 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 & 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",