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) {