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