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 416c0ad  admin: local-vs-remote conflict resolution for diverged 
templates
416c0ad is described below

commit 416c0ad838b386ae71ee4ec67d806299c23ff975
Author: Wu Sheng <[email protected]>
AuthorDate: Thu May 21 20:46:14 2026 +0800

    admin: local-vs-remote conflict resolution for diverged templates
    
    The runtime treats OAP as the source of truth, so a local (bundled) edit
    that diverges from OAP was invisible — and re-login re-pulled remote,
    overriding the local preview. Now the operator chooses which to render:
    
    - BFF: GET /api/configs/bundle?prefer=local renders the LOCAL bundled
      copy for diverged templates (synced rows are byte-equal → no-op);
      default stays remote.
    - A global, per-session preference (reset on login) drives the bundle
      fetch. On landing with diverged templates, a prompt asks once — listing
      the affected items from the MENU perspective (Layer · Kubernetes, …),
      not template file names — to use local edits or the remote live version.
    - A "Showing: Local / Remote" toggle sits next to "Sync all to OAP" in the
      admin pages so the choice (and publish) live together in edit mode.
    
    Validated on demo: prefer=local renders the new bundled K8S (8 cards +
    3 lines + 6 tables) vs the remote-synced 10-widget version on default.
---
 apps/bff/src/http/config/bundle.ts                 |  18 +++-
 apps/ui/src/api/scopes/configs.ts                  |   5 +-
 apps/ui/src/controls/configBundle.ts               |  22 ++++-
 apps/ui/src/controls/templatePreference.ts         |  70 +++++++++++++++
 .../src/features/admin/_shared/SyncAllButton.vue   |  35 +++++++-
 apps/ui/src/shell/AppShell.vue                     |   4 +
 apps/ui/src/shell/TemplateConflictPrompt.vue       | 100 +++++++++++++++++++++
 apps/ui/src/state/auth.ts                          |   3 +
 8 files changed, 248 insertions(+), 9 deletions(-)

diff --git a/apps/bff/src/http/config/bundle.ts 
b/apps/bff/src/http/config/bundle.ts
index 164caee..7db97c4 100644
--- a/apps/bff/src/http/config/bundle.ts
+++ b/apps/bff/src/http/config/bundle.ts
@@ -103,7 +103,11 @@ export function registerConfigBundleRoute(app: 
FastifyInstance, deps: ConfigBund
     '/api/configs/bundle',
     { preHandler: auth },
     async (req: FastifyRequest, reply: FastifyReply) => {
-      const body = await buildBundle(deps);
+      // `?prefer=local` renders the LOCAL bundled copy for templates that
+      // diverge from OAP (so an operator can preview unpublished edits);
+      // default `remote` keeps OAP as the runtime source of truth.
+      const preferLocal = (req.query as { prefer?: string }).prefer === 
'local';
+      const body = await buildBundle(deps, preferLocal);
       const inm = req.headers['if-none-match'];
       if (typeof inm === 'string' && inm === body.etag) {
         return reply.code(304).send();
@@ -115,7 +119,7 @@ export function registerConfigBundleRoute(app: 
FastifyInstance, deps: ConfigBund
   );
 }
 
-async function buildBundle(deps: ConfigBundleDeps): Promise<ConfigBundle> {
+async function buildBundle(deps: ConfigBundleDeps, preferLocal = false): 
Promise<ConfigBundle> {
   const sync = await getSyncStatus({
     client: deps.uiTemplateClient(),
     bundled: () => iterateBundledTemplates(),
@@ -127,7 +131,7 @@ async function buildBundle(deps: ConfigBundleDeps): 
Promise<ConfigBundle> {
 
   const layers: Record<string, ScopeMap> = {};
   for (const tpl of allLayerTemplates()) {
-    const effective = pickLayerContent(tpl, remoteByName);
+    const effective = pickLayerContent(tpl, remoteByName, preferLocal);
     if (effective === null) continue; // disabled
     const scopes: ScopeMap = {};
     for (const scope of ['service', 'instance', 'endpoint'] as const) {
@@ -139,7 +143,7 @@ async function buildBundle(deps: ConfigBundleDeps): 
Promise<ConfigBundle> {
 
   const overviews: OverviewDashboard[] = [];
   for (const dash of loadOverviewDashboards()) {
-    const effective = pickOverviewContent(dash, remoteByName);
+    const effective = pickOverviewContent(dash, remoteByName, preferLocal);
     if (effective === null) continue; // disabled
     overviews.push(effective);
   }
@@ -169,10 +173,14 @@ async function buildBundle(deps: ConfigBundleDeps): 
Promise<ConfigBundle> {
 function pickLayerContent(
   bundled: LayerTemplate,
   byName: Map<string, TemplateRow>,
+  preferLocal = false,
 ): LayerTemplate | null {
   const row = byName.get(formatName('layer', bundled.key));
   if (!row) return bundled;
   if (row.status === 'disabled') return null;
+  // Operator opted to preview unpublished local edits: bundled wins for
+  // diverged templates (synced rows are byte-equal, so it's a no-op there).
+  if (preferLocal && row.status === 'diverged') return bundled;
   if (row.effective === 'remote' && row.remote) {
     const env = parseEnvelope(row.remote.configuration);
     if (env && isLayerLike(env.content)) {
@@ -185,10 +193,12 @@ function pickLayerContent(
 function pickOverviewContent(
   bundled: OverviewDashboard,
   byName: Map<string, TemplateRow>,
+  preferLocal = false,
 ): OverviewDashboard | null {
   const row = byName.get(formatName('overview', bundled.id));
   if (!row) return bundled;
   if (row.status === 'disabled') return null;
+  if (preferLocal && row.status === 'diverged') return bundled;
   if (row.effective === 'remote' && row.remote) {
     const env = parseEnvelope(row.remote.configuration);
     if (env && isOverviewLike(env.content)) {
diff --git a/apps/ui/src/api/scopes/configs.ts 
b/apps/ui/src/api/scopes/configs.ts
index e4a3668..5e83420 100644
--- a/apps/ui/src/api/scopes/configs.ts
+++ b/apps/ui/src/api/scopes/configs.ts
@@ -82,16 +82,17 @@ export class ConfigsApi {
    * validation. Returns `null` on a 304 (the caller's cached copy
    * is current); otherwise a full bundle.
    */
-  async bundle(ifNoneMatch?: string): Promise<ConfigBundle | null> {
+  async bundle(ifNoneMatch?: string, prefer?: 'local' | 'remote'): 
Promise<ConfigBundle | null> {
     const headers: Record<string, string> = {};
     if (ifNoneMatch) headers['If-None-Match'] = ifNoneMatch;
+    const path = prefer === 'local' ? '/api/configs/bundle?prefer=local' : 
'/api/configs/bundle';
     // Direct fetch (not BffClient.request) because we need 304 to be a
     // non-throwing success path. The error logging that lives in
     // BffClient.request is replicated here so a bundle-load failure
     // still lands in the debug event log.
     let res: Response;
     try {
-      res = await fetch(withBase('/api/configs/bundle'), {
+      res = await fetch(withBase(path), {
         method: 'GET',
         credentials: 'include',
         headers,
diff --git a/apps/ui/src/controls/configBundle.ts 
b/apps/ui/src/controls/configBundle.ts
index 4c46893..95e73b4 100644
--- a/apps/ui/src/controls/configBundle.ts
+++ b/apps/ui/src/controls/configBundle.ts
@@ -36,9 +36,20 @@ 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 { useTemplatePreference } from '@/controls/templatePreference';
 import type { ConfigBundle, BundleScopeMap } from '@/api/scopes/configs';
 import type { DashboardWidget, OverviewDashboard } from 
'@skywalking-horizon-ui/api-client';
 
+/** `local` only when the operator opted to preview unpublished edits;
+ *  otherwise `remote` (the default runtime source of truth). */
+function preferParam(): 'local' | 'remote' {
+  try {
+    return useTemplatePreference().mode === 'local' ? 'local' : 'remote';
+  } catch {
+    return 'remote';
+  }
+}
+
 // 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.
@@ -88,7 +99,7 @@ export function ensureConfigBundle(): Promise<void> {
     }
     pushEvent('preload', 'start', 'Pre-loading dashboard + overview configs…');
     try {
-      const fresh = await bffClient.configs.bundle(cached?.etag);
+      const fresh = await bffClient.configs.bundle(cached?.etag, 
preferParam());
       if (fresh) {
         state.value = fresh;
         writeStorage(fresh);
@@ -123,7 +134,7 @@ export function ensureConfigBundle(): Promise<void> {
  */
 export async function refreshConfigBundle(): Promise<void> {
   try {
-    const fresh = await bffClient.configs.bundle();
+    const fresh = await bffClient.configs.bundle(undefined, preferParam());
     if (fresh) {
       state.value = fresh;
       writeStorage(fresh);
@@ -133,6 +144,13 @@ export async function refreshConfigBundle(): Promise<void> 
{
   }
 }
 
+/** Set the global local-vs-remote render preference and re-pull the
+ *  bundle so every dashboard re-renders from the chosen source. */
+export async function setTemplateRenderMode(mode: 'local' | 'remote'): 
Promise<void> {
+  useTemplatePreference().set(mode);
+  await refreshConfigBundle();
+}
+
 /** Sync lookup. Returns null when the bundle hasn't loaded yet OR
  *  when the (layer, scope) pair has no widgets configured. */
 export function getDashboardConfig(
diff --git a/apps/ui/src/controls/templatePreference.ts 
b/apps/ui/src/controls/templatePreference.ts
new file mode 100644
index 0000000..9d3f858
--- /dev/null
+++ b/apps/ui/src/controls/templatePreference.ts
@@ -0,0 +1,70 @@
+/*
+ * 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.
+ */
+
+/**
+ * Global, per-session choice for how to render templates that diverge
+ * between the local bundled copy and the OAP-stored (remote) copy:
+ *
+ *   - `remote` — render OAP's stored template (the live version everyone
+ *     sees). Default.
+ *   - `local`  — render the local bundled copy, so an operator can
+ *     preview unpublished edits before pushing them with "Sync all".
+ *
+ * The choice is one global setting (applies to every diverged template)
+ * and is **per login session**: it is cleared on login so the operator
+ * is re-prompted each time. Backed by sessionStorage so it survives a
+ * page reload within the same session but resets on re-login.
+ */
+
+import { defineStore } from 'pinia';
+import { ref } from 'vue';
+
+export type TemplateRenderMode = 'local' | 'remote';
+
+const SS_KEY = 'horizon:templateRenderMode';
+
+function readStored(): TemplateRenderMode | null {
+  if (typeof sessionStorage === 'undefined') return null;
+  const v = sessionStorage.getItem(SS_KEY);
+  return v === 'local' || v === 'remote' ? v : null;
+}
+
+export const useTemplatePreference = defineStore('template-preference', () => {
+  /** `null` until the operator chooses (or is auto-defaulted). */
+  const mode = ref<TemplateRenderMode | null>(readStored());
+
+  function set(m: TemplateRenderMode): void {
+    mode.value = m;
+    try {
+      sessionStorage.setItem(SS_KEY, m);
+    } catch {
+      /* sessionStorage unavailable — in-memory still works */
+    }
+  }
+
+  /** Clear the choice so the next landing re-prompts. Called on login. */
+  function reset(): void {
+    mode.value = null;
+    try {
+      sessionStorage.removeItem(SS_KEY);
+    } catch {
+      /* noop */
+    }
+  }
+
+  return { mode, set, reset };
+});
diff --git a/apps/ui/src/features/admin/_shared/SyncAllButton.vue 
b/apps/ui/src/features/admin/_shared/SyncAllButton.vue
index f369d92..88d3e3d 100644
--- a/apps/ui/src/features/admin/_shared/SyncAllButton.vue
+++ b/apps/ui/src/features/admin/_shared/SyncAllButton.vue
@@ -25,13 +25,21 @@
 import { computed, ref } from 'vue';
 import Modal from '@/features/operate/_shared/Modal.vue';
 import { useTemplateSync } from '@/features/admin/_shared/useTemplateSync';
-import { refreshConfigBundle } from '@/controls/configBundle';
+import { refreshConfigBundle, setTemplateRenderMode } from 
'@/controls/configBundle';
+import { useTemplatePreference } from '@/controls/templatePreference';
 import { bffClient } from '@/api/client';
 import type { TemplateKind } from '@/api/scopes/configs';
 
 const props = defineProps<{ kind: TemplateKind }>();
 
 const sync = useTemplateSync({ kind: props.kind });
+const pref = useTemplatePreference();
+
+// Effective render source for diverged templates (null defaults to remote).
+const renderMode = computed<'local' | 'remote'>(() => (pref.mode === 'local' ? 
'local' : 'remote'));
+function setMode(m: 'local' | 'remote'): void {
+  if (renderMode.value !== m) void setTemplateRenderMode(m);
+}
 
 /** Templates whose bundled copy differs from OAP (push targets). */
 const diffNames = computed<string[]>(() => {
@@ -85,6 +93,11 @@ async function confirmSync(): Promise<void> {
 </script>
 
 <template>
+  <span class="sab__display" title="Which version this session renders for 
templates that differ from OAP.">
+    <span class="sab__display-label">Showing</span>
+    <button class="sab__seg" :class="{ on: renderMode === 'local' }" 
type="button" @click="setMode('local')">Local</button>
+    <button class="sab__seg" :class="{ on: renderMode === 'remote' }" 
type="button" @click="setMode('remote')">Remote</button>
+  </span>
   <button
     class="sw-btn"
     type="button"
@@ -141,6 +154,26 @@ async function confirmSync(): Promise<void> {
   font-size: 10px;
   font-weight: 700;
 }
+.sab__display {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  margin-right: 8px;
+  font-size: 11px;
+}
+.sab__display-label { color: var(--sw-fg-3); margin-right: 2px; }
+.sab__seg {
+  padding: 2px 8px;
+  border: 1px solid var(--sw-line);
+  background: transparent;
+  color: var(--sw-fg-2);
+  font: inherit;
+  font-size: 11px;
+  cursor: pointer;
+}
+.sab__seg:first-of-type { border-radius: 5px 0 0 5px; }
+.sab__seg:last-of-type { border-radius: 0 5px 5px 0; border-left: none; }
+.sab__seg.on { background: var(--sw-accent); color: #1a1a1a; border-color: 
var(--sw-accent); font-weight: 600; }
 .sab__body { padding: 4px 2px; }
 .sab__lede { margin: 0 0 10px; font-size: 12px; color: var(--sw-fg-2); 
line-height: 1.5; }
 .sab__list {
diff --git a/apps/ui/src/shell/AppShell.vue b/apps/ui/src/shell/AppShell.vue
index d254b69..6a9bf18 100644
--- a/apps/ui/src/shell/AppShell.vue
+++ b/apps/ui/src/shell/AppShell.vue
@@ -23,6 +23,7 @@ import DebugEventPanel from './DebugEventPanel.vue';
 import GlobalConnectivityBanner from './GlobalConnectivityBanner.vue';
 import TracePopout from '@/layer/traces/TracePopout.vue';
 import ZipkinTracePopout from '@/layer/traces/ZipkinTracePopout.vue';
+import TemplateConflictPrompt from './TemplateConflictPrompt.vue';
 import { ensureConfigBundle, useConfigBundle } from '@/controls/configBundle';
 import { useClickTracking } from '@/controls/useClickTracking';
 import { useLayers } from '@/shell/useLayers';
@@ -129,6 +130,9 @@ const { enabled: debugPanelEnabled } = useDebugPanel();
          collision (e.g. an operator drilling into a Zipkin trace from
          a Logs row → trace link on a mesh layer). -->
     <ZipkinTracePopout />
+    <!-- Per-session prompt: when local template edits diverge from OAP,
+         ask once which version to render (local preview vs remote live). -->
+    <TemplateConflictPrompt />
     <!-- Bottom-fixed framework-event panel. Self-hides when the Admin →
          "Debug events" toggle is off (default off in production, on
          when hostname looks local). Always mounted so the toggle
diff --git a/apps/ui/src/shell/TemplateConflictPrompt.vue 
b/apps/ui/src/shell/TemplateConflictPrompt.vue
new file mode 100644
index 0000000..e692881
--- /dev/null
+++ b/apps/ui/src/shell/TemplateConflictPrompt.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-session conflict prompt. When the operator lands with templates
+  that diverge between the local bundled copy and the OAP-stored remote
+  copy, ask once which version to render: their LOCAL (unpublished) edits
+  or the REMOTE (live) version. The choice is global and per login
+  session (reset on login). Until chosen, the runtime defaults to remote.
+-->
+<script setup lang="ts">
+import { computed } from 'vue';
+import Modal from '@/features/operate/_shared/Modal.vue';
+import { useConfigBundle, setTemplateRenderMode } from 
'@/controls/configBundle';
+import { useTemplatePreference } from '@/controls/templatePreference';
+import { useLayers } from '@/shell/useLayers';
+
+const { bundle } = useConfigBundle();
+const pref = useTemplatePreference();
+const { layers } = useLayers();
+
+/** Diverged templates named from the operator's MENU perspective —
+ *  the layer's sidebar label / overview title, not the template file. */
+const divergedItems = computed<string[]>(() => {
+  const badges = (bundle.value?.syncStatus?.badges ?? []).filter((b) => 
b.status === 'diverged');
+  const overviews = bundle.value?.overviews ?? [];
+  return badges.map((b) => {
+    if (b.kind === 'layer') {
+      const L = layers.value.find((l) => l.key.toUpperCase() === 
b.key.toUpperCase());
+      return L?.name ? `Layer · ${L.name}` : `Layer · ${b.key}`;
+    }
+    if (b.kind === 'overview') {
+      const ov = overviews.find((o) => o.id === b.key);
+      return ov?.title ? `Overview · ${ov.title}` : `Overview · ${b.key}`;
+    }
+    return b.key;
+  });
+});
+const divergedCount = computed(() => divergedItems.value.length);
+const open = computed(() => pref.mode === null && divergedCount.value > 0);
+
+async function choose(mode: 'local' | 'remote'): Promise<void> {
+  await setTemplateRenderMode(mode);
+}
+</script>
+
+<template>
+  <Modal :open="open" title="Local template changes not published">
+    <div class="tcp">
+      <p class="tcp__lede">
+        <b>{{ divergedCount }}</b> dashboard{{ divergedCount === 1 ? '' : 's' 
}} differ between your
+        <b>local</b> edits and what the OAP cluster currently serves 
(<b>remote</b>). Which version
+        should this session render?
+      </p>
+      <ul class="tcp__list">
+        <li v-for="(name, i) in divergedItems" :key="i">{{ name }}</li>
+      </ul>
+      <ul class="tcp__opts">
+        <li><b>Local</b> — preview your unpublished edits. Nothing is sent to 
OAP; publish later with “Sync all to OAP”.</li>
+        <li><b>Remote</b> — show the live version everyone else sees. Your 
local edits stay on disk, unpublished.</li>
+      </ul>
+    </div>
+    <template #footer>
+      <button class="sw-btn" type="button" @click="choose('remote')">Use 
remote (live)</button>
+      <button class="sw-btn primary" type="button" 
@click="choose('local')">Use my local edits</button>
+    </template>
+  </Modal>
+</template>
+
+<style scoped>
+.tcp { padding: 4px 2px; }
+.tcp__lede { margin: 0 0 10px; font-size: 12.5px; color: var(--sw-fg-1); 
line-height: 1.55; }
+.tcp__list {
+  margin: 0 0 12px;
+  padding: 8px 10px 8px 24px;
+  max-height: 30vh;
+  overflow: auto;
+  border: 1px solid var(--sw-line);
+  border-radius: 6px;
+  background: var(--sw-bg-2);
+  font-size: 11.5px;
+  color: var(--sw-fg-1);
+  line-height: 1.6;
+}
+.tcp__opts { margin: 0; padding-left: 18px; font-size: 11.5px; color: 
var(--sw-fg-2); line-height: 1.6; }
+.tcp__opts b { color: var(--sw-fg-0); }
+</style>
diff --git a/apps/ui/src/state/auth.ts b/apps/ui/src/state/auth.ts
index b324dbb..b3249a9 100644
--- a/apps/ui/src/state/auth.ts
+++ b/apps/ui/src/state/auth.ts
@@ -18,6 +18,7 @@
 import { defineStore } from 'pinia';
 import { computed, ref } from 'vue';
 import { BffApiError, bffClient, type MeResponse } from '@/api/client';
+import { useTemplatePreference } from '@/controls/templatePreference';
 
 export const useAuthStore = defineStore('auth', () => {
   const user = ref<MeResponse | null>(null);
@@ -39,6 +40,8 @@ export const useAuthStore = defineStore('auth', () => {
     loginError.value = null;
     try {
       user.value = await bffClient.session.login(username, password);
+      // New login session → re-prompt the local-vs-remote template choice.
+      useTemplatePreference().reset();
       return true;
     } catch (err) {
       if (err instanceof BffApiError && err.status === 401) {

Reply via email to