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 351941f  feat(admin): import / export for dashboard templates and 
translations (#39)
351941f is described below

commit 351941fc43a29ebf64f6b946c9cfce038fc858b9
Author: 吴晟 Wu Sheng <[email protected]>
AuthorDate: Tue Jun 2 18:51:27 2026 +0800

    feat(admin): import / export for dashboard templates and translations (#39)
    
    Add Export and Import to all four template admin editors. Export downloads
    the in-use version (live on OAP, else the bundled default) as a JSON file;
    Import validates a file and loads it as a browser-local draft that the
    operator previews and then publishes with "Check diff & push" — Import never
    writes OAP directly.
    
    - Overview templates, Layer dashboards, 3D-map config: act on the source
      template. Import targets the template the file names (overview can create
      or restore an id; layer targets a layer already loaded here; 3D is the
      singleton), and rejects a wrong-kind / malformed file.
    - Translations page: per-locale Export/Import of the in-use overlay. Source
      templates and their translations live on separate pages, so their
      import/export are separate too — each on its own page.
    
    Shared client-side helper (file download/pick, export envelope, per-kind +
    overlay validation) with unit tests; new UI strings translated across all
    eight locales; CHANGELOG and operator docs updated.
---
 CHANGELOG.md                                       |  17 ++
 .../admin/_shared/templatePortability.test.ts      | 161 +++++++++++++
 .../features/admin/_shared/templatePortability.ts  | 268 +++++++++++++++++++++
 .../features/admin/infra-3d/Infra3dAdminView.vue   |  57 +++++
 .../admin/layer-templates/LayerDashboardsAdmin.vue |  62 +++++
 .../overview-templates/OverviewTemplatesAdmin.vue  |  61 ++++-
 .../admin/translations/TranslationsView.vue        | 106 +++++++-
 apps/ui/src/i18n/locales/de.json                   |  15 +-
 apps/ui/src/i18n/locales/en.json                   |  15 +-
 apps/ui/src/i18n/locales/es.json                   |  15 +-
 apps/ui/src/i18n/locales/fr.json                   |  15 +-
 apps/ui/src/i18n/locales/ja.json                   |  15 +-
 apps/ui/src/i18n/locales/ko.json                   |  15 +-
 apps/ui/src/i18n/locales/pt.json                   |  15 +-
 apps/ui/src/i18n/locales/zh-CN.json                |  15 +-
 docs/customization/i18n.md                         |   6 +
 docs/customization/layer-templates.md              |   8 +
 docs/customization/overview-templates.md           |   8 +
 docs/operate/infra-3d-map.md                       |   7 +
 19 files changed, 871 insertions(+), 10 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 921875a..851d697 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,23 @@ packages) plus the BFF's `HORIZON_VERSION` default.
 
 ## 0.7.0
 
+### Dashboard template portability
+
+- Every template admin page — Overview templates, Layer dashboards, and the
+  3D-map config — now has **Export** and **Import** actions. Export downloads
+  the *in-use* version (what end users render: the version live on OAP, or the
+  bundled default when OAP has none) as a JSON file, for backup, sharing, or
+  moving a dashboard to another OAP. Import reads a JSON file, validates it,
+  and loads it as a local draft in this browser — preview it, then publish
+  with “Check diff & push” as usual. Importing never writes OAP directly.
+  Overview import can recreate a deleted dashboard or seed a brand-new one;
+  layer import targets a layer already present on this deployment.
+- The **Translations** page has matching **Export** / **Import**, scoped to
+  the current language: export the in-use translation for a template + locale
+  as a JSON file, or import one as a local draft to review and push. (Source
+  templates and their translations are edited on separate pages, so their
+  import/export are separate too — each on its own page.)
+
 ### Documentation & release tooling
 
 - The website docs were brought current with the 0.6.0 build and the
diff --git a/apps/ui/src/features/admin/_shared/templatePortability.test.ts 
b/apps/ui/src/features/admin/_shared/templatePortability.test.ts
new file mode 100644
index 0000000..e014c11
--- /dev/null
+++ b/apps/ui/src/features/admin/_shared/templatePortability.test.ts
@@ -0,0 +1,161 @@
+/*
+ * 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.
+ */
+
+import { describe, expect, it } from 'vitest';
+import {
+  buildExportEnvelope,
+  buildOverlayExportEnvelope,
+  parseOverlayImport,
+  validateImport,
+} from './templatePortability';
+
+const overview = { id: 'services', title: 'Services', widgets: [] };
+const layer = { key: 'mesh', alias: 'Service Mesh' };
+const infra3d = { filter: { layer: '.*' }, levels: [], layers: {} };
+
+function env(kind: string, name: string, content: unknown): string {
+  return JSON.stringify({ name, kind, version: 1, content });
+}
+
+describe('validateImport', () => {
+  it('accepts an overview envelope and unwraps to bare content', () => {
+    const r = validateImport('overview', env('overview', 
'horizon.overview.services', overview));
+    expect(r).toEqual({ ok: true, key: 'services', content: overview });
+  });
+
+  it('accepts bare overview content, deriving the key from id', () => {
+    const r = validateImport('overview', JSON.stringify(overview));
+    expect(r.ok && r.key).toBe('services');
+    expect(r.ok && r.content).toEqual(overview);
+  });
+
+  it('rejects a layer envelope dropped on the overview page (cross-kind)', () 
=> {
+    const r = validateImport('overview', env('layer', 'horizon.layer.MESH', 
layer));
+    expect(r.ok).toBe(false);
+    expect(!r.ok && r.error).toMatch(/layer dashboard/);
+  });
+
+  it('rejects malformed JSON', () => {
+    const r = validateImport('overview', '{ not json');
+    expect(r.ok).toBe(false);
+    expect(!r.ok && r.error).toMatch(/valid JSON/);
+  });
+
+  it('rejects an overview without a widgets array', () => {
+    const r = validateImport('overview', JSON.stringify({ id: 'x', title: 'x' 
}));
+    expect(r.ok).toBe(false);
+  });
+
+  it('upper-cases the derived layer key (envelope and bare)', () => {
+    expect(validateImport('layer', env('layer', 'horizon.layer.MESH', 
layer)).ok &&
+      (validateImport('layer', env('layer', 'horizon.layer.MESH', layer)) as { 
key: string }).key).toBe('MESH');
+    const bare = validateImport('layer', JSON.stringify(layer));
+    expect(bare.ok && bare.key).toBe('MESH');
+  });
+
+  it('accepts a 3D-map config and derives the singleton key', () => {
+    const r = validateImport('infra-3d', env('infra-3d', 
'horizon.infra-3d.config', infra3d));
+    expect(r).toEqual({ ok: true, key: 'config', content: infra3d });
+  });
+
+  it('rejects a 3D-map config missing levels/layers/filter', () => {
+    const r = validateImport('infra-3d', JSON.stringify({ filter: { layer: 
'.*' } }));
+    expect(r.ok).toBe(false);
+  });
+});
+
+describe('buildExportEnvelope', () => {
+  it('wraps content in the canonical { name, kind, version, content } shape', 
() => {
+    expect(buildExportEnvelope('overview', 'horizon.overview.services', 
overview)).toEqual({
+      name: 'horizon.overview.services',
+      kind: 'overview',
+      version: 1,
+      content: overview,
+    });
+  });
+});
+
+const overlay = { title: '服务', widgets: [{ title: '前 20 接口' }] };
+
+describe('parseOverlayImport', () => {
+  it('accepts an overlay envelope and reports its template + locale', () => {
+    const file = JSON.stringify({
+      name: 'horizon.layer.MESH.i18n.zh-CN',
+      kind: 'layer',
+      version: 1,
+      locale: 'zh-CN',
+      content: overlay,
+    });
+    expect(parseOverlayImport(file)).toEqual({
+      ok: true,
+      kind: 'layer',
+      sourceName: 'horizon.layer.MESH',
+      locale: 'zh-CN',
+      content: overlay,
+    });
+  });
+
+  it('derives the locale from the name tail when no locale field is set', () 
=> {
+    const file = JSON.stringify({
+      name: 'horizon.overview.services.i18n.ja',
+      kind: 'overview',
+      version: 1,
+      content: overlay,
+    });
+    const r = parseOverlayImport(file);
+    expect(r.ok && r.locale).toBe('ja');
+    expect(r.ok && r.sourceName).toBe('horizon.overview.services');
+  });
+
+  it('accepts a bare overlay object with no metadata (targets current 
selection)', () => {
+    const r = parseOverlayImport(JSON.stringify(overlay));
+    expect(r.ok).toBe(true);
+    expect(r.ok && r.kind).toBeUndefined();
+    expect(r.ok && r.content).toEqual(overlay);
+  });
+
+  it('rejects a source-template envelope (no locale) as not a translation', () 
=> {
+    const file = JSON.stringify({ name: 'horizon.layer.MESH', kind: 'layer', 
version: 1, content: layer });
+    const r = parseOverlayImport(file);
+    expect(r.ok).toBe(false);
+    expect(!r.ok && r.error).toMatch(/source template/);
+  });
+
+  it('rejects a kind that has no translations (e.g. infra-3d)', () => {
+    const file = JSON.stringify({ name: 'horizon.infra-3d.config', kind: 
'infra-3d', version: 1, content: {} });
+    const r = parseOverlayImport(file);
+    expect(r.ok).toBe(false);
+    expect(!r.ok && r.error).toMatch(/overview \/ layer/);
+  });
+
+  it('rejects malformed JSON', () => {
+    const r = parseOverlayImport('nope');
+    expect(r.ok).toBe(false);
+  });
+});
+
+describe('buildOverlayExportEnvelope', () => {
+  it('adds the locale and the .i18n.<locale> name tail', () => {
+    expect(buildOverlayExportEnvelope('layer', 'horizon.layer.MESH', 'zh-CN', 
overlay)).toEqual({
+      name: 'horizon.layer.MESH.i18n.zh-CN',
+      kind: 'layer',
+      version: 1,
+      locale: 'zh-CN',
+      content: overlay,
+    });
+  });
+});
diff --git a/apps/ui/src/features/admin/_shared/templatePortability.ts 
b/apps/ui/src/features/admin/_shared/templatePortability.ts
new file mode 100644
index 0000000..c466147
--- /dev/null
+++ b/apps/ui/src/features/admin/_shared/templatePortability.ts
@@ -0,0 +1,268 @@
+/*
+ * 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.
+ */
+
+/**
+ * Client-side import / export for dashboard templates (overview / layer /
+ * 3D-map), shared by the three admin pages.
+ *
+ *   - EXPORT downloads the *in-use* version (what end users render: the
+ *     OAP-live copy, else the bundled default) wrapped in the canonical OAP
+ *     envelope `{ name, kind, version, content }`, so the file is
+ *     self-describing and round-trips back through import.
+ *   - IMPORT reads a file, validates it for the target page's `kind`, and
+ *     returns the BARE inner content to stage as a browser-local draft.
+ *     `localEdits` and `bff.templateSync.save` both take bare content (the
+ *     BFF re-wraps the envelope on save), so import must unwrap.
+ *
+ * The envelope shape mirrors the BFF's `logic/templates/names.ts`; it is
+ * duplicated inline because UI code can't import server modules. Import
+ * tolerates either an envelope or a hand-authored bare-content file, and
+ * derives the target key from the envelope name's tail (handled by the
+ * caller via the returned `key`) or the content's own `id` / `key`.
+ */
+
+import type { TemplateKind } from '@/api/scopes/configs';
+
+/** Canonical OAP template envelope. `version` is the wrapper's schema
+ *  version, not the inner content's. */
+interface TemplateEnvelope {
+  name: string;
+  kind: TemplateKind;
+  version: number;
+  content: unknown;
+}
+
+const ENVELOPE_VERSION = 1;
+
+/** Wrap a template's content for export. `name` is the canonical
+ *  `horizon.<kind>.<key>` the OAP row is keyed by. */
+export function buildExportEnvelope(
+  kind: TemplateKind,
+  name: string,
+  content: unknown,
+): TemplateEnvelope {
+  return { name, kind, version: ENVELOPE_VERSION, content };
+}
+
+/** Trigger a browser download of `obj` as pretty-printed JSON. */
+export function downloadJson(filename: string, obj: unknown): void {
+  const blob = new Blob([JSON.stringify(obj, null, 2)], { type: 
'application/json' });
+  const url = URL.createObjectURL(blob);
+  const a = document.createElement('a');
+  a.href = url;
+  a.download = filename;
+  a.style.display = 'none';
+  document.body.appendChild(a);
+  a.click();
+  a.remove();
+  // Revoke after the click is committed — Safari aborts the download if the
+  // object URL is freed synchronously.
+  setTimeout(() => URL.revokeObjectURL(url), 0);
+}
+
+/** Open the OS file picker for a single JSON file and resolve its text.
+ *  Resolves `null` when the operator cancels or picks nothing — a cancelled
+ *  picker fires no `change` on most browsers, so callers must treat `null`
+ *  as "no file" rather than awaiting forever. */
+export function pickJsonFile(): Promise<string | null> {
+  return new Promise((resolve) => {
+    const input = document.createElement('input');
+    input.type = 'file';
+    input.accept = 'application/json,.json';
+    input.style.display = 'none';
+    input.addEventListener('change', () => {
+      const file = input.files?.[0];
+      input.remove();
+      if (!file) {
+        resolve(null);
+        return;
+      }
+      file.text().then(resolve).catch(() => resolve(null));
+    });
+    document.body.appendChild(input);
+    input.click();
+  });
+}
+
+export type ImportResult =
+  | { ok: true; key: string; content: unknown }
+  | { ok: false; error: string };
+
+function isObject(v: unknown): v is Record<string, unknown> {
+  return typeof v === 'object' && v !== null && !Array.isArray(v);
+}
+
+/** Human label per kind — used in cross-kind rejection messages. */
+const KIND_LABEL: Record<TemplateKind, string> = {
+  overview: 'overview dashboard',
+  layer: 'layer dashboard',
+  alert: 'alert page',
+  theme: 'theme',
+  'time-defaults': 'time defaults',
+  'infra-3d': '3D-map config',
+};
+
+/** Structural sniff + target-key derivation per kind. This is a guard
+ *  against importing the wrong file into the wrong page, NOT full schema
+ *  validation — the deep checks (e.g. the 3D-map zod schema) run
+ *  server-side when the operator pushes the draft to OAP. */
+function validateContent(
+  kind: TemplateKind,
+  content: unknown,
+): { ok: true; key: string } | { ok: false; error: string } {
+  if (!isObject(content)) {
+    return { ok: false, error: 'The template content is not a JSON object.' };
+  }
+  if (kind === 'overview') {
+    if (typeof content.id !== 'string' || !content.id) {
+      return { ok: false, error: 'Not an overview dashboard — missing a string 
"id".' };
+    }
+    if (!Array.isArray(content.widgets)) {
+      return { ok: false, error: 'Not an overview dashboard — missing a 
"widgets" array.' };
+    }
+    return { ok: true, key: content.id };
+  }
+  if (kind === 'layer') {
+    if (typeof content.key !== 'string' || !content.key) {
+      return { ok: false, error: 'Not a layer dashboard — missing a string 
"key".' };
+    }
+    return { ok: true, key: content.key.toUpperCase() };
+  }
+  if (kind === 'infra-3d') {
+    if (!Array.isArray(content.levels) || !isObject(content.layers) || 
!isObject(content.filter)) {
+      return { ok: false, error: 'Not a 3D-map config — missing "levels" / 
"layers" / "filter".' };
+    }
+    return { ok: true, key: 'config' };
+  }
+  return { ok: false, error: `Import is not supported for ${KIND_LABEL[kind]} 
templates.` };
+}
+
+/** Parse + validate an imported file's text for the page's `kind`. Accepts
+ *  the export envelope or a bare-content file; returns the bare inner
+ *  content (never the envelope) plus the derived target key. */
+export function validateImport(kind: TemplateKind, text: string): ImportResult 
{
+  let parsed: unknown;
+  try {
+    parsed = JSON.parse(text);
+  } catch {
+    return { ok: false, error: 'The file is not valid JSON.' };
+  }
+  if (!isObject(parsed)) {
+    return { ok: false, error: 'The file is not a JSON object.' };
+  }
+
+  // Envelope (has a `kind` discriminator + `content`): reject cross-kind
+  // before touching the inner content, then unwrap it.
+  let content: unknown = parsed;
+  if (typeof parsed.kind === 'string' && 'content' in parsed) {
+    if (parsed.kind !== kind) {
+      const got = KIND_LABEL[parsed.kind as TemplateKind] ?? parsed.kind;
+      return {
+        ok: false,
+        error: `This file is a ${got} template — open the matching admin page 
to import it.`,
+      };
+    }
+    content = parsed.content;
+  }
+
+  const v = validateContent(kind, content);
+  if (!v.ok) return v;
+  return { ok: true, key: v.key, content };
+}
+
+/* ── Per-locale translation overlays ──────────────────────────────────
+ * Translations live on the Translations page as their own OAP rows
+ * (`horizon.<kind>.<key>.i18n.<locale>`), separate from the source
+ * template. Their import/export is therefore separate too — these
+ * helpers mirror the template ones for the overlay shape. Only
+ * overview + layer templates carry overlays. */
+
+/** Kinds that have translation overlays (the Translations page scope). */
+type OverlayKind = 'overview' | 'layer';
+
+/** Export envelope for a per-locale overlay. Adds `locale` and the
+ *  `.i18n.<locale>` name tail (mirrors the BFF's buildOverlayEnvelope). */
+export function buildOverlayExportEnvelope(
+  kind: OverlayKind,
+  sourceName: string,
+  locale: string,
+  content: unknown,
+): TemplateEnvelope & { locale: string } {
+  return { name: `${sourceName}.i18n.${locale}`, kind, version: 
ENVELOPE_VERSION, locale, content };
+}
+
+export type OverlayImportResult =
+  | {
+      ok: true;
+      /** Present when the file is a Horizon overlay envelope — the import
+       *  then targets THIS (template, locale). Absent for a bare overlay,
+       *  which targets the page's current selection. */
+      kind?: OverlayKind;
+      sourceName?: string;
+      locale?: string;
+      content: Record<string, unknown>;
+    }
+  | { ok: false; error: string };
+
+/** Parse + validate an imported translation file. Accepts a Horizon
+ *  overlay envelope (carries kind + name + locale → targets its own
+ *  template/locale) or a bare overlay object (no metadata → the caller
+ *  applies it to the current selection). The discriminator that proves a
+ *  file is an *overlay* and not a source template is the presence of a
+ *  locale (explicit field or the `.i18n.<locale>` name tail) — a source
+ *  envelope has neither, and is rejected here. */
+export function parseOverlayImport(text: string): OverlayImportResult {
+  let parsed: unknown;
+  try {
+    parsed = JSON.parse(text);
+  } catch {
+    return { ok: false, error: 'The file is not valid JSON.' };
+  }
+  if (!isObject(parsed)) {
+    return { ok: false, error: 'The file is not a JSON object.' };
+  }
+  if (typeof parsed.kind === 'string' && 'content' in parsed) {
+    const kind = parsed.kind;
+    if (kind !== 'overview' && kind !== 'layer') {
+      const got = KIND_LABEL[kind as TemplateKind] ?? kind;
+      return { ok: false, error: `Translations apply to overview / layer 
templates — this file is a ${got}.` };
+    }
+    if (!isObject(parsed.content)) {
+      return { ok: false, error: 'The translation content is not a JSON 
object.' };
+    }
+    let locale = typeof parsed.locale === 'string' ? parsed.locale : undefined;
+    let sourceName: string | undefined;
+    if (typeof parsed.name === 'string') {
+      const m = /^(.*)\.i18n\.([A-Za-z][A-Za-z0-9-]*)$/.exec(parsed.name);
+      if (m) {
+        sourceName = m[1];
+        locale = locale ?? m[2];
+      } else {
+        sourceName = parsed.name;
+      }
+    }
+    if (!locale) {
+      return {
+        ok: false,
+        error: 'This looks like a source template, not a translation — import 
it on its own template page.',
+      };
+    }
+    return { ok: true, kind, sourceName, locale, content: parsed.content };
+  }
+  // Bare overlay object — no metadata; target the current selection.
+  return { ok: true, content: parsed };
+}
diff --git a/apps/ui/src/features/admin/infra-3d/Infra3dAdminView.vue 
b/apps/ui/src/features/admin/infra-3d/Infra3dAdminView.vue
index 4636d2c..c82fce2 100644
--- a/apps/ui/src/features/admin/infra-3d/Infra3dAdminView.vue
+++ b/apps/ui/src/features/admin/infra-3d/Infra3dAdminView.vue
@@ -56,6 +56,7 @@ import { refresh as refreshLiveInfraConfig } from 
'@/features/infra-3d/composabl
 import { useTemplateSources } from 
'@/features/admin/_shared/useTemplateSources';
 import { useTemplateSync } from '@/features/admin/_shared/useTemplateSync';
 import { useLocalTemplateEdits } from '@/controls/localTemplateEdits';
+import { buildExportEnvelope, downloadJson, pickJsonFile, validateImport } 
from '@/features/admin/_shared/templatePortability';
 import { refreshConfigBundle } from '@/controls/configBundle';
 import { stableStringify } from '@/utils/stableJson';
 import SyncStatusBanner from '@/features/admin/_shared/SyncStatusBanner.vue';
@@ -248,6 +249,35 @@ function resetTo(src: 'bundled' | 'remote'): void {
   resetMenuOpen.value = false;
 }
 
+// ── Import / Export ────────────────────────────────────────────────
+// Export downloads the IN-USE config (remote, else bundled) — what the map
+// renders — not the editor draft. Import stages a file as a local draft;
+// `loadFrom('local')` normalizes any legacy topology/load shapes. Errors
+// reuse the push-issue list; success shows a transient note.
+const importMsg = ref<string | null>(null);
+function onExport(): void {
+  const inUse = sources.remote<Infra3dConfig>(NAME) ?? 
sources.bundled<Infra3dConfig>(NAME);
+  if (!inUse) return;
+  downloadJson(`${NAME}.json`, buildExportEnvelope('infra-3d', NAME, inUse));
+}
+async function onImportFile(): Promise<void> {
+  const text = await pickJsonFile();
+  if (text === null) return;
+  const res = validateImport('infra-3d', text);
+  if (!res.ok) {
+    pushErr.value = [res.error];
+    importMsg.value = null;
+    return;
+  }
+  localEdits.set(NAME, res.content);
+  loadFrom('local');
+  pushErr.value = null;
+  importMsg.value = t('Imported as a local draft — preview, then “Check diff & 
push”.');
+  setTimeout(() => {
+    importMsg.value = null;
+  }, 6000);
+}
+
 // ── Levels editing ────────────────────────────────────────────────────
 function addLevel(): void {
   if (!draft.value) return;
@@ -605,6 +635,23 @@ const stats = computed(() => {
             </button>
           </div>
         </div>
+        <!-- Export the in-use config to a file; import a file as a local
+             draft. Export needs the sources loaded; bundled always exists. -->
+        <button
+          class="btn"
+          :disabled="!ready"
+          :title="t('Download the in-use config (live on OAP, or the bundled 
default) as a JSON file.')"
+          @click="onExport"
+        >
+          {{ t('Export') }}
+        </button>
+        <button
+          class="btn"
+          :title="t('Load a config JSON file as a local draft — preview, then 
publish.')"
+          @click="onImportFile"
+        >
+          {{ t('Import') }}
+        </button>
         <button class="btn" :disabled="!dirty" @click="save">
           {{ hasLocalDraft && !dirty ? t('Saved local') : t('Save local') }}
         </button>
@@ -618,6 +665,7 @@ const stats = computed(() => {
     <ul v-if="pushErr && pushErr.length" class="issues">
       <li v-for="(it, i) in pushErr" :key="i"><code>{{ it }}</code></li>
     </ul>
+    <p v-if="importMsg" class="import-msg">{{ importMsg }}</p>
 
     <div v-if="loadError" class="loading">
       {{ t('Couldn\'t load the 3D-map config — the BFF may be unreachable. 
Refresh the page to retry.') }}
@@ -977,6 +1025,15 @@ export { parseHexColor };
   overflow-y: auto;
 }
 .issues code { color: #fff; }
+.import-msg {
+  margin: 8px 20px 0;
+  padding: 8px 12px;
+  border: 1px solid var(--sw-line-2);
+  border-radius: 4px;
+  background: var(--sw-bg-2);
+  font-size: 11px;
+  color: var(--sw-fg-2);
+}
 .loading { padding: 20px; color: var(--sw-fg-3); font-size: 12px; }
 
 /* Sections */
diff --git 
a/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue 
b/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
index 12b5c01..ea08d37 100644
--- a/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
+++ b/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
@@ -49,6 +49,7 @@ type AdminScope = DashboardScope | 'networkProfiling';
 import { bff, bffClient, BffApiError } from '@/api/client';
 import { useLocalTemplateEdits, layerEditName } from 
'@/controls/localTemplateEdits';
 import { useTemplateSources } from 
'@/features/admin/_shared/useTemplateSources';
+import { buildExportEnvelope, downloadJson, pickJsonFile, validateImport } 
from '@/features/admin/_shared/templatePortability';
 import { usePreviewOverride } from '@/controls/previewOverride';
 import TimeChart from '@/components/charts/TimeChart.vue';
 import TopList from '@/components/charts/TopList.vue';
@@ -832,6 +833,50 @@ async function pushToOap(): Promise<void> {
   }
 }
 
+// ── Import / Export ────────────────────────────────────────────────
+function flashMsg(msg: string): void {
+  saveMsg.value = msg;
+  setTimeout(() => {
+    if (saveMsg.value === msg) saveMsg.value = null;
+  }, 6000);
+}
+// Export downloads the IN-USE version (remote, else bundled) — what end
+// users render — not the editor draft. Every shipped layer has a bundled
+// default, so Export is effectively always available.
+const canExport = computed<boolean>(() => remoteAvailable.value || 
bundledExists.value);
+function onExport(): void {
+  const name = editName.value;
+  const inUse =
+    sources.remote<AdminLayerTemplate>(name) ??
+    sources.bundled<AdminLayerTemplate>(name) ??
+    templates.value.find((t) => t.key === selectedKey.value) ??
+    null;
+  if (!inUse) return;
+  downloadJson(`${name}.json`, buildExportEnvelope('layer', name, inUse));
+}
+// Import stages a file as a local draft for the layer the file names. Layer
+// keys are a closed enum — you can't invent a layer here — so the target
+// KEY must already be loaded on this deployment; otherwise reject.
+async function onImportFile(): Promise<void> {
+  const text = await pickJsonFile();
+  if (text === null) return;
+  const res = validateImport('layer', text);
+  if (!res.ok) {
+    flashMsg(res.error);
+    return;
+  }
+  const key = res.key; // already upper-cased by the validator
+  if (!templates.value.some((t) => t.key === key)) {
+    flashMsg(`Layer “${key}” is not loaded on this deployment — import is 
limited to layers present here.`);
+    return;
+  }
+  selectedKey.value = key;
+  selectedIdx.value = null;
+  localEdits.set(layerEditName(key), res.content);
+  loadFrom('local');
+  flashMsg(`Imported “${key}” as a local draft. Preview, then “Check diff & 
push”.`);
+}
+
 // ── Disable / reactivate (OAP has no hard DELETE) ──────────────────
 // Disabling soft-disables the layer on OAP: a disabled template drops out
 // of the bundle AND the menu, so the layer disappears from the sidebar.
@@ -1674,6 +1719,23 @@ const namingTest = computed<NamingTestResult>(() => {
                 class="src-tag is-remote"
                 :title="t('Showing the OAP-live version. End users render the 
same bytes.')"
               >{{ t('from remote') }}</span>
+              <!-- Export the in-use version to a file; import a file as a
+                   local draft for the layer it names. -->
+              <button
+                class="sw-btn"
+                type="button"
+                :disabled="!canExport"
+                :title="canExport
+                  ? 'Download the in-use version (live on OAP, or the bundled 
default) as a JSON file.'
+                  : 'Nothing to export yet.'"
+                @click="onExport"
+              >Export</button>
+              <button
+                class="sw-btn"
+                type="button"
+                title="Import a layer dashboard JSON file as a local draft — 
preview, then publish."
+                @click="onImportFile"
+              >Import</button>
               <!-- Reset the editor to a source (discard current content). -->
               <div class="reset-dd">
                 <button class="sw-btn" type="button" @click="resetDropdownOpen 
= !resetDropdownOpen">
diff --git 
a/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue 
b/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue
index a71d0b7..05a5d58 100644
--- a/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue
+++ b/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue
@@ -36,7 +36,7 @@
   decisions, not config tweaks.
 -->
 <script setup lang="ts">
-import { computed, onBeforeUnmount, onMounted, reactive, ref, watch, 
watchEffect } from 'vue';
+import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch, 
watchEffect } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { useRouter } from 'vue-router';
 import { useQuery } from '@tanstack/vue-query';
@@ -50,6 +50,7 @@ import type { OverviewTemplateSummary } from 
'@/api/scopes/overview';
 import { useLocalTemplateEdits, overviewEditName } from 
'@/controls/localTemplateEdits';
 import { usePreviewOverride } from '@/controls/previewOverride';
 import { useTemplateSources } from 
'@/features/admin/_shared/useTemplateSources';
+import { buildExportEnvelope, downloadJson, pickJsonFile, validateImport } 
from '@/features/admin/_shared/templatePortability';
 import { useLayers } from '@/shell/useLayers';
 import SyncStatusBanner from '@/features/admin/_shared/SyncStatusBanner.vue';
 import { refreshConfigBundle } from '@/controls/configBundle';
@@ -809,6 +810,46 @@ async function pushToOap(): Promise<void> {
   }
 }
 
+// ── Import / Export ────────────────────────────────────────────────
+// Export downloads the IN-USE version (what end users render: remote,
+// else bundled) — never the editor draft. A never-published local-only
+// draft has no in-use version, so Export is disabled there.
+const canExport = computed<boolean>(() => remoteAvailable.value || 
bundledExists.value);
+function onExport(): void {
+  if (!editName.value) return;
+  const inUse =
+    sources.remote<OverviewDashboard>(editName.value) ??
+    sources.bundled<OverviewDashboard>(editName.value);
+  if (!inUse) return;
+  downloadJson(`${editName.value}.json`, buildExportEnvelope('overview', 
editName.value, inUse));
+}
+// Import stages a file as a LOCAL draft for the dashboard the file names
+// (a new id creates a new local-only draft), then selects it. Order
+// matters: set the draft first so `localOnlyDrafts` sees a new id before
+// the auto-select watchEffect runs, then select + force-load local so an
+// unsaved-but-dirty editor doesn't suppress the seed watcher.
+async function onImportFile(): Promise<void> {
+  const text = await pickJsonFile();
+  if (text === null) return;
+  const res = validateImport('overview', text);
+  if (!res.ok) {
+    setFlash(res.error);
+    return;
+  }
+  const id = res.key;
+  const existed = dashboards.value.some((d) => d.id === id);
+  localEdits.set(overviewEditName(id), res.content);
+  await nextTick();
+  selectedId.value = id;
+  selectedWidgetId.value = null;
+  loadFrom('local');
+  setFlash(
+    existed
+      ? `Imported · overwrote the local draft “${id}”. Preview, then “Check 
diff & push”.`
+      : `Imported “${id}” as a new local draft. Preview, then “Check diff & 
push”.`,
+  );
+}
+
 // ── KPI row helpers (kpi-tile only) ────────────────────────────────
 function addKpi(w: OverviewWidget): void {
   const next = [...(w.kpis ?? []), { label: 'new KPI', mqe: '' } as 
OverviewKpi];
@@ -1113,6 +1154,24 @@ function widgetKindLabel(type: OverviewWidget['type']): 
string {
                 class="ot__src is-remote"
                 :title="t('Showing the OAP-live version. End users render the 
same bytes.')"
               >{{ t('from remote') }}</span>
+              <!-- Export the in-use version to a file; import a file as a
+                   local draft. Export is disabled for a never-published
+                   local-only draft (nothing in use to download). -->
+              <button
+                type="button"
+                class="ot__btn"
+                :disabled="!canExport"
+                :title="canExport
+                  ? 'Download the in-use version (live on OAP, or the bundled 
default) as a JSON file.'
+                  : 'Nothing published yet to export — push this draft first.'"
+                @click="onExport"
+              >export</button>
+              <button
+                type="button"
+                class="ot__btn"
+                title="Import a dashboard JSON file as a local draft — 
preview, then publish."
+                @click="onImportFile"
+              >import</button>
               <div class="reset-dd">
                 <button type="button" class="ot__btn" 
@click="resetDropdownOpen = !resetDropdownOpen">
                   reset to <span class="caret" :class="{ open: 
resetDropdownOpen }">›</span>
diff --git a/apps/ui/src/features/admin/translations/TranslationsView.vue 
b/apps/ui/src/features/admin/translations/TranslationsView.vue
index 4f7cc40..34f958b 100644
--- a/apps/ui/src/features/admin/translations/TranslationsView.vue
+++ b/apps/ui/src/features/admin/translations/TranslationsView.vue
@@ -31,9 +31,15 @@
   diff modal (remote vs local) and publishes via templateSync.save.
 -->
 <script setup lang="ts">
-import { computed, ref, watch } from 'vue';
+import { computed, nextTick, ref, watch } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { useTemplateSources } from 
'@/features/admin/_shared/useTemplateSources';
+import {
+  buildOverlayExportEnvelope,
+  downloadJson,
+  parseOverlayImport,
+  pickJsonFile,
+} from '@/features/admin/_shared/templatePortability';
 import { useTemplateSync } from '@/features/admin/_shared/useTemplateSync';
 import SyncStatusBanner from '@/features/admin/_shared/SyncStatusBanner.vue';
 import LayerDashboardCanvas from 
'@/features/admin/_shared/LayerDashboardCanvas.vue';
@@ -662,6 +668,87 @@ const readOnly = computed<boolean>(() =>
   selectedKind.value === 'overview' ? overviewSync.readOnly.value : 
layerSync.readOnly.value,
 );
 
+// ── Import / Export ────────────────────────────────────────────────
+// Translations are their own OAP rows on their own page, so their
+// import/export is separate from the source-template pages. Both act on
+// the CURRENT (template, target locale) — the same unit Stage / Push use.
+function flashMsg(msg: string): void {
+  saveMsg.value = msg;
+  setTimeout(() => {
+    if (saveMsg.value === msg) saveMsg.value = null;
+  }, 6000);
+}
+/** The in-use overlay for (selected template, target locale): the OAP row
+ *  (what's published) wins, else the disk-shipped seed. A pushed row is
+ *  already the full merged overlay, so this is the complete in-use copy. */
+const inUseOverlayForTarget = computed<Record<string, unknown> | null>(() => {
+  const snap = fetchedOverlays.value[overlayKey(selectedName.value, 
target.value)];
+  const v = snap?.oap ?? snap?.disk ?? null;
+  return v && typeof v === 'object' ? (v as Record<string, unknown>) : null;
+});
+const canExport = computed<boolean>(() => inUseOverlayForTarget.value !== 
null);
+function onExport(): void {
+  const overlay = inUseOverlayForTarget.value;
+  const name = selectedName.value;
+  if (!overlay || !name) return;
+  downloadJson(
+    `${name}.i18n.${target.value}.json`,
+    buildOverlayExportEnvelope(selectedKind.value, name, target.value, 
overlay),
+  );
+}
+// Import stages a translation file as a LOCAL draft. A Horizon overlay
+// envelope targets its own (template, locale) — switching the picker to
+// it; a bare overlay object goes into the current selection.
+async function onImportFile(): Promise<void> {
+  const text = await pickJsonFile();
+  if (text === null) return;
+  const res = parseOverlayImport(text);
+  if (!res.ok) {
+    flashMsg(t('Import failed: {error}', { error: res.error }));
+    return;
+  }
+  const kind = res.kind ?? selectedKind.value;
+  const name = res.sourceName ?? selectedName.value;
+  const locStr = res.locale ?? target.value;
+  if (locStr === 'en' || !(SUPPORTED_LOCALES as readonly 
string[]).includes(locStr)) {
+    flashMsg(t('Unsupported language: {locale}', { locale: locStr }));
+    return;
+  }
+  const loc = locStr as Locale;
+  if (res.sourceName) {
+    const entries = kind === 'overview' ? overviewEntries.value : 
layerEntries.value;
+    if (!entries.some((e) => e.value === name)) {
+      flashMsg(t('Template {name} is not loaded on this deployment.', { name 
}));
+      return;
+    }
+    selectedKind.value = kind;
+    selectedName.value = name;
+  }
+  target.value = loc;
+  await nextTick();
+  const eff = effective.value;
+  if (!eff) {
+    flashMsg(t('Could not load the target template.'));
+    return;
+  }
+  // Overwrite the (template, locale) draft from the imported overlay, then
+  // stage it locally so Push publishes exactly the imported translation.
+  const tplMap = { ...(draft.value[name] ?? {}) };
+  delete tplMap[loc];
+  draft.value = { ...draft.value, [name]: tplMap };
+  applyOverlayToDraft(name, loc, res.content, eff);
+  const overlay = buildOverlayContent(name, loc, eff);
+  if (overlay) localEdits.set(name, loc, overlay);
+  else localEdits.remove(name, loc);
+  editorSource.value = 'local';
+  closePanel();
+  flashMsg(
+    t('Imported {locale} translations as a local draft — review, then “Check 
diff & push”.', {
+      locale: LOCALE_NATIVE_LABEL[loc],
+    }),
+  );
+}
+
 /** Human label for a translatable field shown next to the EN source.
  *  The wire path (e.g. `kpis[0].label`) is internal — translators
  *  shouldn't see it; they should see what KIND of string they're
@@ -771,6 +858,23 @@ function leafLabel(segments: Array<string | number>): 
string {
           class="tv__src is-remote"
           :title="t('Showing the OAP-live version. End users render the same 
bytes.')"
         >{{ t('from remote') }}</span>
+        <!-- Export the in-use translation to a file; import a translation
+             file as a local draft. Both act on the current target locale. -->
+        <button
+          type="button"
+          class="sw-btn"
+          :disabled="!canExport"
+          :title="canExport
+            ? t('Download the in-use {locale} translation as a JSON file.', { 
locale: LOCALE_NATIVE_LABEL[target] })
+            : t('No published {locale} translation to export yet.', { locale: 
LOCALE_NATIVE_LABEL[target] })"
+          @click="onExport"
+        >{{ t('Export') }}</button>
+        <button
+          type="button"
+          class="sw-btn"
+          :title="t('Import a translation JSON file as a local draft — review, 
then publish.')"
+          @click="onImportFile"
+        >{{ t('Import') }}</button>
         <!-- Reset to ▾ dropdown — matches the layer / overview
              editors. Discards local edits and re-seeds the draft from
              the picked source. -->
diff --git a/apps/ui/src/i18n/locales/de.json b/apps/ui/src/i18n/locales/de.json
index 0d800f8..9066b5c 100644
--- a/apps/ui/src/i18n/locales/de.json
+++ b/apps/ui/src/i18n/locales/de.json
@@ -1269,5 +1269,18 @@
   "The single catch-all: any layer no tier pins lands here.": "Die einzige 
Auffangstufe: jede Ebene, die an keine Stufe angeheftet ist, landet hier.",
   "Push 3D-map config to OAP": "3D-map-Konfiguration auf OAP veröffentlichen",
   "Left = live on OAP (remote). Right = your local draft. Pushing replaces the 
OAP copy — the map renders it on the next visit.": "Links = live auf OAP 
(Remote). Rechts = dein lokaler Entwurf. Die Veröffentlichung ersetzt die 
OAP-Kopie — die Karte zeigt sie beim nächsten Besuch.",
-  "Push to OAP": "Auf OAP veröffentlichen"
+  "Push to OAP": "Auf OAP veröffentlichen",
+  "Export": "Exportieren",
+  "Import": "Importieren",
+  "Download the in-use config (live on OAP, or the bundled default) as a JSON 
file.": "Lädt die aktive Konfiguration (live auf OAP, oder die mitgelieferte 
Vorgabe) als JSON-Datei herunter.",
+  "Load a config JSON file as a local draft — preview, then publish.": "Lädt 
eine JSON-Konfigurationsdatei als lokalen Entwurf — Vorschau, dann 
veröffentlichen.",
+  "Imported as a local draft — preview, then “Check diff & push”.": "Als 
lokaler Entwurf importiert — Vorschau, dann „Diff prüfen & übertragen“.",
+  "Download the in-use {locale} translation as a JSON file.": "Lädt die aktive 
{locale}-Übersetzung als JSON-Datei herunter.",
+  "No published {locale} translation to export yet.": "Noch keine 
veröffentlichte {locale}-Übersetzung zum Exportieren.",
+  "Import a translation JSON file as a local draft — review, then publish.": 
"Importiert eine Übersetzungs-JSON-Datei als lokalen Entwurf — prüfen, dann 
veröffentlichen.",
+  "Imported {locale} translations as a local draft — review, then “Check diff 
& push”.": "{locale}-Übersetzungen als lokaler Entwurf importiert — prüfen, 
dann „Diff prüfen & übertragen“.",
+  "Import failed: {error}": "Import fehlgeschlagen: {error}",
+  "Unsupported language: {locale}": "Nicht unterstützte Sprache: {locale}",
+  "Template {name} is not loaded on this deployment.": "Vorlage {name} ist auf 
dieser Instanz nicht geladen.",
+  "Could not load the target template.": "Die Zielvorlage konnte nicht geladen 
werden."
 }
diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json
index 1780696..ef9ec97 100644
--- a/apps/ui/src/i18n/locales/en.json
+++ b/apps/ui/src/i18n/locales/en.json
@@ -1280,5 +1280,18 @@
   "The single catch-all: any layer no tier pins lands here.": "The single 
catch-all: any layer no tier pins lands here.",
   "Push 3D-map config to OAP": "Push 3D-map config to OAP",
   "Left = live on OAP (remote). Right = your local draft. Pushing replaces the 
OAP copy — the map renders it on the next visit.": "Left = live on OAP 
(remote). Right = your local draft. Pushing replaces the OAP copy — the map 
renders it on the next visit.",
-  "Push to OAP": "Push to OAP"
+  "Push to OAP": "Push to OAP",
+  "Export": "Export",
+  "Import": "Import",
+  "Download the in-use config (live on OAP, or the bundled default) as a JSON 
file.": "Download the in-use config (live on OAP, or the bundled default) as a 
JSON file.",
+  "Load a config JSON file as a local draft — preview, then publish.": "Load a 
config JSON file as a local draft — preview, then publish.",
+  "Imported as a local draft — preview, then “Check diff & push”.": "Imported 
as a local draft — preview, then “Check diff & push”.",
+  "Download the in-use {locale} translation as a JSON file.": "Download the 
in-use {locale} translation as a JSON file.",
+  "No published {locale} translation to export yet.": "No published {locale} 
translation to export yet.",
+  "Import a translation JSON file as a local draft — review, then publish.": 
"Import a translation JSON file as a local draft — review, then publish.",
+  "Imported {locale} translations as a local draft — review, then “Check diff 
& push”.": "Imported {locale} translations as a local draft — review, then 
“Check diff & push”.",
+  "Import failed: {error}": "Import failed: {error}",
+  "Unsupported language: {locale}": "Unsupported language: {locale}",
+  "Template {name} is not loaded on this deployment.": "Template {name} is not 
loaded on this deployment.",
+  "Could not load the target template.": "Could not load the target template."
 }
diff --git a/apps/ui/src/i18n/locales/es.json b/apps/ui/src/i18n/locales/es.json
index 4df1934..28927a6 100644
--- a/apps/ui/src/i18n/locales/es.json
+++ b/apps/ui/src/i18n/locales/es.json
@@ -1269,5 +1269,18 @@
   "The single catch-all: any layer no tier pins lands here.": "El único nivel 
comodín: cualquier capa que ningún nivel fije termina aquí.",
   "Push 3D-map config to OAP": "Publicar la configuración del 3D-map en OAP",
   "Left = live on OAP (remote). Right = your local draft. Pushing replaces the 
OAP copy — the map renders it on the next visit.": "Izquierda = en vivo en OAP 
(remote). Derecha = tu borrador local. El push reemplaza la copia en OAP — el 
mapa la mostrará en la próxima visita.",
-  "Push to OAP": "Publicar en OAP"
+  "Push to OAP": "Publicar en OAP",
+  "Export": "Exportar",
+  "Import": "Importar",
+  "Download the in-use config (live on OAP, or the bundled default) as a JSON 
file.": "Descarga la configuración en uso (la versión activa en OAP, o el valor 
por defecto incluido) como un archivo JSON.",
+  "Load a config JSON file as a local draft — preview, then publish.": "Carga 
un archivo JSON de configuración como borrador local — previsualiza y luego 
publica.",
+  "Imported as a local draft — preview, then “Check diff & push”.": "Importado 
como borrador local — previsualiza y luego «Ver diff y publicar».",
+  "Download the in-use {locale} translation as a JSON file.": "Descarga la 
traducción {locale} en uso como un archivo JSON.",
+  "No published {locale} translation to export yet.": "Aún no hay una 
traducción {locale} publicada para exportar.",
+  "Import a translation JSON file as a local draft — review, then publish.": 
"Importa un archivo JSON de traducción como borrador local — revisa y luego 
publica.",
+  "Imported {locale} translations as a local draft — review, then “Check diff 
& push”.": "Traducciones {locale} importadas como borrador local — revisa y 
luego «Ver diff y publicar».",
+  "Import failed: {error}": "Error al importar: {error}",
+  "Unsupported language: {locale}": "Idioma no admitido: {locale}",
+  "Template {name} is not loaded on this deployment.": "La plantilla {name} no 
está cargada en este despliegue.",
+  "Could not load the target template.": "No se pudo cargar la plantilla de 
destino."
 }
diff --git a/apps/ui/src/i18n/locales/fr.json b/apps/ui/src/i18n/locales/fr.json
index d0fd337..254f381 100644
--- a/apps/ui/src/i18n/locales/fr.json
+++ b/apps/ui/src/i18n/locales/fr.json
@@ -1269,5 +1269,18 @@
   "The single catch-all: any layer no tier pins lands here.": "Le palier 
fourre-tout unique : toute couche qu'aucun palier n'épingle atterrit ici.",
   "Push 3D-map config to OAP": "Publier la configuration de la 3D-map sur OAP",
   "Left = live on OAP (remote). Right = your local draft. Pushing replaces the 
OAP copy — the map renders it on the next visit.": "À gauche = en ligne sur OAP 
(Remote). À droite = votre brouillon local. La publication remplace la copie 
OAP — la carte l'affiche à la prochaine visite.",
-  "Push to OAP": "Publier sur OAP"
+  "Push to OAP": "Publier sur OAP",
+  "Export": "Exporter",
+  "Import": "Importer",
+  "Download the in-use config (live on OAP, or the bundled default) as a JSON 
file.": "Télécharge la configuration utilisée (la version active sur OAP, ou la 
valeur par défaut fournie) sous forme de fichier JSON.",
+  "Load a config JSON file as a local draft — preview, then publish.": "Charge 
un fichier JSON de configuration comme brouillon local — prévisualisez, puis 
publiez.",
+  "Imported as a local draft — preview, then “Check diff & push”.": "Importé 
comme brouillon local — prévisualisez, puis « Voir le diff et publier ».",
+  "Download the in-use {locale} translation as a JSON file.": "Télécharge la 
traduction {locale} utilisée sous forme de fichier JSON.",
+  "No published {locale} translation to export yet.": "Aucune traduction 
{locale} publiée à exporter pour l'instant.",
+  "Import a translation JSON file as a local draft — review, then publish.": 
"Importe un fichier JSON de traduction comme brouillon local — vérifiez, puis 
publiez.",
+  "Imported {locale} translations as a local draft — review, then “Check diff 
& push”.": "Traductions {locale} importées comme brouillon local — vérifiez, 
puis « Voir le diff et publier ».",
+  "Import failed: {error}": "Échec de l'import : {error}",
+  "Unsupported language: {locale}": "Langue non prise en charge : {locale}",
+  "Template {name} is not loaded on this deployment.": "Le modèle {name} n'est 
pas chargé sur ce déploiement.",
+  "Could not load the target template.": "Impossible de charger le modèle 
cible."
 }
diff --git a/apps/ui/src/i18n/locales/ja.json b/apps/ui/src/i18n/locales/ja.json
index 81511ff..6629ec3 100644
--- a/apps/ui/src/i18n/locales/ja.json
+++ b/apps/ui/src/i18n/locales/ja.json
@@ -1269,5 +1269,18 @@
   "The single catch-all: any layer no tier pins lands here.": 
"唯一の受け皿です。どのティアにもピン留めされていないレイヤーはここに配置されます。",
   "Push 3D-map config to OAP": "3D-map 設定を OAP に公開",
   "Left = live on OAP (remote). Right = your local draft. Pushing replaces the 
OAP copy — the map renders it on the next visit.": "左 = OAP 上の公開版(リモート)。右 = 
あなたのローカル草稿。公開すると OAP 上のコピーが置き換えられ、次回アクセス時にマップへ反映されます。",
-  "Push to OAP": "OAP に公開"
+  "Push to OAP": "OAP に公開",
+  "Export": "エクスポート",
+  "Import": "インポート",
+  "Download the in-use config (live on OAP, or the bundled default) as a JSON 
file.": "使用中の設定(OAP 上のライブ版、またはバンドルされた既定値)を JSON ファイルとしてダウンロードします。",
+  "Load a config JSON file as a local draft — preview, then publish.": "設定の 
JSON ファイルをローカルの下書きとして読み込みます — プレビューしてから公開します。",
+  "Imported as a local draft — preview, then “Check diff & push”.": 
"ローカルの下書きとしてインポートしました — プレビューしてから「差分を確認して公開」します。",
+  "Download the in-use {locale} translation as a JSON file.": "使用中の {locale} 
翻訳を JSON ファイルとしてダウンロードします。",
+  "No published {locale} translation to export yet.": "エクスポートできる公開済みの {locale} 
翻訳がまだありません。",
+  "Import a translation JSON file as a local draft — review, then publish.": 
"翻訳の JSON ファイルをローカルの下書きとして読み込みます — 確認してから公開します。",
+  "Imported {locale} translations as a local draft — review, then “Check diff 
& push”.": "{locale} の翻訳をローカルの下書きとしてインポートしました — 確認してから「差分を確認して公開」します。",
+  "Import failed: {error}": "インポートに失敗しました: {error}",
+  "Unsupported language: {locale}": "サポートされていない言語: {locale}",
+  "Template {name} is not loaded on this deployment.": "テンプレート {name} 
はこのデプロイメントに読み込まれていません。",
+  "Could not load the target template.": "対象のテンプレートを読み込めませんでした。"
 }
diff --git a/apps/ui/src/i18n/locales/ko.json b/apps/ui/src/i18n/locales/ko.json
index 4b1f50b..069ade1 100644
--- a/apps/ui/src/i18n/locales/ko.json
+++ b/apps/ui/src/i18n/locales/ko.json
@@ -1269,5 +1269,18 @@
   "The single catch-all: any layer no tier pins lands here.": "단일 포괄 티어입니다: 어느 
티어에도 고정되지 않은 레이어가 모두 여기에 배치됩니다.",
   "Push 3D-map config to OAP": "3D-map 설정을 OAP에 게시",
   "Left = live on OAP (remote). Right = your local draft. Pushing replaces the 
OAP copy — the map renders it on the next visit.": "왼쪽 = OAP 라이브(원격). 오른쪽 = 내 
로컬 초안. 게시하면 OAP 복사본을 교체하며, 다음 방문 시 맵이 이를 렌더링합니다.",
-  "Push to OAP": "OAP에 게시"
+  "Push to OAP": "OAP에 게시",
+  "Export": "내보내기",
+  "Import": "가져오기",
+  "Download the in-use config (live on OAP, or the bundled default) as a JSON 
file.": "사용 중인 구성(OAP의 라이브 버전 또는 번들된 기본값)을 JSON 파일로 다운로드합니다.",
+  "Load a config JSON file as a local draft — preview, then publish.": "구성 
JSON 파일을 로컬 초안으로 불러옵니다 — 미리 본 후 게시하세요.",
+  "Imported as a local draft — preview, then “Check diff & push”.": "로컬 초안으로 
가져왔습니다 — 미리 본 후 “차이 보고 게시”하세요.",
+  "Download the in-use {locale} translation as a JSON file.": "사용 중인 {locale} 
번역을 JSON 파일로 다운로드합니다.",
+  "No published {locale} translation to export yet.": "내보낼 게시된 {locale} 번역이 아직 
없습니다.",
+  "Import a translation JSON file as a local draft — review, then publish.": 
"번역 JSON 파일을 로컬 초안으로 불러옵니다 — 검토한 후 게시하세요.",
+  "Imported {locale} translations as a local draft — review, then “Check diff 
& push”.": "{locale} 번역을 로컬 초안으로 가져왔습니다 — 검토한 후 “차이 보고 게시”하세요.",
+  "Import failed: {error}": "가져오기 실패: {error}",
+  "Unsupported language: {locale}": "지원하지 않는 언어: {locale}",
+  "Template {name} is not loaded on this deployment.": "템플릿 {name}은(는) 이 배포에 
로드되어 있지 않습니다.",
+  "Could not load the target template.": "대상 템플릿을 불러올 수 없습니다."
 }
diff --git a/apps/ui/src/i18n/locales/pt.json b/apps/ui/src/i18n/locales/pt.json
index 7228384..b3514fe 100644
--- a/apps/ui/src/i18n/locales/pt.json
+++ b/apps/ui/src/i18n/locales/pt.json
@@ -1269,5 +1269,18 @@
   "The single catch-all: any layer no tier pins lands here.": "O único nível 
coringa: toda camada que nenhum nível fixa cai aqui.",
   "Push 3D-map config to OAP": "Publicar a configuração do 3D-map no OAP",
   "Left = live on OAP (remote). Right = your local draft. Pushing replaces the 
OAP copy — the map renders it on the next visit.": "Esquerda = em produção no 
OAP (remote). Direita = seu rascunho local. Publicar substitui a cópia do OAP — 
o mapa passa a renderizá-la na próxima visita.",
-  "Push to OAP": "Publicar no OAP"
+  "Push to OAP": "Publicar no OAP",
+  "Export": "Exportar",
+  "Import": "Importar",
+  "Download the in-use config (live on OAP, or the bundled default) as a JSON 
file.": "Baixa a configuração em uso (a versão ativa no OAP, ou o padrão 
incluído) como um arquivo JSON.",
+  "Load a config JSON file as a local draft — preview, then publish.": 
"Carrega um arquivo JSON de configuração como rascunho local — visualize e 
depois publique.",
+  "Imported as a local draft — preview, then “Check diff & push”.": "Importado 
como rascunho local — visualize e depois «Ver diff e publicar».",
+  "Download the in-use {locale} translation as a JSON file.": "Baixa a 
tradução {locale} em uso como um arquivo JSON.",
+  "No published {locale} translation to export yet.": "Ainda não há uma 
tradução {locale} publicada para exportar.",
+  "Import a translation JSON file as a local draft — review, then publish.": 
"Importa um arquivo JSON de tradução como rascunho local — revise e depois 
publique.",
+  "Imported {locale} translations as a local draft — review, then “Check diff 
& push”.": "Traduções {locale} importadas como rascunho local — revise e depois 
«Ver diff e publicar».",
+  "Import failed: {error}": "Falha na importação: {error}",
+  "Unsupported language: {locale}": "Idioma não suportado: {locale}",
+  "Template {name} is not loaded on this deployment.": "O modelo {name} não 
está carregado nesta implantação.",
+  "Could not load the target template.": "Não foi possível carregar o modelo 
de destino."
 }
diff --git a/apps/ui/src/i18n/locales/zh-CN.json 
b/apps/ui/src/i18n/locales/zh-CN.json
index 9ba66c1..83ee593 100644
--- a/apps/ui/src/i18n/locales/zh-CN.json
+++ b/apps/ui/src/i18n/locales/zh-CN.json
@@ -1269,5 +1269,18 @@
   "The single catch-all: any layer no tier pins lands here.": 
"唯一的兜底层组:任何未被固定到具体层组的 layer 都会落到这里。",
   "Push 3D-map config to OAP": "将 3D-map 配置推送至 OAP",
   "Left = live on OAP (remote). Right = your local draft. Pushing replaces the 
OAP copy — the map renders it on the next visit.": "左侧 = OAP 上的线上版本(远端)。右侧 = 
你的本地草稿。推送会替换 OAP 上的副本 —— 下次访问时地图便以其渲染。",
-  "Push to OAP": "推送至 OAP"
+  "Push to OAP": "推送至 OAP",
+  "Export": "导出",
+  "Import": "导入",
+  "Download the in-use config (live on OAP, or the bundled default) as a JSON 
file.": "将使用中的配置(OAP 上的实时版本,或内置默认值)下载为 JSON 文件。",
+  "Load a config JSON file as a local draft — preview, then publish.": "将配置 
JSON 文件作为本地草稿载入——预览后再发布。",
+  "Imported as a local draft — preview, then “Check diff & push”.": 
"已作为本地草稿导入——预览后“查看差异并发布”。",
+  "Download the in-use {locale} translation as a JSON file.": "将使用中的 {locale} 
翻译下载为 JSON 文件。",
+  "No published {locale} translation to export yet.": "尚无已发布的 {locale} 翻译可导出。",
+  "Import a translation JSON file as a local draft — review, then publish.": 
"将翻译 JSON 文件作为本地草稿载入——检查后再发布。",
+  "Imported {locale} translations as a local draft — review, then “Check diff 
& push”.": "已将 {locale} 翻译作为本地草稿导入——检查后“查看差异并发布”。",
+  "Import failed: {error}": "导入失败:{error}",
+  "Unsupported language: {locale}": "不支持的语言:{locale}",
+  "Template {name} is not loaded on this deployment.": "模板 {name} 未在此部署中加载。",
+  "Could not load the target template.": "无法加载目标模板。"
 }
diff --git a/docs/customization/i18n.md b/docs/customization/i18n.md
index 0d728a4..5474274 100644
--- a/docs/customization/i18n.md
+++ b/docs/customization/i18n.md
@@ -115,6 +115,12 @@ Rules:
   common widget vocabulary from the shared lexicon so you only need
   to translate your own prose.
 
+## Import / Export
+
+On the **Translations** page, **Export** downloads the in-use translation for 
the picked template and **Target** language — the version live on OAP, or the 
shipped seed — as a JSON file. **Import** reads such a file and loads it as a 
**local draft** for that language; review it in the preview, then **Check diff 
& push** to publish. Import never writes OAP directly.
+
+Each language is its own file and its own row, so you export/import one 
language at a time. Source templates and their translations are edited on 
separate pages, so their import/export are separate too: a [template's 
export](/customization/overview-templates) carries the English source only — 
move its translations across here, per language.
+
 ## Adding a new locale
 
 Adding `de`, `fr`, or any other locale is three steps:
diff --git a/docs/customization/layer-templates.md 
b/docs/customization/layer-templates.md
index d35adb3..1858413 100644
--- a/docs/customization/layer-templates.md
+++ b/docs/customization/layer-templates.md
@@ -339,6 +339,14 @@ Your work-in-progress lives **in your browser**, never on 
the server until you p
 
 A top banner summarizes page state — *Synced from OAP — N diverged, Y local* — 
and **Diverged** / **Local** filters narrow the picker. Each row shows a status 
chip: **synced** (bundled == OAP), **diverged** (OAP differs from bundled — OAP 
wins at render), **remote-only** (on OAP, no bundled default), **disabled** 
(deleted — see below), or **bundled** (OAP has no copy right now).
 
+### Import / Export
+
+**Export** downloads the layer's **in-use version** — what end users render 
now (the OAP-live copy, or the bundled default when OAP has none) — as a JSON 
file, for backup, sharing, or moving the dashboard to another OAP.
+
+**Import** reads a layer-template JSON file and loads it as a **local draft** 
in this browser — it never writes OAP directly. Preview it, then **Check diff & 
push** to publish. Because layer keys are a fixed set, import targets the layer 
the file names (e.g. `MESH`), and that layer must already be present on this 
deployment; a file for a layer not loaded here, or one that isn't a valid layer 
template, is rejected with a message.
+
+Import/export covers the **source layer template** (the English authoring 
layer) only. Per-locale translations are stored separately in OAP and managed 
on the [Translations](/customization/i18n) page — they're not part of this 
file. A layer exported to a *different* OAP arrives with its English source 
only; move its translations across on the Translations page if you need them 
there.
+
 ### Disabling / reactivating a layer
 
 OAP has no hard delete, so the **Disable** button next to the layer title 
soft-disables the layer on OAP. A disabled layer is dropped from the sidebar 
and renders nowhere, for everyone.
diff --git a/docs/customization/overview-templates.md 
b/docs/customization/overview-templates.md
index 6e8be11..3ff21db 100644
--- a/docs/customization/overview-templates.md
+++ b/docs/customization/overview-templates.md
@@ -243,6 +243,14 @@ A **+ New dashboard** form (inside the picker) creates a 
dashboard the same way:
 
 A top banner summarizes state — *Synced from OAP — N diverged, Y local* — with 
**Diverged** / **Local** filters. Status chips per row: **synced**, 
**diverged** (OAP wins at render), **remote-only**, **disabled** (deleted), 
**bundled**.
 
+### Import / Export
+
+**Export** downloads the dashboard's **in-use version** — the copy end users 
render right now (the version live on OAP, or the bundled default when OAP has 
none) — as a JSON file. Use it to back up a dashboard, share it, or move it to 
another OAP. Export is unavailable for a brand-new local draft you haven't 
published yet, since nothing is in use to download.
+
+**Import** reads a dashboard JSON file and loads it as a **local draft** — it 
never writes OAP directly. After a valid import the dashboard is selected and 
tagged **local**; preview it, then **Check diff & push** to publish, exactly 
like any other edit. The file targets the dashboard it names: an existing id is 
replaced as a local draft, and a new id creates a new dashboard (like **+ New 
dashboard**, pre-filled from the file) — handy for restoring one you deleted. A 
file that isn't a val [...]
+
+Import/export covers the **source dashboard** (the English authoring layer) 
only. Per-locale translations are stored separately in OAP and managed on the 
[Translations](/customization/i18n) page — they're not part of this file. A 
dashboard exported to a *different* OAP arrives with its English source only; 
move its translations across on the Translations page if you need them there.
+
 ### Deleting a dashboard
 
 OAP has no hard delete, so the **Delete** button next to the title 
soft-disables the dashboard on OAP (a disabled dashboard drops from the 
picker's live state, the sidebar, and the live page). A dashboard that exists 
only as an unpublished local draft is removed from your browser instead. Either 
action is confirmed in a dialog first; deletion is irreversible from the UI, 
but a new dashboard can always be created with the same id.
diff --git a/docs/operate/infra-3d-map.md b/docs/operate/infra-3d-map.md
index 95f21d7..770a9db 100644
--- a/docs/operate/infra-3d-map.md
+++ b/docs/operate/infra-3d-map.md
@@ -178,6 +178,13 @@ Pushed changes take effect the next time the map is 
opened. A **Reset**
 action reloads either the shipped bundled default or OAP's current
 version, so you can start over before saving.
 
+**Export** downloads the map's in-use configuration — the version live on
+OAP, or the bundled default when OAP has none — as a JSON file, for backup,
+sharing, or moving it to another OAP. **Import** reads a configuration JSON
+file and loads it as a local draft; preview it, then **Check diff & push**
+to publish. Import never writes OAP directly, and a file that isn't a valid
+3D-map configuration is rejected with a message.
+
 Viewing the map needs read access (`infra-3d:read`, held by the built-in
 viewer role and above); editing and publishing the configuration needs
 `overview:write` (operators and admins by default). See

Reply via email to