This is an automated email from the ASF dual-hosted git repository. wu-sheng pushed a commit to branch refactor/drop-bff-state-files in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
commit 0d2d473283d4fa792563f2ddf27305f95b25c724 Author: Wu Sheng <[email protected]> AuthorDate: Mon Jul 6 00:51:20 2026 +0800 refactor(config): move per-layer + alarm setup to OAP, add an audit toggle; always-show the AI assistant Two operator-facing cleanups from the same review. State-file / config rework — config lives in OAP, not local BFF files: - Remove the dead per-layer setup override store (SetupStore + /api/setup + setup.file/HORIZON_SETUP_FILE). Nothing wrote it and the layer loader never read it; per-layer config is authored in the layer-dashboards template and the browser resolves the rendered LayerConfig from those template defaults. - The alarm-page setup (pinned layers / default window / overview fetch cap — the whole horizon.alert.page-setup singleton) is OAP-only now: bff.alarms.config() reads its effective copy from the config-bundle sync status like the theme / time-defaults singletons, and the editor saves only via templateSync.save. The redundant local read-cache (AlarmsStore / horizon-alarms.json), the /api/alarms/config route, and alarms.file/HORIZON_ALARMS_FILE are gone; the Alert-page lede drops its stale "defaults are GENERAL + MESH" note. - Add audit.enabled (env HORIZON_AUDIT_ENABLED, default true) so the audit trail can be disabled entirely — matching debugLog's enabled + file shape. Prune the now-dead setup/alarms .gitignore entries. AI assistant launcher — discoverable to everyone: - Show the launcher + panel for every signed-in user. Until a model is configured the panel is read-only with an "ask your administrator to set it up" notice (docked drawer and full /ai page). The config probe is auth-readable so the panel reflects readiness for all; ai:read is enforced on the chat request (rejected on send, with a clear message), granted to every role by default. Not yet validated against a live OAP: the alarm-page-setup read/write repoint needs a save round-trip smoke on a real OAP (the read path renders correctly). --- .gitignore | 2 - CHANGELOG.md | 4 +- Dockerfile | 2 - apps/bff/src/audit/logger.ts | 12 +- apps/bff/src/config/schema.test.ts | 4 +- apps/bff/src/config/schema.ts | 23 +-- apps/bff/src/http/config/alarms.ts | 130 ------------- apps/bff/src/http/config/setup.ts | 121 ------------ apps/bff/src/logic/alarms/bundled.ts | 13 +- apps/bff/src/logic/alarms/store.ts | 188 ------------------ apps/bff/src/logic/layers/loader.ts | 4 +- apps/bff/src/logic/setup/store.ts | 92 --------- apps/bff/src/rbac/route-policy.test.ts | 3 - apps/bff/src/rbac/route-policy.ts | 25 ++- apps/bff/src/server.ts | 12 +- apps/ui/src/ai/AiChatPanel.vue | 72 ++++++- apps/ui/src/ai/AiFullPageView.vue | 67 ++++++- apps/ui/src/ai/useAiChat.ts | 23 ++- apps/ui/src/api/alarmsConfig.ts | 87 +++++++++ apps/ui/src/api/client.ts | 43 ++-- apps/ui/src/api/scopes/alarms.test.ts | 48 +++-- apps/ui/src/api/scopes/alarms.ts | 19 +- apps/ui/src/api/scopes/setup.ts | 31 --- .../admin/alert-page/AlertPageSetupView.vue | 18 +- apps/ui/src/i18n/locales/de.json | 7 +- apps/ui/src/i18n/locales/en.json | 7 +- apps/ui/src/i18n/locales/es.json | 7 +- apps/ui/src/i18n/locales/fr.json | 7 +- apps/ui/src/i18n/locales/ja.json | 7 +- apps/ui/src/i18n/locales/ko.json | 7 +- apps/ui/src/i18n/locales/pt.json | 7 +- apps/ui/src/i18n/locales/zh-CN.json | 7 +- apps/ui/src/state/setup.ts | 216 ++------------------- docs/access-control/audit-log.md | 4 +- docs/design-target.md | 2 +- docs/operate/ai-assistant.md | 2 +- docs/setup/audit.md | 2 + docs/setup/container-image.md | 14 +- docs/setup/files.md | 42 ++-- docs/setup/overview.md | 2 - horizon.yaml | 15 +- packages/api-client/src/index.ts | 2 - packages/api-client/src/setup.ts | 21 +- 43 files changed, 434 insertions(+), 987 deletions(-) diff --git a/.gitignore b/.gitignore index bc3b51b..6d991e5 100644 --- a/.gitignore +++ b/.gitignore @@ -44,8 +44,6 @@ Thumbs.db # BFF runtime files (horizon.yaml itself is committed — the env-driven config) horizon-audit.jsonl horizon-wire.jsonl -horizon-setup.json -horizon-alarms.json # Release scripts/.release-work diff --git a/CHANGELOG.md b/CHANGELOG.md index 74866f0..23a652b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ The version line is shared by every package in the monorepo (apps + shared packa - **Read live Kubernetes pod logs, right in the chat.** For a k8s workload the assistant pulls a pod container's on-demand logs (the error stack) and shows the fetched lines inline as a read-only result — the same on-demand-log path as the Pod Logs tab, so nothing is stored and it inherits your `logs:read` permission. It's a result, not a console: no tail or refresh controls (operate a live tail in the Pod Logs tab); when a content filter was applied the block shows it, so an empty resul [...] - **It can propose profiling — and only you start it.** When metrics and traces can't localise a cause, the assistant presents a **decision card** explaining what it found, why profiling would help, and what it expects to reveal; nothing runs until you **approve it in the popout**, and only if you hold the profiling permission. On approve it starts the trace-profiling task; you then ask it to analyze the results once they've collected. It never triggers anything on its own. - **Guided root-cause analysis.** Ask "what's the root cause?" and the assistant follows built-in investigation playbooks — a master method (locate the root service → calling chain → error stack; walk the dependency topology upstream and fix a sick upstream first; a remote / Virtual_* dependency exposes only its client-side edge metric) plus latency, error-rate/SLA, saturation, middleware, **Kubernetes-workload**, and service-mesh specializations. It can also follow the **cross-layer hie [...] -- **Bring your own LLM — vendor-neutral, and off by default.** Enable it with the new `ai:` config block (`HORIZON_AI_*`). The default transport is **OpenAI-compatible** (any OpenAI-shaped endpoint — a hosted model, a local model, or an AI gateway; set model + base URL + API key); **Amazon Bedrock** is also supported (`provider: bedrock`). The API key is a secret, env-only, redacted from logs and excluded from the audit trail. The launcher stays hidden until the feature is enabled and a [...] +- **Bring your own LLM — vendor-neutral, and off by default.** Enable it with the new `ai:` config block (`HORIZON_AI_*`). The default transport is **OpenAI-compatible** (any OpenAI-shaped endpoint — a hosted model, a local model, or an AI gateway; set model + base URL + API key); **Amazon Bedrock** is also supported (`provider: bedrock`). The API key is a secret, env-only, redacted from logs and excluded from the audit trail. The launcher shows for every signed-in user so the AI-powered [...] - **Both prompts are yours.** The assistant's system prompt (`ai.systemPrompt`) and the starter example chips shown in an empty chat (`ai.starters`) ship with sensible defaults and can be replaced entirely in `horizon.yaml`. - **Starter chips can ask for a service first.** A starter that names a `<service>` (e.g. "Investigate latency for <service>") opens a one-field fill-in on click: type the service free-form — a partial name or a description is fine — and it's dropped into the prompt before sending. You don't have to know the exact name or its layer; the assistant matches what you typed to a real service through its own cross-layer search. Plain starters without a placeholder still send in one click [...] @@ -26,6 +26,8 @@ The version line is shared by every package in the monorepo (apps + shared packa - **Run on the bundled templates, read-only — no OAP ui_template API needed.** A new `templates.mode` setting (`HORIZON_TEMPLATES_MODE`) adds a `readonly` mode: Horizon renders every dashboard / overview / alert-page / 3D-map / translation from the **local bundle** and never calls OAP's ui_template admin API. The whole config surface goes **read-only** — the admin pages still open and show the bundled config, but editing and publishing are disabled (and the BFF rejects a write even if it [...] - **The container image runs with environment variables only — no mounted config file.** There is now **one committed, env-driven `horizon.yaml`** (the former `horizon.example.yaml` / local-copy split is gone): every field is a `${HORIZON_…:default}` token, and the image bakes that same file. So `docker run -e HORIZON_OAP_QUERY_URL=… -e HORIZON_AUTH_LOCAL_USERS='[…]' …` is enough — no `-v` mount, no repackaging. Previously `oap.*`, `auth.*`, users, LDAP, RBAC, and performance tuning were [...] +- **Per-layer and alarm-page setup live only in OAP now — two local state files retired.** Horizon no longer writes `horizon-setup.json` or `horizon-alarms.json`: the `setup.file` / `HORIZON_SETUP_FILE` config is gone (the per-layer override it backed was unused — per-layer config is authored in the layer-dashboards template), and the **alarm-page setup** (pinned layers, default window, overview fetch cap) now reads and writes only OAP's `horizon.alert.page-setup` template, exactly like [...] +- **Audit logging can be turned off.** `audit.enabled` (env `HORIZON_AUDIT_ENABLED`, default `true`) — the audit trail stays on by default (it is a security record) but can be disabled entirely, mirroring `debugLog`'s enabled + file shape. - **Cluster Status now reports admin-feature reachability, not just config-presence.** The admin-host pane fires a safe GET at the real REST path each feature calls on OAP — dashboard templates → `/ui-management/templates`, DSL management → `/runtime/rule/list`, live debugger → `/dsl-debugging/status`, Inspect → `/inspect/metrics` — and colors each row by whether that path actually responds. A feature whose module is loaded but whose endpoint 404s (a renamed or forked module, a selector [...] ### General Service — PHP runtime (PHM) diff --git a/Dockerfile b/Dockerfile index aa8238c..ad8ce6d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -81,8 +81,6 @@ ENV NODE_ENV=production \ HORIZON_STATIC_DIR=/app/static \ HORIZON_CONFIG=/app/horizon.yaml \ HORIZON_AUDIT_FILE=/data/horizon-audit.jsonl \ - HORIZON_SETUP_FILE=/data/horizon-setup.json \ - HORIZON_ALARMS_FILE=/data/horizon-alarms.json \ HORIZON_WIRE_LOG_FILE=/data/horizon-wire.jsonl \ HORIZON_SOURCEMAPS_DIR=/app/sourcemaps \ # Match this to the container memory limit and your sourceMaps budget — the in-heap map cache lives inside it. diff --git a/apps/bff/src/audit/logger.ts b/apps/bff/src/audit/logger.ts index 325c656..46d6d04 100644 --- a/apps/bff/src/audit/logger.ts +++ b/apps/bff/src/audit/logger.ts @@ -38,18 +38,28 @@ export interface AuditEvent { export class AuditLogger { private stream: WriteStream | null = null; private readonly absPath: string; + /** When false (`audit.enabled=false`) the logger is a no-op — no file is + * opened and `record()` drops every event. On by default: the audit trail is + * a security record, so silencing it takes an explicit opt-out. */ + private readonly enabled: boolean; - constructor(filePath: string) { + constructor(filePath: string, enabled = true) { this.absPath = resolve(filePath); + this.enabled = enabled; } async open(): Promise<void> { + if (!this.enabled) { + logger.info('audit disabled (audit.enabled=false) — no audit log is written'); + return; + } await mkdir(dirname(this.absPath), { recursive: true }); this.stream = createWriteStream(this.absPath, { flags: 'a' }); this.stream.on('error', (err) => logger.error({ err }, 'audit stream error')); } record(evt: Omit<AuditEvent, 'ts'>): void { + if (!this.enabled) return; const line: AuditEvent = { ts: new Date().toISOString(), ...evt }; if (!this.stream) { logger.warn({ evt: line }, 'audit logged before open()'); diff --git a/apps/bff/src/config/schema.test.ts b/apps/bff/src/config/schema.test.ts index ae16cd0..ae5b4ea 100644 --- a/apps/bff/src/config/schema.test.ts +++ b/apps/bff/src/config/schema.test.ts @@ -48,8 +48,8 @@ describe('horizon.yaml — tokenized default + parity', () => { // in a dev/CI env is ignored (it would otherwise read as drift, since the // schema default for e.g. oap.queryUrl is a literal, not env-read). const SCHEMA_ENV = [ - 'HORIZON_SERVER_HOST', 'HORIZON_SERVER_PORT', 'HORIZON_AUDIT_FILE', 'HORIZON_SETUP_FILE', - 'HORIZON_ALARMS_FILE', 'HORIZON_WIRE_LOG_FILE', 'HORIZON_SOURCEMAPS_DIR', 'HORIZON_TEMPLATES_MODE', + 'HORIZON_SERVER_HOST', 'HORIZON_SERVER_PORT', 'HORIZON_AUDIT_FILE', + 'HORIZON_WIRE_LOG_FILE', 'HORIZON_SOURCEMAPS_DIR', 'HORIZON_TEMPLATES_MODE', ]; const env: NodeJS.ProcessEnv = {}; for (const k of SCHEMA_ENV) if (process.env[k] !== undefined) env[k] = process.env[k]; diff --git a/apps/bff/src/config/schema.ts b/apps/bff/src/config/schema.ts index 782e145..6454506 100644 --- a/apps/bff/src/config/schema.ts +++ b/apps/bff/src/config/schema.ts @@ -273,30 +273,17 @@ const sessionSchema = z // that omits these blocks) gets writes routed to the writable `/data` // volume instead of `/app` (which is root-owned and EACCESes). const auditDefault = process.env.HORIZON_AUDIT_FILE ?? './horizon-audit.jsonl'; -const setupDefault = process.env.HORIZON_SETUP_FILE ?? './horizon-setup.json'; -const alarmsDefault = process.env.HORIZON_ALARMS_FILE ?? './horizon-alarms.json'; const wireLogDefault = process.env.HORIZON_WIRE_LOG_FILE ?? './horizon-wire.jsonl'; const auditSchema = z .object({ + // On by default — the audit trail is a security record; disabling it is an + // explicit opt-out. Mirrors `debugLog.enabled` (which is opt-IN instead). + enabled: z.boolean().default(true), file: z.string().default(auditDefault), }) .strict() - .default({ file: auditDefault }); - -const setupSchema = z - .object({ - file: z.string().default(setupDefault), - }) - .strict() - .default({ file: setupDefault }); - -const alarmsSchema = z - .object({ - file: z.string().default(alarmsDefault), - }) - .strict() - .default({ file: alarmsDefault }); + .default({ enabled: true, file: auditDefault }); const debugLogSchema = z @@ -546,8 +533,6 @@ export const configSchema = z rbac: rbacSchema, session: sessionSchema, audit: auditSchema, - setup: setupSchema, - alarms: alarmsSchema, debugLog: debugLogSchema, query: querySchema, sourceMaps: sourceMapsSchema, diff --git a/apps/bff/src/http/config/alarms.ts b/apps/bff/src/http/config/alarms.ts deleted file mode 100644 index 467259d..0000000 --- a/apps/bff/src/http/config/alarms.ts +++ /dev/null @@ -1,130 +0,0 @@ -/* - * 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. - */ - -/** - * `/api/alarms/config` — read + write the alarm-page setup. The shape - * is `{ pinnedLayers: string[] }`: OAP layer keys that get a - * dedicated tile + breakdown chip on the alarms page header. The - * `serviceLayer` cache is invalidated on save so per-row layer tags - * pick up any new layer immediately instead of waiting for the 60s - * TTL. - */ - -import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; -import { z } from 'zod'; -import type { ConfigSource } from '../../config/loader.js'; -import type { SessionStore } from '../../user/sessions.js'; -import type { AuditLogger } from '../../audit/logger.js'; -import { requireAuth } from '../../user/middleware.js'; -import { isTemplateReadOnly } from '../../logic/templates/sync.js'; -import { loadBundledAlertPageSetup } from '../../logic/alarms/bundled.js'; -import type { ServiceLayerCatalog } from '../../logic/services/service-layer-catalog.js'; -import { - ALARMS_WINDOW_OPTIONS, - OVERVIEW_ALARMS_LIMIT_MAX, - OVERVIEW_ALARMS_LIMIT_MIN, - type AlarmsStore, - type AlarmsConfig, -} from '../../logic/alarms/store.js'; - -export interface AlarmsConfigRouteDeps { - config: ConfigSource; - sessions: SessionStore; - audit: AuditLogger; - store: AlarmsStore; - serviceLayer: ServiceLayerCatalog; -} - -const configSaveSchema = z.object({ - pinnedLayers: z.array(z.string().min(1)).max(8), - /* Locked to the page-picker preset set so the admin's pick always - * lights up a real tab. Hand-edited values are rejected with a 400 - * rather than silently coerced — keeps debugging predictable. */ - defaultWindowMs: z - .number() - .int() - .refine((v) => (ALARMS_WINDOW_OPTIONS as readonly number[]).includes(v), { - message: `must be one of ${ALARMS_WINDOW_OPTIONS.join(', ')}`, - }), - /* Bounded range — too low starves the widget's incident merge of - * variety; too high pulls more bytes per poll than necessary. */ - overviewAlarmsLimit: z - .number() - .int() - .min(OVERVIEW_ALARMS_LIMIT_MIN) - .max(OVERVIEW_ALARMS_LIMIT_MAX), -}); - -export function registerAlarmsConfigRoutes( - app: FastifyInstance, - deps: AlarmsConfigRouteDeps, -): void { - const auth = requireAuth(deps); - - app.get( - '/api/alarms/config', - { preHandler: auth }, - async (_req: FastifyRequest, reply: FastifyReply) => { - // Readonly mode serves the alert page-setup from the bundle like every - // other template; the local store (a prior live session's edits) is not - // the source here. Writes are denied by the route-policy backstop. - const cfg = isTemplateReadOnly() ? loadBundledAlertPageSetup() : await deps.store.load(); - return reply.send(cfg); - }, - ); - - app.post( - '/api/alarms/config', - { preHandler: auth }, - async (req: FastifyRequest, reply: FastifyReply) => { - const parsed = configSaveSchema.safeParse(req.body); - if (!parsed.success) { - return reply.code(400).send({ error: 'invalid_body', detail: parsed.error.flatten() }); - } - /* Normalise layer keys to upper-case + dedup so the persisted - * shape is canonical regardless of what the admin form sent. */ - const seen = new Set<string>(); - const pinnedLayers: string[] = []; - for (const raw of parsed.data.pinnedLayers) { - const k = raw.trim().toUpperCase(); - if (!k || seen.has(k)) continue; - seen.add(k); - pinnedLayers.push(k); - } - const next: AlarmsConfig = { - pinnedLayers, - defaultWindowMs: parsed.data.defaultWindowMs, - overviewAlarmsLimit: parsed.data.overviewAlarmsLimit, - }; - await deps.store.save(next); - deps.serviceLayer.invalidate(); - deps.audit.record({ - action: 'alarms.config.save', - actor: req.session?.username ?? null, - outcome: 'ok', - details: { - pinnedLayers: next.pinnedLayers, - defaultWindowMs: next.defaultWindowMs, - overviewAlarmsLimit: next.overviewAlarmsLimit, - }, - fromIp: req.ip, - sessionId: req.session?.sid, - }); - return reply.send(next); - }, - ); -} diff --git a/apps/bff/src/http/config/setup.ts b/apps/bff/src/http/config/setup.ts deleted file mode 100644 index 6c473ba..0000000 --- a/apps/bff/src/http/config/setup.ts +++ /dev/null @@ -1,121 +0,0 @@ -/* - * 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 '../../user/middleware.js'; -import type { ConfigSource } from '../../config/loader.js'; -import type { SessionStore } from '../../user/sessions.js'; -import { badRequest } from '../../errors.js'; -import type { SetupStore } from '../../logic/setup/store.js'; - -export interface SetupRouteDeps { - config: ConfigSource; - sessions: SessionStore; - audit: AuditLogger; - store: SetupStore; -} - -const aggregationSchema = z.enum(['sum', 'avg']); - -const landingColumnSchema = z - .object({ - metric: z.string().min(1), - label: z.string().min(1), - unit: z.string().optional(), - mqe: z.string().optional(), - aggregation: aggregationSchema.optional(), - scale: z.number().finite().optional(), - precision: z.number().int().min(0).max(6).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), - style: z.enum(['table', 'bar', 'mini-topology']), - }) - // `.strip()` (not `.strict()`): setups persisted by older builds carry the - // retired `spark` / `throughput` keys — drop them silently on re-save - // instead of failing the whole payload. - .strip(); - -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({ - 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/logic/alarms/bundled.ts b/apps/bff/src/logic/alarms/bundled.ts index 40a89ba..b4e658e 100644 --- a/apps/bff/src/logic/alarms/bundled.ts +++ b/apps/bff/src/logic/alarms/bundled.ts @@ -29,7 +29,18 @@ import { existsSync, readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import type { AlarmsConfig } from './store.js'; + +/** The Alert page-setup config — the whole of the `horizon.alert.page-setup` + * template. Authored in the Alert-page admin, persisted to OAP like every other + * template; the UI reads the effective (bundled ↔ OAP) copy from the config + * bundle's sync status. `pinnedLayers` get a header KPI tile on the Alarms page; + * `defaultWindowMs` is the shared default window for the badge + page + widget; + * `overviewAlarmsLimit` caps the overview "Active alarms" widget's per-poll fetch. */ +export interface AlarmsConfig { + pinnedLayers: string[]; + defaultWindowMs: number; + overviewAlarmsLimit: number; +} const HERE = dirname(fileURLToPath(import.meta.url)); diff --git a/apps/bff/src/logic/alarms/store.ts b/apps/bff/src/logic/alarms/store.ts deleted file mode 100644 index df7603f..0000000 --- a/apps/bff/src/logic/alarms/store.ts +++ /dev/null @@ -1,188 +0,0 @@ -/* - * 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. - */ - -/** - * File-backed store for the Alarms-page setup. Holds the list of OAP - * layers that get their own dedicated KPI tile + breakdown chip on the - * alarms page header (the "pinned" layers). Everything not on this - * list shows up under the overflow-chip row when it has at least one - * alarm in the visible window. - * - * Defaults seed agent (`GENERAL`) + mesh (`MESH`) so a fresh install - * has sensible top-of-page tiles out-of-the-box. - * - * Backward compatibility: an older shape stored `trafficLayers: - * [{layerKey, mqe, label}]` for the (now-removed) traffic-backdrop - * chart. The loader silently projects that down to `pinnedLayers` - * (just the layer keys, deduped, order preserved) so operators don't - * have to re-pick their layers after upgrade. - * - * Storage convention mirrors `SetupStore`: serialised writes with a - * `.tmp` rename so the file never appears half-written to a watcher. - */ - -import { existsSync } from 'node:fs'; -import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; -import { dirname, resolve } from 'node:path'; -import { logger } from '../../logger.js'; - -export interface AlarmsConfig { - /** OAP layer keys (canonical `GENERAL`, `MESH`, `K8S_SERVICE`, …) - * that get a dedicated tile on the alarms page header. The order - * here is the render order on the page. Defaults to - * `['GENERAL', 'MESH']`. */ - pinnedLayers: string[]; - /** Default time window in milliseconds for the topbar alarm badge - * AND the alarms page's initial picker selection. The overview - * "Active alarms" widget keeps its own 60m window; this only - * affects the recent-triage surfaces. Must be one of the page - * picker presets — 20m / 2h / 4h. Defaults to 20m. */ - defaultWindowMs: number; - /** Fetch cap for the overview "Active alarms" widget. The widget - * pulls this many rows from `/api/alarms` per poll, merges them - * into incidents client-side, and surfaces the top `widget.limit` - * rows. Higher caps catch more incident variety in noisy - * installs at the cost of more wire bytes per poll. Defaults to - * 200; must be in [10, 500]. */ - overviewAlarmsLimit: number; -} - -/** Allowed values for `defaultWindowMs`. Matches the alarms page's - * preset list so the admin's choice always lights up an existing - * tab on the page. */ -export const ALARMS_WINDOW_OPTIONS = [ - 20 * 60_000, - 2 * 60 * 60_000, - 4 * 60 * 60_000, -] as const; - -export const OVERVIEW_ALARMS_LIMIT_MIN = 10; -export const OVERVIEW_ALARMS_LIMIT_MAX = 500; -export const OVERVIEW_ALARMS_LIMIT_DEFAULT = 200; - -const DEFAULT_CONFIG: AlarmsConfig = { - pinnedLayers: ['GENERAL', 'MESH'], - defaultWindowMs: ALARMS_WINDOW_OPTIONS[0], - overviewAlarmsLimit: OVERVIEW_ALARMS_LIMIT_DEFAULT, -}; - -/* Legacy shape — kept only as a tolerant input type for `load()`. New - * code reads / writes `pinnedLayers` exclusively; the next `save()` - * after an upgraded load rewrites the file in the new shape. */ -interface LegacyTrafficLayer { - layerKey?: unknown; -} - -export class AlarmsStore { - private readonly absPath: string; - private cache: AlarmsConfig | null = null; - private writing: Promise<void> | null = null; - - constructor(filePath: string) { - this.absPath = resolve(filePath); - } - - async load(): Promise<AlarmsConfig> { - if (this.cache) return this.cache; - if (!existsSync(this.absPath)) { - this.cache = { ...DEFAULT_CONFIG, pinnedLayers: [...DEFAULT_CONFIG.pinnedLayers] }; - return this.cache; - } - try { - const raw = await readFile(this.absPath, 'utf8'); - const parsed = JSON.parse(raw) as { - pinnedLayers?: unknown; - trafficLayers?: unknown; - defaultWindowMs?: unknown; - overviewAlarmsLimit?: unknown; - }; - - let pinned: string[] | null = null; - if (Array.isArray(parsed.pinnedLayers)) { - pinned = parsed.pinnedLayers.filter( - (v): v is string => typeof v === 'string' && v.length > 0, - ); - } else if (Array.isArray(parsed.trafficLayers)) { - // Project legacy `trafficLayers: [{layerKey,...}]` down to the - // pinned-layer set. Dedup + preserve first-seen order so the - // header reads the same as the prior traffic-chart order. - const seen = new Set<string>(); - const out: string[] = []; - for (const l of parsed.trafficLayers as LegacyTrafficLayer[]) { - const k = typeof l?.layerKey === 'string' ? l.layerKey : ''; - if (!k || seen.has(k)) continue; - seen.add(k); - out.push(k); - } - pinned = out.length > 0 ? out : null; - } - - /* defaultWindowMs is one of the page-picker preset values; any - * out-of-allowlist value (file edited by hand, drifted enum) - * falls back to the default. */ - const rawWin = typeof parsed.defaultWindowMs === 'number' ? parsed.defaultWindowMs : NaN; - const defaultWindowMs = (ALARMS_WINDOW_OPTIONS as readonly number[]).includes(rawWin) - ? rawWin - : DEFAULT_CONFIG.defaultWindowMs; - - const rawLim = typeof parsed.overviewAlarmsLimit === 'number' ? parsed.overviewAlarmsLimit : NaN; - const overviewAlarmsLimit = - Number.isInteger(rawLim) && - rawLim >= OVERVIEW_ALARMS_LIMIT_MIN && - rawLim <= OVERVIEW_ALARMS_LIMIT_MAX - ? rawLim - : DEFAULT_CONFIG.overviewAlarmsLimit; - - this.cache = { - pinnedLayers: pinned ?? [...DEFAULT_CONFIG.pinnedLayers], - defaultWindowMs, - overviewAlarmsLimit, - }; - return this.cache; - } catch (err) { - logger.warn({ err, path: this.absPath }, 'alarms store unreadable; using defaults'); - this.cache = { ...DEFAULT_CONFIG, pinnedLayers: [...DEFAULT_CONFIG.pinnedLayers] }; - return this.cache; - } - } - - async save(next: AlarmsConfig): Promise<void> { - while (this.writing) await this.writing; - const tmp = `${this.absPath}.tmp`; - const snapshot: AlarmsConfig = { - pinnedLayers: [...next.pinnedLayers], - defaultWindowMs: next.defaultWindowMs, - overviewAlarmsLimit: next.overviewAlarmsLimit, - }; - const work = (async () => { - await mkdir(dirname(this.absPath), { recursive: true }); - await writeFile( - tmp, - JSON.stringify({ generatedAt: Date.now(), ...snapshot }, null, 2), - 'utf8', - ); - await rename(tmp, this.absPath); - this.cache = snapshot; - })(); - this.writing = work; - try { - await work; - } finally { - this.writing = null; - } - } -} diff --git a/apps/bff/src/logic/layers/loader.ts b/apps/bff/src/logic/layers/loader.ts index 8d6d8fd..080d036 100644 --- a/apps/bff/src/logic/layers/loader.ts +++ b/apps/bff/src/logic/layers/loader.ts @@ -21,8 +21,8 @@ * shape (landing card columns, dashboard widgets, MQE expressions). * * The bundled defaults live under `../bundled_templates/layers/<key>.json`, - * one file per OAP layer enum. Operator overrides land in the SetupStore - * (JSON file on disk) and merge on top. + * one file per OAP layer enum. Operator edits are authored in the Layer + * dashboards admin and persisted to OAP as `horizon.layer.<KEY>` templates. * * Lifting these from TS code into JSON gets us: * - One file per layer to review or copy diff --git a/apps/bff/src/logic/setup/store.ts b/apps/bff/src/logic/setup/store.ts deleted file mode 100644 index fbabdb5..0000000 --- a/apps/bff/src/logic/setup/store.ts +++ /dev/null @@ -1,92 +0,0 @@ -/* - * 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/bff/src/rbac/route-policy.test.ts b/apps/bff/src/rbac/route-policy.test.ts index 750dc3a..7997356 100644 --- a/apps/bff/src/rbac/route-policy.test.ts +++ b/apps/bff/src/rbac/route-policy.test.ts @@ -27,13 +27,10 @@ describe('isTemplateWriteRoute — which routes the readonly backstop covers', ( expect(isTemplateWriteRoute('POST', '/api/admin/templates/sync-all')).toBe(true); expect(isTemplateWriteRoute('POST', '/api/admin/overview-templates')).toBe(true); expect(isTemplateWriteRoute('DELETE', '/api/admin/overview-templates/x')).toBe(true); - // The alert page-setup (horizon.alert.page-setup) is config-surface too. - expect(isTemplateWriteRoute('POST', '/api/alarms/config')).toBe(true); }); it('does NOT match reads, nor non-config writes (runtime-rule / live-debug stay editable)', () => { expect(isTemplateWriteRoute('GET', '/api/admin/templates/sync-status')).toBe(false); expect(isTemplateWriteRoute('HEAD', '/api/admin/templates/sync-status')).toBe(false); - expect(isTemplateWriteRoute('GET', '/api/alarms/config')).toBe(false); // read stays open expect(isTemplateWriteRoute('POST', '/api/rule')).toBe(false); // runtime rule expect(isTemplateWriteRoute('POST', '/api/debug/session')).toBe(false); // live-debug }); diff --git a/apps/bff/src/rbac/route-policy.ts b/apps/bff/src/rbac/route-policy.ts index bc946c0..8e0a861 100644 --- a/apps/bff/src/rbac/route-policy.ts +++ b/apps/bff/src/rbac/route-policy.ts @@ -33,18 +33,17 @@ import { logger } from '../logger.js'; export type RoutePolicy = 'public' | 'auth' | string; -/** A config-surface write. The template routes push to OAP's ui_template store; - * `/api/alarms/config` writes the alert page-setup (the `horizon.alert.page-setup` - * template, file-backed). In `templates.mode=readonly` the whole config surface - * is served from the local bundle and read-only, so these are denied at the edge +/** A config-surface write. The template routes push to OAP's ui_template store + * (the alert page-setup rides this path too, as the `horizon.alert.page-setup` + * singleton). In `templates.mode=readonly` the whole config surface is served + * from the local bundle and read-only, so these are denied at the edge * regardless of the verb grant (the UI hides them too, but a direct request must * still fail — the BFF is the authority). */ export function isTemplateWriteRoute(method: string, url: string): boolean { if (method === 'GET' || method === 'HEAD') return false; return ( url.startsWith('/api/admin/templates') || - url.startsWith('/api/admin/overview-templates') || - url.startsWith('/api/alarms/config') + url.startsWith('/api/admin/overview-templates') ); } export async function denyTemplateWriteWhenReadOnly( @@ -168,9 +167,13 @@ export const ROUTE_POLICY: Record<string, RoutePolicy> = { 'GET /api/infra-3d/config': 'infra-3d:read', 'POST /api/infra-3d/metrics': 'infra-3d:read', - // AI assistant. Chat entry is read-tier; each of the agent's data tools - // ALSO checks its own read verb, so the agent inherits the caller's scopes. - 'GET /api/ai/config': 'ai:read', + // AI assistant. The config probe is readable by any signed-in user so the + // launcher shows for everyone (discoverable) and the panel reflects whether a + // model is configured; `ai:read` is enforced on the CHAT request itself — a + // user without it still sees the launcher, but their send is rejected (403). + // Each of the agent's data tools ALSO checks its own read verb, so the agent + // inherits the caller's scopes. + 'GET /api/ai/config': 'auth', 'POST /api/ai/chat': 'ai:read', // ── Maintainer — platform-monitoring reads ── @@ -185,10 +188,6 @@ export const ROUTE_POLICY: Record<string, RoutePolicy> = { 'GET /api/oap/config': 'config:read', // ── Operator — config / dashboard / rule / diagnostics writes ── - 'GET /api/setup': 'setup:read', - 'POST /api/setup': 'setup:write', - 'GET /api/alarms/config': 'alarm-setup:read', - 'POST /api/alarms/config': 'alarm-setup:write', 'GET /api/admin/layer-templates': 'dashboard:read', diff --git a/apps/bff/src/server.ts b/apps/bff/src/server.ts index fae5a81..fda8bf5 100644 --- a/apps/bff/src/server.ts +++ b/apps/bff/src/server.ts @@ -61,10 +61,8 @@ import { registerAsyncProfileRoutes } from './http/query/async-profile.js'; import { registerDashboardConfigRoute } from './http/config/dashboard.js'; import { registerLayerTemplateRoutes } from './http/config/layer-template.js'; import { startLayerTemplateWatcher } from './logic/layers/loader.js'; -import { registerAlarmsConfigRoutes } from './http/config/alarms.js'; import { registerInfra3dConfigRoutes } from './http/config/infra-3d.js'; import { registerInfra3dMetricsRoute } from './http/query/infra-3d-metrics.js'; -import { registerSetupRoutes } from './http/config/setup.js'; import { registerOverviewRoutes } from './http/config/overview.js'; import { registerConfigBundleRoute } from './http/config/bundle.js'; import { registerTemplateSyncAdminRoutes } from './http/admin/template-sync.js'; @@ -88,8 +86,6 @@ import { registerSourceMapRoutes } from './http/admin/source-maps.js'; import { registerAuthHealthRoute } from './http/auth-health.js'; import { registerColdStageHook } from './util/duration.js'; // Logic / stores -import { AlarmsStore } from './logic/alarms/store.js'; -import { SetupStore } from './logic/setup/store.js'; import { SourceMapStore } from './logic/browser-errors/store.js'; import { serviceLayerCatalog } from './logic/services/service-layer-catalog.js'; import { HttpError } from './errors.js'; @@ -181,14 +177,10 @@ app.addHook('onSend', (_req, reply, payload, done) => { }); const sessions = new SessionStore({ ttlMinutes: source.current.session.ttlMinutes }); -const audit = new AuditLogger(source.current.audit.file); +const audit = new AuditLogger(source.current.audit.file, source.current.audit.enabled); await audit.open(); const ldapHealth = new LdapHealth(); const seenCache = new UserSeenCache(); -const setupStore = new SetupStore(source.current.setup.file); -await setupStore.load(); -const alarmsStore = new AlarmsStore(source.current.alarms.file); -await alarmsStore.load(); // In-memory source-map cache for the Browser Errors tab (#6784). Process- // global (NOT per-session); statically-mounted maps are indexed once here. // The store reads config through a live getter so `enabled` + the budgets @@ -312,14 +304,12 @@ registerLayerTemplateRoutes(app, { config: source, sessions }); // before (each test file imports the loader; a watcher per import // would exhaust the fd ceiling on low-ulimit CI). if (process.env.NODE_ENV === 'development') startLayerTemplateWatcher(); -registerAlarmsConfigRoutes(app, { config: source, sessions, audit, store: alarmsStore, serviceLayer }); registerInfra3dConfigRoutes(app, { config: source, sessions, uiTemplateClient: () => buildOapClients(source.current).uiTemplate(), }); registerInfra3dMetricsRoute(app, { config: source, sessions }); -registerSetupRoutes(app, { config: source, sessions, audit, store: setupStore }); registerOverviewRoutes(app, { config: source, sessions, diff --git a/apps/ui/src/ai/AiChatPanel.vue b/apps/ui/src/ai/AiChatPanel.vue index 60ab083..c3c51b9 100644 --- a/apps/ui/src/ai/AiChatPanel.vue +++ b/apps/ui/src/ai/AiChatPanel.vue @@ -164,15 +164,30 @@ onBeforeUnmount(() => { </div> </header> - <div class="ai-drawer__scopebar"><ChatScopeBar /></div> + <template v-if="chat.ready.value"> + <div class="ai-drawer__scopebar"><ChatScopeBar /></div> - <div ref="body" class="ai-drawer__body"> - <ChatTranscript :messages="messages" :starters="chat.starters.value" @ask="onSend" /> - </div> + <div ref="body" class="ai-drawer__body"> + <ChatTranscript :messages="messages" :starters="chat.starters.value" @ask="onSend" /> + </div> + + <footer class="ai-drawer__composer"> + <ChatComposer :streaming="conv.streaming.value" @send="onSend" @stop="conv.stop()" /> + </footer> + </template> - <footer class="ai-drawer__composer"> - <ChatComposer :streaming="conv.streaming.value" @send="onSend" @stop="conv.stop()" /> - </footer> + <!-- Read-only setup state: the launcher shows for everyone, but the panel + can only chat once an admin has configured a model provider. --> + <div v-else class="ai-drawer__body ai-drawer__setup"> + <span class="ai-drawer__setup-mark" aria-hidden="true"><Icon name="ai" :size="30" /></span> + <h3 class="ai-drawer__setup-title">{{ t('The AI Assistant is not set up yet') }}</h3> + <p class="ai-drawer__setup-text"> + {{ t('It answers questions about your services, metrics, traces and logs from live data — but a model provider must be configured first.') }} + </p> + <p class="ai-drawer__setup-tip"> + {{ t('Ask your administrator to enable it and configure a model provider and API key.') }} + </p> + </div> </aside> </Transition> </Teleport> @@ -299,6 +314,49 @@ onBeforeUnmount(() => { border-top: 1px solid var(--sw-line); background: var(--sw-bg-2); } +.ai-drawer__setup { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + gap: 12px; + padding: 32px 28px; +} +.ai-drawer__setup-mark { + width: 52px; + height: 52px; + display: grid; + place-items: center; + border-radius: 14px; + background: linear-gradient(135deg, var(--sw-accent) 0%, var(--sw-purple) 115%); + color: #fff; + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06) inset; +} +.ai-drawer__setup-title { + margin: 0; + font-size: var(--sw-fs-lg); + font-weight: var(--sw-fw-semibold); + color: var(--sw-fg-0); +} +.ai-drawer__setup-text { + margin: 0; + max-width: 46ch; + font-size: var(--sw-fs-sm); + color: var(--sw-fg-2); + line-height: var(--sw-lh-normal); +} +.ai-drawer__setup-tip { + margin: 4px 0 0; + max-width: 46ch; + font-size: var(--sw-fs-sm); + font-weight: var(--sw-fw-medium); + color: var(--sw-fg-1); + padding: 10px 14px; + border: 1px solid var(--sw-line-2); + border-radius: 8px; + background: var(--sw-bg-2); +} .ai-drawer__scopebar { flex: 0 0 auto; padding: 8px 12px; diff --git a/apps/ui/src/ai/AiFullPageView.vue b/apps/ui/src/ai/AiFullPageView.vue index 636718d..0c28610 100644 --- a/apps/ui/src/ai/AiFullPageView.vue +++ b/apps/ui/src/ai/AiFullPageView.vue @@ -69,13 +69,28 @@ function when(ts: number): string { <span class="aifp__mark" aria-hidden="true"><Icon name="ai" :size="16" /></span> <strong class="aifp__brand">{{ t('AI Assistant') }}</strong> <div class="aifp__top-acts"> - <ChatScopeBar /> - <button type="button" class="aifp__btn" @click="conv.newChat()"><Icon name="plus" :size="13" />{{ t('New chat') }}</button> + <template v-if="chat.ready.value"> + <ChatScopeBar /> + <button type="button" class="aifp__btn" @click="conv.newChat()"><Icon name="plus" :size="13" />{{ t('New chat') }}</button> + </template> <button type="button" class="aifp__btn" @click="dockToSide"><Icon name="expand" :size="13" />{{ t('Dock to side') }}</button> </div> </header> - <div class="aifp__main"> + <!-- Read-only setup state: the assistant is reachable by everyone, but can + only chat once an admin has configured a model provider. --> + <div v-if="!chat.ready.value" class="aifp__setup"> + <span class="aifp__setup-mark" aria-hidden="true"><Icon name="ai" :size="34" /></span> + <h2 class="aifp__setup-title">{{ t('The AI Assistant is not set up yet') }}</h2> + <p class="aifp__setup-text"> + {{ t('It answers questions about your services, metrics, traces and logs from live data — but a model provider must be configured first.') }} + </p> + <p class="aifp__setup-tip"> + {{ t('Ask your administrator to enable it and configure a model provider and API key.') }} + </p> + </div> + + <div v-else class="aifp__main"> <aside class="aifp__hist"> <div class="aifp__hist-head">{{ t('History') }}</div> <div v-if="ordered.length === 0" class="aifp__hist-empty">{{ t('No conversations yet.') }}</div> @@ -277,4 +292,50 @@ function when(ts: number): string { max-width: 900px; margin: 0 auto; } + +.aifp__setup { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + gap: 14px; + padding: 40px; +} +.aifp__setup-mark { + width: 60px; + height: 60px; + display: grid; + place-items: center; + border-radius: 16px; + background: linear-gradient(135deg, var(--sw-accent) 0%, var(--sw-purple) 115%); + color: #fff; + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06) inset; +} +.aifp__setup-title { + margin: 0; + font-size: var(--sw-fs-xl); + font-weight: var(--sw-fw-semibold); + color: var(--sw-fg-0); +} +.aifp__setup-text { + margin: 0; + max-width: 52ch; + font-size: var(--sw-fs-base); + color: var(--sw-fg-2); + line-height: var(--sw-lh-normal); +} +.aifp__setup-tip { + margin: 6px 0 0; + max-width: 52ch; + font-size: var(--sw-fs-base); + font-weight: var(--sw-fw-medium); + color: var(--sw-fg-1); + padding: 12px 16px; + border: 1px solid var(--sw-line-2); + border-radius: 10px; + background: var(--sw-bg-1); +} </style> diff --git a/apps/ui/src/ai/useAiChat.ts b/apps/ui/src/ai/useAiChat.ts index 00224d3..99e0497 100644 --- a/apps/ui/src/ai/useAiChat.ts +++ b/apps/ui/src/ai/useAiChat.ts @@ -57,8 +57,13 @@ async function ensureConfig(): Promise<void> { export interface AiChatController { /** Whether the slide-over panel is open. */ open: Ref<boolean>; - /** Whether the launcher should be shown: authenticated AND the server reports ready. */ + /** Whether the launcher + panel are shown — any authenticated user. */ available: ComputedRef<boolean>; + /** Whether the assistant can actually chat (feature enabled + a usable + * provider). When false, the panel is read-only with a setup notice. */ + ready: ComputedRef<boolean>; + /** Whether the operator has enabled the AI feature at all (`ai.enabled`). */ + enabled: ComputedRef<boolean>; /** Whether to play the first-run attention nudge on the launcher. */ showHint: ComputedRef<boolean>; /** Server-supplied starter prompts (empty until config loads). */ @@ -74,8 +79,18 @@ export interface AiChatController { export function useAiChat(): AiChatController { const auth = useAuthStore(); - const available = computed<boolean>(() => auth.isAuthenticated && (aiConfig.value?.ready ?? false)); - const showHint = computed<boolean>(() => available.value && !hintSeen.value); + // The launcher + panel show for ANY authenticated user — to surface the + // AI-powered APM to everyone. `ai:read` is default for all roles; a user + // without it (custom RBAC) still sees the launcher and just gets the chat + // request rejected on send. `ready` (feature enabled + a usable provider) + // gates whether the panel can chat vs. show a read-only "ask your admin" + // setup notice. + const available = computed<boolean>(() => auth.isAuthenticated); + const ready = computed<boolean>(() => aiConfig.value?.ready ?? false); + const enabled = computed<boolean>(() => aiConfig.value?.enabled ?? false); + // Only nudge with the first-run hint when the assistant actually works — + // don't draw attention to a read-only, not-yet-configured panel. + const showHint = computed<boolean>(() => ready.value && !hintSeen.value); const starters = computed<string[]>(() => aiConfig.value?.starters ?? []); function dismissHint(): void { @@ -100,5 +115,5 @@ export function useAiChat(): AiChatController { else openPanel(); } - return { open: openState, available, showHint, starters, ensureConfig, openPanel, closePanel, toggle, dismissHint }; + return { open: openState, available, ready, enabled, showHint, starters, ensureConfig, openPanel, closePanel, toggle, dismissHint }; } diff --git a/apps/ui/src/api/alarmsConfig.ts b/apps/ui/src/api/alarmsConfig.ts new file mode 100644 index 0000000..7c3539a --- /dev/null +++ b/apps/ui/src/api/alarmsConfig.ts @@ -0,0 +1,87 @@ +/* + * 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. + */ + +// The Alarms page-setup config and its parse/validate helper. A leaf module +// (imports nothing internal) so the alarms api scope can read the effective +// config from OAP without a cycle through client.ts. The config lives ONLY in +// OAP as the `horizon.alert.page-setup` singleton template — edited on the Alert +// page admin and read from the config bundle's sync status, like every other +// template. + +/** Allowed values for `AlarmsConfig.defaultWindowMs`, in ms. Matches the alarms + * page's preset list so the admin's choice always corresponds to a real tab. */ +export const ALARMS_WINDOW_OPTIONS = [ + 20 * 60_000, + 2 * 60 * 60_000, + 4 * 60 * 60_000, +] as const; +export type AlarmsWindowMs = (typeof ALARMS_WINDOW_OPTIONS)[number]; + +export const OVERVIEW_ALARMS_LIMIT_MIN = 10; +export const OVERVIEW_ALARMS_LIMIT_MAX = 500; +export const OVERVIEW_ALARMS_LIMIT_DEFAULT = 200; + +export interface AlarmsConfig { + /** OAP layer keys (canonical `GENERAL`, `MESH`, …) that get a dedicated tile + * on the alarms page header. Render order matches the array order. */ + pinnedLayers: string[]; + /** Default time window in ms for the topbar alarm badge AND the alarms page's + * initial picker selection. */ + defaultWindowMs: number; + /** Fetch cap for the overview "Active alarms" widget. Default 200, range + * [10, 500]. Bigger = more incident variety; smaller = cheaper poll. */ + overviewAlarmsLimit: number; +} + +/** Shipped default — kept in sync with the bundled alert page-setup template + * (`apps/bff/src/bundled_templates/alert/page-setup.json`). */ +export const DEFAULT_ALARMS_CONFIG: AlarmsConfig = { + pinnedLayers: ['GENERAL', 'MESH'], + defaultWindowMs: ALARMS_WINDOW_OPTIONS[0], + overviewAlarmsLimit: OVERVIEW_ALARMS_LIMIT_DEFAULT, +}; + +/** Parse + validate the alert page-setup from a template-sync `configuration` + * string — the `{ name, kind, version, content }` envelope OAP stores verbatim. + * Any missing / out-of-range field falls back to the shipped default, so a + * partial or absent template still yields a usable config. */ +export function normalizeAlarmsConfig(configuration: string | null | undefined): AlarmsConfig { + if (!configuration) return { ...DEFAULT_ALARMS_CONFIG }; + let content: Partial<AlarmsConfig>; + try { + content = (JSON.parse(configuration) as { content?: Partial<AlarmsConfig> }).content ?? {}; + } catch { + return { ...DEFAULT_ALARMS_CONFIG }; + } + const pinnedLayers = Array.isArray(content.pinnedLayers) + ? content.pinnedLayers.filter((l): l is string => typeof l === 'string') + : DEFAULT_ALARMS_CONFIG.pinnedLayers; + const defaultWindowMs = (ALARMS_WINDOW_OPTIONS as readonly number[]).includes( + content.defaultWindowMs as number, + ) + ? (content.defaultWindowMs as number) + : DEFAULT_ALARMS_CONFIG.defaultWindowMs; + const lim = content.overviewAlarmsLimit; + const overviewAlarmsLimit = + typeof lim === 'number' && + Number.isInteger(lim) && + lim >= OVERVIEW_ALARMS_LIMIT_MIN && + lim <= OVERVIEW_ALARMS_LIMIT_MAX + ? lim + : DEFAULT_ALARMS_CONFIG.overviewAlarmsLimit; + return { pinnedLayers, defaultWindowMs, overviewAlarmsLimit }; +} diff --git a/apps/ui/src/api/client.ts b/apps/ui/src/api/client.ts index 09579cb..010a2a8 100644 --- a/apps/ui/src/api/client.ts +++ b/apps/ui/src/api/client.ts @@ -74,7 +74,6 @@ export function withBase(path: string): string { } import { MenuApi } from './scopes/menu'; import { OverviewApi } from './scopes/overview'; -import { SetupApi } from './scopes/setup'; import { LayerApi } from './scopes/layer'; import { TraceApi } from './scopes/trace'; import { ZipkinApi } from './scopes/zipkin'; @@ -115,8 +114,6 @@ export type { OapTtlResponse, OapConfigEntry, OapConfigResponse, - SetupResponse, - SetupSavePayload, LayerConfig, LandingConfig, LandingColumn, @@ -643,33 +640,18 @@ export interface AlertingRuleContextResponse { error?: string; nodes: Array<{ address: string; ok: boolean; error?: string; context: AlarmRunningContext | null }>; } -/** Allowed values for `AlarmsConfig.defaultWindowMs`, in ms. Matches - * the alarms page's preset list so the admin's choice always - * corresponds to a real tab. */ -export const ALARMS_WINDOW_OPTIONS = [ - 20 * 60_000, - 2 * 60 * 60_000, - 4 * 60 * 60_000, -] as const; -export type AlarmsWindowMs = (typeof ALARMS_WINDOW_OPTIONS)[number]; - -export const OVERVIEW_ALARMS_LIMIT_MIN = 10; -export const OVERVIEW_ALARMS_LIMIT_MAX = 500; -export const OVERVIEW_ALARMS_LIMIT_DEFAULT = 200; - -export interface AlarmsConfig { - /** OAP layer keys (canonical `GENERAL`, `MESH`, …) that get a - * dedicated tile on the alarms page header. Render order matches - * the array order. */ - pinnedLayers: string[]; - /** Default time window in milliseconds for the topbar alarm badge - * AND the alarms page's initial picker selection. */ - defaultWindowMs: number; - /** Fetch cap for the overview "Active alarms" widget. Default 200, - * range [10, 500]. Bigger = more incident variety; smaller = - * cheaper poll. */ - overviewAlarmsLimit: number; -} +// Alarms page-setup config + parse/validate helper live in a leaf module (no +// cycle with the alarms scope, which reads it from OAP). Re-exported here so +// `@/api/client` stays the single import site for consumers. +export { + ALARMS_WINDOW_OPTIONS, + OVERVIEW_ALARMS_LIMIT_MIN, + OVERVIEW_ALARMS_LIMIT_MAX, + OVERVIEW_ALARMS_LIMIT_DEFAULT, + DEFAULT_ALARMS_CONFIG, + normalizeAlarmsConfig, +} from './alarmsConfig'; +export type { AlarmsConfig, AlarmsWindowMs } from './alarmsConfig'; // ── 3D Infrastructure Map admin config ─────────────────────────────── // Mirrors `apps/bff/src/logic/infra-3d/types.ts`. Kept structurally @@ -916,7 +898,6 @@ export class BffClient { readonly session = new SessionApi(this); readonly menu = new MenuApi(this); readonly overview = new OverviewApi(this); - readonly setup = new SetupApi(this); readonly layer = new LayerApi(this); readonly trace = new TraceApi(this); readonly zipkin = new ZipkinApi(this); diff --git a/apps/ui/src/api/scopes/alarms.test.ts b/apps/ui/src/api/scopes/alarms.test.ts index ca6cc5a..5806b2d 100644 --- a/apps/ui/src/api/scopes/alarms.test.ts +++ b/apps/ui/src/api/scopes/alarms.test.ts @@ -85,23 +85,37 @@ describe('AlarmsApi.services + config + count', () => { expect(calls[0][1]).toBe('/api/alarms/services?layer=MESH'); }); - it('config GET / saveConfig POST hit /api/alarms/config', async () => { - const { bff, calls } = makeStub(); - const api = new AlarmsApi(bff); - await api.config(); - await api.saveConfig({ - pinnedLayers: ['GENERAL', 'MESH'], - defaultWindowMs: 1200000, - overviewAlarmsLimit: 200, - }); - expect(calls[0]).toEqual(['GET', '/api/alarms/config', undefined]); - expect(calls[1][0]).toBe('POST'); - expect(calls[1][1]).toBe('/api/alarms/config'); - expect(calls[1][2]).toEqual({ - pinnedLayers: ['GENERAL', 'MESH'], - defaultWindowMs: 1200000, - overviewAlarmsLimit: 200, - }); + function stubSyncStatus(rows: unknown[]) { + const { bff } = makeStub(); + (bff as unknown as { templateSync: { syncStatus: () => Promise<unknown> } }).templateSync = { + syncStatus: vi.fn(async () => ({ rows })), + }; + return bff; + } + + it('config reads + normalizes the alert page-setup from the template sync status', async () => { + const bff = stubSyncStatus([ + { + name: 'horizon.alert.page-setup', + effective: 'remote', + remote: { + configuration: JSON.stringify({ + name: 'horizon.alert.page-setup', + kind: 'alert', + version: 1, + content: { pinnedLayers: ['MESH'], defaultWindowMs: 7200000, overviewAlarmsLimit: 300 }, + }), + }, + bundled: null, + }, + ]); + const cfg = await new AlarmsApi(bff).config(); + expect(cfg).toEqual({ pinnedLayers: ['MESH'], defaultWindowMs: 7200000, overviewAlarmsLimit: 300 }); + }); + + it('config falls back to shipped defaults when the alert row is absent', async () => { + const cfg = await new AlarmsApi(stubSyncStatus([])).config(); + expect(cfg).toEqual({ pinnedLayers: ['GENERAL', 'MESH'], defaultWindowMs: 1200000, overviewAlarmsLimit: 200 }); }); it('adminRules + adminRule hit /api/admin/alarm-rules', async () => { diff --git a/apps/ui/src/api/scopes/alarms.ts b/apps/ui/src/api/scopes/alarms.ts index cb8e2dc..b377a65 100644 --- a/apps/ui/src/api/scopes/alarms.ts +++ b/apps/ui/src/api/scopes/alarms.ts @@ -16,7 +16,6 @@ */ import type { - AlarmsConfig, AlarmsCountResponse, AlarmsQuery, AlarmsResponse, @@ -25,6 +24,7 @@ import type { AlertingRulesListResponse, BffClient, } from '../client'; +import { normalizeAlarmsConfig, type AlarmsConfig } from '../alarmsConfig'; /** `bff.alarms` — alarm list + topbar count probe + alarm-page * config CRUD. */ @@ -66,12 +66,19 @@ export class AlarmsApi { return this.bff.request('GET', `/api/alarms/services?${p.toString()}`); } + /** The alarms page-setup — the `horizon.alert.page-setup` singleton template. + * Read its effective (OAP-remote ↔ bundled) copy from the config bundle's + * sync status, like the theme / time-defaults singletons; there is no local + * BFF store. Edits are saved to OAP via `bff.templateSync.save`. */ config(): Promise<AlarmsConfig> { - return this.bff.request<AlarmsConfig>('GET', '/api/alarms/config'); - } - - saveConfig(next: AlarmsConfig): Promise<AlarmsConfig> { - return this.bff.request<AlarmsConfig>('POST', '/api/alarms/config', next); + return this.bff.templateSync.syncStatus().then((status) => { + const row = status.rows.find((r) => r.name === 'horizon.alert.page-setup'); + const source = + row?.effective === 'remote' && row.remote + ? row.remote.configuration + : row?.bundled?.configuration; + return normalizeAlarmsConfig(source); + }); } /** OAP `/status/alarm/rules` fan-out + per-rule detail merge. diff --git a/apps/ui/src/api/scopes/setup.ts b/apps/ui/src/api/scopes/setup.ts deleted file mode 100644 index 96a8095..0000000 --- a/apps/ui/src/api/scopes/setup.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* - * 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 { SetupResponse, SetupSavePayload } from '@skywalking-horizon-ui/api-client'; -import type { BffClient } from '../client'; - -/** `bff.setup` — per-layer enabled-scope toggles (first-run setup). */ -export class SetupApi { - constructor(private readonly bff: BffClient) {} - - load(): Promise<SetupResponse> { - return this.bff.request<SetupResponse>('GET', '/api/setup'); - } - save(payload: SetupSavePayload): Promise<SetupResponse> { - return this.bff.request<SetupResponse>('POST', '/api/setup', payload); - } -} diff --git a/apps/ui/src/features/admin/alert-page/AlertPageSetupView.vue b/apps/ui/src/features/admin/alert-page/AlertPageSetupView.vue index b56c8ae..c787b29 100644 --- a/apps/ui/src/features/admin/alert-page/AlertPageSetupView.vue +++ b/apps/ui/src/features/admin/alert-page/AlertPageSetupView.vue @@ -27,7 +27,7 @@ <script setup lang="ts"> import { computed, ref, watch } from 'vue'; import { useI18n } from 'vue-i18n'; -import { useQuery } from '@tanstack/vue-query'; +import { useQuery, useQueryClient } from '@tanstack/vue-query'; import { useLayers } from '@/shell/useLayers'; import { ALARMS_WINDOW_OPTIONS, @@ -42,6 +42,7 @@ import TemplateDiffModal from '@/features/admin/_shared/TemplateDiffModal.vue'; import { useTemplateSync } from '@/features/admin/_shared/useTemplateSync'; const { t } = useI18n({ useScope: 'global' }); +const queryClient = useQueryClient(); // OAP UI-template sync status for the alert page-setup template // (`horizon.alert.page-setup`). Drives the read-only banner + Save @@ -163,12 +164,13 @@ async function onSave(): Promise<void> { defaultWindowMs: draftWindowMs.value, overviewAlarmsLimit: Number(draftLimit.value), }; - // Save to OAP via the template-sync proxy (canonical envelope - // wrapped server-side). The local AlarmsStore is still the read - // source for /api/alarms/config; the BFF refreshes it after the - // OAP write succeeds. + // Save to OAP via the template-sync proxy (canonical envelope wrapped + // server-side) — the alert page-setup is the `horizon.alert.page-setup` + // singleton, stored only on OAP like every other template. await bff.templateSync.save('horizon.alert.page-setup', next); - await bff.alarms.saveConfig(next); + // Refetch the shared config so the alarms page + topbar badge + overview + // widget pick up the change (all read it from the sync status). + await queryClient.invalidateQueries({ queryKey: ['alarms/config'] }); draft.value = [...next.pinnedLayers]; draftWindowMs.value = next.defaultWindowMs; draftLimit.value = next.overviewAlarmsLimit; @@ -228,10 +230,8 @@ function prettyLayer(k: string): string { sentence; the agent's earlier split-into-three-t-calls left operators on zh-CN/de seeing English prose with one Chinese word in the middle. --> - <i18n-t keypath="Pin the OAP layers that get their own KPI tile at the top of {alarms}. Defaults are {general} + {mesh}." tag="span" scope="global"> + <i18n-t keypath="Pin the OAP layers that get their own KPI tile at the top of {alarms}." tag="span" scope="global"> <template #alarms><RouterLink to="/alarms">{{ t('Alarms') }}</RouterLink></template> - <template #general><code>GENERAL</code></template> - <template #mesh><code>MESH</code></template> </i18n-t> {{ ' ' }} {{ t('Every other layer with at least one firing alarm appears in the overflow chip row underneath. Reorder with the arrows — left-to-right matches the page header. Up to {n} layers.', { n: MAX_PINNED }) }} diff --git a/apps/ui/src/i18n/locales/de.json b/apps/ui/src/i18n/locales/de.json index 676729a..7852374 100644 --- a/apps/ui/src/i18n/locales/de.json +++ b/apps/ui/src/i18n/locales/de.json @@ -1091,7 +1091,6 @@ "Observability Analysis Language": "Observability Analysis Language", "Operate · Data retention": "Betrieb · Datenaufbewahrung", "Per-data-class · widths proportional to total retention": "Pro Datenklasse · Breiten proportional zur Gesamtaufbewahrung", - "Pin the OAP layers that get their own KPI tile at the top of {alarms}. Defaults are {general} + {mesh}.": "Hefte die OAP-Ebenen an, die oben auf {alarms} eine eigene KPI-Kachel erhalten. Standard sind {general} + {mesh}.", "Push one with": "Pushe eine mit", "Records": "Datensätze", "Retention": "Aufbewahrung", @@ -1654,5 +1653,9 @@ "Content filter": "Inhaltsfilter", "match": "passt", "exclude": "ausschließen", - "No lines matched this content filter in the window.": "Keine Zeilen entsprachen diesem Inhaltsfilter im Fenster." + "No lines matched this content filter in the window.": "Keine Zeilen entsprachen diesem Inhaltsfilter im Fenster.", + "Pin the OAP layers that get their own KPI tile at the top of {alarms}.": "Hefte die OAP-Ebenen an, die oben auf {alarms} eine eigene KPI-Kachel erhalten.", + "The AI Assistant is not set up yet": "Der KI-Assistent ist noch nicht eingerichtet", + "It answers questions about your services, metrics, traces and logs from live data — but a model provider must be configured first.": "Er beantwortet Fragen zu Ihren Services, Metriken, Traces und Logs aus Live-Daten — aber zuerst muss ein Modellanbieter konfiguriert werden.", + "Ask your administrator to enable it and configure a model provider and API key.": "Bitten Sie Ihren Administrator, ihn zu aktivieren und einen Modellanbieter sowie einen API-Schlüssel zu konfigurieren." } diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json index 6b045e6..1b9d2ee 100644 --- a/apps/ui/src/i18n/locales/en.json +++ b/apps/ui/src/i18n/locales/en.json @@ -1072,7 +1072,6 @@ "Observability Analysis Language": "Observability Analysis Language", "Operate · Data retention": "Operate · Data retention", "Per-data-class · widths proportional to total retention": "Per-data-class · widths proportional to total retention", - "Pin the OAP layers that get their own KPI tile at the top of {alarms}. Defaults are {general} + {mesh}.": "Pin the OAP layers that get their own KPI tile at the top of {alarms}. Defaults are {general} + {mesh}.", "Push one with": "Push one with", "Records": "Records", "Retention": "Retention", @@ -1654,5 +1653,9 @@ "Content filter": "Content filter", "match": "match", "exclude": "exclude", - "No lines matched this content filter in the window.": "No lines matched this content filter in the window." + "No lines matched this content filter in the window.": "No lines matched this content filter in the window.", + "Pin the OAP layers that get their own KPI tile at the top of {alarms}.": "Pin the OAP layers that get their own KPI tile at the top of {alarms}.", + "The AI Assistant is not set up yet": "The AI Assistant is not set up yet", + "It answers questions about your services, metrics, traces and logs from live data — but a model provider must be configured first.": "It answers questions about your services, metrics, traces and logs from live data — but a model provider must be configured first.", + "Ask your administrator to enable it and configure a model provider and API key.": "Ask your administrator to enable it and configure a model provider and API key." } diff --git a/apps/ui/src/i18n/locales/es.json b/apps/ui/src/i18n/locales/es.json index 1db48e5..1d87a5f 100644 --- a/apps/ui/src/i18n/locales/es.json +++ b/apps/ui/src/i18n/locales/es.json @@ -1091,7 +1091,6 @@ "Observability Analysis Language": "Observability Analysis Language", "Operate · Data retention": "Operar · Retención de datos", "Per-data-class · widths proportional to total retention": "Por clase de datos · anchos proporcionales a la retención total", - "Pin the OAP layers that get their own KPI tile at the top of {alarms}. Defaults are {general} + {mesh}.": "Fija las capas de OAP que obtienen su propia tarjeta KPI en la parte superior de {alarms}. Las predeterminadas son {general} + {mesh}.", "Push one with": "Empuja uno con", "Records": "Registros", "Retention": "Retención", @@ -1654,5 +1653,9 @@ "Content filter": "Filtro de contenido", "match": "coincide", "exclude": "excluye", - "No lines matched this content filter in the window.": "Ninguna línea coincidió con este filtro de contenido en la ventana." + "No lines matched this content filter in the window.": "Ninguna línea coincidió con este filtro de contenido en la ventana.", + "Pin the OAP layers that get their own KPI tile at the top of {alarms}.": "Fija las capas de OAP que obtienen su propia tarjeta KPI en la parte superior de {alarms}.", + "The AI Assistant is not set up yet": "El asistente de IA aún no está configurado", + "It answers questions about your services, metrics, traces and logs from live data — but a model provider must be configured first.": "Responde preguntas sobre tus servicios, métricas, trazas y registros a partir de datos en vivo, pero primero se debe configurar un proveedor de modelo.", + "Ask your administrator to enable it and configure a model provider and API key.": "Pide a tu administrador que lo habilite y configure un proveedor de modelo y una clave de API." } diff --git a/apps/ui/src/i18n/locales/fr.json b/apps/ui/src/i18n/locales/fr.json index e249454..fff8b37 100644 --- a/apps/ui/src/i18n/locales/fr.json +++ b/apps/ui/src/i18n/locales/fr.json @@ -1091,7 +1091,6 @@ "Observability Analysis Language": "Observability Analysis Language", "Operate · Data retention": "Exploitation · Rétention des données", "Per-data-class · widths proportional to total retention": "Par classe de données · largeurs proportionnelles à la rétention totale", - "Pin the OAP layers that get their own KPI tile at the top of {alarms}. Defaults are {general} + {mesh}.": "Épinglez les couches OAP qui obtiennent leur propre tuile KPI en haut de {alarms}. Les valeurs par défaut sont {general} + {mesh}.", "Push one with": "Poussez-en un avec", "Records": "Enregistrements", "Retention": "Rétention", @@ -1654,5 +1653,9 @@ "Content filter": "Filtre de contenu", "match": "correspond", "exclude": "exclut", - "No lines matched this content filter in the window.": "Aucune ligne ne correspond à ce filtre de contenu dans la fenêtre." + "No lines matched this content filter in the window.": "Aucune ligne ne correspond à ce filtre de contenu dans la fenêtre.", + "Pin the OAP layers that get their own KPI tile at the top of {alarms}.": "Épinglez les couches OAP qui obtiennent leur propre tuile KPI en haut de {alarms}.", + "The AI Assistant is not set up yet": "L'assistant IA n'est pas encore configuré", + "It answers questions about your services, metrics, traces and logs from live data — but a model provider must be configured first.": "Il répond aux questions sur vos services, métriques, traces et logs à partir de données en direct, mais un fournisseur de modèle doit d'abord être configuré.", + "Ask your administrator to enable it and configure a model provider and API key.": "Demandez à votre administrateur de l'activer et de configurer un fournisseur de modèle et une clé d'API." } diff --git a/apps/ui/src/i18n/locales/ja.json b/apps/ui/src/i18n/locales/ja.json index 3834227..f218286 100644 --- a/apps/ui/src/i18n/locales/ja.json +++ b/apps/ui/src/i18n/locales/ja.json @@ -1091,7 +1091,6 @@ "Observability Analysis Language": "Observability Analysis Language", "Operate · Data retention": "運用 · データ保持", "Per-data-class · widths proportional to total retention": "データクラスごと · 幅は合計保持期間に比例", - "Pin the OAP layers that get their own KPI tile at the top of {alarms}. Defaults are {general} + {mesh}.": "{alarms} の上部に独自の KPI タイルを持つ OAP レイヤーをピン留めします。デフォルトは {general} + {mesh}。", "Push one with": "次でプッシュ:", "Records": "レコード", "Retention": "保持期間", @@ -1654,5 +1653,9 @@ "Content filter": "コンテンツフィルター", "match": "一致", "exclude": "除外", - "No lines matched this content filter in the window.": "このウィンドウ内でこのコンテンツフィルターに一致するログ行はありません。" + "No lines matched this content filter in the window.": "このウィンドウ内でこのコンテンツフィルターに一致するログ行はありません。", + "Pin the OAP layers that get their own KPI tile at the top of {alarms}.": "{alarms} の上部に独自の KPI タイルを持つ OAP レイヤーをピン留めします。", + "The AI Assistant is not set up yet": "AI アシスタントはまだセットアップされていません", + "It answers questions about your services, metrics, traces and logs from live data — but a model provider must be configured first.": "サービス、メトリクス、トレース、ログに関する質問にライブデータから回答しますが、先にモデルプロバイダーを構成する必要があります。", + "Ask your administrator to enable it and configure a model provider and API key.": "管理者に有効化とモデルプロバイダー・API キーの構成を依頼してください。" } diff --git a/apps/ui/src/i18n/locales/ko.json b/apps/ui/src/i18n/locales/ko.json index 40e2988..ded3542 100644 --- a/apps/ui/src/i18n/locales/ko.json +++ b/apps/ui/src/i18n/locales/ko.json @@ -1091,7 +1091,6 @@ "Observability Analysis Language": "Observability Analysis Language", "Operate · Data retention": "운영 · 데이터 보존", "Per-data-class · widths proportional to total retention": "데이터 클래스별 · 너비는 총 보존 기간에 비례", - "Pin the OAP layers that get their own KPI tile at the top of {alarms}. Defaults are {general} + {mesh}.": "{alarms} 상단에 전용 KPI 타일이 표시되는 OAP 레이어를 고정합니다. 기본값은 {general} + {mesh}.", "Push one with": "다음으로 푸시:", "Records": "레코드", "Retention": "보존", @@ -1654,5 +1653,9 @@ "Content filter": "내용 필터", "match": "일치", "exclude": "제외", - "No lines matched this content filter in the window.": "이 창에서 이 내용 필터와 일치하는 로그 줄이 없습니다." + "No lines matched this content filter in the window.": "이 창에서 이 내용 필터와 일치하는 로그 줄이 없습니다.", + "Pin the OAP layers that get their own KPI tile at the top of {alarms}.": "{alarms} 상단에 전용 KPI 타일이 표시되는 OAP 레이어를 고정합니다.", + "The AI Assistant is not set up yet": "AI 어시스턴트가 아직 설정되지 않았습니다", + "It answers questions about your services, metrics, traces and logs from live data — but a model provider must be configured first.": "실시간 데이터를 기반으로 서비스, 메트릭, 트레이스, 로그에 대한 질문에 답하지만 먼저 모델 제공자를 구성해야 합니다.", + "Ask your administrator to enable it and configure a model provider and API key.": "관리자에게 활성화 및 모델 제공자와 API 키 구성을 요청하세요." } diff --git a/apps/ui/src/i18n/locales/pt.json b/apps/ui/src/i18n/locales/pt.json index bc2f8d2..a5dfe72 100644 --- a/apps/ui/src/i18n/locales/pt.json +++ b/apps/ui/src/i18n/locales/pt.json @@ -1091,7 +1091,6 @@ "Observability Analysis Language": "Observability Analysis Language", "Operate · Data retention": "Operar · Retenção de dados", "Per-data-class · widths proportional to total retention": "Por classe de dado · larguras proporcionais à retenção total", - "Pin the OAP layers that get their own KPI tile at the top of {alarms}. Defaults are {general} + {mesh}.": "Fixe as camadas do OAP que ganham seu próprio tile de KPI no topo de {alarms}. Os padrões são {general} + {mesh}.", "Push one with": "Envie um com", "Records": "Registros", "Retention": "Retenção", @@ -1654,5 +1653,9 @@ "Content filter": "Filtro de conteúdo", "match": "corresponde", "exclude": "exclui", - "No lines matched this content filter in the window.": "Nenhuma linha correspondeu a este filtro de conteúdo na janela." + "No lines matched this content filter in the window.": "Nenhuma linha correspondeu a este filtro de conteúdo na janela.", + "Pin the OAP layers that get their own KPI tile at the top of {alarms}.": "Fixe as camadas do OAP que ganham seu próprio tile de KPI no topo de {alarms}.", + "The AI Assistant is not set up yet": "O assistente de IA ainda não está configurado", + "It answers questions about your services, metrics, traces and logs from live data — but a model provider must be configured first.": "Ele responde a perguntas sobre seus serviços, métricas, traces e logs a partir de dados ao vivo — mas um provedor de modelo precisa ser configurado primeiro.", + "Ask your administrator to enable it and configure a model provider and API key.": "Peça ao seu administrador para habilitá-lo e configurar um provedor de modelo e uma chave de API." } diff --git a/apps/ui/src/i18n/locales/zh-CN.json b/apps/ui/src/i18n/locales/zh-CN.json index 7708c90..bfc7630 100644 --- a/apps/ui/src/i18n/locales/zh-CN.json +++ b/apps/ui/src/i18n/locales/zh-CN.json @@ -1092,7 +1092,6 @@ "Observability Analysis Language": "Observability Analysis Language", "Operate · Data retention": "运维 · 数据保留", "Per-data-class · widths proportional to total retention": "按数据类别 · 宽度与总保留期成正比", - "Pin the OAP layers that get their own KPI tile at the top of {alarms}. Defaults are {general} + {mesh}.": "固定要在 {alarms} 顶部拥有独立 KPI 图块的 OAP layer。默认值为 {general} + {mesh}。", "Push one with": "请通过以下方式推送:", "Records": "记录", "Retention": "保留期", @@ -1654,5 +1653,9 @@ "Content filter": "内容过滤", "match": "匹配", "exclude": "排除", - "No lines matched this content filter in the window.": "该窗口内没有日志行匹配此内容过滤。" + "No lines matched this content filter in the window.": "该窗口内没有日志行匹配此内容过滤。", + "Pin the OAP layers that get their own KPI tile at the top of {alarms}.": "固定要在 {alarms} 顶部拥有独立 KPI 图块的 OAP layer。", + "The AI Assistant is not set up yet": "AI 助手尚未配置", + "It answers questions about your services, metrics, traces and logs from live data — but a model provider must be configured first.": "它基于实时数据回答有关服务、指标、trace 和日志的问题,但需要先配置模型提供方。", + "Ask your administrator to enable it and configure a model provider and API key.": "请让管理员启用它并配置模型提供方和 API 密钥。" } diff --git a/apps/ui/src/state/setup.ts b/apps/ui/src/state/setup.ts index 622c177..46b41f9 100644 --- a/apps/ui/src/state/setup.ts +++ b/apps/ui/src/state/setup.ts @@ -16,7 +16,7 @@ */ import { defineStore } from 'pinia'; -import { computed, reactive, ref } from 'vue'; +import { reactive } from 'vue'; import type { AggregationKind, LandingConfig, @@ -26,7 +26,6 @@ import type { LayerOverviewConfig, LayerSlots, } from '@skywalking-horizon-ui/api-client'; -import { bffClient } from '@/api/client'; import { defaultColumnsForLayer, defaultOrderByForLayer, @@ -206,77 +205,24 @@ export function defaultLayerConfig( } /** - * Layer customization store. + * Layer config resolver. * - * 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. + * Derives each layer's rendered `LayerConfig` (slots / caps / landing) from that + * layer's template defaults and caches it per layer so many components can read a + * stable object. The layer template is the source of truth — authored in the + * Layer dashboards admin and OAP-synced; this store holds NO server state and + * persists nothing (there is no separate per-layer override store). */ export const useSetupStore = defineStore('setup', () => { 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> = {}; - /** Set of layers whose persisted config has been reconciled against the - * current bundle defaults. Each layer is reconciled exactly ONCE per - * server snapshot — re-running the patches on every `ensure()` call - * was unconditionally re-assigning `landing.headerColumns` (and - * similar) and triggering a reactive cascade. With many components - * reading `ensure(...).landing` from inside a `computed`, the cascade - * hit Vue's recursive-update cap and froze the page. The bootstrap + - * discard paths reset this set so the next reconciliation pass runs - * against the freshly-loaded server data. */ - const reconciledLayers = new Set<string>(); - - 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)); - reconciledLayers.clear(); - 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.setup.load(); - applyServerSnapshot(res.layers); - bootstrapped.value = true; - } catch (err) { - lastError.value = err instanceof Error ? err.message : 'failed to load setup'; - } finally { - loading.value = false; - } - } - - // Form bindings mutate `configs[layer]…` through the Vue proxy, and we - // can't tell a user edit from a programmatic write on that proxy — so - // dirty tracking needs an explicit signal: callers (LayerSetupCard on - // input) must call markDirty from the form handler. - 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. + * Return the resolved config for a layer, deriving it from `defaults` on first + * touch and caching it. Called from `computed()` deliberately: the cache write + * happens once per layer, and every subsequent read returns the cached object + * WITHOUT mutating — so a computed reading `ensure(...).landing` never triggers + * a reactive cascade (an earlier per-read reconciliation pass did, which froze + * AppSidebar / LayerShell with "Maximum recursive updates exceeded"). */ function ensure( layerKey: string, @@ -291,145 +237,15 @@ export const useSetupStore = defineStore('setup', () => { if (!cfg) { cfg = defaultLayerConfig(layerKey, defaults); configs[layerKey] = cfg; - // Freshly built from defaults — already in canonical shape. - reconciledLayers.add(layerKey); - return cfg; - } - // Reconciliation is a one-time bootstrap step per server snapshot, - // not a per-read action. The earlier code re-ran the reconciliation - // on every `ensure()` call, and several of those writes were - // unconditional (e.g. `cfg.landing.headerColumns = fresh...`). Since - // many computeds call `ensure()`, that turned every read into a - // reactive mutation and produced "Maximum recursive updates exceeded" - // in AppSidebar / LayerShell. Now we early-return for already- - // reconciled layers — the reactive read stays pure. - if (reconciledLayers.has(layerKey)) return cfg; - reconciledLayers.add(layerKey); - { - // Reconcile stale persisted state — three issues to patch: - // 1. caps fields added after the persisted snapshot. We fill - // undefined caps from the template defaults so checkbox - // toggles read the right state (undefined → defaults). - // 2. overview groups / metric ids that don't resolve to any - // column → seed the fresh group set. - // 3. columns that DO resolve but carry stale data (empty mqe - // from an old schema) → patch the missing fields from the - // fresh template. Operator-edited fields stay untouched - // (we never overwrite a non-empty persisted value). - for (const [k, v] of Object.entries(defaults.caps)) { - const key = k as keyof typeof cfg.caps; - if (cfg.caps[key] === undefined) cfg.caps[key] = v; - } - const fresh = defaultLandingFor(layerKey, defaults.metrics, defaults.overview); - // When the bundled template now ships `columns: []` (e.g. - // so11y_* layers whose meters are SERVICE_INSTANCE-only and have - // no service-level KPIs), clear any leftover persisted columns — - // they're stale baggage from a prior bundle version and would - // resurrect a row of `—` cells. Operators that explicitly want - // custom columns can re-add them via the setup admin. - if (fresh.columns.length === 0 && cfg.landing.columns.length > 0) { - cfg.landing.columns = []; - cfg.landing.orderBy = fresh.orderBy; - } - // Always re-derive `headerColumns` from the fresh template — it's - // the operator-defined header set (excludes overview-promoted - // synthetic cols). New field; legacy persisted configs won't - // have it. We trust the template here rather than preserving - // stale persisted state because the field is a pure derivation, - // not operator-editable. - cfg.landing.headerColumns = fresh.headerColumns; - // Patch / add columns from the fresh template — only fields - // that are missing on the persisted column get filled. mqe is - // the common stale field; this is also where label fixes from - // a renamed catalog land for un-customized columns. - for (const fc of fresh.columns) { - const existing = cfg.landing.columns.find((c) => c.metric === fc.metric); - if (!existing) { - cfg.landing.columns.push(fc); - continue; - } - if (!existing.mqe && fc.mqe) existing.mqe = fc.mqe; - if (existing.label === existing.metric && fc.label) existing.label = fc.label; - if (!existing.unit && fc.unit) existing.unit = fc.unit; - if (!existing.aggregation && fc.aggregation) existing.aggregation = fc.aggregation; - if (!existing.tip && fc.tip) existing.tip = fc.tip; - } - // Refresh overviewMetrics / overviewGroups when persisted state - // is stale (missing groups, or ids that don't resolve). - const ids = cfg.landing.overviewMetrics ?? []; - const allResolve = ids.length > 0 && ids.every( - (id) => cfg.landing.columns.some((c) => c.metric === id), - ); - const hasGroups = (cfg.landing.overviewGroups?.length ?? 0) > 0; - if (!allResolve || !hasGroups) { - cfg.landing.overviewMetrics = fresh.overviewMetrics; - cfg.landing.overviewGroups = fresh.overviewGroups; - } } return cfg; } - function reset( - layerKey: string, - defaults: { - slots: LayerSlots; - caps: LayerCaps; - metrics?: LayerMetricsConfig; - overview?: LayerOverviewConfig; - }, - ): void { - configs[layerKey] = defaultLayerConfig(layerKey, defaults); - markDirty(); - } - - async function save(): Promise<void> { - if (saving.value) return; - saving.value = true; - lastError.value = null; - try { - // Client sends the full touched set; the server stores it sparse. - const payload = JSON.parse(JSON.stringify(configs)) as Record<string, LayerConfig>; - const res = await bffClient.setup.save({ layers: payload }); - applyServerSnapshot(res.layers); - } catch (err) { - lastError.value = err instanceof Error ? err.message : 'save failed'; - throw err; - } finally { - saving.value = false; - } - } - - async function discard(): Promise<void> { - applyServerSnapshot(serverSnapshot); - } - - /** Side-effect-free priority read. Returns the persisted priority - * for a layer when one exists, or the static default otherwise. - * - * Critically, this MUST NOT call `ensure()` or otherwise mutate the - * store. `useLandingOrder` reads priority from inside a computed — - * using `ensure()` there triggered a recursive-update loop - * ("Maximum recursive updates exceeded in component <AppSidebar>") - * because `ensure` writes to `configs.<layer>.landing.headerColumns` - * on every call, and that's a dependency of the same computed. */ + /** Side-effect-free priority read — used from `useLandingOrder` inside a + * computed, so it MUST NOT call `ensure()` or otherwise mutate the store. */ function priorityFor(layerKey: string): number { return configs[layerKey]?.landing.priority ?? defaultPriority(layerKey); } - return { - configs, - dirty, - loading, - saving, - bootstrapped, - lastError, - layerCount: computed(() => Object.keys(configs).length), - bootstrap, - ensure, - priorityFor, - reset, - markDirty, - save, - discard, - }; + return { configs, ensure, priorityFor }; }); diff --git a/docs/access-control/audit-log.md b/docs/access-control/audit-log.md index 1275cef..e8490f5 100644 --- a/docs/access-control/audit-log.md +++ b/docs/access-control/audit-log.md @@ -1,6 +1,6 @@ # Audit Log -The audit log records sensitive operations as JSON Lines, one event per line, append-only. Configure the path via `audit.file` in `horizon.yaml` (see [Setup → audit](../setup/audit.md)). +The audit log records sensitive operations as JSON Lines, one event per line, append-only. It is **on by default**; disable it with `audit.enabled: false` and set the path via `audit.file` in `horizon.yaml` (see [Setup → audit](../setup/audit.md)). ## Event Fields @@ -36,8 +36,6 @@ The recorded set can grow between releases. The current set: | `addOrUpdate` | OAP `applyStatus`, or `http_<code>` on failure | DSL Management — create / update a rule. `details.name` is the rule name; `details.catalog` is the rule catalog (alarm, MAL, OAL, …). | | `inactivate` | OAP `applyStatus`, or `http_<code>` | DSL Management — deactivate a rule. | | `delete` | OAP `applyStatus`, or `http_<code>` | DSL Management — delete a rule. `details.mode` is the delete mode. | -| `alarms.config.save` | `ok` | Alarm page setup save. Recorded on success only — an invalid payload is rejected before any audit entry. | -| `setup.save` | `success` | Per-user setup state write (service / instance / endpoint setup). Recorded on success only — an invalid payload is rejected before any audit entry. | | `debug.start` | `ok`, or an error status on failure | Live Debugger — start a session. | | `debug.stop` | `ok`, or an error status on failure | Live Debugger — stop a session. | diff --git a/docs/design-target.md b/docs/design-target.md index e9fed63..c0d0de0 100644 --- a/docs/design-target.md +++ b/docs/design-target.md @@ -55,7 +55,7 @@ The renderer is template-driven (see [Customization → Layer Dashboard Template Every visual decision a site operator wants to make is template-driven: -- **Sidebar layer order** — per-user layer priority, persisted in the setup state file. +- **Sidebar layer order** — a default priority by layer family (General → Virtual* → Mesh → K8s). - **Layer alias / color / group / visibility** — layer template fields (`alias`, `color`, `group`, `visibility`). - **Which tabs appear on a layer** — `components` flags on the layer template. - **What appears under each tab** — `dashboards.<scope>` widget arrays. diff --git a/docs/operate/ai-assistant.md b/docs/operate/ai-assistant.md index 5ddd96e..92e99d1 100644 --- a/docs/operate/ai-assistant.md +++ b/docs/operate/ai-assistant.md @@ -31,7 +31,7 @@ Crucially, that skill is **not immutable** — it is *your* configuration, edite ## Enabling it -The assistant is disabled until you turn it on and point it at a model. The launcher (a floating **AI Assistant** button on the right edge, after login) stays hidden until the feature is enabled *and* a usable provider is configured. +The launcher — a floating **AI Assistant** button on the right edge, after login — shows for every signed-in user, so the assistant is discoverable across the product. Until you turn the feature on and point it at a model, the panel opens **read-only**: it explains what the assistant does and shows a short "ask your administrator to set it up" notice instead of a chat box. Who may actually use it once configured is controlled by RBAC — the `ai:read` permission, granted to every role by d [...] Configure it under the `ai:` block of `horizon.yaml`, or entirely via `HORIZON_AI_*` environment variables: diff --git a/docs/setup/audit.md b/docs/setup/audit.md index 1097bdd..3d02f39 100644 --- a/docs/setup/audit.md +++ b/docs/setup/audit.md @@ -4,6 +4,7 @@ Audit log file path. The format and event schema are documented in [Access Contr ```yaml audit: + enabled: true file: ./horizon-audit.jsonl ``` @@ -11,6 +12,7 @@ audit: | Field | Type | Default | Required | Notes | |---|---|---|---|---| +| `enabled` | boolean | `true` | no | Whether to record the audit trail. **On by default** — the audit log is a security record, so silencing it is an explicit opt-out. When `false`, no file is opened and every event is dropped. Env: `HORIZON_AUDIT_ENABLED`. | | `file` | string | `./horizon-audit.jsonl` | no | Filesystem path to the JSON Lines audit log. Relative paths resolve from the BFF working directory. The BFF appends; it never rotates. | ## Operational notes diff --git a/docs/setup/container-image.md b/docs/setup/container-image.md index 0e89006..502656c 100644 --- a/docs/setup/container-image.md +++ b/docs/setup/container-image.md @@ -30,7 +30,7 @@ The full commit SHA is the canonical, immutable identifier. Moving tags are conv | `/app/static/` | root | no | Built UI assets (Vite `dist/`). | | `/app/horizon.yaml` | root | no | The **active** config — a **baked, fully tokenized default** (every field is a `${HORIZON_…:default}` env token). The image runs with no mounted file; override any field via env (see [Run with env vars only](#run-with-env-vars-only-no-mounted-file)), or bind-mount your own to replace it. | | `/app/bundled_templates/` | **horizon** | **yes** | Layer + overview JSON templates. Owned by `horizon` because the admin **Layer-Templates** and **Overview-Templates** editors write into per-key files here. | -| `/data/` | **horizon** | **yes** | Declared `VOLUME`. Default destination for the audit log, setup state, alarm state, and wire debug log. Mount a PVC / named volume / host bind here for durable storage. | +| `/data/` | **horizon** | **yes** | Declared `VOLUME`. Default destination for the audit log and wire debug log. Mount a PVC / named volume / host bind here for durable storage. | | `/app/sourcemaps/` | **horizon** | (read) | Static source maps for the **Browser Logs** tab. Bind-mount or copy `.map` files here and they're loaded at boot — durable across restarts. Optional; runtime uploads work without it. See [Browser Logs & Source Maps](../operate/browser-source-maps.md). | The runtime stage runs as the non-root user `horizon`. Two locations are owned by `horizon` so writes work without operator intervention: `bundled_templates/` (so the admin editors save) and `/data/` (so state files land somewhere durable). @@ -44,12 +44,10 @@ The runtime stage runs as the non-root user `horizon`. Two locations are owned b | `HORIZON_CONFIG` | `/app/horizon.yaml` | Where the BFF looks for `horizon.yaml`. Override to mount elsewhere. | | `HORIZON_STATIC_DIR` | `/app/static` | Where the BFF serves UI assets from. | | `HORIZON_AUDIT_FILE` | `/data/horizon-audit.jsonl` | Default for `audit.file` when `horizon.yaml` doesn't override it. | -| `HORIZON_SETUP_FILE` | `/data/horizon-setup.json` | Default for `setup.file`. | -| `HORIZON_ALARMS_FILE` | `/data/horizon-alarms.json` | Default for `alarms.file`. | | `HORIZON_WIRE_LOG_FILE` | `/data/horizon-wire.jsonl` | Default for `debugLog.file`. | | `HORIZON_SOURCEMAPS_DIR` | `/app/sourcemaps` | Default for `sourceMaps.bootMountDir` — the directory scanned at boot for statically-provisioned `.map` files. See [Browser Logs & Source Maps](../operate/browser-source-maps.md). | -The four `HORIZON_*_FILE` env vars seed the **defaults** the config schema uses when `horizon.yaml` doesn't supply a value. An explicit value in `horizon.yaml` always wins. The intent: an operator who runs the published image with only a minimal `horizon.yaml` (no `audit/setup/alarms/debugLog` blocks) gets state files routed to `/data/` automatically, no manual path overrides needed. +The two `HORIZON_*_FILE` env vars seed the **defaults** the config schema uses when `horizon.yaml` doesn't supply a value. An explicit value in `horizon.yaml` always wins. The intent: an operator who runs the published image with only a minimal `horizon.yaml` (no `audit/debugLog` blocks) gets state files routed to `/data/` automatically, no manual path overrides needed. `server.host` and `server.port` come from the YAML when present. If they are omitted, the image supplies defaults via `HORIZON_SERVER_HOST=0.0.0.0` and `HORIZON_SERVER_PORT=8081`. The image sets `EXPOSE 8081`; if you change `server.port`, also publish the new port. @@ -240,13 +238,13 @@ Notes: - `subPath: horizon.yaml` mounts the single file rather than the directory, so it doesn't shadow `/app`'s other contents. - Mount with `readOnly: true` on the config — the BFF only reads it. -- `/data` is the image's declared `VOLUME` for runtime state (audit log, setup, alarms, wire debug). The four `HORIZON_*_FILE` env vars baked into the image point at `/data/*`, so mounting a PVC here is enough — no path overrides in `horizon.yaml` are required. +- `/data` is the image's declared `VOLUME` for runtime state (audit log, wire debug). The two `HORIZON_*_FILE` env vars baked into the image point at `/data/*`, so mounting a PVC here is enough — no path overrides in `horizon.yaml` are required. - `fsGroup: 101` is the typical alpine `nobody` GID that `adduser -S -G horizon horizon` falls into. Run `docker run --rm <image> id horizon` to confirm if you've forked the image. - Run a single replica unless you accept that sessions are per-pod (the in-memory session store does not federate; see [session](session.md)). -### Persisting state files (`audit`, `setup`, `alarms`, `debugLog`) +### Persisting state files (`audit`, `debugLog`) -The BFF writes runtime state to JSON Lines / JSON files. The image declares `/data` as a `VOLUME` and points the four defaults at `/data/*` via env vars, so **no `horizon.yaml` configuration is required** to get writable, persistable paths — operators just mount a volume at `/data`: +The BFF writes runtime state to JSON Lines files. The image declares `/data` as a `VOLUME` and points the two defaults at `/data/*` via env vars, so **no `horizon.yaml` configuration is required** to get writable, persistable paths — operators just mount a volume at `/data`: ```sh docker run -d --name horizon \ @@ -265,8 +263,6 @@ If you want to override the locations, you can either: ```yaml audit: { file: /var/log/horizon/audit.jsonl } - setup: { file: /var/lib/horizon/setup.json } - alarms: { file: /var/lib/horizon/alarms.json } debugLog: { file: /var/log/horizon/wire.jsonl } ``` diff --git a/docs/setup/files.md b/docs/setup/files.md index cb98dcc..b86e80e 100644 --- a/docs/setup/files.md +++ b/docs/setup/files.md @@ -1,57 +1,37 @@ # Runtime State Files -State files for user-configured settings. Both write JSON, both are managed by the BFF, neither needs hand-editing. +The BFF writes a small amount of runtime state to files, managed automatically — neither needs hand-editing. -```yaml -setup: - file: ./horizon-setup.json - -alarms: - file: ./horizon-alarms.json -``` - -## `setup.file` +## `audit.file` | Field | Type | Default | Required | Notes | |---|---|---|---|---| -| `setup.file` | string | `./horizon-setup.json` | no | Filesystem path to the setup state. Relative paths resolve from the BFF working directory. | - -Holds: +| `audit.enabled` | boolean | `true` | no | Whether to record the audit trail. On by default; set `false` to disable audit logging entirely. Env: `HORIZON_AUDIT_ENABLED`. | +| `audit.file` | string | `./horizon-audit.jsonl` | no | Filesystem path to the append-only audit log. | -- Per-user landing layer order (`landing.priority`). -- Layer-level setup state (which layers the user has marked as enabled / disabled in their sidebar). -- Other persistent UI preferences that survive sessions. +The append-only audit trail — one JSON line per mutating admin action. On by default; disable with `audit.enabled: false`. See [Audit Log](../access-control/audit-log.md). -The UI writes this file as users change their landing order and layer enablement; the file is updated atomically (write-temp-then-rename). - -## `alarms.file` +## `debugLog.file` | Field | Type | Default | Required | Notes | |---|---|---|---|---| -| `alarms.file` | string | `./horizon-alarms.json` | no | Filesystem path to the alarm rules state. | +| `debugLog.file` | string | `./horizon-wire.jsonl` | no | Filesystem path to the OAP wire debug log. Only written when `debugLog.enabled` is set. | -Holds user-created alarm rules (in addition to whatever the OAP cluster ships bundled). The Alarm Rule Editor (Operate → Alarm Rules) writes here. +The OAP request/response wire trace, off by default. See [Debug Log](debug-log.md). ## Env-var fallbacks -When `horizon.yaml` does not supply a `setup.file` or `alarms.file` (or `audit.file` / `debugLog.file`), the default is seeded from an env var: +When `horizon.yaml` does not supply `audit.file` or `debugLog.file`, the default is seeded from an env var: | YAML key | Env-var fallback | Default | |---|---|---| -| `setup.file` | `HORIZON_SETUP_FILE` | `./horizon-setup.json` | -| `alarms.file` | `HORIZON_ALARMS_FILE` | `./horizon-alarms.json` | | `audit.file` | `HORIZON_AUDIT_FILE` | `./horizon-audit.jsonl` | | `debugLog.file` | `HORIZON_WIRE_LOG_FILE` | `./horizon-wire.jsonl` | -The published Docker image sets all four env vars to `/data/...` paths so an operator who runs the image without a `horizon.yaml` override gets writes routed to the declared `/data` volume — see [Container Image → Persisting state files](container-image.md#persisting-state-files-audit-setup-alarms-debuglog). An explicit value in `horizon.yaml` always wins over the env-var fallback. +The published Docker image sets both env vars to `/data/...` paths so an operator who runs the image without a `horizon.yaml` override gets writes routed to the declared `/data` volume — see [Container Image → Persisting state files](container-image.md#persisting-state-files-audit-debuglog). An explicit value in `horizon.yaml` always wins over the env-var fallback. ## Operational notes - **Both are mutable runtime state.** They should be on durable storage, not a container tmpfs. - **Both are gitignored by default** (see `.gitignore`). They are not source-controlled; they are operational state. -- **Atomic writes.** Both stores write-temp-then-rename, so a crash mid-write cannot truncate the file. -- **No schema migration tooling yet.** If a future release changes the on-disk shape, you may need to delete the file and re-configure. (Documented per release.) - -## Hot reload - -The files are watched at startup. Changes made by the BFF itself take effect immediately. Editing the files by hand mid-run is not supported — restart the BFF after manual edits. +- **Atomic appends.** The audit log is append-only, so a crash mid-write cannot corrupt earlier records. diff --git a/docs/setup/overview.md b/docs/setup/overview.md index ea218d7..1177cf0 100644 --- a/docs/setup/overview.md +++ b/docs/setup/overview.md @@ -143,8 +143,6 @@ Two changes still require a BFF restart: |---|---|---| | Config | `./horizon.yaml` | `HORIZON_CONFIG=` | | Audit log | `./horizon-audit.jsonl` | `audit.file` | -| Setup state | `./horizon-setup.json` | `setup.file` | -| Alarm rules | `./horizon-alarms.json` | `alarms.file` | | Wire debug log | `./horizon-wire.jsonl` | `debugLog.file` | | Bundled overview / layer templates | inside the BFF bundle | not user-editable as files; edit via admin pages | diff --git a/horizon.yaml b/horizon.yaml index 81fca14..8cb03c4 100644 --- a/horizon.yaml +++ b/horizon.yaml @@ -103,21 +103,16 @@ session: cookieName: "${HORIZON_SESSION_COOKIE_NAME:horizon_sid}" cookieSecure: ${HORIZON_SESSION_COOKIE_SECURE:false} # set true behind HTTPS -# State files. The image sets HORIZON_*_FILE=/data/* (its writable volume); -# a local run defaults to ./horizon-* in the working dir. Set an explicit -# path only if you need one the running process can write. -audit: - file: "${HORIZON_AUDIT_FILE:./horizon-audit.jsonl}" -setup: - file: "${HORIZON_SETUP_FILE:./horizon-setup.json}" -alarms: - file: "${HORIZON_ALARMS_FILE:./horizon-alarms.json}" - debugLog: enabled: ${HORIZON_DEBUG_LOG_ENABLED:false} file: "${HORIZON_WIRE_LOG_FILE:./horizon-wire.jsonl}" maxBodyChars: ${HORIZON_DEBUG_LOG_MAX_BODY_CHARS:8192} redactAuthHeaders: ${HORIZON_DEBUG_LOG_REDACT_AUTH:true} +audit: + # On by default (the audit trail is a security record). Set false to disable + # audit logging entirely — no file is written. + enabled: ${HORIZON_AUDIT_ENABLED:true} + file: "${HORIZON_AUDIT_FILE:./horizon-audit.jsonl}" query: # Max services a layer landing runs metric MQE for, per request. diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index d49208b..c5e818e 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -35,8 +35,6 @@ export type { LandingColumn, LandingConfig, LayerConfig, - SetupResponse, - SetupSavePayload, } from './setup.js'; export type { LandingAggregates, LandingResponse, LandingServiceRow } from './landing.js'; export type { diff --git a/packages/api-client/src/setup.ts b/packages/api-client/src/setup.ts index 5397907..8959201 100644 --- a/packages/api-client/src/setup.ts +++ b/packages/api-client/src/setup.ts @@ -16,12 +16,11 @@ */ /** - * 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. + * The resolved per-layer config shape. The UI derives a `LayerConfig` for each + * layer from that layer's template (slots / caps / metrics / overview) — see the + * setup resolver in `apps/ui/src/state/setup.ts`. Layer config is authored in the + * layer template (OAP-synced via the Layer dashboards admin); this is the shape + * the renderer reads, not a separately-persisted override. */ import type { LayerCaps, LayerSlots } from './menu.js'; @@ -128,13 +127,3 @@ export interface LayerConfig { 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>; -}
