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 {
