This is an automated email from the ASF dual-hosted git repository. wu-sheng pushed a commit to branch feat/template-modes-env-config in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
commit 2e4b26baa91c9dfc8c9d800b16087f1983d53586 Author: Wu Sheng <[email protected]> AuthorDate: Fri Jun 26 08:56:41 2026 +0800 feat(cluster): reachability-based admin preflight + ui_template unified as a feature Health on the Cluster Status admin pane is now the live probe, not config- presence. Each admin feature declares the relative REST path it actually calls on OAP; the BFF fires a safe GET at it and reports whether it responds: admin-server /debugging/config/dump receiver-runtime-rule /runtime/rule/list dsl-debugging /dsl-debugging/status inspect /inspect/metrics ui-management /ui-management/templates `reachable` drives the green/red + the per-page warning gates. The config-dump prefix check (`enabled`) is demoted to an informational "selector detected" footnote — it only tells you the official upstream release advertises that selector, so a renamed / forked / on-but-broken module that 404s now reads as unreachable instead of a misleading green (validated live: inspect on the demo OAP advertises its selector but only /inspect/metrics responds — the catalog path 404s). ui_template joins the same table as a feature, mode-aware: in readonly it is never probed (`reachable: null` → "readonly · bundled"). Each row shows how long ago it was last checked (anchored to the BFF's generatedAt, so it reflects real probe + cache age), and a force re-check bypasses the cache. The 5 probes are single-flighted for 30s on the BFF so the UI's 60s poll across N sessions doesn't fan out on OAP. Validated live (demo OAP): live = all five reachable; readonly = ui_template null + others probed; admin-port down = all unreachable, no path probes. type-check / lint / license / 161 BFF + 116 UI tests / builds / 8-locale i18n green. --- CHANGELOG.md | 1 + apps/bff/src/http/query/preflight.ts | 14 +- apps/bff/src/logic/preflight/preflight.test.ts | 145 +++++++++++++++++++ apps/bff/src/logic/preflight/preflight.ts | 159 ++++++++++++++++++--- apps/ui/src/api/scopes/menu.ts | 4 +- .../features/operate/cluster/ClusterStatusView.vue | 123 ++++++++-------- apps/ui/src/i18n/locales/de.json | 10 +- apps/ui/src/i18n/locales/en.json | 10 +- apps/ui/src/i18n/locales/es.json | 10 +- apps/ui/src/i18n/locales/fr.json | 10 +- apps/ui/src/i18n/locales/ja.json | 10 +- apps/ui/src/i18n/locales/ko.json | 10 +- apps/ui/src/i18n/locales/pt.json | 10 +- apps/ui/src/i18n/locales/zh-CN.json | 10 +- apps/ui/src/shell/AdminFeatureWarning.vue | 12 +- apps/ui/src/shell/useAdminFeatures.ts | 40 +++--- packages/api-client/src/preflight.ts | 49 +++++-- 17 files changed, 490 insertions(+), 137 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82983e4..fedb72b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ 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.** The image now bakes a **fully tokenized** `horizon.yaml` where every field is a `${HORIZON_…:default}` env var, 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 YAML-only. Lists and secrets (users, LDAP, OAP auth) are set as JSON-string env vars; preced [...] +- **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/apps/bff/src/http/query/preflight.ts b/apps/bff/src/http/query/preflight.ts index 99a2902..530e8ce 100644 --- a/apps/bff/src/http/query/preflight.ts +++ b/apps/bff/src/http/query/preflight.ts @@ -20,7 +20,7 @@ import type { FetchLike } from '@skywalking-horizon-ui/api-client'; import type { ConfigSource } from '../../config/loader.js'; import { requireAuth } from '../../user/middleware.js'; import type { SessionStore } from '../../user/sessions.js'; -import { runPreflight } from '../../logic/preflight/preflight.js'; +import { getPreflight } from '../../logic/preflight/preflight.js'; export interface PreflightRouteDeps { config: ConfigSource; @@ -28,17 +28,19 @@ export interface PreflightRouteDeps { fetch?: FetchLike; } -/** `GET /api/preflight` — interrogates OAP's config-dump and returns - * per-module enablement. Authenticated but ungated by verb — every - * logged-in user can see whether OAP is correctly set up. */ +/** `GET /api/preflight` — probes each admin feature's REST path and + * returns per-feature reachability (30s single-flight cache; pass + * `?refresh=1` to force a fresh round). Authenticated but ungated by + * verb — every logged-in user can see whether OAP is correctly set up. */ export function registerPreflightRoutes(app: FastifyInstance, deps: PreflightRouteDeps): void { const auth = requireAuth(deps); app.get( '/api/preflight', { preHandler: auth }, - async (_req: FastifyRequest, reply: FastifyReply) => { + async (req: FastifyRequest, reply: FastifyReply) => { const fetchImpl = deps.fetch ?? globalThis.fetch.bind(globalThis); - const result = await runPreflight(deps.config.current, fetchImpl); + const force = (req.query as { refresh?: string }).refresh === '1'; + const result = await getPreflight(deps.config.current, fetchImpl, { force }); return reply.send(result); }, ); diff --git a/apps/bff/src/logic/preflight/preflight.test.ts b/apps/bff/src/logic/preflight/preflight.test.ts new file mode 100644 index 0000000..53adff4 --- /dev/null +++ b/apps/bff/src/logic/preflight/preflight.test.ts @@ -0,0 +1,145 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import type { FetchLike } from '@skywalking-horizon-ui/api-client'; +import type { HorizonConfig } from '../../config/schema.js'; +import { runPreflight, getPreflight, invalidatePreflightCache } from './preflight.js'; + +const DUMP = '/debugging/config/dump'; +// The config dump advertises every module prefix (the "selector detected" +// footnote) — so reachability, not config-presence, is what the test drives. +const DUMP_BODY: Record<string, string> = { + 'admin-server.provider': 'default', + 'receiver-runtime-rule.provider': 'default', + 'dsl-debugging.provider': 'default', + 'inspect.provider': 'default', + 'ui-management.provider': 'default', +}; + +function res(status: number, body: unknown = {}): Response { + return { + ok: status >= 200 && status < 300, + status, + text: async () => (typeof body === 'string' ? body : JSON.stringify(body)), + json: async () => body, + } as unknown as Response; +} + +/** A fetch that answers the dump 200 and each probe path by a status map; + * a path mapped to `'throw'` simulates a network error. Records every URL. */ +function fetchWith(statusByPath: Record<string, number | 'throw'>, calls: string[] = []): FetchLike { + return ((url: string) => { + calls.push(url); + if (url.endsWith(DUMP)) return Promise.resolve(res(200, DUMP_BODY)); + for (const [path, st] of Object.entries(statusByPath)) { + if (url.endsWith(path)) { + if (st === 'throw') return Promise.reject(new Error('ECONNREFUSED')); + return Promise.resolve(res(st)); + } + } + return Promise.resolve(res(404)); + }) as unknown as FetchLike; +} + +function cfg(mode: 'live' | 'readonly' = 'live'): HorizonConfig { + return { + oap: { adminUrl: 'http://oap:17128', timeoutMs: 0, queryUrl: '', zipkinUrl: '' }, + templates: { mode }, + } as unknown as HorizonConfig; +} + +const ALL_OK = { + '/ui-management/templates': 200, + '/runtime/rule/list': 200, + '/dsl-debugging/status': 200, + '/inspect/metrics': 200, +} as const; + +const byName = (r: Awaited<ReturnType<typeof runPreflight>>, name: string) => + r.modules.find((m) => m.name === name)!; + +describe('runPreflight — reachability via path probes', () => { + beforeEach(() => invalidatePreflightCache()); + + it('probes every feature path; reachable comes from the real GET', async () => { + const r = await runPreflight(cfg('live'), fetchWith(ALL_OK)); + expect(r.adminReachable).toBe(true); + expect(r.templatesMode).toBe('live'); + // admin-server's probe IS the dump fetch — reachable without a 2nd call. + expect(byName(r, 'admin-server').reachable).toBe(true); + for (const n of ['receiver-runtime-rule', 'dsl-debugging', 'inspect', 'ui-management']) { + expect(byName(r, n).reachable).toBe(true); + } + // config-presence is a footnote, present here for every module. + expect(r.modules.every((m) => m.enabled)).toBe(true); + }); + + it('a 404 / 5xx / network error on a path reads as unreachable', async () => { + const r = await runPreflight( + cfg('live'), + fetchWith({ ...ALL_OK, '/inspect/metrics': 404, '/runtime/rule/list': 500, '/dsl-debugging/status': 'throw' }), + ); + expect(byName(r, 'inspect').reachable).toBe(false); // 404 = route not registered + expect(byName(r, 'receiver-runtime-rule').reachable).toBe(false); // 5xx + expect(byName(r, 'dsl-debugging').reachable).toBe(false); // network error + expect(byName(r, 'ui-management').reachable).toBe(true); + // enabled stays true (the dump still advertises them) — proving reachable, + // not config-presence, drives health. + expect(byName(r, 'inspect').enabled).toBe(true); + }); + + it('a 4xx that is not 404 still counts as reachable (route exists)', async () => { + const r = await runPreflight(cfg('live'), fetchWith({ ...ALL_OK, '/runtime/rule/list': 400 })); + expect(byName(r, 'receiver-runtime-rule').reachable).toBe(true); + }); + + it('readonly mode does not probe ui_template — reachable is null', async () => { + const calls: string[] = []; + const r = await runPreflight(cfg('readonly'), fetchWith(ALL_OK, calls)); + expect(byName(r, 'ui-management').reachable).toBeNull(); + expect(calls.some((u) => u.endsWith('/ui-management/templates'))).toBe(false); + expect(byName(r, 'inspect').reachable).toBe(true); // others still probed + }); + + it('admin port down — every feature unreachable, no path probes', async () => { + const calls: string[] = []; + const downFetch = ((url: string) => { + calls.push(url); + return Promise.reject(new Error('ECONNREFUSED')); + }) as unknown as FetchLike; + const r = await runPreflight(cfg('live'), downFetch); + expect(r.adminReachable).toBe(false); + expect(r.modules.every((m) => m.reachable === false)).toBe(true); + expect(calls.every((u) => u.endsWith(DUMP))).toBe(true); // only the dump was tried + }); +}); + +describe('getPreflight — single-flight cache', () => { + beforeEach(() => invalidatePreflightCache()); + + it('serves a cached round to concurrent / repeat reads, force bypasses it', async () => { + const calls: string[] = []; + const f = fetchWith(ALL_OK, calls); + await getPreflight(cfg('live'), f); + const dumpCalls1 = calls.filter((u) => u.endsWith(DUMP)).length; + await getPreflight(cfg('live'), f); // cached → no new probes + expect(calls.filter((u) => u.endsWith(DUMP)).length).toBe(dumpCalls1); + await getPreflight(cfg('live'), f, { force: true }); // bypass → fresh round + expect(calls.filter((u) => u.endsWith(DUMP)).length).toBe(dumpCalls1 + 1); + }); +}); diff --git a/apps/bff/src/logic/preflight/preflight.ts b/apps/bff/src/logic/preflight/preflight.ts index 6026a81..5c551be 100644 --- a/apps/bff/src/logic/preflight/preflight.ts +++ b/apps/bff/src/logic/preflight/preflight.ts @@ -16,19 +16,25 @@ */ /** - * Preflight check — interrogates `/debugging/config/dump` on OAP and - * reports which of Studio's required OAP modules are enabled. The - * SPA shows a one-time modal at login when any required selector is - * missing, listing the env var and what UI breaks without it. + * Preflight — a REACHABILITY check of every OAP admin feature Horizon + * depends on. Each feature declares the relative admin REST path it + * actually calls; the BFF fires a safe GET at that path and reports + * whether it responds. * - * The dump returns a `Map<String,String>` of dotted keys - * `<module>.<provider>.<property>`. A module is enabled iff at least - * one key with its prefix appears in the dump. + * Health = `reachable`, NOT `enabled`. The config-dump prefix check + * (`enabled`) only tells us the *official upstream release* advertises + * that selector — a fork / rename / different layout can be perfectly + * reachable yet show "disabled", or advertise a selector that 404s. So + * `enabled` is demoted to an informational footnote and the real GET + * decides green/red. A 404 = the route isn't there = unreachable. * - * If admin-server itself is unreachable we return early with - * `adminReachable: false` and every module marked `enabled: false`; - * the operator's first move is "check OAP / admin port" rather than - * "set selectors". + * Cost is bounded by a single-flight cache (`getPreflight`): concurrent + * / multi-user `/api/preflight` reads share one round of probes. It + * stays on-demand (only runs while a page is watching), not a timer. + * + * If the admin port itself is down (`/debugging/config/dump` fails) we + * return early with `adminReachable: false` and every feature + * `reachable: false` — fix the network / port first, not selectors. */ import type { @@ -44,6 +50,8 @@ interface ModuleDef { name: string; envVar: string; required: boolean; + /** Relative admin REST path the BFF GETs to test reachability. */ + probePath: string; affects: string; } @@ -52,13 +60,15 @@ const REQUIRED_MODULES: readonly ModuleDef[] = [ name: 'admin-server', envVar: 'SW_ADMIN_SERVER', required: true, + probePath: '/debugging/config/dump', affects: - 'Everything Studio does against the admin port. Without admin-server, the other three modules fail at boot with ModuleNotFoundException.', + 'Everything Studio does against the admin port. Without admin-server, the other modules fail at boot with ModuleNotFoundException.', }, { name: 'receiver-runtime-rule', envVar: 'SW_RECEIVER_RUNTIME_RULE', required: true, + probePath: '/runtime/rule/list', affects: "DSL Management (Catalog, OAL catalog), Editor save/load, Cluster status rule matrix, Live debugger rule picker, and the Inspect drawer's source attribution.", }, @@ -66,6 +76,7 @@ const REQUIRED_MODULES: readonly ModuleDef[] = [ name: 'dsl-debugging', envVar: 'SW_DSL_DEBUGGING', required: true, + probePath: '/dsl-debugging/status', affects: 'Live debugger across MAL / LAL / OAL (start / poll / stop) and the DSL-debugging health pane on Cluster status.', }, @@ -73,9 +84,18 @@ const REQUIRED_MODULES: readonly ModuleDef[] = [ name: 'inspect', envVar: 'SW_INSPECT', required: true, + probePath: '/inspect/metrics', affects: 'The Inspect page — every /api/inspect/* call returns 404 inspect_not_enabled and the page shows a banner instead of the board.', }, + { + name: 'ui-management', + envVar: 'SW_UI_MANAGEMENT', + required: true, + probePath: '/ui-management/templates', + affects: + 'Dashboard templates — the layer / overview / alert / 3D template store the config surface reads and writes. Unreachable in live mode blocks the config surface; in readonly mode Horizon serves bundled templates and never calls it.', + }, ]; export async function runPreflight( @@ -83,50 +103,143 @@ export async function runPreflight( fetch: FetchLike, ): Promise<PreflightResult> { const adminUrl = config.oap.adminUrl; + const templatesMode = config.templates.mode; const generatedAt = Date.now(); const dump = await fetchConfigDump(adminUrl, fetch, config.oap.timeoutMs, config.oap.auth); if (!dump.ok) { + // Admin port down — nothing downstream is reachable. Probing each + // path would just repeat the same failure N times. return { adminUrl, adminReachable: false, adminError: dump.error, + templatesMode, modules: REQUIRED_MODULES.map((m) => ({ name: m.name, envVar: m.envVar, required: m.required, - affects: m.affects, + probePath: m.probePath, + reachable: false, enabled: false, + affects: m.affects, })), dumpKeyCount: 0, generatedAt, }; } - const keys = Object.keys(dump.body); const enabledPrefixes = new Set<string>(); - for (const k of keys) { + for (const k of Object.keys(dump.body)) { const top = k.split('.', 1)[0]; if (top) enabledPrefixes.add(top); } - const modules: PreflightModule[] = REQUIRED_MODULES.map((m) => ({ - name: m.name, - envVar: m.envVar, - required: m.required, - affects: m.affects, - enabled: enabledPrefixes.has(m.name), - })); + const modules: PreflightModule[] = await Promise.all( + REQUIRED_MODULES.map(async (m): Promise<PreflightModule> => { + const base = { + name: m.name, + envVar: m.envVar, + required: m.required, + probePath: m.probePath, + enabled: enabledPrefixes.has(m.name), + affects: m.affects, + }; + // admin-server's probe IS the dump fetch we just did. + if (m.name === 'admin-server') return { ...base, reachable: true }; + // ui_template is not called in readonly mode — don't probe it. + if (m.name === 'ui-management' && templatesMode === 'readonly') { + return { ...base, reachable: null }; + } + const reachable = await probeReachable(adminUrl, m.probePath, fetch, config.oap.timeoutMs, config.oap.auth); + return { ...base, reachable }; + }), + ); return { adminUrl, adminReachable: true, + templatesMode, modules, - dumpKeyCount: keys.length, + dumpKeyCount: Object.keys(dump.body).length, generatedAt, }; } +/** + * Cached `runPreflight`. A single-flight 30s cache bounds OAP load when + * many sessions poll `/api/preflight` (the UI polls every 60s). Pass + * `force` for the cluster page's manual "re-check now" after the + * operator fixes the network / a selector. + */ +let cache: { at: number; result: PreflightResult } | null = null; +let inFlight: Promise<PreflightResult> | null = null; +const CACHE_TTL_MS = 30_000; + +export async function getPreflight( + config: HorizonConfig, + fetch: FetchLike, + opts: { force?: boolean } = {}, +): Promise<PreflightResult> { + const now = Date.now(); + if (!opts.force && cache && now - cache.at < CACHE_TTL_MS) return cache.result; + if (!opts.force && inFlight) return inFlight; + inFlight = runPreflight(config, fetch) + .then((result) => { + cache = { at: Date.now(), result }; + inFlight = null; + return result; + }) + .catch((err) => { + inFlight = null; + throw err; + }); + return inFlight; +} + +/** Test seam — drop the cached probe round. */ +export function invalidatePreflightCache(): void { + cache = null; + inFlight = null; +} + +/** + * Reachability of one admin REST path. Reachable = the GET got an HTTP + * response that isn't 404 or 5xx; a 404 means the route isn't + * registered (selector off / module absent), a 5xx / network error + * means the feature can't serve. A 4xx like 400/401 still proves the + * route exists, so it counts as reachable. + */ +async function probeReachable( + adminUrl: string, + path: string, + fetch: FetchLike, + timeoutMs: number, + auth?: { username: string; password: string }, +): Promise<boolean> { + const url = `${adminUrl.replace(/\/$/, '')}${path}`; + const headers: Record<string, string> = { Accept: 'application/json' }; + if (auth) { + const b64 = Buffer.from(`${auth.username}:${auth.password}`, 'utf8').toString('base64'); + headers.authorization = `Basic ${b64}`; + } + let init: RequestInit = { method: 'GET', headers }; + let timer: ReturnType<typeof setTimeout> | null = null; + if (timeoutMs > 0) { + const ctrl = new AbortController(); + timer = setTimeout(() => ctrl.abort(), timeoutMs); + init = { ...init, signal: ctrl.signal }; + } + try { + const res = await fetch(url, init); + return res.status !== 404 && res.status < 500; + } catch { + return false; + } finally { + if (timer) clearTimeout(timer); + } +} + interface DumpOk { ok: true; body: Record<string, string>; diff --git a/apps/ui/src/api/scopes/menu.ts b/apps/ui/src/api/scopes/menu.ts index 9a332d3..b08eac9 100644 --- a/apps/ui/src/api/scopes/menu.ts +++ b/apps/ui/src/api/scopes/menu.ts @@ -29,7 +29,7 @@ export class MenuApi { oapInfo(): Promise<OapInfo> { return this.bff.request<OapInfo>('GET', '/api/oap/info'); } - preflight(): Promise<PreflightResult> { - return this.bff.request<PreflightResult>('GET', '/api/preflight'); + preflight(force = false): Promise<PreflightResult> { + return this.bff.request<PreflightResult>('GET', force ? '/api/preflight?refresh=1' : '/api/preflight'); } } diff --git a/apps/ui/src/features/operate/cluster/ClusterStatusView.vue b/apps/ui/src/features/operate/cluster/ClusterStatusView.vue index 7c8fb73..45ed9ba 100644 --- a/apps/ui/src/features/operate/cluster/ClusterStatusView.vue +++ b/apps/ui/src/features/operate/cluster/ClusterStatusView.vue @@ -15,11 +15,11 @@ limitations under the License. --> <script setup lang="ts"> -import { computed } from 'vue'; +import { computed, ref, onMounted, onUnmounted } from 'vue'; import { useI18n } from 'vue-i18n'; +import type { PreflightModule } from '@skywalking-horizon-ui/api-client'; import { useOapInfo } from '@/shell/useOapInfo'; import { useAdminFeatures } from '@/shell/useAdminFeatures'; -import { useConfigBundle } from '@/controls/configBundle'; // Two-pane Cluster Status: // - Pane A (graphql / :12800): version, server clock, timezone, @@ -44,23 +44,42 @@ const { refetch: refetchInfo, } = useOapInfo(); -// Dashboard-template source mode (the ui_template store vs the local bundle) -// rides on the config bundle the shell preloads. -const { bundle } = useConfigBundle(); -const templateMode = computed<'live' | 'readonly'>(() => bundle.value?.syncStatus?.mode ?? 'live'); -// ui_template API availability — N/A (null) in readonly (the store isn't used). -const uiTemplateAvailable = computed<boolean | null>(() => - templateMode.value === 'readonly' ? null : bundle.value?.syncStatus?.unreachable === false, -); - const { result: preflight, adminUrl, adminReachable, adminError, - refetch: refetchPreflight, + recheck: refetchPreflight, } = useAdminFeatures(); +// "Checked Ns ago" advances against a slow ticker, anchored to the BFF's +// generatedAt (so it reflects the real probe time incl. cache age, not the +// render moment). 5s granularity is plenty for a 30s-cached / 60s-polled check. +const now = ref(Date.now()); +let nowTimer: ReturnType<typeof setInterval> | null = null; +onMounted(() => { + nowTimer = setInterval(() => (now.value = Date.now()), 5_000); +}); +onUnmounted(() => { + if (nowTimer) clearInterval(nowTimer); +}); + +function agoLabel(ts: number | undefined): string { + if (!ts) return '—'; + const sec = Math.max(0, Math.round((now.value - ts) / 1000)); + if (sec < 60) return t('{n}s ago', { n: sec }); + return t('{n}m ago', { n: Math.round(sec / 60) }); +} + +// Health = reachability of the feature's probed REST path, NOT config-presence. +// reachable === null = not probed (ui_template in readonly: bundled, never called). +function featureState(m: PreflightModule): { cls: string; label: string } { + if (m.reachable === null) return { cls: 'is-warn', label: t('readonly · bundled') }; + return m.reachable + ? { cls: 'is-ok', label: t('reachable') } + : { cls: 'is-err', label: t('unreachable') }; +} + const serverClockLocal = computed<string>(() => { const ts = info.value?.currentTimestamp; if (!ts) return '—'; @@ -87,24 +106,20 @@ const healthLabel = computed<string>(() => { const adminBadgeState = computed<'ok' | 'warn' | 'err' | 'unknown'>(() => { if (!preflight.value) return 'unknown'; if (!adminReachable.value) return 'err'; - // Admin port replied but some required selectors are off. - if (preflight.value.modules.some((m) => m.required && !m.enabled)) return 'warn'; + // Admin port replied but a required feature's path doesn't respond. + if (preflight.value.modules.some((m) => m.required && m.reachable === false)) return 'warn'; return 'ok'; }); const adminBadgeLabel = computed<string>(() => { if (!preflight.value) return t('loading…'); if (!adminReachable.value) return t('unreachable'); - const off = preflight.value.modules.filter((m) => m.required && !m.enabled); - if (off.length === 0) return t('all selectors on'); - return t('{n} selectors off', { n: off.length }); + const down = preflight.value.modules.filter((m) => m.required && m.reachable === false); + if (down.length === 0) return t('all reachable'); + return t('{n} unreachable', { n: down.length }); }); -const adminGeneratedAt = computed<string>(() => { - const ts = preflight.value?.generatedAt; - if (!ts) return '—'; - return new Date(ts).toLocaleTimeString(); -}); +const adminGeneratedAt = computed<string>(() => agoLabel(preflight.value?.generatedAt)); // Zipkin / OTLP trace endpoint. Probed on the same poll as Pane A but // independently — it only feeds the Zipkin/OTLP trace menu, so a red @@ -199,7 +214,7 @@ function refreshAll(): void { </header> <p class="pane-lede"> - {{ t("Per-module enablement on the admin port. Each row gates a slice of horizon's UI — flip the corresponding env var on OAP and restart to enable, or remove the corresponding page from your operator menu if you don't need it.") }} + {{ t("Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is the live probe, not config-presence: a path that 404s (selector off, renamed, or absent in a fork) reads as unreachable. 'selector detected' below is only an upstream-release hint, not the verdict.") }} </p> <div v-if="!preflight" class="empty">{{ t('loading preflight…') }}</div> @@ -215,44 +230,33 @@ function refreshAll(): void { <table v-else class="mod-table"> <thead> <tr> - <th>{{ t('Module') }}</th> + <th>{{ t('Feature') }}</th> <th>{{ t('State') }}</th> + <th>{{ t('Probe path') }}</th> <th>{{ t('Env var') }}</th> <th>{{ t('Gates') }}</th> </tr> </thead> <tbody> - <tr v-for="m in preflight.modules" :key="m.name" :class="{ off: !m.enabled }"> + <tr v-for="m in preflight.modules" :key="m.name" :class="{ off: m.reachable === false }"> <td class="modname"><code>{{ m.name }}</code></td> <td> - <span class="sw-badge" :class="m.enabled ? 'is-ok' : 'is-err'"> - <span class="state-dot" />{{ m.enabled ? t('enabled') : t('missing') }} + <span class="sw-badge" :class="featureState(m).cls"> + <span class="state-dot" />{{ featureState(m).label }} </span> + <div class="state-foot"> + <span class="checked">{{ t('checked {at}', { at: agoLabel(preflight.generatedAt) }) }}</span> + <span class="sel" :class="{ 'sel-off': !m.enabled }">{{ + m.enabled ? t('selector detected') : t('selector not detected') + }}</span> + </div> </td> + <td class="modpath"><code>{{ m.probePath }}</code></td> <td class="modenv"><code>{{ m.envVar }}</code></td> <td class="modaffects">{{ t(m.affects) }}</td> </tr> </tbody> </table> - - <!-- Dashboard-template source: live (OAP ui_template) vs readonly (bundle). --> - <div class="tpl-source"> - <span class="tpl-label">{{ t('Dashboard templates') }}</span> - <span class="sw-badge" :class="templateMode === 'readonly' ? 'is-warn' : 'is-ok'"> - <span class="state-dot" />{{ templateMode === 'readonly' ? t('read-only · bundled') : t('live · OAP ui_template') }} - </span> - <span class="tpl-hint"> - <template v-if="templateMode === 'readonly'"> - {{ t('Rendering from the local bundle; the ui_template API is not used and editing is disabled.') }} - </template> - <template v-else-if="uiTemplateAvailable"> - {{ t('ui_template store reachable — editing enabled.') }} - </template> - <template v-else> - {{ t('ui_template store unreachable — editing disabled.') }} - </template> - </span> - </div> </section> <!-- ── Pane C · Zipkin / OTLP trace endpoint ─────────────────── --> @@ -470,25 +474,22 @@ function refreshAll(): void { border-radius: 6px; } -.tpl-source { +.state-foot { display: flex; - align-items: center; flex-wrap: wrap; - gap: 10px; - margin-top: 12px; - padding: 10px 12px; - background: var(--sw-bg-1); - border: 1px solid var(--sw-line); - border-radius: 8px; - font-size: var(--sw-fs-base); + gap: 4px 10px; + margin-top: 4px; + font-size: var(--sw-fs-xs); + color: var(--sw-fg-3); } -.tpl-label { - font-weight: var(--sw-fw-bold); - color: var(--sw-fg-1); +.state-foot .sel-off { + color: var(--sw-fg-4); + text-decoration: line-through; } -.tpl-hint { - color: var(--sw-fg-3); - font-size: var(--sw-fs-xs); +.modpath code { + font-family: var(--sw-mono); + font-size: var(--sw-fs-sm); + color: var(--sw-fg-2); } .mod-table { width: 100%; diff --git a/apps/ui/src/i18n/locales/de.json b/apps/ui/src/i18n/locales/de.json index f8d147b..3da9721 100644 --- a/apps/ui/src/i18n/locales/de.json +++ b/apps/ui/src/i18n/locales/de.json @@ -1539,5 +1539,13 @@ "live · OAP ui_template": "live · OAP ui_template", "Rendering from the local bundle; the ui_template API is not used and editing is disabled.": "Rendering aus dem lokalen Bundle; die ui_template-API wird nicht verwendet und die Bearbeitung ist deaktiviert.", "ui_template store reachable — editing enabled.": "ui_template-Speicher erreichbar — Bearbeitung aktiviert.", - "ui_template store unreachable — editing disabled.": "ui_template-Speicher nicht erreichbar — Bearbeitung deaktiviert." + "ui_template store unreachable — editing disabled.": "ui_template-Speicher nicht erreichbar — Bearbeitung deaktiviert.", + "Feature": "Funktion", + "Probe path": "Prüfpfad", + "readonly · bundled": "schreibgeschützt · gebündelt", + "selector detected": "Selektor erkannt", + "selector not detected": "Selektor nicht erkannt", + "all reachable": "alle erreichbar", + "{n} unreachable": "{n} nicht erreichbar", + "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is the live probe, not config-presence: a path that 404s (selector off, renamed, or absent in a fork) reads as unreachable. 'selector detected' below is only an upstream-release hint, not the verdict.": "Erreichbarkeit jeder Admin-Funktion — der BFF sendet ein GET an den relativen REST-Pfad, den die Funktion tatsächlich aufruft, und meldet, ob er [...] } diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json index 0a9ba6a..cdb5e69 100644 --- a/apps/ui/src/i18n/locales/en.json +++ b/apps/ui/src/i18n/locales/en.json @@ -1539,5 +1539,13 @@ "live · OAP ui_template": "live · OAP ui_template", "Rendering from the local bundle; the ui_template API is not used and editing is disabled.": "Rendering from the local bundle; the ui_template API is not used and editing is disabled.", "ui_template store reachable — editing enabled.": "ui_template store reachable — editing enabled.", - "ui_template store unreachable — editing disabled.": "ui_template store unreachable — editing disabled." + "ui_template store unreachable — editing disabled.": "ui_template store unreachable — editing disabled.", + "Feature": "Feature", + "Probe path": "Probe path", + "readonly · bundled": "readonly · bundled", + "selector detected": "selector detected", + "selector not detected": "selector not detected", + "all reachable": "all reachable", + "{n} unreachable": "{n} unreachable", + "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is the live probe, not config-presence: a path that 404s (selector off, renamed, or absent in a fork) reads as unreachable. 'selector detected' below is only an upstream-release hint, not the verdict.": "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is th [...] } diff --git a/apps/ui/src/i18n/locales/es.json b/apps/ui/src/i18n/locales/es.json index bff51fd..8ac4dfb 100644 --- a/apps/ui/src/i18n/locales/es.json +++ b/apps/ui/src/i18n/locales/es.json @@ -1539,5 +1539,13 @@ "live · OAP ui_template": "en vivo · OAP ui_template", "Rendering from the local bundle; the ui_template API is not used and editing is disabled.": "Renderizado desde el paquete local; la API ui_template no se usa y la edición está deshabilitada.", "ui_template store reachable — editing enabled.": "Almacén ui_template accesible — edición habilitada.", - "ui_template store unreachable — editing disabled.": "Almacén ui_template inaccesible — edición deshabilitada." + "ui_template store unreachable — editing disabled.": "Almacén ui_template inaccesible — edición deshabilitada.", + "Feature": "Función", + "Probe path": "Ruta de sondeo", + "readonly · bundled": "solo lectura · incluido", + "selector detected": "selector detectado", + "selector not detected": "selector no detectado", + "all reachable": "todos accesibles", + "{n} unreachable": "{n} inaccesible(s)", + "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is the live probe, not config-presence: a path that 404s (selector off, renamed, or absent in a fork) reads as unreachable. 'selector detected' below is only an upstream-release hint, not the verdict.": "Accesibilidad de cada función de administración: el BFF hace un GET a la ruta REST relativa que la función realmente llama e informa si responde. [...] } diff --git a/apps/ui/src/i18n/locales/fr.json b/apps/ui/src/i18n/locales/fr.json index 08363a3..690208d 100644 --- a/apps/ui/src/i18n/locales/fr.json +++ b/apps/ui/src/i18n/locales/fr.json @@ -1539,5 +1539,13 @@ "live · OAP ui_template": "en direct · OAP ui_template", "Rendering from the local bundle; the ui_template API is not used and editing is disabled.": "Rendu à partir du paquet local ; l’API ui_template n’est pas utilisée et l’édition est désactivée.", "ui_template store reachable — editing enabled.": "Magasin ui_template accessible — édition activée.", - "ui_template store unreachable — editing disabled.": "Magasin ui_template inaccessible — édition désactivée." + "ui_template store unreachable — editing disabled.": "Magasin ui_template inaccessible — édition désactivée.", + "Feature": "Fonctionnalité", + "Probe path": "Chemin de sonde", + "readonly · bundled": "lecture seule · intégré", + "selector detected": "sélecteur détecté", + "selector not detected": "sélecteur non détecté", + "all reachable": "tous accessibles", + "{n} unreachable": "{n} inaccessible(s)", + "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is the live probe, not config-presence: a path that 404s (selector off, renamed, or absent in a fork) reads as unreachable. 'selector detected' below is only an upstream-release hint, not the verdict.": "Accessibilité de chaque fonctionnalité d'administration — le BFF envoie un GET sur le chemin REST relatif que la fonctionnalité appelle réellemen [...] } diff --git a/apps/ui/src/i18n/locales/ja.json b/apps/ui/src/i18n/locales/ja.json index 6a51ac0..55ed2db 100644 --- a/apps/ui/src/i18n/locales/ja.json +++ b/apps/ui/src/i18n/locales/ja.json @@ -1539,5 +1539,13 @@ "live · OAP ui_template": "ライブ · OAP ui_template", "Rendering from the local bundle; the ui_template API is not used and editing is disabled.": "ローカルバンドルからレンダリングしています。ui_template API は使用されず、編集は無効です。", "ui_template store reachable — editing enabled.": "ui_template ストアに到達可能 —— 編集が有効です。", - "ui_template store unreachable — editing disabled.": "ui_template ストアに到達不可 —— 編集が無効です。" + "ui_template store unreachable — editing disabled.": "ui_template ストアに到達不可 —— 編集が無効です。", + "Feature": "機能", + "Probe path": "プローブパス", + "readonly · bundled": "読み取り専用 · バンドル", + "selector detected": "selector を検出", + "selector not detected": "selector を検出せず", + "all reachable": "すべて到達可能", + "{n} unreachable": "{n} 件が到達不可", + "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is the live probe, not config-presence: a path that 404s (selector off, renamed, or absent in a fork) reads as unreachable. 'selector detected' below is only an upstream-release hint, not the verdict.": "各管理機能の到達可能性 —— BFF は機能が実際に呼び出す相対 REST パスへ GET し、応答するかどうかを報告します。健全性は設定の有無ではなくライブプローブで判定されます。404 を返すパス(selector が無効、リネーム、またはフォークで欠落)は到達不可とみなされます。下の [...] } diff --git a/apps/ui/src/i18n/locales/ko.json b/apps/ui/src/i18n/locales/ko.json index 6bf74f0..3f6dd9a 100644 --- a/apps/ui/src/i18n/locales/ko.json +++ b/apps/ui/src/i18n/locales/ko.json @@ -1539,5 +1539,13 @@ "live · OAP ui_template": "라이브 · OAP ui_template", "Rendering from the local bundle; the ui_template API is not used and editing is disabled.": "로컬 번들에서 렌더링합니다. ui_template API는 사용되지 않으며 편집이 비활성화됩니다.", "ui_template store reachable — editing enabled.": "ui_template 저장소에 연결됨 — 편집이 활성화됩니다.", - "ui_template store unreachable — editing disabled.": "ui_template 저장소에 연결할 수 없음 — 편집이 비활성화됩니다." + "ui_template store unreachable — editing disabled.": "ui_template 저장소에 연결할 수 없음 — 편집이 비활성화됩니다.", + "Feature": "기능", + "Probe path": "프로브 경로", + "readonly · bundled": "읽기 전용 · 번들", + "selector detected": "selector 감지됨", + "selector not detected": "selector 감지 안 됨", + "all reachable": "모두 도달 가능", + "{n} unreachable": "{n}개 도달 불가", + "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is the live probe, not config-presence: a path that 404s (selector off, renamed, or absent in a fork) reads as unreachable. 'selector detected' below is only an upstream-release hint, not the verdict.": "각 관리 기능의 도달 가능성 — BFF가 해당 기능이 실제로 호출하는 상대 REST 경로로 GET 요청을 보내 응답 여부를 보고합니다. 상태는 설정 존재 여부가 아니라 실시간 프로브로 판단합니다. 404를 반환하는 경로(selector 꺼짐, 이름 변경 또는 [...] } diff --git a/apps/ui/src/i18n/locales/pt.json b/apps/ui/src/i18n/locales/pt.json index 5e8ab13..5dc1edb 100644 --- a/apps/ui/src/i18n/locales/pt.json +++ b/apps/ui/src/i18n/locales/pt.json @@ -1539,5 +1539,13 @@ "live · OAP ui_template": "ao vivo · OAP ui_template", "Rendering from the local bundle; the ui_template API is not used and editing is disabled.": "Renderizando a partir do pacote local; a API ui_template não é usada e a edição está desabilitada.", "ui_template store reachable — editing enabled.": "Armazenamento ui_template acessível — edição habilitada.", - "ui_template store unreachable — editing disabled.": "Armazenamento ui_template inacessível — edição desabilitada." + "ui_template store unreachable — editing disabled.": "Armazenamento ui_template inacessível — edição desabilitada.", + "Feature": "Recurso", + "Probe path": "Caminho de sondagem", + "readonly · bundled": "somente leitura · incluído", + "selector detected": "selector detectado", + "selector not detected": "selector não detectado", + "all reachable": "todos acessíveis", + "{n} unreachable": "{n} inacessível(is)", + "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is the live probe, not config-presence: a path that 404s (selector off, renamed, or absent in a fork) reads as unreachable. 'selector detected' below is only an upstream-release hint, not the verdict.": "Acessibilidade de cada recurso de administração: o BFF faz um GET no caminho REST relativo que o recurso realmente chama e informa se ele respond [...] } diff --git a/apps/ui/src/i18n/locales/zh-CN.json b/apps/ui/src/i18n/locales/zh-CN.json index dba1e96..03e6f6d 100644 --- a/apps/ui/src/i18n/locales/zh-CN.json +++ b/apps/ui/src/i18n/locales/zh-CN.json @@ -1539,5 +1539,13 @@ "live · OAP ui_template": "在线 · OAP ui_template", "Rendering from the local bundle; the ui_template API is not used and editing is disabled.": "从本地内置模板渲染;不使用 ui_template API,编辑已禁用。", "ui_template store reachable — editing enabled.": "ui_template 存储可达 —— 编辑已启用。", - "ui_template store unreachable — editing disabled.": "ui_template 存储不可达 —— 编辑已禁用。" + "ui_template store unreachable — editing disabled.": "ui_template 存储不可达 —— 编辑已禁用。", + "Feature": "功能", + "Probe path": "探测路径", + "readonly · bundled": "只读 · 内置", + "selector detected": "检测到 selector", + "selector not detected": "未检测到 selector", + "all reachable": "全部可达", + "{n} unreachable": "{n} 个不可达", + "Reachability of each admin feature — the BFF GETs the relative REST path the feature actually calls and reports whether it responds. Health is the live probe, not config-presence: a path that 404s (selector off, renamed, or absent in a fork) reads as unreachable. 'selector detected' below is only an upstream-release hint, not the verdict.": "每个管理功能的可达性 —— BFF 会向该功能实际调用的相对 REST 路径发起 GET,并报告其是否响应。健康状态取决于实时探测,而非配置存在性:返回 404 的路径(selector 关闭、被重命名或在 fork 版本中缺失)将被视为不可达。下方的“检测到 selector”仅为上游发 [...] } diff --git a/apps/ui/src/shell/AdminFeatureWarning.vue b/apps/ui/src/shell/AdminFeatureWarning.vue index 628b069..8825b54 100644 --- a/apps/ui/src/shell/AdminFeatureWarning.vue +++ b/apps/ui/src/shell/AdminFeatureWarning.vue @@ -53,15 +53,17 @@ const { result, adminReachable, adminError, adminUrl, moduleByName, refetch } = useAdminFeatures(); const mod = moduleByName(props.module); -const moduleEnabled = computed<boolean>(() => mod.value?.enabled ?? false); +// Health is the feature's path probe, not config-presence. `reachable` +// is a boolean for these three (null is only ui_template/readonly). +const moduleReachable = computed<boolean>(() => mod.value?.reachable !== false); -/** Hide when everything is fine — admin reachable AND target module - * on. Loading state also hides (page renders normally; banner pops - * in if/when preflight returns a failure). */ +/** Hide when everything is fine — admin reachable AND this feature's + * path responds. Loading state also hides (page renders normally; + * banner pops in if/when the probe comes back unreachable). */ const visible = computed<boolean>(() => { if (!result.value) return false; if (!adminReachable.value) return true; - return !moduleEnabled.value; + return !moduleReachable.value; }); const kind = computed<'host' | 'module'>(() => diff --git a/apps/ui/src/shell/useAdminFeatures.ts b/apps/ui/src/shell/useAdminFeatures.ts index a3cefa2..28c10fd 100644 --- a/apps/ui/src/shell/useAdminFeatures.ts +++ b/apps/ui/src/shell/useAdminFeatures.ts @@ -16,14 +16,15 @@ */ import { computed } from 'vue'; -import { useQuery } from '@tanstack/vue-query'; +import { useQuery, useQueryClient } from '@tanstack/vue-query'; import type { PreflightResult } from '@skywalking-horizon-ui/api-client'; import { bffClient } from '@/api/client'; /** - * Admin-port preflight — interrogates OAP's `/debugging/config/dump` - * (port 17128 by default) and exposes per-module enablement plus a - * shorthand `adminReachable` flag. Drives: + * Admin-port preflight — a REACHABILITY check of each OAP admin feature + * Horizon depends on (the BFF GETs the feature's real REST path). + * Exposes per-feature reachability plus a shorthand `adminReachable`. + * Drives: * - the admin section of `/operate/cluster` * - the per-page warning header on admin-host routes (DSL Mgmt, * Live Debugger, Dump, OAL viewer, Inspect) @@ -32,10 +33,13 @@ import { bffClient } from '@/api/client'; * keeping the two checks split lets the cluster page show "graphql * fine, admin down" without false negatives on either side. * - * Polled every 60s; the cluster page can call `refetch()` to poke - * it on demand after the operator fixes the network / selectors. + * Polled every 60s (the BFF single-flights the underlying probes for + * 30s). `recheck()` forces a fresh round on the BFF — for the cluster + * page's "re-check now" after the operator fixes the network / a + * selector — and seeds it into the shared cache. */ export function useAdminFeatures() { + const qc = useQueryClient(); const q = useQuery({ queryKey: ['oap-preflight'], queryFn: () => bffClient.menu.preflight(), @@ -48,22 +52,13 @@ export function useAdminFeatures() { const adminReachable = computed<boolean>(() => result.value?.adminReachable ?? false); const adminUrl = computed<string | undefined>(() => result.value?.adminUrl); const adminError = computed<string | undefined>(() => result.value?.adminError); + const templatesMode = computed<'live' | 'readonly'>(() => result.value?.templatesMode ?? 'live'); /** Look up a single module by OAP name (e.g. `receiver-runtime-rule`). */ function moduleByName(name: string) { return computed(() => result.value?.modules.find((m) => m.name === name)); } - const runtimeRuleEnabled = computed<boolean>( - () => moduleByName('receiver-runtime-rule').value?.enabled ?? false, - ); - const dslDebuggingEnabled = computed<boolean>( - () => moduleByName('dsl-debugging').value?.enabled ?? false, - ); - const inspectEnabled = computed<boolean>( - () => moduleByName('inspect').value?.enabled ?? false, - ); - /** Convenience for the per-page warning header — true when nothing * on the admin port works (port itself down OR admin-server module * off). Page UIs render the warning + still try to mount so the @@ -72,6 +67,14 @@ export function useAdminFeatures() { () => !result.value || !adminReachable.value, ); + /** Force a fresh probe round on the BFF (bypasses its 30s cache) and + * push the result into the shared query cache. */ + async function recheck(): Promise<PreflightResult> { + const fresh = await bffClient.menu.preflight(true); + qc.setQueryData(['oap-preflight'], fresh); + return fresh; + } + return { isLoading: q.isLoading, result, @@ -79,10 +82,9 @@ export function useAdminFeatures() { adminReachable, adminUnavailable, adminError, - runtimeRuleEnabled, - dslDebuggingEnabled, - inspectEnabled, + templatesMode, moduleByName, refetch: q.refetch, + recheck, }; } diff --git a/packages/api-client/src/preflight.ts b/packages/api-client/src/preflight.ts index c4f61f7..9c471ad 100644 --- a/packages/api-client/src/preflight.ts +++ b/packages/api-client/src/preflight.ts @@ -16,17 +16,24 @@ */ /** - * Wire shape for `GET /api/preflight` — interrogates OAP admin's - * `/debugging/config/dump` and reports which OAP modules / SWIP-13 - * selectors are enabled. Drives the admin-port section of the - * Cluster Status page and the per-page warning headers on admin-host - * routes (DSL Management, Live Debugger, Dump, OAL viewer, Inspect). + * Wire shape for `GET /api/preflight` — a REACHABILITY check of each + * OAP admin feature Horizon depends on. Drives the admin-port section + * of the Cluster Status page and the per-page warning headers on + * admin-host routes (DSL Management, Live Debugger, Dump, OAL viewer, + * Inspect, dashboard templates). + * + * Health = `reachable`, NOT `enabled`. Each feature declares the + * relative admin REST path it actually calls; the BFF fires a safe GET + * at that path and reports whether the feature responds. A 404 (module + * selector off, renamed, or absent in a fork) reads as unreachable — + * an honest, release-agnostic signal. `enabled` (the config-dump + * prefix) is kept only as an informational "the upstream release + * advertises this selector" footnote, never as the health verdict. * * `adminReachable === false` means the admin host itself is down - * (network, port not exposed, OAP not started); every module is - * reported `enabled: false` regardless of selector config. The - * operator's first move in that case is to fix the network / port - * exposure, not to flip selectors on. + * (network, port not exposed, OAP not started); every feature is then + * reported `reachable: false`. The operator's first move is to fix the + * network / port exposure, not to flip selectors on. */ export interface PreflightModule { @@ -34,20 +41,36 @@ export interface PreflightModule { name: string; /** The env var that toggles this module's selector on OAP. */ envVar: string; - /** True when horizon depends on this module being on. */ + /** True when horizon depends on this module being reachable. */ required: boolean; - /** True iff the dump carries at least one key with this module's prefix. */ + /** The relative admin REST path the BFF GETs to test reachability. */ + probePath: string; + /** + * THE health signal: true iff the safe GET on `probePath` responded + * (any status except 404 / 5xx / network error). `null` = not probed + * (e.g. ui_template in readonly mode — Horizon never calls it). + */ + reachable: boolean | null; + /** + * Informational footnote only: the upstream config dump carries a key + * under this module's prefix. Release-specific; NOT a health verdict. + */ enabled: boolean; - /** What part of horizon breaks when this module is off. */ + /** What part of horizon breaks when this feature is unreachable. */ affects: string; } export interface PreflightResult { adminUrl: string; - /** True iff `/debugging/config/dump` responded 2xx. */ + /** True iff `/debugging/config/dump` responded 2xx (admin port up). */ adminReachable: boolean; /** Short reason when `adminReachable` is false. */ adminError?: string; + /** + * Active template mode. In `readonly` the ui_template feature is not + * probed (Horizon serves bundled templates) and its row reads "n/a". + */ + templatesMode: 'live' | 'readonly'; modules: PreflightModule[]; /** Total keys in the dump. Diagnostic only. */ dumpKeyCount: number;
