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

commit 94f864eb4507355a39d43cf061517055cd5e0086
Author: Wu Sheng <[email protected]>
AuthorDate: Thu May 21 17:25:35 2026 +0800

    admin: add "Sync all to OAP" (diffs-only) with confirm + diverged filter
    
    A single batch push that writes the bundled copy of only the templates
    that differ from OAP (diverged) or are absent on OAP (bundled-fallback),
    scoped per family (layer / overview) and re-derived server-side so it's a
    no-op for already-synced templates. The push always goes through a manual
    confirm dialog listing exactly which templates will be written, since it
    mutates OAP's shared store. The layer admin list gains a "Diverged only"
    filter. After a push the config bundle is force-refreshed (the bundled
    etag is unchanged, so an etag-gated fetch would keep stale badges).
    Validated on demo: syncing pushed only the 1 diverged layer.
---
 apps/bff/src/http/admin/template-sync.ts           |  44 ++++++
 apps/bff/src/rbac/route-policy.ts                  |   1 +
 apps/ui/src/api/scopes/template-sync.ts            |  20 +++
 apps/ui/src/controls/configBundle.ts               |  19 +++
 .../src/features/admin/_shared/SyncAllButton.vue   | 158 +++++++++++++++++++++
 .../admin/layer-templates/LayerDashboardsAdmin.vue |  55 ++++++-
 .../overview-templates/OverviewTemplatesAdmin.vue  |   6 +
 7 files changed, 298 insertions(+), 5 deletions(-)

diff --git a/apps/bff/src/http/admin/template-sync.ts 
b/apps/bff/src/http/admin/template-sync.ts
index 07a9854..bba5551 100644
--- a/apps/bff/src/http/admin/template-sync.ts
+++ b/apps/bff/src/http/admin/template-sync.ts
@@ -147,6 +147,50 @@ export function registerTemplateSyncAdminRoutes(
     },
   );
 
+  // Sync-all: push the bundled copy of every template that differs from
+  // OAP (status `diverged`) or is absent from OAP (status
+  // `bundled-fallback`) in one batch. Optionally scoped to a single
+  // `kind` so the layer / overview admin pages each sync only their own
+  // family. The caller is expected to have confirmed the operation (the
+  // UI shows the affected list first); this route re-derives the diff
+  // set server-side so a stale UI can't push something already in sync.
+  app.post<{
+    Body: { kind?: TemplateKind };
+  }>('/api/admin/templates/sync-all', { 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 — templates are read-only',
+      });
+    }
+    const targets = status.rows.filter(
+      (r) =>
+        (!kind || r.kind === kind) &&
+        !!r.bundled &&
+        (r.status === 'diverged' || r.status === 'bundled-fallback'),
+    );
+    const synced: string[] = [];
+    const failed: Array<{ name: string; error: string }> = [];
+    for (const row of targets) {
+      try {
+        if (row.remote) {
+          await deps.uiTemplateClient().update(row.remote.id, 
row.bundled!.configuration);
+        } else {
+          await deps.uiTemplateClient().create(row.bundled!.configuration);
+        }
+        synced.push(row.name);
+      } catch (err) {
+        logger.warn({ err: errMsg(err), name: row.name }, 'sync-all push 
failed');
+        failed.push({ name: row.name, error: errMsg(err) });
+      }
+    }
+    resync();
+    const fresh = await loadStatus(deps);
+    return reply.send({ ...fresh, synced, failed });
+  });
+
   app.post<{
     Body: {
       name?: string;
diff --git a/apps/bff/src/rbac/route-policy.ts 
b/apps/bff/src/rbac/route-policy.ts
index 94500f3..9bfd4c9 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/:name/push-bundled':  'overview:write',
+  'POST /api/admin/templates/sync-all':            'overview:write',
 
   // ── Auth/admin self-introspection ────────────────────────────────
   'GET /api/admin/auth-status':                    'auth:read',
diff --git a/apps/ui/src/api/scopes/template-sync.ts 
b/apps/ui/src/api/scopes/template-sync.ts
index cc28826..7aad7eb 100644
--- a/apps/ui/src/api/scopes/template-sync.ts
+++ b/apps/ui/src/api/scopes/template-sync.ts
@@ -75,4 +75,24 @@ export class TemplateSyncApi {
       `/api/admin/templates/${encodeURIComponent(name)}/push-bundled`,
     );
   }
+
+  /** 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
+   *  BFF re-derives the diff set, so this is a no-op for already-synced
+   *  templates. Returns the fresh status plus what was pushed. */
+  syncAll(kind?: TemplateKind): Promise<TemplateSyncAllResult> {
+    return this.bff.request<TemplateSyncAllResult>(
+      'POST',
+      '/api/admin/templates/sync-all',
+      kind ? { kind } : {},
+    );
+  }
+}
+
+export interface TemplateSyncAllResult extends TemplateSyncStatus {
+  /** Template names successfully pushed to OAP. */
+  synced: string[];
+  /** Templates that failed to push, with the error message. */
+  failed: Array<{ name: string; error: string }>;
 }
diff --git a/apps/ui/src/controls/configBundle.ts 
b/apps/ui/src/controls/configBundle.ts
index 76da50f..4c46893 100644
--- a/apps/ui/src/controls/configBundle.ts
+++ b/apps/ui/src/controls/configBundle.ts
@@ -114,6 +114,25 @@ export function ensureConfigBundle(): Promise<void> {
   return loadPromise;
 }
 
+/**
+ * Force a fresh bundle pull, ignoring the cached etag. Needed after a
+ * template push to OAP: the bundled content is unchanged (so an
+ * etag-gated fetch would 304 and keep stale `syncStatus` badges), but
+ * the OAP-side sync state HAS changed. Fetches without the etag so the
+ * server returns the full bundle with a freshly computed `syncStatus`.
+ */
+export async function refreshConfigBundle(): Promise<void> {
+  try {
+    const fresh = await bffClient.configs.bundle();
+    if (fresh) {
+      state.value = fresh;
+      writeStorage(fresh);
+    }
+  } catch {
+    /* leave the previous bundle in place — badges just stay stale */
+  }
+}
+
 /** 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/features/admin/_shared/SyncAllButton.vue 
b/apps/ui/src/features/admin/_shared/SyncAllButton.vue
new file mode 100644
index 0000000..f369d92
--- /dev/null
+++ b/apps/ui/src/features/admin/_shared/SyncAllButton.vue
@@ -0,0 +1,158 @@
+<!--
+  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.
+-->
+<!--
+  "Sync all to OAP" for one template family. Pushes the bundled copy of
+  every template that differs from OAP (diverged) or is missing on OAP
+  (bundled-fallback) — already-synced templates are left untouched. The
+  push always goes through a manual confirm listing exactly what will be
+  written, since it mutates OAP's shared template store.
+-->
+<script setup lang="ts">
+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 { bffClient } from '@/api/client';
+import type { TemplateKind } from '@/api/scopes/configs';
+
+const props = defineProps<{ kind: TemplateKind }>();
+
+const sync = useTemplateSync({ kind: props.kind });
+
+/** Templates whose bundled copy differs from OAP (push targets). */
+const diffNames = computed<string[]>(() => {
+  const s = sync.status.value;
+  if (!s) return [];
+  return s.badges
+    .filter(
+      (b) => b.kind === props.kind && (b.status === 'diverged' || b.status === 
'bundled-fallback'),
+    )
+    .map((b) => b.name)
+    .sort();
+});
+
+const open = ref(false);
+const busy = ref(false);
+const result = ref<{ synced: number; failed: { name: string; error: string }[] 
} | null>(null);
+
+const disabled = computed(() => sync.readOnly.value || diffNames.value.length 
=== 0);
+const buttonTitle = computed(() => {
+  if (sync.readOnly.value) return 'OAP unreachable — page is read-only';
+  if (diffNames.value.length === 0) return 'Everything already matches OAP — 
nothing to push';
+  return `Push ${diffNames.value.length} changed template(s) to OAP`;
+});
+
+function openConfirm(): void {
+  if (disabled.value) return;
+  result.value = null;
+  open.value = true;
+}
+
+async function confirmSync(): Promise<void> {
+  if (busy.value) return;
+  busy.value = true;
+  try {
+    const res = await bffClient.templateSync.syncAll(props.kind);
+    await refreshConfigBundle();
+    result.value = { synced: res.synced.length, failed: res.failed };
+    if (res.failed.length === 0) {
+      // Clean success — close shortly so the operator sees the count.
+      setTimeout(() => {
+        open.value = false;
+        result.value = null;
+      }, 1400);
+    }
+  } catch (err) {
+    result.value = { synced: 0, failed: [{ name: '—', error: err instanceof 
Error ? err.message : String(err) }] };
+  } finally {
+    busy.value = false;
+  }
+}
+</script>
+
+<template>
+  <button
+    class="sw-btn"
+    type="button"
+    :disabled="disabled"
+    :title="buttonTitle"
+    @click="openConfirm"
+  >
+    Sync all to OAP<span v-if="diffNames.length" class="sab__count">{{ 
diffNames.length }}</span>
+  </button>
+
+  <Modal :open="open" title="Sync all to OAP" @close="open = false">
+    <div v-if="!result" class="sab__body">
+      <p class="sab__lede">
+        Push the bundled copy of these <b>{{ diffNames.length }}</b> 
template(s) to OAP,
+        overwriting what OAP currently stores. Already-synced templates are 
not touched.
+        This affects every operator using this OAP.
+      </p>
+      <ul class="sab__list">
+        <li v-for="n in diffNames" :key="n" class="mono">{{ n }}</li>
+      </ul>
+    </div>
+    <div v-else class="sab__body">
+      <p class="sab__lede">
+        Pushed <b>{{ result.synced }}</b> template(s).
+        <span v-if="result.failed.length" class="sab__err">{{ 
result.failed.length }} failed.</span>
+      </p>
+      <ul v-if="result.failed.length" class="sab__list">
+        <li v-for="f in result.failed" :key="f.name" class="mono sab__err">{{ 
f.name }}: {{ f.error }}</li>
+      </ul>
+    </div>
+
+    <template #footer>
+      <button class="sw-btn ghost" type="button" @click="open = false">{{ 
result ? 'Close' : 'Cancel' }}</button>
+      <button
+        v-if="!result"
+        class="sw-btn primary"
+        type="button"
+        :disabled="busy || diffNames.length === 0"
+        @click="confirmSync"
+      >
+        {{ busy ? 'Pushing…' : `Push ${diffNames.length} to OAP` }}
+      </button>
+    </template>
+  </Modal>
+</template>
+
+<style scoped>
+.sab__count {
+  margin-left: 6px;
+  padding: 0 6px;
+  border-radius: 8px;
+  background: var(--sw-accent);
+  color: #1a1a1a;
+  font-size: 10px;
+  font-weight: 700;
+}
+.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 {
+  margin: 0;
+  padding: 8px 10px;
+  list-style: none;
+  max-height: 40vh;
+  overflow: auto;
+  border: 1px solid var(--sw-line);
+  border-radius: 6px;
+  background: var(--sw-bg-2);
+}
+.sab__list li { font-size: 11.5px; padding: 2px 0; color: var(--sw-fg-1); }
+.sab__err { color: var(--sw-err); }
+</style>
diff --git 
a/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue 
b/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
index 6e88db9..4074404 100644
--- a/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
+++ b/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
@@ -51,6 +51,7 @@ 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 SyncAllButton from '@/features/admin/_shared/SyncAllButton.vue';
 import TemplateStatusBadge from 
'@/features/admin/_shared/TemplateStatusBadge.vue';
 import TemplateDiffModal from '@/features/admin/_shared/TemplateDiffModal.vue';
 import { useTemplateSync } from '@/features/admin/_shared/useTemplateSync';
@@ -104,12 +105,22 @@ const saveMsg = ref<string | null>(null);
 const layerListOpen = ref(true);
 /** Free-text filter for the layers rail — matches alias or key. */
 const layerSearch = ref('');
+// When on, the list shows only layers whose bundled copy differs from
+// OAP (diverged) or isn't on OAP yet (bundled-fallback) — i.e. the set
+// "Sync all to OAP" would push. Off shows every layer.
+const divergedOnly = ref(false);
+function isDivergedRow(key: string): boolean {
+  const s = sync.badgeFor(`horizon.layer.${key}`);
+  return s === 'diverged' || s === 'bundled-fallback';
+}
+const divergedCount = computed(() => templates.value.filter((t) => 
isDivergedRow(t.key)).length);
 const filteredTemplates = computed<AdminLayerTemplate[]>(() => {
   const q = layerSearch.value.trim().toLowerCase();
-  if (!q) return templates.value;
-  return templates.value.filter(
-    (t) => (t.alias ?? '').toLowerCase().includes(q) || 
t.key.toLowerCase().includes(q),
-  );
+  return templates.value.filter((t) => {
+    if (divergedOnly.value && !isDivergedRow(t.key)) return false;
+    if (!q) return true;
+    return (t.alias ?? '').toLowerCase().includes(q) || 
t.key.toLowerCase().includes(q);
+  });
 });
 
 /** Working copy — reactively edited. Diffs against `templates` to drive
@@ -947,6 +958,13 @@ const namingTest = computed<NamingTestResult>(() => {
               spellcheck="false"
             />
           </div>
+          <div class="list-actions">
+            <label class="diverged-filter" :class="{ on: divergedOnly }" 
:title="divergedCount === 0 ? 'No layers differ from OAP' : `${divergedCount} 
layer(s) differ from OAP`">
+              <input v-model="divergedOnly" type="checkbox" 
:disabled="divergedCount === 0" />
+              Diverged only<span v-if="divergedCount" 
class="diverged-count">{{ divergedCount }}</span>
+            </label>
+            <SyncAllButton kind="layer" />
+          </div>
           <button
             v-for="t in filteredTemplates"
             :key="t.key"
@@ -959,7 +977,7 @@ const namingTest = computed<NamingTestResult>(() => {
             <TemplateStatusBadge 
:status="sync.badgeFor(`horizon.layer.${t.key}`)" />
           </button>
           <p v-if="filteredTemplates.length === 0" class="list-empty">
-            No layers match “{{ layerSearch }}”.
+            {{ divergedOnly && !layerSearch.trim() ? 'No layers differ from 
OAP.' : `No layers match “${layerSearch}”.` }}
           </p>
         </template>
         <!-- Collapsed mode shows just colored dots for navigation; click
@@ -2128,6 +2146,33 @@ const namingTest = computed<NamingTestResult>(() => {
   outline: none;
   border-color: var(--sw-accent);
 }
+.list-actions {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 0 10px 8px;
+}
+.diverged-filter {
+  display: inline-flex;
+  align-items: center;
+  gap: 5px;
+  font-size: 11px;
+  color: var(--sw-fg-2);
+  cursor: pointer;
+  user-select: none;
+}
+.diverged-filter input:disabled { cursor: not-allowed; }
+.diverged-filter.on { color: var(--sw-fg-0); }
+.diverged-count {
+  margin-left: 2px;
+  padding: 0 5px;
+  border-radius: 8px;
+  background: var(--sw-warn, var(--sw-accent));
+  color: #1a1a1a;
+  font-size: 10px;
+  font-weight: 700;
+}
+.list-actions .sw-btn { margin-left: auto; }
 .list-empty {
   padding: 8px 10px;
   font-size: 11px;
diff --git 
a/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue 
b/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue
index 1667a07..882919b 100644
--- a/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue
+++ b/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue
@@ -46,6 +46,7 @@ import type {
 import { bff } from '@/api/client';
 import { useLayers } from '@/shell/useLayers';
 import SyncStatusBanner from '@/features/admin/_shared/SyncStatusBanner.vue';
+import SyncAllButton from '@/features/admin/_shared/SyncAllButton.vue';
 import TemplateStatusBadge from 
'@/features/admin/_shared/TemplateStatusBadge.vue';
 import TemplateDiffModal from '@/features/admin/_shared/TemplateDiffModal.vue';
 import { useTemplateSync } from '@/features/admin/_shared/useTemplateSync';
@@ -504,6 +505,10 @@ function widgetKindLabel(type: OverviewWidget['type']): 
string {
 
     <SyncStatusBanner :banner="sync.banner.value" />
 
+    <div class="ot__toolbar">
+      <SyncAllButton kind="overview" />
+    </div>
+
     <div v-if="listQuery.isPending.value" class="ot__empty">loading…</div>
 
     <div v-else class="ot__split">
@@ -1083,6 +1088,7 @@ function widgetKindLabel(type: OverviewWidget['type']): 
string {
   color: var(--sw-fg-0); background: var(--sw-bg-2); padding: 1px 5px; 
border-radius: 3px;
 }
 .ot__empty { padding: 32px; text-align: center; color: var(--sw-fg-3); 
font-size: 12px; }
+.ot__toolbar { display: flex; justify-content: flex-end; margin: 8px 0; }
 
 .ot__split { display: grid; grid-template-columns: 280px 1fr; gap: 16px; 
align-items: start; }
 .ot__list {

Reply via email to