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