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 });
}