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;

Reply via email to