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.