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 ffbe7f5  admin(conflict): "use live" overrides local; forced-choice 
prompt
ffbe7f5 is described below

commit ffbe7f56c5eca31414b332a2696f8227ffdfdedf
Author: Wu Sheng <[email protected]>
AuthorDate: Thu May 21 21:07:36 2026 +0800

    admin(conflict): "use live" overrides local; forced-choice prompt
    
    Reframe the divergence prompt around "OAP holds a newer version":
    - "Keep my local edits" → render local (preview), publish later.
    - "Use live (remote)" → confirm, then OVERRIDE local: a new
      revert-local route overwrites the bundled file with the remote
      version for every diverged template (the reconciliation when remote
      wins). The confirm states the local edits are discarded and cannot be
      recovered.
    
    The prompt is a forced choice — Modal gains a `dismissable` prop
    (default true); the conflict prompt sets it false so there's no dead ×
    button and no backdrop/Escape dismiss.
---
 apps/bff/src/http/admin/template-sync.ts       | 46 +++++++++++++++++++++++
 apps/bff/src/rbac/route-policy.ts              |  1 +
 apps/ui/src/api/scopes/template-sync.ts        | 11 ++++++
 apps/ui/src/features/operate/_shared/Modal.vue | 13 +++++--
 apps/ui/src/shell/TemplateConflictPrompt.vue   | 52 ++++++++++++++++++--------
 5 files changed, 105 insertions(+), 18 deletions(-)

diff --git a/apps/bff/src/http/admin/template-sync.ts 
b/apps/bff/src/http/admin/template-sync.ts
index ddaafad..99b08b1 100644
--- a/apps/bff/src/http/admin/template-sync.ts
+++ b/apps/bff/src/http/admin/template-sync.ts
@@ -63,11 +63,14 @@ import {
 import { iterateBundledTemplates } from '../../logic/templates/aggregator.js';
 import {
   buildEnvelope,
+  parseEnvelope,
   parseName,
   serializeEnvelope,
   type TemplateKind,
 } from '../../logic/templates/names.js';
+import type { LayerTemplate } from '../../logic/layers/loader.js';
 import { writeLayerTemplate } from '../../logic/layers/loader.js';
+import type { OverviewDashboard } from '@skywalking-horizon-ui/api-client';
 import { writeOverviewDashboard } from '../../logic/overview/loader.js';
 import { logger } from '../../logger.js';
 
@@ -193,6 +196,49 @@ export function registerTemplateSyncAdminRoutes(
     return reply.send({ ...fresh, synced, failed });
   });
 
+  // Revert-local: discard local edits by overwriting the BUNDLED file
+  // with the REMOTE (live) version for every diverged template — the
+  // "use live, override local" reconciliation when OAP holds the newer
+  // copy. Optionally scoped by `kind`. Requires OAP reachable (we need
+  // the remote content).
+  app.post<{
+    Body: { kind?: TemplateKind };
+  }>('/api/admin/templates/revert-local', { preHandler: auth }, async (req, 
reply) => {
+    const kind = req.body?.kind;
+    const status = await loadStatus(deps);
+    if (status.unreachable) {
+      return reply.code(409).send({
+        code: 'oap_unreachable',
+        message: 'OAP admin port unreachable — cannot fetch the remote 
version',
+      });
+    }
+    const targets = status.rows.filter(
+      (r) => (!kind || r.kind === kind) && r.status === 'diverged' && 
!!r.remote,
+    );
+    const reverted: string[] = [];
+    const failed: Array<{ name: string; error: string }> = [];
+    for (const row of targets) {
+      try {
+        const env = parseEnvelope(row.remote!.configuration);
+        if (!env) throw new Error('remote envelope not parseable');
+        if (row.kind === 'layer') {
+          writeLayerTemplate(env.content as LayerTemplate);
+        } else if (row.kind === 'overview') {
+          writeOverviewDashboard(row.key, env.content as OverviewDashboard);
+        } else {
+          throw new Error(`revert supports layer + overview, not ${row.kind}`);
+        }
+        reverted.push(row.name);
+      } catch (err) {
+        logger.warn({ err: errMsg(err), name: row.name }, 'revert-local 
failed');
+        failed.push({ name: row.name, error: errMsg(err) });
+      }
+    }
+    resync();
+    const fresh = await loadStatus(deps);
+    return reply.send({ ...fresh, reverted, failed });
+  });
+
   // Save-local: write the edited template to the BUNDLED file on disk
   // (the local seed), NOT to OAP. The in-memory cache is refreshed so
   // the local instance immediately renders the edit; the template then
diff --git a/apps/bff/src/rbac/route-policy.ts 
b/apps/bff/src/rbac/route-policy.ts
index 02fa118..e4dd6cb 100644
--- a/apps/bff/src/rbac/route-policy.ts
+++ b/apps/bff/src/rbac/route-policy.ts
@@ -205,6 +205,7 @@ export const ROUTE_POLICY: Record<string, RoutePolicy> = {
   'POST /api/admin/templates/resync':              'overview:write',
   'POST /api/admin/templates/save':                'overview:write',
   'POST /api/admin/templates/save-local':          'overview:write',
+  'POST /api/admin/templates/revert-local':        'overview:write',
   'POST /api/admin/templates/:name/push-bundled':  'overview:write',
   'POST /api/admin/templates/sync-all':            'overview:write',
 
diff --git a/apps/ui/src/api/scopes/template-sync.ts 
b/apps/ui/src/api/scopes/template-sync.ts
index 8bb81e5..88b311e 100644
--- a/apps/ui/src/api/scopes/template-sync.ts
+++ b/apps/ui/src/api/scopes/template-sync.ts
@@ -87,6 +87,17 @@ export class TemplateSyncApi {
     );
   }
 
+  /** Discard local edits: overwrite the bundled file with the REMOTE
+   *  (live) version for every diverged template. The "use live, override
+   *  local" reconciliation. Optionally scoped by `kind`. */
+  revertLocal(kind?: TemplateKind): Promise<TemplateSyncStatus> {
+    return this.bff.request<TemplateSyncStatus>(
+      'POST',
+      '/api/admin/templates/revert-local',
+      kind ? { kind } : {},
+    );
+  }
+
   /** Push the bundled copy of every template that differs from OAP
    *  (`diverged`) or is absent on OAP (`bundled-fallback`), in one batch.
    *  Scoped to `kind` so each admin page syncs only its own family. The
diff --git a/apps/ui/src/features/operate/_shared/Modal.vue 
b/apps/ui/src/features/operate/_shared/Modal.vue
index f6db00f..9170cb6 100644
--- a/apps/ui/src/features/operate/_shared/Modal.vue
+++ b/apps/ui/src/features/operate/_shared/Modal.vue
@@ -29,14 +29,20 @@ const props = withDefaults(
      *  leftover height and scrolls internally, so the popout never grows
      *  a vertical scrollbar. */
     fitBody?: boolean;
+    /** When false the dialog is a forced choice: no × button, and
+     *  backdrop-click / Escape don't dismiss it. Defaults true. */
+    dismissable?: boolean;
   }>(),
-  { width: '520px', fitBody: false },
+  { width: '520px', fitBody: false, dismissable: true },
 );
 
 const emit = defineEmits<{ close: [] }>();
 
+function onBackdrop(): void {
+  if (props.dismissable) emit('close');
+}
 function onKey(e: KeyboardEvent): void {
-  if (props.open && e.key === 'Escape') emit('close');
+  if (props.open && props.dismissable && e.key === 'Escape') emit('close');
 }
 
 onMounted(() => window.addEventListener('keydown', onKey));
@@ -45,11 +51,12 @@ onBeforeUnmount(() => window.removeEventListener('keydown', 
onKey));
 
 <template>
   <Teleport to="body">
-    <div v-if="open" class="modal" role="dialog" aria-modal="true" 
@click.self="emit('close')">
+    <div v-if="open" class="modal" role="dialog" aria-modal="true" 
@click.self="onBackdrop">
       <div class="modal__panel" :class="{ 'modal__panel--fit': fitBody }" 
:style="{ width: props.width }">
         <header class="modal__header">
           <span class="modal__title">{{ title }}</span>
           <button
+            v-if="dismissable"
             type="button"
             class="modal__close"
             aria-label="close"
diff --git a/apps/ui/src/shell/TemplateConflictPrompt.vue 
b/apps/ui/src/shell/TemplateConflictPrompt.vue
index def196c..8a25d14 100644
--- a/apps/ui/src/shell/TemplateConflictPrompt.vue
+++ b/apps/ui/src/shell/TemplateConflictPrompt.vue
@@ -24,9 +24,10 @@
 <script setup lang="ts">
 import { computed, ref } from 'vue';
 import Modal from '@/features/operate/_shared/Modal.vue';
-import { useConfigBundle, setTemplateRenderMode } from 
'@/controls/configBundle';
+import { useConfigBundle, setTemplateRenderMode, refreshConfigBundle } from 
'@/controls/configBundle';
 import { useTemplatePreference } from '@/controls/templatePreference';
 import { useLayers } from '@/shell/useLayers';
+import { bffClient } from '@/api/client';
 
 const { bundle } = useConfigBundle();
 const pref = useTemplatePreference();
@@ -52,18 +53,37 @@ const divergedItems = computed<string[]>(() => {
 const divergedCount = computed(() => divergedItems.value.length);
 const open = computed(() => pref.mode === null && divergedCount.value > 0);
 
-// Second step shown when the operator picks "use live" — abandoning the
-// local preview for the remote version needs an explicit confirm.
+// Second step shown when the operator picks "use live" — overriding the
+// local edits with the remote version is destructive, so it's confirmed.
 const confirmingRemote = ref(false);
+const busy = ref(false);
 
-async function choose(mode: 'local' | 'remote'): Promise<void> {
-  confirmingRemote.value = false;
-  await setTemplateRenderMode(mode);
+async function useLocal(): Promise<void> {
+  await setTemplateRenderMode('local');
+}
+
+// Override local with remote: discard the local edits (revert bundled to
+// the live version), then render remote.
+async function useLive(): Promise<void> {
+  if (busy.value) return;
+  busy.value = true;
+  try {
+    await bffClient.templateSync.revertLocal();
+    await refreshConfigBundle();
+    pref.set('remote');
+  } finally {
+    busy.value = false;
+    confirmingRemote.value = false;
+  }
 }
 </script>
 
 <template>
-  <Modal :open="open" :title="confirmingRemote ? 'Use the live version?' : 
'Local template changes not published'">
+  <Modal
+    :open="open"
+    :dismissable="false"
+    :title="confirmingRemote ? 'Use the live version?' : 'Local template 
changes not published'"
+  >
     <div v-if="!confirmingRemote" class="tcp">
       <p class="tcp__lede">
         <b>{{ divergedCount }}</b> dashboard{{ divergedCount === 1 ? '' : 's' 
}} differ between your
@@ -80,21 +100,23 @@ async function choose(mode: 'local' | 'remote'): 
Promise<void> {
     </div>
     <div v-else class="tcp">
       <p class="tcp__lede tcp__warn">
-        This session will render the <b>live (remote)</b> version. Your <b>{{ 
divergedCount }}</b>
-        local change{{ divergedCount === 1 ? '' : 's' }} will <b>not be 
shown</b> and stay
-        unpublished — publish them with “Sync all to OAP” first if you want to 
keep them, or they
-        can be lost the next time the bundled templates are regenerated. 
Continue?
+        OAP holds a different (newer) version. Using live will <b>overwrite 
your
+        {{ divergedCount }} local change{{ divergedCount === 1 ? '' : 's' 
}}</b> with the remote
+        version — your local edits are <b>discarded and cannot be 
recovered</b>. Publish them with
+        “Sync all to OAP” instead if you want to keep them. Override local 
with live?
       </p>
     </div>
 
     <template #footer>
       <template v-if="!confirmingRemote">
-        <button class="sw-btn" type="button" @click="confirmingRemote = 
true">Use remote (live)</button>
-        <button class="sw-btn primary" type="button" 
@click="choose('local')">Use my local edits</button>
+        <button class="sw-btn" type="button" @click="confirmingRemote = 
true">Use live (remote)</button>
+        <button class="sw-btn primary" type="button" @click="useLocal">Keep my 
local edits</button>
       </template>
       <template v-else>
-        <button class="sw-btn" type="button" @click="confirmingRemote = 
false">Back</button>
-        <button class="sw-btn danger" type="button" 
@click="choose('remote')">Use live, ignore local</button>
+        <button class="sw-btn" type="button" :disabled="busy" 
@click="confirmingRemote = false">Back</button>
+        <button class="sw-btn danger" type="button" :disabled="busy" 
@click="useLive">
+          {{ busy ? 'Overriding…' : 'Overwrite local with live' }}
+        </button>
       </template>
     </template>
   </Modal>

Reply via email to