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 d8c2b91  admin: edit-locally → publish workflow (Save writes bundled, 
sync publishes)
d8c2b91 is described below

commit d8c2b9157bec242af01a2d75d6d877869c4a113e
Author: Wu Sheng <[email protected]>
AuthorDate: Thu May 21 20:33:23 2026 +0800

    admin: edit-locally → publish workflow (Save writes bundled, sync publishes)
    
    Admin Save now writes the LOCAL bundled template (new save-local route →
    writeLayerTemplate / writeOverviewDashboard, which refresh the in-memory
    cache) instead of pushing straight to OAP. The edit renders locally and
    shows as diverged until the operator publishes it via "Sync all to OAP".
    
    - Save works even when OAP is unreachable (relabelled "Save locally"); the
      read-only gate now only blocks the OAP push, not local edits.
    - After save, a clear tip: "Saved locally — not yet live for others. Use
      Sync all to OAP to publish."
    - The sidebar "Layer dashboards" / "Overview templates" rows show a yellow
      warning badge with the count of diverged (locally-edited, unpublished)
      templates.
    
    Validated on demo: the 9 locally-edited layer templates register as
    diverged, driving the warning badge.
---
 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            | 11 ++++++
 .../admin/layer-templates/LayerDashboardsAdmin.vue | 27 ++++++-------
 .../overview-templates/OverviewTemplatesAdmin.vue  | 24 +++++-------
 apps/ui/src/shell/AppSidebar.vue                   | 24 +++++++++++-
 6 files changed, 101 insertions(+), 30 deletions(-)

diff --git a/apps/bff/src/http/admin/template-sync.ts 
b/apps/bff/src/http/admin/template-sync.ts
index bba5551..ddaafad 100644
--- a/apps/bff/src/http/admin/template-sync.ts
+++ b/apps/bff/src/http/admin/template-sync.ts
@@ -67,6 +67,8 @@ import {
   serializeEnvelope,
   type TemplateKind,
 } from '../../logic/templates/names.js';
+import { writeLayerTemplate } from '../../logic/layers/loader.js';
+import { writeOverviewDashboard } from '../../logic/overview/loader.js';
 import { logger } from '../../logger.js';
 
 export interface TemplateSyncAdminDeps {
@@ -191,6 +193,48 @@ export function registerTemplateSyncAdminRoutes(
     return reply.send({ ...fresh, synced, 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
+  // shows as `diverged` until the operator manually pushes it to OAP
+  // via sync-all. This is the "edit locally → preview → publish" flow.
+  app.post<{
+    Body: { name?: string; content?: unknown };
+  }>('/api/admin/templates/save-local', { preHandler: auth }, async (req, 
reply) => {
+    const { name, content } = req.body ?? {};
+    if (typeof name !== 'string' || content === undefined || content === null 
|| typeof content !== 'object') {
+      return reply.code(400).send({
+        code: 'invalid_save_body',
+        message: 'body must be { name: string, content: object }',
+      });
+    }
+    const parsed = parseName(name);
+    if (!parsed) {
+      return reply.code(400).send({
+        code: 'invalid_template_name',
+        message: `expected horizon.<overview|layer|alert>.<key>, got 
${JSON.stringify(name)}`,
+      });
+    }
+    try {
+      if (parsed.kind === 'layer') {
+        writeLayerTemplate(content as Parameters<typeof 
writeLayerTemplate>[0]);
+      } else if (parsed.kind === 'overview') {
+        writeOverviewDashboard(parsed.key, content as Parameters<typeof 
writeOverviewDashboard>[1]);
+      } else {
+        return reply.code(400).send({
+          code: 'unsupported_kind',
+          message: `local save supports layer + overview templates, not 
${parsed.kind}`,
+        });
+      }
+    } catch (err) {
+      logger.warn({ err: errMsg(err), name }, 'save-local (bundled write) 
failed');
+      return reply.code(500).send({ code: 'local_write_failed', message: 
errMsg(err) });
+    }
+    resync();
+    const fresh = await loadStatus(deps);
+    return reply.send(fresh);
+  });
+
   app.post<{
     Body: {
       name?: string;
diff --git a/apps/bff/src/rbac/route-policy.ts 
b/apps/bff/src/rbac/route-policy.ts
index 9bfd4c9..02fa118 100644
--- a/apps/bff/src/rbac/route-policy.ts
+++ b/apps/bff/src/rbac/route-policy.ts
@@ -204,6 +204,7 @@ export const ROUTE_POLICY: Record<string, RoutePolicy> = {
   'GET /api/admin/templates/sync-status':          'overview:read',
   '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/: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 7aad7eb..8bb81e5 100644
--- a/apps/ui/src/api/scopes/template-sync.ts
+++ b/apps/ui/src/api/scopes/template-sync.ts
@@ -67,6 +67,17 @@ export class TemplateSyncApi {
     });
   }
 
+  /** Save a template to the LOCAL bundled file (not OAP). The template
+   *  immediately renders locally and shows as `diverged` until pushed to
+   *  OAP via {@link syncAll}. This is the edit-locally→preview→publish
+   *  path; `save()` (direct-to-OAP) is retained for callers that want it. */
+  saveLocal(name: string, content: unknown): Promise<TemplateSyncStatus> {
+    return this.bff.request<TemplateSyncStatus>('POST', 
'/api/admin/templates/save-local', {
+      name,
+      content,
+    });
+  }
+
   /** Operator wants the bundled JSON to overwrite whatever OAP has for
    *  this name. Used for "adopt my code defaults" from the diff view. */
   pushBundled(name: string): Promise<TemplateSyncStatus> {
diff --git 
a/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue 
b/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
index 4074404..20bf99f 100644
--- a/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
+++ b/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
@@ -52,6 +52,7 @@ 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 { refreshConfigBundle } from '@/controls/configBundle';
 import TemplateStatusBadge from 
'@/features/admin/_shared/TemplateStatusBadge.vue';
 import TemplateDiffModal from '@/features/admin/_shared/TemplateDiffModal.vue';
 import { useTemplateSync } from '@/features/admin/_shared/useTemplateSync';
@@ -502,26 +503,22 @@ function onDiffReset(): void {
 
 async function save(): Promise<void> {
   if (!draft.template || isSaving.value) return;
-  if (sync.readOnly.value) {
-    saveMsg.value = 'cannot save — OAP is unreachable, page is read-only';
-    return;
-  }
   isSaving.value = true;
   saveMsg.value = null;
   try {
-    // Save goes to OAP via the template-sync proxy (bundled is
-    // immutable at runtime). The BFF wraps draft.template in the
-    // canonical envelope before PUTting to OAP.
-    await bff.templateSync.save(`horizon.layer.${draft.template.key}`, 
draft.template);
-    // After OAP write, mirror the change into the in-memory editor
-    // list so the local diff baseline updates immediately.
+    // Save writes the LOCAL bundled template (not OAP) — the edit
+    // renders locally immediately and shows as diverged until it's
+    // published to OAP via "Sync all to OAP". Works even when OAP is
+    // unreachable.
+    await bff.templateSync.saveLocal(`horizon.layer.${draft.template.key}`, 
draft.template);
     const idx = templates.value.findIndex((t) => t.key === selectedKey.value);
     if (idx >= 0) {
       templates.value[idx] = JSON.parse(JSON.stringify(draft.template));
     }
     syncDraft();
-    saveMsg.value = 'Saved to OAP.';
-    setTimeout(() => (saveMsg.value = null), 2400);
+    await refreshConfigBundle(); // refresh diverged badges + sidebar warning
+    saveMsg.value = 'Saved locally — not yet live for others. Use “Sync all to 
OAP” to publish this change.';
+    setTimeout(() => (saveMsg.value = null), 7000);
   } catch (err) {
     saveMsg.value = err instanceof Error ? err.message : String(err);
   } finally {
@@ -1029,11 +1026,11 @@ const namingTest = computed<NamingTestResult>(() => {
               <button
                 class="sw-btn is-primary"
                 type="button"
-                :disabled="!dirty || isSaving || sync.readOnly.value"
-                :title="sync.readOnly.value ? 'OAP unreachable — page is 
read-only' : ''"
+                :disabled="!dirty || isSaving"
+                title="Save this edit to the local bundled template. Publish 
it to OAP for everyone with “Sync all to OAP”."
                 @click="save"
               >
-                {{ isSaving ? 'Saving…' : sync.readOnly.value ? 'Read-only' : 
'Save to OAP' }}
+                {{ isSaving ? 'Saving…' : 'Save locally' }}
               </button>
             </div>
           </div>
diff --git 
a/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue 
b/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue
index 882919b..d2ecb21 100644
--- a/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue
+++ b/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue
@@ -47,6 +47,7 @@ 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 { refreshConfigBundle } from '@/controls/configBundle';
 import TemplateStatusBadge from 
'@/features/admin/_shared/TemplateStatusBadge.vue';
 import TemplateDiffModal from '@/features/admin/_shared/TemplateDiffModal.vue';
 import { useTemplateSync } from '@/features/admin/_shared/useTemplateSync';
@@ -329,20 +330,15 @@ async function onDiffReset(): Promise<void> {
 
 async function onSave(): Promise<void> {
   if (!draft.value || !selectedId.value) return;
-  if (sync.readOnly.value) {
-    setFlash('cannot save — OAP is unreachable, page is read-only');
-    return;
-  }
   saving.value = true;
   try {
-    // Save goes to OAP via the BFF template-sync proxy (not to disk).
-    // The new `/api/admin/templates/save` endpoint wraps content in
-    // the canonical envelope and PUTs it to OAP. Bundled JSON stays
-    // immutable at runtime — it's a code-shape decision, not an
-    // operator one.
-    await bff.templateSync.save(`horizon.overview.${selectedId.value}`, 
draft.value);
+    // Save writes the LOCAL bundled template (not OAP). The edit renders
+    // locally and shows as diverged until published to OAP via "Sync all
+    // to OAP". Works even when OAP is unreachable.
+    await bff.templateSync.saveLocal(`horizon.overview.${selectedId.value}`, 
draft.value);
     await detailQuery.refetch();
-    setFlash('saved to OAP · reload the overview to see widget changes');
+    await refreshConfigBundle(); // refresh diverged badges + sidebar warning
+    setFlash('Saved locally — not yet live for others. Use “Sync all to OAP” 
to publish.');
   } catch (err) {
     setFlash(err instanceof Error ? `error: ${err.message}` : 'save failed');
   } finally {
@@ -1052,11 +1048,11 @@ function widgetKindLabel(type: OverviewWidget['type']): 
string {
             <button
               type="button"
               class="ot__btn ot__btn--primary"
-              :disabled="!isDirty || saving || sync.readOnly.value"
-              :title="sync.readOnly.value ? 'OAP unreachable — page is 
read-only' : ''"
+              :disabled="!isDirty || saving"
+              title="Save this edit to the local bundled template. Publish it 
to OAP for everyone with “Sync all to OAP”."
               @click="onSave"
             >
-              {{ saving ? 'saving…' : sync.readOnly.value ? 'read-only' : 
'save to OAP' }}
+              {{ saving ? 'saving…' : 'save locally' }}
             </button>
           </div>
         </template>
diff --git a/apps/ui/src/shell/AppSidebar.vue b/apps/ui/src/shell/AppSidebar.vue
index 884d3d8..b30df55 100644
--- a/apps/ui/src/shell/AppSidebar.vue
+++ b/apps/ui/src/shell/AppSidebar.vue
@@ -34,6 +34,7 @@ import { useLandingOrder } from '@/shell/useLandingOrder';
 import { useOverviewDashboards } from 
'@/render/overview/useOverviewDashboards';
 import { useDebugPanel } from '@/controls/debugPanel';
 import { useAlarmCount } from '@/shell/useAlarmCount';
+import { useConfigBundle } from '@/controls/configBundle';
 
 const { enabled: debugPanelEnabled, toggle: toggleDebugPanel } = 
useDebugPanel();
 /* Shares the same composable as the topbar badge — one query feeds
@@ -42,6 +43,7 @@ const { enabled: debugPanelEnabled, toggle: toggleDebugPanel 
} = useDebugPanel()
 const alarmCount = useAlarmCount();
 
 const auth = useAuthStore();
+const { bundle } = useConfigBundle();
 const router = useRouter();
 const themeStore = useThemeStore();
 const isLightAppearance = computed<boolean>(
@@ -265,10 +267,30 @@ const sections: NavSection[] = [
  * Hiding is a UX nicety — the BFF enforces the same verbs server-side,
  * so this is "don't show controls that won't work," not security.
  */
+// Count of templates edited locally but not yet pushed to OAP
+// (diverged = bundled differs from the stored remote). Drives the
+// yellow "unsynced changes" warning on the template-admin menu rows.
+function divergedCount(kind: 'layer' | 'overview'): number {
+  const badges = bundle.value?.syncStatus?.badges ?? [];
+  return badges.filter((b) => b.kind === kind && b.status === 
'diverged').length;
+}
+/** Per-route warn badge for the template-admin rows. */
+function syncBadgeFor(to: string): NavRow['badge'] | undefined {
+  const kind = to === '/admin/layer-dashboards' ? 'layer' : to === 
'/admin/overview-templates' ? 'overview' : null;
+  if (!kind) return undefined;
+  const n = divergedCount(kind);
+  return n > 0 ? { text: String(n), kind: 'warn' } : undefined;
+}
+
 const visibleSections = computed<NavSection[]>(() => {
   const out: NavSection[] = [];
   for (const sec of sections) {
-    const links = sec.links.filter((r) => !r.verb || auth.hasVerb(r.verb));
+    const links = sec.links
+      .filter((r) => !r.verb || auth.hasVerb(r.verb))
+      .map((r) => {
+        const badge = syncBadgeFor(r.to);
+        return badge ? { ...r, badge } : r;
+      });
     if (links.length === 0) continue;
     out.push({ kicker: sec.kicker, links });
   }

Reply via email to