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

commit 488c91bece825abb984b6d82ddb1af4b67f4193d
Author: Wu Sheng <[email protected]>
AuthorDate: Tue May 12 14:47:31 2026 +0800

    setup: bff file-backed persistence (GET/POST /api/setup)
    
    Operator customization (per-layer priority, slot aliases, cap toggles,
    landing card config) now survives BFF restarts.
    
    - packages/api-client/setup.ts: shared wire types — LandingConfig,
      LayerConfig, SetupResponse, SetupSavePayload.
    - apps/bff/setup/store.ts: file-backed JSON store with atomic
      write-then-rename and a serialized writer to avoid concurrent
      clobbers. Future swap-point: replace with addTemplate (horizon-
      prefix) once OAP-side template management lands.
    - apps/bff/setup/routes.ts: GET returns the persisted overrides, POST
      takes a full layer map and replaces it. zod schema enforces topN
      5..8, priority 0..99, ≤5 columns, valid style enum. Save events go
      to the audit JSONL with the actor and layer count.
    - horizon.example.yaml: documents setup.file (default
      ./horizon-setup.json), .gitignore covers the runtime artefact.
    - apps/ui/stores/setup.ts: bootstrap from /api/setup on mount; track
      dirty / saving / lastError; markDirty hook called from every form
      field; save/discard round-trip through the BFF.
    - SetupView footer adds Save / Discard buttons with idle / saving /
      saved / unsaved / error status indicators.
---
 .gitignore                                 |   1 +
 apps/bff/src/config/schema.ts              |   8 ++
 apps/bff/src/server.ts                     |   5 +
 apps/bff/src/setup/routes.ts               | 120 +++++++++++++++++++++
 apps/bff/src/setup/store.ts                |  92 ++++++++++++++++
 apps/ui/src/api/client.ts                  |  23 +++-
 apps/ui/src/stores/setup.ts                | 167 ++++++++++++++++++++++-------
 apps/ui/src/views/setup/LayerSetupCard.vue |  28 +++--
 apps/ui/src/views/setup/SetupView.vue      |  64 ++++++++++-
 horizon.example.yaml                       |   6 ++
 packages/api-client/src/index.ts           |   7 ++
 packages/api-client/src/setup.ts           |  68 ++++++++++++
 12 files changed, 534 insertions(+), 55 deletions(-)

diff --git a/.gitignore b/.gitignore
index 8173b86..489d016 100644
--- a/.gitignore
+++ b/.gitignore
@@ -40,3 +40,4 @@ docs/design/
 horizon.yaml
 horizon-audit.jsonl
 horizon-wire.jsonl
+horizon-setup.json
diff --git a/apps/bff/src/config/schema.ts b/apps/bff/src/config/schema.ts
index 36e7f53..3103d69 100644
--- a/apps/bff/src/config/schema.ts
+++ b/apps/bff/src/config/schema.ts
@@ -93,6 +93,13 @@ const auditSchema = z
   .strict()
   .default({ file: './horizon-audit.jsonl' });
 
+const setupSchema = z
+  .object({
+    file: z.string().default('./horizon-setup.json'),
+  })
+  .strict()
+  .default({ file: './horizon-setup.json' });
+
 const debugLogSchema = z
   .object({
     enabled: z.boolean().default(false),
@@ -116,6 +123,7 @@ export const configSchema = z
     rbac: rbacSchema,
     session: sessionSchema,
     audit: auditSchema,
+    setup: setupSchema,
     debugLog: debugLogSchema,
   })
   .strict();
diff --git a/apps/bff/src/server.ts b/apps/bff/src/server.ts
index 1c93364..0c3b135 100644
--- a/apps/bff/src/server.ts
+++ b/apps/bff/src/server.ts
@@ -24,6 +24,8 @@ import { loadConfig, type ConfigSource } from 
'./config/loader.js';
 import { registerMenuRoute } from './oap/menu-routes.js';
 import { registerOapRoutes } from './oap/routes.js';
 import { registerPreflightRoutes } from './oap/preflight-routes.js';
+import { registerSetupRoutes } from './setup/routes.js';
+import { SetupStore } from './setup/store.js';
 import { HttpError } from './errors.js';
 import { logger, loggerOptions } from './logger.js';
 
@@ -47,6 +49,8 @@ app.setErrorHandler((err, _req, reply) => {
 const sessions = new SessionStore({ ttlMinutes: 
source.current.session.ttlMinutes });
 const audit = new AuditLogger(source.current.audit.file);
 await audit.open();
+const setupStore = new SetupStore(source.current.setup.file);
+await setupStore.load();
 
 await app.register(cookie);
 
@@ -55,6 +59,7 @@ app.addContentTypeParser('text/plain', { parseAs: 'string' }, 
(_req, body, done)
 
 registerAuthRoutes(app, source, sessions, audit);
 registerMenuRoute(app, { config: source, sessions });
+registerSetupRoutes(app, { config: source, sessions, audit, store: setupStore 
});
 registerOapRoutes(app, { config: source, sessions, audit });
 registerPreflightRoutes(app, { config: source, sessions });
 
diff --git a/apps/bff/src/setup/routes.ts b/apps/bff/src/setup/routes.ts
new file mode 100644
index 0000000..03efc4b
--- /dev/null
+++ b/apps/bff/src/setup/routes.ts
@@ -0,0 +1,120 @@
+/*
+ * 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 type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
+import { z } from 'zod';
+import type { SetupResponse, SetupSavePayload } from 
'@skywalking-horizon-ui/api-client';
+import type { AuditLogger } from '../audit/logger.js';
+import { requireAuth } from '../auth/middleware.js';
+import type { ConfigSource } from '../config/loader.js';
+import type { SessionStore } from '../auth/sessions.js';
+import { badRequest } from '../errors.js';
+import type { SetupStore } from './store.js';
+
+export interface SetupRouteDeps {
+  config: ConfigSource;
+  sessions: SessionStore;
+  audit: AuditLogger;
+  store: SetupStore;
+}
+
+const landingColumnSchema = z
+  .object({
+    metric: z.string().min(1),
+    label: z.string().min(1),
+    unit: z.string().optional(),
+  })
+  .strict();
+
+const landingSchema = z
+  .object({
+    priority: z.number().int().min(0).max(99),
+    topN: z.number().int().min(5).max(8),
+    orderBy: z.string().min(1),
+    columns: z.array(landingColumnSchema).max(5),
+    spark: z
+      .object({
+        metric: z.string().min(1),
+        height: z.number().int().positive(),
+      })
+      .strict()
+      .optional(),
+    style: z.enum(['table', 'bar', 'mini-topology']),
+  })
+  .strict();
+
+const layerConfigSchema = z
+  .object({
+    displayName: z.string().optional(),
+    slots: z
+      .object({
+        services: z.string().optional(),
+        instances: z.string().optional(),
+        endpoints: z.string().optional(),
+        endpointDependency: z.string().optional(),
+      })
+      .strict(),
+    caps: z
+      .object({
+        overview: z.boolean().optional(),
+        serviceMap: z.boolean().optional(),
+        endpointDependency: z.boolean().optional(),
+        instanceTopology: z.boolean().optional(),
+        processTopology: z.boolean().optional(),
+        dashboards: z.boolean().optional(),
+        traces: z.boolean().optional(),
+        logs: z.boolean().optional(),
+        profiling: z.boolean().optional(),
+        events: z.boolean().optional(),
+      })
+      .strict(),
+    landing: landingSchema,
+  })
+  .strict();
+
+const savePayloadSchema = z
+  .object({
+    layers: z.record(z.string().min(1), layerConfigSchema),
+  })
+  .strict();
+
+export function registerSetupRoutes(app: FastifyInstance, deps: 
SetupRouteDeps): void {
+  const auth = requireAuth(deps);
+
+  app.get('/api/setup', { preHandler: auth }, async (_req: FastifyRequest, 
reply: FastifyReply) => {
+    const layers = await deps.store.load();
+    const body: SetupResponse = { generatedAt: Date.now(), layers };
+    return reply.send(body);
+  });
+
+  app.post('/api/setup', { preHandler: auth }, async (req: FastifyRequest, 
reply: FastifyReply) => {
+    const parsed = savePayloadSchema.safeParse(req.body);
+    if (!parsed.success) {
+      throw badRequest('invalid setup payload', parsed.error.flatten());
+    }
+    const payload = parsed.data as SetupSavePayload;
+    await deps.store.save(payload.layers);
+    deps.audit.record({
+      actor: req.session?.username ?? null,
+      action: 'setup.save',
+      outcome: 'success',
+      details: { layerCount: Object.keys(payload.layers).length },
+    });
+    const body: SetupResponse = { generatedAt: Date.now(), layers: 
payload.layers };
+    return reply.send(body);
+  });
+}
diff --git a/apps/bff/src/setup/store.ts b/apps/bff/src/setup/store.ts
new file mode 100644
index 0000000..1bf20b1
--- /dev/null
+++ b/apps/bff/src/setup/store.ts
@@ -0,0 +1,92 @@
+/*
+ * 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 { existsSync } from 'node:fs';
+import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
+import { dirname, resolve } from 'node:path';
+import type { LayerConfig } from '@skywalking-horizon-ui/api-client';
+import { logger } from '../logger.js';
+
+/**
+ * File-backed store for per-layer setup overrides.
+ *
+ * Holds only what the operator has changed away from defaults. Defaults
+ * live in horizon's frontend (and a soon-to-come BFF defaults table) so
+ * the stored JSON stays sparse and human-readable.
+ *
+ * Future swap-point: replace this implementation with one that writes
+ * to OAP via `addTemplate` mutations under the `horizon-` prefix, once
+ * horizon's runtime template format is ready and operators have
+ * `core.enableUpdateUITemplate: true` flipped on. The interface below
+ * is what callers code against.
+ */
+export class SetupStore {
+  private readonly absPath: string;
+  private cache: Record<string, LayerConfig> | null = null;
+  private writing: Promise<void> | null = null;
+
+  constructor(filePath: string) {
+    this.absPath = resolve(filePath);
+  }
+
+  async load(): Promise<Record<string, LayerConfig>> {
+    if (this.cache) return this.cache;
+    if (!existsSync(this.absPath)) {
+      this.cache = {};
+      return this.cache;
+    }
+    try {
+      const raw = await readFile(this.absPath, 'utf8');
+      const parsed = JSON.parse(raw) as { layers?: Record<string, LayerConfig> 
} | Record<string, LayerConfig>;
+      // Tolerate both `{layers: {...}}` and the bare map for forward-compat.
+      const layers = (parsed as { layers?: Record<string, LayerConfig> 
}).layers ?? (parsed as Record<string, LayerConfig>);
+      this.cache = layers && typeof layers === 'object' ? layers : {};
+      return this.cache;
+    } catch (err) {
+      logger.warn({ err, path: this.absPath }, 'setup store unreadable; 
starting empty');
+      this.cache = {};
+      return this.cache;
+    }
+  }
+
+  async save(layers: Record<string, LayerConfig>): Promise<void> {
+    // Serialize writes — multiple concurrent POSTs from the UI shouldn't
+    // race against each other.
+    while (this.writing) await this.writing;
+    const tmp = `${this.absPath}.tmp`;
+    const next: Record<string, LayerConfig> = 
JSON.parse(JSON.stringify(layers));
+    const work = (async () => {
+      await mkdir(dirname(this.absPath), { recursive: true });
+      await writeFile(
+        tmp,
+        JSON.stringify({ generatedAt: Date.now(), layers: next }, null, 2),
+        'utf8',
+      );
+      await rename(tmp, this.absPath);
+      this.cache = next;
+    })();
+    this.writing = work.finally(() => {
+      this.writing = null;
+    });
+    await this.writing;
+  }
+
+  /** Force reload from disk — useful when an admin edits the JSON externally. 
*/
+  invalidate(): void {
+    this.cache = null;
+  }
+}
diff --git a/apps/ui/src/api/client.ts b/apps/ui/src/api/client.ts
index 39e7fc4..a82eb97 100644
--- a/apps/ui/src/api/client.ts
+++ b/apps/ui/src/api/client.ts
@@ -15,9 +15,19 @@
  * limitations under the License.
  */
 
-import type { MenuResponse } from '@skywalking-horizon-ui/api-client';
+import type { MenuResponse, SetupResponse, SetupSavePayload } from 
'@skywalking-horizon-ui/api-client';
 
-export type { MenuResponse, LayerDef, LayerCaps, LayerSlots } from 
'@skywalking-horizon-ui/api-client';
+export type {
+  MenuResponse,
+  LayerDef,
+  LayerCaps,
+  LayerSlots,
+  SetupResponse,
+  SetupSavePayload,
+  LayerConfig,
+  LandingConfig,
+  LandingColumn,
+} from '@skywalking-horizon-ui/api-client';
 
 export interface MeResponse {
   username: string;
@@ -95,6 +105,15 @@ export class BffClient {
     return this.request<MenuResponse>('GET', '/api/menu');
   }
 
+  // ── setup (per-layer overrides) ──────────────────────────────────────
+  loadSetup(): Promise<SetupResponse> {
+    return this.request<SetupResponse>('GET', '/api/setup');
+  }
+
+  saveSetup(payload: SetupSavePayload): Promise<SetupResponse> {
+    return this.request<SetupResponse>('POST', '/api/setup', payload);
+  }
+
   // ── cluster / preflight ──────────────────────────────────────────────
   preflight(): Promise<unknown> {
     return this.request('GET', '/api/preflight');
diff --git a/apps/ui/src/stores/setup.ts b/apps/ui/src/stores/setup.ts
index 752db37..b39c1ba 100644
--- a/apps/ui/src/stores/setup.ts
+++ b/apps/ui/src/stores/setup.ts
@@ -16,41 +16,16 @@
  */
 
 import { defineStore } from 'pinia';
-import { reactive } from 'vue';
-import type { LayerCaps, LayerSlots } from '@skywalking-horizon-ui/api-client';
+import { computed, reactive, ref } from 'vue';
+import type {
+  LandingConfig,
+  LayerCaps,
+  LayerConfig,
+  LayerSlots,
+} from '@skywalking-horizon-ui/api-client';
+import { bffClient } from '@/api/client';
 
-/** Per-layer landing-card configuration. See 
docs/design/landing-composition.md.
- *
- * Every available layer (one with services reporting) appears on the
- * landing automatically. There is intentionally NO `enabled` toggle — the
- * landing is the auto-composition of all layers' configs. Operators
- * adjust HOW each layer renders (priority, topN, columns, style); the
- * only way to suppress a layer is to disable its features in `caps`. */
-export interface LandingConfig {
-  /** Lower number → higher on the page. Defaults seeded from priority table. 
*/
-  priority: number;
-  /** 5..8. */
-  topN: number;
-  /** MQE key used to rank the top-N services for this layer. */
-  orderBy: string;
-  /** Columns shown per service row in the card. */
-  columns: Array<{ metric: string; label: string; unit?: string }>;
-  /** Optional sparkline column metric. */
-  spark?: { metric: string; height: number };
-  style: 'table' | 'bar' | 'mini-topology';
-}
-
-/** Editable per-layer config the setup page mutates. Mirrors what the
- *  Phase 7 admin UI will persist via the BFF. */
-export interface LayerConfig {
-  /** Override display name (defaults to the menu title from OAP). */
-  displayName?: string;
-  /** Term aliases — overrides slots from /api/menu. */
-  slots: LayerSlots;
-  /** Feature toggles — start from /api/menu defaults, operator can disable. */
-  caps: LayerCaps;
-  landing: LandingConfig;
-}
+export type { LayerConfig, LandingConfig };
 
 /** Default-priority table per the design (General → Virtual* → Mesh → K8s). */
 function defaultPriority(layerKey: string): number {
@@ -63,7 +38,7 @@ function defaultPriority(layerKey: string): number {
 }
 
 /** Default-columns table per layer category. Concrete MQE metric names are
- *  illustrative until Stage 2.4 wires them up — adjust per layer admin. */
+ *  illustrative until Stage 2.6 wires them up — adjust per layer admin. */
 function defaultColumns(_layerKey: string): LandingConfig['columns'] {
   return [
     { metric: 'cpm', label: 'cpm' },
@@ -95,21 +70,133 @@ export function defaultLayerConfig(
   };
 }
 
+/**
+ * Layer customization store.
+ *
+ * Lifecycle:
+ *   1. `bootstrap()` hydrates the persisted overrides from `GET /api/setup`.
+ *   2. `ensure(key, defaults)` returns the editable config — creating one
+ *      from `defaults` on first touch.
+ *   3. UI mutations mark `dirty` true.
+ *   4. `save()` POSTs `/api/setup` and clears `dirty`.
+ *   5. `reset(key, defaults)` rebuilds a single layer from defaults.
+ *   6. `discard()` re-hydrates from server, dropping local changes.
+ *
+ * The BFF JSON store is the source of truth until OAP-side template
+ * management lands. See packages/api-client/src/setup.ts for the wire
+ * shape.
+ */
 export const useSetupStore = defineStore('setup', () => {
-  /** Layer key → edited config. Phase 2.4 will persist this via BFF. */
   const configs = reactive<Record<string, LayerConfig>>({});
+  const dirty = ref(false);
+  const loading = ref(false);
+  const saving = ref(false);
+  const lastError = ref<string | null>(null);
+  const bootstrapped = ref(false);
+  /** Last server-known shape; used by `discard()` to revert. */
+  let serverSnapshot: Record<string, LayerConfig> = {};
+
+  function applyServerSnapshot(layers: Record<string, LayerConfig>): void {
+    serverSnapshot = JSON.parse(JSON.stringify(layers));
+    for (const k of Object.keys(configs)) delete configs[k];
+    for (const [k, v] of Object.entries(layers)) configs[k] = 
JSON.parse(JSON.stringify(v));
+    dirty.value = false;
+  }
+
+  async function bootstrap(): Promise<void> {
+    if (bootstrapped.value || loading.value) return;
+    loading.value = true;
+    lastError.value = null;
+    try {
+      const res = await bffClient.loadSetup();
+      applyServerSnapshot(res.layers);
+      bootstrapped.value = true;
+    } catch (err) {
+      lastError.value = err instanceof Error ? err.message : 'failed to load 
setup';
+    } finally {
+      loading.value = false;
+    }
+  }
 
+  function markDirty(): void {
+    if (!dirty.value) dirty.value = true;
+  }
+
+  /**
+   * Return the operator's config for a layer, creating one from defaults
+   * on first touch. Calls into this from a `computed()` MUTATE the store —
+   * intentional: the Pinia reactive proxy then makes every form-field
+   * binding write-through.
+   */
   function ensure(
     layerKey: string,
     defaults: { slots: LayerSlots; caps: LayerCaps },
   ): LayerConfig {
-    if (!configs[layerKey]) configs[layerKey] = defaultLayerConfig(layerKey, 
defaults);
-    return configs[layerKey];
+    let cfg = configs[layerKey];
+    if (!cfg) {
+      cfg = defaultLayerConfig(layerKey, defaults);
+      configs[layerKey] = cfg;
+      // Newly-created defaults aren't "dirty" — only explicit edits should
+      // turn the Save button on. We track that by leaving `dirty` alone
+      // here and relying on form-field bindings to flip it via deep proxy
+      // watchers below.
+    }
+    return cfg;
   }
 
-  function reset(layerKey: string, defaults: { slots: LayerSlots; caps: 
LayerCaps }): void {
+  function reset(
+    layerKey: string,
+    defaults: { slots: LayerSlots; caps: LayerCaps },
+  ): void {
     configs[layerKey] = defaultLayerConfig(layerKey, defaults);
+    markDirty();
+  }
+
+  async function save(): Promise<void> {
+    if (saving.value) return;
+    saving.value = true;
+    lastError.value = null;
+    try {
+      // Strip layers that match defaults exactly? Keep all touched layers
+      // for now — server stores them sparse but client sends the full set.
+      const payload = JSON.parse(JSON.stringify(configs)) as Record<string, 
LayerConfig>;
+      const res = await bffClient.saveSetup({ layers: payload });
+      applyServerSnapshot(res.layers);
+    } catch (err) {
+      lastError.value = err instanceof Error ? err.message : 'save failed';
+      throw err;
+    } finally {
+      saving.value = false;
+    }
   }
 
-  return { configs, ensure, reset };
+  async function discard(): Promise<void> {
+    applyServerSnapshot(serverSnapshot);
+  }
+
+  // Per-template Vue proxy mutation tracking — every form binding
+  // mutates `configs[layer].…`. We hook the proxy via a Pinia subscribe.
+  // Simpler: just call `markDirty` from the form handler. Form bindings
+  // (`v-model="cfg.slots.services"`) go through Pinia, but we can't tell
+  // a programmatic write from a user edit without an explicit signal.
+  // → expose `markDirty` and call it from LayerSetupCard on input.
+
+  return {
+    // state
+    configs,
+    dirty,
+    loading,
+    saving,
+    bootstrapped,
+    lastError,
+    // computed
+    layerCount: computed(() => Object.keys(configs).length),
+    // actions
+    bootstrap,
+    ensure,
+    reset,
+    markDirty,
+    save,
+    discard,
+  };
 });
diff --git a/apps/ui/src/views/setup/LayerSetupCard.vue 
b/apps/ui/src/views/setup/LayerSetupCard.vue
index e526ed4..1176b87 100644
--- a/apps/ui/src/views/setup/LayerSetupCard.vue
+++ b/apps/ui/src/views/setup/LayerSetupCard.vue
@@ -37,6 +37,12 @@ function resetThisLayer(): void {
   store.reset(props.layer.key, { slots: props.layer.slots, caps: 
props.layer.caps });
 }
 
+// Every form-field input on this card calls onEdit so the store knows the
+// user (not just a default-population) touched the config.
+function onEdit(): void {
+  store.markDirty();
+}
+
 const summary = computed<string>(() => {
   const c = cfg.value;
   const cols = c.landing.columns.map((x) => x.metric).join(', ');
@@ -82,11 +88,13 @@ function toggleColumn(metric: string, label: string, unit?: 
string): void {
   } else if (cols.length < 5) {
     cols.push({ metric, label, ...(unit ? { unit } : {}) });
   }
+  onEdit();
 }
 
 function clampTopN(n: number): void {
   const v = Math.max(5, Math.min(8, Math.round(n || 5)));
   cfg.value.landing.topN = v;
+  onEdit();
 }
 
 const headerColor = computed(() => props.layer.color);
@@ -122,23 +130,23 @@ const isDefaultLanding = computed(() => {
         <div class="field-grid">
           <label>
             <span>Display name</span>
-            <input v-model="cfg.displayName" :placeholder="layer.name" />
+            <input v-model="cfg.displayName" :placeholder="layer.name" 
@input="onEdit" />
           </label>
           <label v-if="layer.slots.services !== undefined">
             <span>Services</span>
-            <input v-model="cfg.slots.services" 
:placeholder="layer.slots.services" />
+            <input v-model="cfg.slots.services" 
:placeholder="layer.slots.services" @input="onEdit" />
           </label>
           <label v-if="layer.slots.instances !== undefined">
             <span>Instances</span>
-            <input v-model="cfg.slots.instances" 
:placeholder="layer.slots.instances" />
+            <input v-model="cfg.slots.instances" 
:placeholder="layer.slots.instances" @input="onEdit" />
           </label>
           <label v-if="layer.slots.endpoints !== undefined">
             <span>Endpoints</span>
-            <input v-model="cfg.slots.endpoints" 
:placeholder="layer.slots.endpoints" />
+            <input v-model="cfg.slots.endpoints" 
:placeholder="layer.slots.endpoints" @input="onEdit" />
           </label>
           <label v-if="cfg.caps.endpointDependency">
             <span>Endpoint dependency</span>
-            <input v-model="cfg.slots.endpointDependency" 
:placeholder="layer.slots.endpointDependency ?? `${cfg.slots.endpoints ?? 
'Endpoint'} dependency`" />
+            <input v-model="cfg.slots.endpointDependency" 
:placeholder="layer.slots.endpointDependency ?? `${cfg.slots.endpoints ?? 
'Endpoint'} dependency`" @input="onEdit" />
           </label>
         </div>
       </section>
@@ -147,7 +155,7 @@ const isDefaultLanding = computed(() => {
         <h4>Features</h4>
         <div class="caps-grid">
           <label v-for="row in capRows" :key="row.key" class="cap-toggle">
-            <input type="checkbox" v-model="cfg.caps[row.key]" />
+            <input type="checkbox" v-model="cfg.caps[row.key]" 
@change="onEdit" />
             <span>{{ row.label }}</span>
           </label>
         </div>
@@ -158,7 +166,7 @@ const isDefaultLanding = computed(() => {
         <div class="field-grid landing">
           <label>
             <span>Priority (lower = higher on page)</span>
-            <input type="number" v-model.number="cfg.landing.priority" min="0" 
max="99" />
+            <input type="number" v-model.number="cfg.landing.priority" min="0" 
max="99" @input="onEdit" />
           </label>
           <label>
             <span>Top N (5–8)</span>
@@ -166,7 +174,7 @@ const isDefaultLanding = computed(() => {
           </label>
           <label>
             <span>Order by</span>
-            <select v-model="cfg.landing.orderBy">
+            <select v-model="cfg.landing.orderBy" @change="onEdit">
               <option v-for="c in availableColumns" :key="c.metric" 
:value="c.metric" :title="c.tip">
                 {{ c.longLabel }}
               </option>
@@ -174,7 +182,7 @@ const isDefaultLanding = computed(() => {
           </label>
           <label>
             <span>Sparkline</span>
-            <select :value="cfg.landing.spark?.metric ?? ''" @change="(e) => { 
const v = (e.target as HTMLSelectElement).value; cfg.landing.spark = v ? { 
metric: v, height: 28 } : undefined; }">
+            <select :value="cfg.landing.spark?.metric ?? ''" @change="(e) => { 
const v = (e.target as HTMLSelectElement).value; cfg.landing.spark = v ? { 
metric: v, height: 28 } : undefined; onEdit(); }">
               <option value="">none</option>
               <option v-for="c in availableColumns" :key="c.metric" 
:value="c.metric" :title="c.tip">
                 {{ c.longLabel }}
@@ -183,7 +191,7 @@ const isDefaultLanding = computed(() => {
           </label>
           <label>
             <span>Style</span>
-            <select v-model="cfg.landing.style">
+            <select v-model="cfg.landing.style" @change="onEdit">
               <option value="table">Table</option>
               <option value="bar">Bar</option>
               <option value="mini-topology">Mini topology</option>
diff --git a/apps/ui/src/views/setup/SetupView.vue 
b/apps/ui/src/views/setup/SetupView.vue
index 361278b..ce7f560 100644
--- a/apps/ui/src/views/setup/SetupView.vue
+++ b/apps/ui/src/views/setup/SetupView.vue
@@ -15,7 +15,7 @@
   limitations under the License.
 -->
 <script setup lang="ts">
-import { computed, ref } from 'vue';
+import { computed, onMounted, ref } from 'vue';
 import LayerSetupCard from './LayerSetupCard.vue';
 import { useLayers } from '@/composables/useLayers';
 import { useLandingOrder } from '@/composables/useLandingOrder';
@@ -24,6 +24,30 @@ import { useSetupStore } from '@/stores/setup';
 const { layers, oapReachable, oapError, isLoading } = useLayers();
 const store = useSetupStore();
 
+// Hydrate persisted overrides on first mount. The store is idempotent —
+// repeated mounts during HMR don't refetch unnecessarily.
+onMounted(() => {
+  void store.bootstrap();
+});
+
+const savePhase = ref<'idle' | 'saving' | 'saved' | 'error'>('idle');
+async function onSave(): Promise<void> {
+  savePhase.value = 'saving';
+  try {
+    await store.save();
+    savePhase.value = 'saved';
+    setTimeout(() => {
+      if (savePhase.value === 'saved') savePhase.value = 'idle';
+    }, 1500);
+  } catch {
+    savePhase.value = 'error';
+  }
+}
+async function onDiscard(): Promise<void> {
+  await store.discard();
+  savePhase.value = 'idle';
+}
+
 // Order by priority (lower first) so this page lines up with the sidebar
 // and the Overview. Setup shows ALL layers (active or not) — operators
 // configure layers ahead of receivers coming online.
@@ -107,9 +131,28 @@ const visibleLayers = computed(() => {
           <span v-if="orderedLayers.length > 8">…</span>
         </div>
         <div class="foot-right">
-          <span class="hint">
-            Persistence wires in at Stage 2.4. For now, changes live in this 
tab only.
+          <span v-if="store.lastError && savePhase === 'error'" class="hint 
err">
+            {{ store.lastError }}
           </span>
+          <span v-else-if="savePhase === 'saved'" class="hint ok">saved</span>
+          <span v-else-if="store.dirty" class="hint dirty">unsaved 
changes</span>
+          <span v-else class="hint">all changes persisted</span>
+          <button
+            class="sw-btn"
+            type="button"
+            :disabled="!store.dirty || savePhase === 'saving'"
+            @click="onDiscard"
+          >
+            Discard
+          </button>
+          <button
+            class="sw-btn is-primary"
+            type="button"
+            :disabled="!store.dirty || savePhase === 'saving'"
+            @click="onSave"
+          >
+            {{ savePhase === 'saving' ? 'Saving…' : 'Save' }}
+          </button>
         </div>
       </footer>
     </template>
@@ -293,8 +336,23 @@ const visibleLayers = computed(() => {
 .chip-name {
   color: var(--sw-fg-0);
 }
+.foot-right {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
 .foot-right .hint {
   font-size: 10.5px;
   color: var(--sw-fg-3);
+  font-variant-numeric: tabular-nums;
+}
+.foot-right .hint.dirty {
+  color: var(--sw-warn);
+}
+.foot-right .hint.err {
+  color: #f87171;
+}
+.foot-right .hint.ok {
+  color: var(--sw-ok);
 }
 </style>
diff --git a/horizon.example.yaml b/horizon.example.yaml
index edb3cb8..87c615f 100644
--- a/horizon.example.yaml
+++ b/horizon.example.yaml
@@ -51,6 +51,12 @@ session:
 audit:
   file: ./horizon-audit.jsonl
 
+# Per-layer setup overrides (priority / slots / caps / landing card config)
+# persist here. Until OAP-side template management lands, this file is the
+# source of truth for operator customization.
+setup:
+  file: ./horizon-setup.json
+
 debugLog:
   enabled: false
   file: ./horizon-wire.jsonl
diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts
index c3e5294..fe55b6a 100644
--- a/packages/api-client/src/index.ts
+++ b/packages/api-client/src/index.ts
@@ -17,6 +17,13 @@
 
 export * from './types.js';
 export type { LayerSlots, LayerCaps, LayerDef, MenuResponse } from './menu.js';
+export type {
+  LandingColumn,
+  LandingConfig,
+  LayerConfig,
+  SetupResponse,
+  SetupSavePayload,
+} from './setup.js';
 export {
   RuntimeRuleClient,
   type RuntimeRuleClientOptions,
diff --git a/packages/api-client/src/setup.ts b/packages/api-client/src/setup.ts
new file mode 100644
index 0000000..d9b988a
--- /dev/null
+++ b/packages/api-client/src/setup.ts
@@ -0,0 +1,68 @@
+/*
+ * 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.
+ */
+
+/**
+ * Wire shape for the per-layer customization persisted by the BFF.
+ *
+ * Eventually these configs will live in OAP via `addTemplate` mutations
+ * (under the `horizon-` ID prefix) — see PLAN.md "Locked decisions" #2.
+ * Until then, the BFF stores them in a JSON file on disk. The UI and
+ * server agree on this shape so the swap is a one-place change.
+ */
+
+import type { LayerCaps, LayerSlots } from './menu.js';
+
+export interface LandingColumn {
+  /** MQE-result key. */
+  metric: string;
+  /** Short header label (e.g. `cpm`). */
+  label: string;
+  /** Suffix unit (`%`, `ms`, etc.). */
+  unit?: string;
+}
+
+export interface LandingConfig {
+  /** Lower number → higher on the Overview. */
+  priority: number;
+  /** Number of services to surface in the landing card, clamped 5..8. */
+  topN: number;
+  /** Metric key used to rank the top-N. */
+  orderBy: string;
+  columns: LandingColumn[];
+  /** Optional sparkline column. */
+  spark?: { metric: string; height: number };
+  style: 'table' | 'bar' | 'mini-topology';
+}
+
+export interface LayerConfig {
+  /** Override display name (defaults to OAP `getMenuItems.title`). */
+  displayName?: string;
+  slots: LayerSlots;
+  caps: LayerCaps;
+  landing: LandingConfig;
+}
+
+export interface SetupResponse {
+  generatedAt: number;
+  /** Layer key → operator-overridden config. Layers without an override
+   *  fall through to horizon-side defaults at render time. */
+  layers: Record<string, LayerConfig>;
+}
+
+export interface SetupSavePayload {
+  layers: Record<string, LayerConfig>;
+}

Reply via email to