You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by wu...@apache.org on 2026/05/12 07:06:45 UTC
(skywalking-horizon-ui) 01/02: setup: bff file-backed persistence (GET/POST /api/setup)
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 <wu...@foxmail.com>
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>;
+}