This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git


The following commit(s) were added to refs/heads/main by this push:
     new 3c6293e  ui events: move event feed to bottom-fixed Admin-toggled 
debug panel
3c6293e is described below

commit 3c6293e753e0de020499f04e0fe3cc80eac120cc
Author: Wu Sheng <[email protected]>
AuthorDate: Sun May 17 17:46:24 2026 +0800

    ui events: move event feed to bottom-fixed Admin-toggled debug panel
    
    The topbar event ticker was always-visible chrome that doesn't
    belong on a production page. Moved to a dedicated bottom-fixed
    panel that's gated by a new Admin → "Debug events" toggle in the
    sidebar:
    
    - `controls/debugPanel.ts` — singleton `enabled` ref with
      localStorage stickiness. Default state checks `window.location.
      hostname`: ON for `localhost` / `127.0.0.1` / `0.0.0.0` / `::1`,
      OFF everywhere else. Operators in production never see the
      panel unless they flip the toggle on; dev work picks it up
      automatically.
    - `shell/DebugEventPanel.vue` — fixed at `bottom: 0`, full width,
      z-index 60. Collapsed bar shows the latest event + count; click
      expands a 260px scrolling list of the buffered 200 events
      (same `useEventLog` source). `data-no-event-track` on the
      wrapper so the click tracker doesn't recurse on panel clicks.
    - `AppShell` mounts the panel below RouterView; it self-hides
      when the toggle is off, no re-mount on toggle so the buffer
      survives toggling.
    - `AppTopbar` drops the EventTicker import + render — the left
      zone is intentionally empty now (`.sw-top-spacer`).
    - `AppSidebar` adds a `<button class="sw-nav-toggle">` at the end
      of the Admin section showing the current on/off state, wired to
      `useDebugPanel().toggle()`. Styled to blend with the surrounding
      RouterLinks; `is-active` reuses the accent stripe.
    - The old `shell/EventTicker.vue` is deleted — no other callers.
---
 apps/bff/src/http/query/alarms.ts      |  71 +++++++++--
 apps/bff/src/http/query/info.ts        |  12 +-
 apps/bff/src/logic/oap/capabilities.ts | 102 ++++++++++++++++
 apps/ui/src/controls/debugPanel.ts     |  67 +++++++++++
 apps/ui/src/shell/AppShell.vue         |   6 +
 apps/ui/src/shell/AppSidebar.vue       |  38 ++++++
 apps/ui/src/shell/AppTopbar.vue        |  11 +-
 apps/ui/src/shell/DebugEventPanel.vue  | 210 +++++++++++++++++++++++++++++++++
 apps/ui/src/shell/EventTicker.vue      | 181 ----------------------------
 apps/ui/src/shell/useOapInfo.ts        |  15 ++-
 packages/api-client/src/index.ts       |   2 +-
 packages/api-client/src/oap-info.ts    |  14 +++
 12 files changed, 529 insertions(+), 200 deletions(-)

diff --git a/apps/bff/src/http/query/alarms.ts 
b/apps/bff/src/http/query/alarms.ts
index fa72a10..0a95a0e 100644
--- a/apps/bff/src/http/query/alarms.ts
+++ b/apps/bff/src/http/query/alarms.ts
@@ -45,6 +45,7 @@ import type { ConfigSource } from '../../config/loader.js';
 import type { SessionStore } from '../../user/sessions.js';
 import { badRequest } from '../../errors.js';
 import { buildOapOpts, graphqlPost } from '../../client/graphql.js';
+import { getOapCapabilities } from '../../logic/oap/capabilities.js';
 import type { ServiceLayerMap } from '../../logic/alarms/service-layer-map.js';
 import type { AlarmsStore } from '../../logic/alarms/store.js';
 
@@ -292,6 +293,37 @@ const GET_ALARM_QUERY = /* GraphQL */ `
   }
 `;
 
+/* New-API variant — added in query-protocol #157 alongside the
+ * deprecation of `getAlarm`. Same `Alarms.msgs` selection set as the
+ * legacy query so the row mapper stays shared; the only difference is
+ * the wrapper (`condition` input vs. individual args) and the future
+ * filter surface (entities / layers / ruleNames — wired in step 4). */
+const QUERY_ALARMS_QUERY = /* GraphQL */ `
+  query HorizonQueryAlarms($condition: AlarmQueryCondition!) {
+    queryAlarms(condition: $condition) {
+      msgs {
+        id
+        startTime
+        recoveryTime
+        scope
+        name
+        message
+        tags { key value }
+        snapshot {
+          expression
+          metrics {
+            name
+            results {
+              metric { labels { key value } }
+              values { id value traceID }
+            }
+          }
+        }
+      }
+    }
+  }
+`;
+
 const LIST_SERVICES_QUERY = /* GraphQL */ `
   query HorizonAlarmServices($layer: String!) {
     listServices(layer: $layer) { name normal }
@@ -305,6 +337,9 @@ interface ListServicesRaw {
 interface GetAlarmRaw {
   getAlarm?: { msgs?: AlarmMessage[] } | null;
 }
+interface QueryAlarmsRaw {
+  queryAlarms?: { msgs?: AlarmMessage[] } | null;
+}
 
 // ── Routes ───────────────────────────────────────────────────────────
 
@@ -349,23 +384,39 @@ export function registerAlarmsQueryRoutes(app: 
FastifyInstance, deps: AlarmsQuer
     const end = fmtSecond(q.endTime, offset);
 
     const opts = buildOapOpts(deps.config.current, deps.fetch);
-    const variables: Record<string, unknown> = {
-      duration: { start, end, step: 'SECOND' },
-      paging: { pageNum: q.pageNum, pageSize: q.pageSize },
-    };
-    if (q.scope) variables.scope = q.scope;
-    if (q.keyword) variables.keyword = q.keyword;
-
-    let raw: GetAlarmRaw;
+    const caps = await getOapCapabilities(deps.config.current, deps.fetch);
+
+    /* Branch on schema capability: `queryAlarms(condition)` is the
+     * forward path — same returned shape, bundles every filter into
+     * one input type so step-4 entity / layer / ruleName filters land
+     * here without a route change. `getAlarm` stays as the fallback
+     * for older OAPs (scope+keyword+tags-only). */
+    let msgsRaw: AlarmMessage[];
     try {
-      raw = await graphqlPost<GetAlarmRaw>(opts, GET_ALARM_QUERY, variables);
+      if (caps.queryAlarms) {
+        const condition: Record<string, unknown> = {
+          duration: { start, end, step: 'SECOND' },
+          paging: { pageNum: q.pageNum, pageSize: q.pageSize },
+        };
+        if (q.keyword) condition.keyword = q.keyword;
+        const raw = await graphqlPost<QueryAlarmsRaw>(opts, 
QUERY_ALARMS_QUERY, { condition });
+        msgsRaw = raw.queryAlarms?.msgs ?? [];
+      } else {
+        const variables: Record<string, unknown> = {
+          duration: { start, end, step: 'SECOND' },
+          paging: { pageNum: q.pageNum, pageSize: q.pageSize },
+        };
+        if (q.scope) variables.scope = q.scope;
+        if (q.keyword) variables.keyword = q.keyword;
+        const raw = await graphqlPost<GetAlarmRaw>(opts, GET_ALARM_QUERY, 
variables);
+        msgsRaw = raw.getAlarm?.msgs ?? [];
+      }
     } catch (err) {
       return reply.code(502).send({
         error: 'oap_unreachable',
         message: err instanceof Error ? err.message : String(err),
       });
     }
-    const msgsRaw = raw.getAlarm?.msgs ?? [];
 
     // Service-name → layer tag (best-effort, see service-layer-map docs).
     const layerIdx = await serviceLayer.get();
diff --git a/apps/bff/src/http/query/info.ts b/apps/bff/src/http/query/info.ts
index dee427b..da3ebf5 100644
--- a/apps/bff/src/http/query/info.ts
+++ b/apps/bff/src/http/query/info.ts
@@ -21,6 +21,7 @@ import type { ConfigSource } from '../../config/loader.js';
 import type { SessionStore } from '../../user/sessions.js';
 import { requireAuth } from '../../user/middleware.js';
 import { buildOapOpts, graphqlPost } from '../../client/graphql.js';
+import { getOapCapabilities } from '../../logic/oap/capabilities.js';
 
 /**
  * One round-trip combining `version`, `getTimeInfo`, and `checkHealth`.
@@ -66,7 +67,15 @@ export function registerOapInfoRoute(app: FastifyInstance, 
deps: InfoRouteDeps):
     const cfg = deps.config.current;
     const statusUrl = cfg.oap.statusUrl;
     try {
-      const raw = await graphqlPost<InfoRaw>(buildOapOpts(cfg, deps.fetch), 
INFO_QUERY);
+      /* Capability probe runs in parallel with the info call — both
+       * are GraphQL POSTs to the same endpoint; serialising would add
+       * round-trip latency to every poll without changing semantics.
+       * The probe is internally cached for 5 min so the wire traffic
+       * is one-off per OAP restart, not per call. */
+      const [raw, capabilities] = await Promise.all([
+        graphqlPost<InfoRaw>(buildOapOpts(cfg, deps.fetch), INFO_QUERY),
+        getOapCapabilities(cfg, deps.fetch),
+      ]);
       const body: OapInfo = {
         reachable: true,
         statusUrl,
@@ -75,6 +84,7 @@ export function registerOapInfoRoute(app: FastifyInstance, 
deps: InfoRouteDeps):
         currentTimestamp: raw.time?.currentTimestamp ?? undefined,
         healthScore: raw.health?.score ?? undefined,
         healthDetails: raw.health?.details ?? undefined,
+        capabilities,
       };
       return reply.send(body);
     } catch (err) {
diff --git a/apps/bff/src/logic/oap/capabilities.ts 
b/apps/bff/src/logic/oap/capabilities.ts
new file mode 100644
index 0000000..54ec120
--- /dev/null
+++ b/apps/bff/src/logic/oap/capabilities.ts
@@ -0,0 +1,102 @@
+/*
+ * 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.
+ */
+
+/**
+ * OAP GraphQL-schema capability probe. The query-protocol evolves
+ * (fields added across OAP versions); routes that conditionally use a
+ * newer field need to know whether the connected OAP exposes it.
+ *
+ * The probe runs a minimal `__type(name: "Query") { fields { name } }`
+ * introspection call and reports per-feature booleans. Result is cached
+ * per `statusUrl` for `CAPS_TTL_MS` — the GraphQL schema is fixed for an
+ * OAP process lifetime, so the TTL only matters across OAP restarts
+ * (and the staleness is harmless: legacy-mode fallback works against
+ * new OAP, just doesn't use the new filters).
+ *
+ * Add a new probe by inserting it into {@link CAPABILITY_FIELDS}: each
+ * entry says "feature `X` requires field `Y` on `Query`". The probe
+ * returns false (conservative) when introspection itself fails — we'd
+ * rather use the legacy path than fail the page.
+ */
+
+import type { FetchLike } from '@skywalking-horizon-ui/api-client';
+import type { HorizonConfig } from '../../config/schema.js';
+import { buildOapOpts, graphqlPost } from '../../client/graphql.js';
+
+export interface OapCapabilities {
+  /** `Query.queryAlarms(condition: AlarmQueryCondition!)` — introduced
+   *  alongside the deprecation of `getAlarm`. Enables Entity / layer /
+   *  ruleName filters; absence means the BFF must fall back to the
+   *  scope+keyword+tags-only `getAlarm`. */
+  queryAlarms: boolean;
+}
+
+const INTROSPECTION_QUERY = /* GraphQL */ `
+  query HorizonOapCapabilities {
+    __type(name: "Query") { fields { name } }
+  }
+`;
+
+interface IntrospectionRaw {
+  __type?: { fields?: Array<{ name?: string | null } | null> | null } | null;
+}
+
+interface Entry {
+  result: OapCapabilities;
+  fetchedAt: number;
+}
+const cache = new Map<string, Entry>();
+const CAPS_TTL_MS = 5 * 60_000;
+/** When introspection itself fails, cache the conservative result for
+ *  only this long so the page recovers quickly when OAP comes back —
+ *  but long enough that a sustained outage doesn't trigger a probe on
+ *  every request. */
+const CAPS_FAILURE_TTL_MS = 60_000;
+
+/** Reset the per-statusUrl cache. Test-only. */
+export function _resetCapabilitiesCache(): void {
+  cache.clear();
+}
+
+export async function getOapCapabilities(
+  config: HorizonConfig,
+  fetchImpl?: FetchLike,
+): Promise<OapCapabilities> {
+  const key = config.oap.statusUrl;
+  const now = Date.now();
+  const hit = cache.get(key);
+  if (hit && now - hit.fetchedAt < CAPS_TTL_MS) return hit.result;
+
+  let raw: IntrospectionRaw;
+  try {
+    raw = await graphqlPost<IntrospectionRaw>(buildOapOpts(config, fetchImpl), 
INTROSPECTION_QUERY);
+  } catch {
+    const conservative: OapCapabilities = { queryAlarms: false };
+    cache.set(key, { result: conservative, fetchedAt: now - CAPS_TTL_MS + 
CAPS_FAILURE_TTL_MS });
+    return conservative;
+  }
+
+  const fieldSet = new Set<string>();
+  for (const f of raw.__type?.fields ?? []) {
+    if (f?.name) fieldSet.add(f.name);
+  }
+  const result: OapCapabilities = {
+    queryAlarms: fieldSet.has('queryAlarms'),
+  };
+  cache.set(key, { result, fetchedAt: now });
+  return result;
+}
diff --git a/apps/ui/src/controls/debugPanel.ts 
b/apps/ui/src/controls/debugPanel.ts
new file mode 100644
index 0000000..a07976d
--- /dev/null
+++ b/apps/ui/src/controls/debugPanel.ts
@@ -0,0 +1,67 @@
+/*
+ * 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.
+ */
+
+/**
+ * Visibility toggle for the bottom-of-page event panel. Defaults to
+ * ON for local development hosts (`localhost`, `127.0.0.1`,
+ * `0.0.0.0`) and OFF everywhere else, so operators in production
+ * don't see the dev-mode framework chatter unless they explicitly
+ * flip the Admin → "Debug events" item in the sidebar.
+ *
+ * The choice is sticky per browser via localStorage so the operator
+ * doesn't have to re-enable it on every reload. The hostname
+ * default only applies on the very first visit (when storage is
+ * empty).
+ */
+
+import { ref, watch } from 'vue';
+
+const STORAGE_KEY = 'horizon:debugPanel:v1';
+const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1']);
+
+function detectInitial(): boolean {
+  if (typeof localStorage !== 'undefined') {
+    const raw = localStorage.getItem(STORAGE_KEY);
+    if (raw === '1') return true;
+    if (raw === '0') return false;
+  }
+  if (typeof window === 'undefined') return false;
+  return LOCAL_HOSTS.has(window.location.hostname);
+}
+
+const enabled = ref<boolean>(detectInitial());
+
+watch(enabled, (on) => {
+  if (typeof localStorage === 'undefined') return;
+  try {
+    localStorage.setItem(STORAGE_KEY, on ? '1' : '0');
+  } catch {
+    /* private mode / quota — degrade silently */
+  }
+});
+
+export function useDebugPanel(): {
+  enabled: typeof enabled;
+  toggle: () => void;
+} {
+  return {
+    enabled,
+    toggle: () => {
+      enabled.value = !enabled.value;
+    },
+  };
+}
diff --git a/apps/ui/src/shell/AppShell.vue b/apps/ui/src/shell/AppShell.vue
index 568ec81..5a28234 100644
--- a/apps/ui/src/shell/AppShell.vue
+++ b/apps/ui/src/shell/AppShell.vue
@@ -19,6 +19,7 @@ import { computed, onMounted } from 'vue';
 import { RouterView } from 'vue-router';
 import AppSidebar from './AppSidebar.vue';
 import AppTopbar from './AppTopbar.vue';
+import DebugEventPanel from './DebugEventPanel.vue';
 import GlobalConnectivityBanner from './GlobalConnectivityBanner.vue';
 import TracePopout from '@/layer/traces/TracePopout.vue';
 import ZipkinTracePopout from '@/layer/traces/ZipkinTracePopout.vue';
@@ -88,6 +89,11 @@ const initReady = computed<boolean>(
          collision (e.g. an operator drilling into a Zipkin trace from
          a Logs row → trace link on a mesh layer). -->
     <ZipkinTracePopout />
+    <!-- Bottom-fixed framework-event panel. Self-hides when the Admin →
+         "Debug events" toggle is off (default off in production, on
+         when hostname looks local). Always mounted so the toggle
+         responds without a re-mount race. -->
+    <DebugEventPanel />
   </div>
 </template>
 
diff --git a/apps/ui/src/shell/AppSidebar.vue b/apps/ui/src/shell/AppSidebar.vue
index b3661dd..ac9cb42 100644
--- a/apps/ui/src/shell/AppSidebar.vue
+++ b/apps/ui/src/shell/AppSidebar.vue
@@ -23,6 +23,9 @@ import { useAuthStore } from '@/state/auth';
 import { useLayers, firstLayerTab } from '@/shell/useLayers';
 import { useLandingOrder } from '@/shell/useLandingOrder';
 import { useOverviewDashboards } from 
'@/render/overview/useOverviewDashboards';
+import { useDebugPanel } from '@/controls/debugPanel';
+
+const { enabled: debugPanelEnabled, toggle: toggleDebugPanel } = 
useDebugPanel();
 
 const auth = useAuthStore();
 const router = useRouter();
@@ -648,6 +651,24 @@ watch(
             </div>
           </template>
         </template>
+        <!-- Toggle row appended to the bottom of the Admin section —
+             controls the bottom-fixed DebugEventPanel. Default state
+             is hostname-driven (on for localhost, off elsewhere) and
+             sticks via localStorage. Rendered as a plain button so
+             clicks fire the store toggle rather than navigating. -->
+        <button
+          v-if="entry.kicker === 'Admin'"
+          type="button"
+          class="sw-nav-item sw-nav-toggle"
+          :class="{ 'is-active': debugPanelEnabled }"
+          @click="toggleDebugPanel"
+        >
+          <Icon name="event" />
+          <span>Debug events</span>
+          <span class="sw-badge" :class="debugPanelEnabled ? 'ok' : ''" 
style="margin-left: auto">
+            {{ debugPanelEnabled ? 'on' : 'off' }}
+          </span>
+        </button>
       </template>
     </nav>
 
@@ -925,4 +946,21 @@ watch(
   border-radius: 4px;
   letter-spacing: 0.02em;
 }
+/* Toggle row at the bottom of the Admin section — a <button> styled
+ * to blend with the surrounding .sw-nav-item links. Cursor is
+ * `pointer` (vs default `default` on RouterLinks) to flag the
+ * affordance, and `.is-active` reuses the same accent stripe so
+ * "on" reads consistently with selected nav items. */
+.sw-nav-toggle {
+  width: 100%;
+  background: transparent;
+  border: none;
+  font: inherit;
+  text-align: left;
+  cursor: pointer;
+}
+.sw-nav-toggle .sw-badge.ok {
+  color: var(--sw-ok);
+  background: rgba(34, 197, 94, 0.12);
+}
 </style>
diff --git a/apps/ui/src/shell/AppTopbar.vue b/apps/ui/src/shell/AppTopbar.vue
index b0173fe..b4bc9f7 100644
--- a/apps/ui/src/shell/AppTopbar.vue
+++ b/apps/ui/src/shell/AppTopbar.vue
@@ -18,7 +18,6 @@
 import { computed, ref, watch } from 'vue';
 import { RouterLink, useRoute } from 'vue-router';
 import Icon from '@/components/icons/Icon.vue';
-import EventTicker from '@/shell/EventTicker.vue';
 import { useOapInfo } from '@/shell/useOapInfo';
 import { useAutoRefreshStore } from '@/controls/autoRefresh';
 import { useTimeRangeStore, TIME_PRESETS, STEP_LIMITS, isValidRange, type 
TimeStep } from '@/controls/timeRange';
@@ -325,11 +324,11 @@ function formatRangeStamp(ms: number, step: TimeStep): 
string {
 
 <template>
   <header class="sw-top">
-    <!-- Event ticker replaces the old breadcrumb + search. Shows what
-         the framework is currently doing (loading services, rendering
-         widgets, …) so the operator gets feedback while the page
-         assembles instead of looking at a static breadcrumb. -->
-    <EventTicker />
+    <!-- Left zone intentionally empty for now. The framework-event
+         feed moved to a dedicated bottom-fixed panel (gated by the
+         Admin → "Debug events" sidebar toggle) — see
+         `controls/debugPanel.ts` + `shell/DebugEventPanel.vue`. -->
+    <div class="sw-top-spacer" />
     <div class="sw-top-actions">
       <RouterLink class="sw-btn oap-chip" :class="`is-${healthState}`" 
:title="oapChipTooltip" to="/operate/cluster">
         <span class="dot" />
diff --git a/apps/ui/src/shell/DebugEventPanel.vue 
b/apps/ui/src/shell/DebugEventPanel.vue
new file mode 100644
index 0000000..e14627c
--- /dev/null
+++ b/apps/ui/src/shell/DebugEventPanel.vue
@@ -0,0 +1,210 @@
+<!--
+  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.
+-->
+<!--
+  Bottom-fixed framework-event drawer. Renders only when the
+  Admin → "Debug events" toggle (controls/debugPanel.ts) is on.
+  Default visibility is hostname-driven: enabled on localhost /
+  127.0.0.1 / 0.0.0.0, disabled in production.
+
+  Collapsed: one-line ticker showing the latest event.
+  Expanded: ~10 rows visible, scroll for the rest (last 200 events).
+-->
+<script setup lang="ts">
+import { computed, ref } from 'vue';
+import { useEventLog } from '@/controls/eventLog';
+import { useDebugPanel } from '@/controls/debugPanel';
+
+const { latest, all } = useEventLog();
+const { enabled, toggle } = useDebugPanel();
+const open = ref(false);
+function toggleOpen(): void {
+  open.value = !open.value;
+}
+
+function fmtTime(ts: number): string {
+  const d = new Date(ts);
+  return `${String(d.getHours()).padStart(2, 
'0')}:${String(d.getMinutes()).padStart(2, 
'0')}:${String(d.getSeconds()).padStart(2, '0')}`;
+}
+function kindGlyph(k: 'start' | 'ok' | 'err' | 'info'): string {
+  return k === 'ok' ? '✓' : k === 'err' ? '✕' : k === 'start' ? '…' : '·';
+}
+
+const latestText = computed<string>(() => {
+  const e = latest.value;
+  if (!e) return 'idle';
+  const dur = e.durationMs != null ? ` · ${e.durationMs}ms` : '';
+  return `${kindGlyph(e.kind)} ${e.text}${dur}`;
+});
+const latestKind = computed<string>(() => latest.value?.kind ?? 'info');
+const eventCount = computed<number>(() => all.value.length);
+</script>
+
+<template>
+  <div v-if="enabled" class="dbg" :class="['dbg-kind-' + latestKind, { open 
}]" data-no-event-track>
+    <div v-if="open" class="dbg-popover">
+      <header class="dbg-pop-head">
+        <span class="dbg-title">Framework events</span>
+        <span class="dbg-tag">last {{ eventCount }}</span>
+        <button class="dbg-x" type="button" title="Hide panel" 
@click="toggle">hide</button>
+      </header>
+      <div v-if="all.length === 0" class="dbg-empty">no events yet</div>
+      <ol v-else class="dbg-list">
+        <li
+          v-for="e in [...all].reverse()"
+          :key="e.id"
+          :class="'dbg-row dbg-kind-' + e.kind"
+        >
+          <span class="dbg-row-time">{{ fmtTime(e.ts) }}</span>
+          <span class="dbg-row-glyph">{{ kindGlyph(e.kind) }}</span>
+          <span class="dbg-row-topic">{{ e.topic }}</span>
+          <span class="dbg-row-text">{{ e.text }}</span>
+          <span v-if="e.durationMs != null" class="dbg-row-dur">{{ 
e.durationMs }}ms</span>
+        </li>
+      </ol>
+    </div>
+    <button class="dbg-bar" type="button" @click="toggleOpen" 
:aria-expanded="open">
+      <span class="dbg-caret" :class="{ open }">▾</span>
+      <span class="dbg-bar-text">{{ latestText }}</span>
+      <span v-if="eventCount > 0" class="dbg-bar-count">({{ eventCount 
}})</span>
+      <span class="dbg-bar-label">framework events</span>
+    </button>
+  </div>
+</template>
+
+<style scoped>
+.dbg {
+  position: fixed;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  z-index: 60;
+  font-variant-numeric: tabular-nums;
+  pointer-events: none;
+}
+.dbg > * { pointer-events: auto; }
+.dbg-bar {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  width: 100%;
+  height: 26px;
+  padding: 0 14px;
+  background: var(--sw-bg-2);
+  border: none;
+  border-top: 1px solid var(--sw-line);
+  color: var(--sw-fg-1);
+  font: inherit;
+  font-size: 11.5px;
+  cursor: pointer;
+  text-align: left;
+  overflow: hidden;
+  box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.35);
+}
+.dbg-bar:hover { background: var(--sw-bg-1); }
+.dbg-bar-text {
+  flex: 1;
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  font-family: var(--sw-mono);
+}
+.dbg-bar-count {
+  color: var(--sw-fg-3);
+  font-size: 10.5px;
+}
+.dbg-bar-label {
+  color: var(--sw-fg-3);
+  font-size: 10px;
+  text-transform: uppercase;
+  letter-spacing: 0.06em;
+}
+.dbg-caret {
+  color: var(--sw-fg-3);
+  font-size: 10px;
+  transition: transform 0.12s;
+}
+.dbg-caret.open { transform: rotate(180deg); }
+.dbg-kind-start .dbg-bar-text { color: var(--sw-accent-2); }
+.dbg-kind-ok    .dbg-bar-text { color: var(--sw-ok); }
+.dbg-kind-err   .dbg-bar-text { color: var(--sw-err); }
+.dbg-kind-info  .dbg-bar-text { color: var(--sw-fg-2); }
+
+.dbg-popover {
+  background: var(--sw-bg-2);
+  border: 1px solid var(--sw-line-2);
+  border-bottom: none;
+  max-height: 260px;
+  overflow-y: auto;
+  box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.45);
+}
+.dbg-pop-head {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 6px 14px;
+  border-bottom: 1px solid var(--sw-line);
+  font-size: 10.5px;
+  position: sticky;
+  top: 0;
+  background: var(--sw-bg-2);
+  z-index: 1;
+}
+.dbg-title {
+  color: var(--sw-fg-0);
+  font-weight: 600;
+  text-transform: uppercase;
+  letter-spacing: 0.06em;
+  font-size: 10px;
+}
+.dbg-tag { color: var(--sw-fg-3); font-size: 10px; }
+.dbg-x {
+  margin-left: auto;
+  background: transparent;
+  border: 1px solid var(--sw-line-2);
+  color: var(--sw-fg-2);
+  border-radius: 3px;
+  padding: 2px 8px;
+  font: inherit;
+  font-size: 10.5px;
+  cursor: pointer;
+}
+.dbg-x:hover { color: var(--sw-fg-0); border-color: var(--sw-fg-3); }
+.dbg-empty { padding: 12px; font-size: 11px; color: var(--sw-fg-3); 
text-align: center; }
+.dbg-list { list-style: none; margin: 0; padding: 4px 0; }
+.dbg-row {
+  display: grid;
+  grid-template-columns: auto auto auto 1fr auto;
+  align-items: baseline;
+  gap: 8px;
+  padding: 4px 14px;
+  font-size: 11px;
+  font-family: var(--sw-mono);
+  color: var(--sw-fg-1);
+  border-bottom: 1px solid var(--sw-line);
+}
+.dbg-row:last-child { border-bottom: none; }
+.dbg-row-time  { color: var(--sw-fg-3); font-size: 10px; }
+.dbg-row-glyph { color: var(--sw-fg-2); width: 12px; text-align: center; }
+.dbg-row-topic { color: var(--sw-fg-3); font-size: 10px; text-transform: 
uppercase; letter-spacing: 0.04em; }
+.dbg-row-text  { color: var(--sw-fg-1); overflow: hidden; text-overflow: 
ellipsis; }
+.dbg-row-dur   { color: var(--sw-fg-3); font-size: 10px; }
+.dbg-row.dbg-kind-ok    .dbg-row-glyph { color: var(--sw-ok); }
+.dbg-row.dbg-kind-err   .dbg-row-glyph { color: var(--sw-err); }
+.dbg-row.dbg-kind-err   .dbg-row-text  { color: var(--sw-err); }
+.dbg-row.dbg-kind-start .dbg-row-glyph { color: var(--sw-accent-2); }
+</style>
diff --git a/apps/ui/src/shell/EventTicker.vue 
b/apps/ui/src/shell/EventTicker.vue
deleted file mode 100644
index 73a10f3..0000000
--- a/apps/ui/src/shell/EventTicker.vue
+++ /dev/null
@@ -1,181 +0,0 @@
-<!--
-  Licensed to the Apache Software Foundation (ASF) under one or more
-  contributor license agreements.  See the NOTICE file distributed with
-  this work for additional information regarding copyright ownership.
-  The ASF licenses this file to You under the Apache License, Version 2.0
-  (the "License"); you may not use this file except in compliance with
-  the License.  You may obtain a copy of the License at
-
-      http://www.apache.org/licenses/LICENSE-2.0
-
-  Unless required by applicable law or agreed to in writing, software
-  distributed under the License is distributed on an "AS IS" BASIS,
-  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-  See the License for the specific language governing permissions and
-  limitations under the License.
--->
-<!--
-  Single-line event ticker that lives in the left half of the topbar.
-  Shows the latest framework event ("Loading services…", "✓ services
-  ready · 84ms") so the operator can see what the SPA is actually
-  doing while a page assembles. Click the ▾ caret to expand the full
-  history of the current page's events; resets on every route change
-  via the eventLog store.
--->
-<script setup lang="ts">
-import { computed, ref } from 'vue';
-import { useEventLog } from '@/controls/eventLog';
-
-const { latest, all } = useEventLog();
-const open = ref(false);
-function toggle(): void {
-  open.value = !open.value;
-}
-
-function fmtTime(ts: number): string {
-  const d = new Date(ts);
-  return `${String(d.getHours()).padStart(2, 
'0')}:${String(d.getMinutes()).padStart(2, 
'0')}:${String(d.getSeconds()).padStart(2, '0')}`;
-}
-function kindGlyph(k: 'start' | 'ok' | 'err' | 'info'): string {
-  return k === 'ok' ? '✓' : k === 'err' ? '✕' : k === 'start' ? '…' : '·';
-}
-
-const latestText = computed<string>(() => {
-  const e = latest.value;
-  if (!e) return 'idle';
-  const dur = e.durationMs != null ? ` · ${e.durationMs}ms` : '';
-  return `${kindGlyph(e.kind)} ${e.text}${dur}`;
-});
-const latestKind = computed<string>(() => latest.value?.kind ?? 'info');
-const eventCount = computed<number>(() => all.value.length);
-</script>
-
-<template>
-  <div class="ev-zone" :class="['ev-kind-' + latestKind, { open }]">
-    <button class="ev-line" type="button" @click="toggle" 
:aria-expanded="open">
-      <span class="ev-text">{{ latestText }}</span>
-      <span class="ev-count" v-if="eventCount > 0">({{ eventCount }})</span>
-      <span class="ev-caret" :class="{ open }">▾</span>
-    </button>
-    <div v-if="open" class="ev-popover">
-      <div v-if="all.length === 0" class="ev-empty">no events yet</div>
-      <ol v-else class="ev-list">
-        <li
-          v-for="e in [...all].reverse()"
-          :key="e.id"
-          :class="'ev-row ev-kind-' + e.kind"
-        >
-          <span class="ev-row-time">{{ fmtTime(e.ts) }}</span>
-          <span class="ev-row-glyph">{{ kindGlyph(e.kind) }}</span>
-          <span class="ev-row-topic">{{ e.topic }}</span>
-          <span class="ev-row-text">{{ e.text }}</span>
-          <span v-if="e.durationMs != null" class="ev-row-dur">{{ e.durationMs 
}}ms</span>
-        </li>
-      </ol>
-    </div>
-  </div>
-</template>
-
-<style scoped>
-.ev-zone {
-  position: relative;
-  flex: 1;
-  min-width: 0;
-  font-variant-numeric: tabular-nums;
-}
-.ev-line {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  width: 100%;
-  height: 28px;
-  padding: 0 10px;
-  background: var(--sw-bg-2);
-  border: 1px solid var(--sw-line);
-  border-radius: 4px;
-  color: var(--sw-fg-1);
-  font: inherit;
-  font-size: 11.5px;
-  cursor: pointer;
-  text-align: left;
-  overflow: hidden;
-}
-.ev-line:hover {
-  border-color: var(--sw-line-2);
-  background: var(--sw-bg-1);
-}
-.ev-text {
-  flex: 1;
-  min-width: 0;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-  font-family: var(--sw-mono);
-}
-.ev-count {
-  color: var(--sw-fg-3);
-  font-size: 10.5px;
-}
-.ev-caret {
-  color: var(--sw-fg-3);
-  font-size: 10px;
-  transition: transform 0.12s;
-}
-.ev-caret.open {
-  transform: rotate(180deg);
-}
-/* Kind tint applies to the latest line so the operator can read state
- * at a glance — error red, success green-ish, in-flight neutral. */
-.ev-kind-start .ev-text { color: var(--sw-accent-2); }
-.ev-kind-ok    .ev-text { color: var(--sw-ok); }
-.ev-kind-err   .ev-text { color: var(--sw-err); }
-.ev-kind-info  .ev-text { color: var(--sw-fg-2); }
-
-.ev-popover {
-  position: absolute;
-  top: calc(100% + 4px);
-  left: 0;
-  right: 0;
-  /* ~10 rows visible at once; the rest scrolls. Row is ~24px
-   * (4px top + 4px bottom padding + 11px text + a touch of slack). */
-  max-height: 260px;
-  overflow-y: auto;
-  z-index: 50;
-  background: var(--sw-bg-2);
-  border: 1px solid var(--sw-line-2);
-  border-radius: 4px;
-  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
-}
-.ev-empty {
-  padding: 12px;
-  font-size: 11px;
-  color: var(--sw-fg-3);
-  text-align: center;
-}
-.ev-list {
-  list-style: none;
-  margin: 0;
-  padding: 4px 0;
-}
-.ev-row {
-  display: grid;
-  grid-template-columns: auto auto auto 1fr auto;
-  align-items: baseline;
-  gap: 8px;
-  padding: 4px 10px;
-  font-size: 11px;
-  font-family: var(--sw-mono);
-  color: var(--sw-fg-1);
-  border-bottom: 1px solid var(--sw-line);
-}
-.ev-row:last-child { border-bottom: none; }
-.ev-row-time { color: var(--sw-fg-3); font-size: 10px; }
-.ev-row-glyph { color: var(--sw-fg-2); width: 12px; text-align: center; }
-.ev-row-topic { color: var(--sw-fg-3); font-size: 10px; text-transform: 
uppercase; letter-spacing: 0.04em; }
-.ev-row-text { color: var(--sw-fg-1); overflow: hidden; text-overflow: 
ellipsis; }
-.ev-row-dur { color: var(--sw-fg-3); font-size: 10px; }
-.ev-row.ev-kind-ok  .ev-row-glyph { color: var(--sw-ok); }
-.ev-row.ev-kind-err .ev-row-glyph { color: var(--sw-err); }
-.ev-row.ev-kind-err .ev-row-text  { color: var(--sw-err); }
-.ev-row.ev-kind-start .ev-row-glyph { color: var(--sw-accent-2); }
-</style>
diff --git a/apps/ui/src/shell/useOapInfo.ts b/apps/ui/src/shell/useOapInfo.ts
index 32690f6..0861319 100644
--- a/apps/ui/src/shell/useOapInfo.ts
+++ b/apps/ui/src/shell/useOapInfo.ts
@@ -18,7 +18,11 @@
 import { computed } from 'vue';
 import { useQuery } from '@tanstack/vue-query';
 import { useAutoRefreshSubscribe } from '../controls/useAutoRefreshSubscribe';
-import { parseOapTimezoneMinutes, type OapInfo } from 
'@skywalking-horizon-ui/api-client';
+import {
+  parseOapTimezoneMinutes,
+  type OapCapabilities,
+  type OapInfo,
+} from '@skywalking-horizon-ui/api-client';
 import { bffClient } from '@/api/client';
 
 /**
@@ -77,6 +81,14 @@ export function useOapInfo() {
     return `${y}-${mo}-${d} ${h}${mi}`;
   }
 
+  /** GraphQL-schema feature flags reported by the BFF. Defaults to a
+   *  conservative all-false shape until the first poll lands, so pages
+   *  branching on a capability render the legacy path during initial
+   *  load rather than flashing a "new" UI that then disappears. */
+  const capabilities = computed<OapCapabilities>(() => ({
+    queryAlarms: info.value?.capabilities?.queryAlarms ?? false,
+  }));
+
   /** Health status pill colour: ok / warn / err / unknown. */
   const healthState = computed<'ok' | 'warn' | 'err' | 'unknown'>(() => {
     if (!reachable.value) return 'err';
@@ -98,6 +110,7 @@ export function useOapInfo() {
     tzOffsetLabel,
     healthScore,
     healthState,
+    capabilities,
     toServerTzString,
     refetch: q.refetch,
   };
diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts
index 399c420..990bd24 100644
--- a/packages/api-client/src/index.ts
+++ b/packages/api-client/src/index.ts
@@ -93,7 +93,7 @@ export type {
   LogsResponse,
   LogFacetsResponse,
 } from './logs.js';
-export type { OapInfo } from './oap-info.js';
+export type { OapInfo, OapCapabilities } from './oap-info.js';
 export type { PreflightModule, PreflightResult } from './preflight.js';
 export type {
   ProfileTask,
diff --git a/packages/api-client/src/oap-info.ts 
b/packages/api-client/src/oap-info.ts
index 0df4bc6..9ed0838 100644
--- a/packages/api-client/src/oap-info.ts
+++ b/packages/api-client/src/oap-info.ts
@@ -36,9 +36,23 @@ export interface OapInfo {
   /** Health score: 0 = OK, >0 = degraded, <0 = not started. */
   healthScore?: number;
   healthDetails?: string;
+  /** Per-feature schema capabilities probed via GraphQL introspection.
+   *  Absent when `reachable === false`. Each field is `true` iff the
+   *  corresponding `Query.<name>` field exists on the connected OAP.
+   *  Routes / pages branch on this to choose between legacy + new
+   *  query shapes (e.g. `getAlarm` vs `queryAlarms`). */
+  capabilities?: OapCapabilities;
   error?: string;
 }
 
+export interface OapCapabilities {
+  /** `Query.queryAlarms(condition: AlarmQueryCondition!)` — introduced
+   *  alongside the deprecation of `getAlarm`. Enables Entity / layer /
+   *  ruleName filters; absence means clients fall back to the
+   *  scope+keyword+tags-only `getAlarm`. */
+  queryAlarms: boolean;
+}
+
 /**
  * Convert OAP's `±HHmm` timezone string to signed minutes-from-UTC.
  * Returns `undefined` for malformed input.


Reply via email to