+
@@ -220,6 +222,7 @@ import {
onBeforeUnmount,
onDeactivated,
onMounted,
+ ref,
useSlots,
} from "vue";
import { useRouter } from "vue-router";
@@ -230,6 +233,7 @@ import OButton from "@/lib/core/Button/OButton.vue";
import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue";
import useBreakpoint from "@/composables/useBreakpoint";
import { PAGE_LAYOUT_SIDEBAR_KEY } from "@/lib/core/PageLayout/pageLayoutSidebar";
+import { usePageHeaderStatusHost } from "@/lib/core/PageHeader/pageHeaderStatus";
import type { IconName } from "@/lib/core/Icon/OIcon.icons";
interface BackTarget {
@@ -299,6 +303,9 @@ const ownsSidebarTrigger = computed(
() => isMobile.value && layoutSidebar?.owner.value === sidebarClaim,
);
+const headerEl = ref
(null);
+const statusComponent = usePageHeaderStatusHost(headerEl);
+
// A slot passed with an always-present but a falsy inner v-if still
// yields a comment placeholder node — so checking `$slots.x` is truthy even
// when there's nothing to show. Detect *real* content instead, so we never
diff --git a/web/src/lib/core/PageHeader/OPageHeaderStatus.vue b/web/src/lib/core/PageHeader/OPageHeaderStatus.vue
new file mode 100644
index 0000000000..06149a85a6
--- /dev/null
+++ b/web/src/lib/core/PageHeader/OPageHeaderStatus.vue
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/lib/core/PageHeader/pageHeaderStatus.ts b/web/src/lib/core/PageHeader/pageHeaderStatus.ts
new file mode 100644
index 0000000000..90bda6ac6d
--- /dev/null
+++ b/web/src/lib/core/PageHeader/pageHeaderStatus.ts
@@ -0,0 +1,148 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import {
+ computed,
+ inject,
+ onActivated,
+ onBeforeUnmount,
+ onDeactivated,
+ onMounted,
+ provide,
+ ref,
+ shallowReactive,
+ watch,
+ type Component,
+ type ComputedRef,
+ type InjectionKey,
+ type Ref,
+} from "vue";
+
+/** A page host sits in a page header or toolbar; the fallback host sits in the app top bar and shows only what no page host took. */
+export type PageHeaderStatusPriority = "page" | "fallback";
+
+/** One provider's status and the page hosts competing to show it. */
+export interface PageHeaderStatusContext {
+ /** What this provider's owning page host renders: its own status, or a nested provider's that found no host. */
+ display: ComputedRef;
+ /** The display once settled with no page host, for an ancestor provider or the fallback host. */
+ unhosted: ComputedRef;
+ owner: ComputedRef;
+ claim: (id: symbol, el: Element) => void;
+ release: (id: symbol) => void;
+ adopt: (child: PageHeaderStatusContext) => () => void;
+}
+
+export const PAGE_HEADER_STATUS_KEY: InjectionKey =
+ Symbol("pageHeaderStatus");
+
+const OVERLAY_SELECTOR = '[role="dialog"], [role="alertdialog"]';
+// A page's toolbar can arrive with a lazily loaded child, so a status waits this long before it falls back to an outer host.
+const SETTLE_MS = 250;
+
+const rootContexts = shallowReactive(new Set());
+
+/** Offers `component` to the first page host below; `resetKey` names the screen, so a change restarts the wait before falling back. */
+export function providePageHeaderStatus(
+ component: () => Component | null,
+ resetKey: () => unknown,
+): PageHeaderStatusContext {
+ const parent = inject(PAGE_HEADER_STATUS_KEY, null);
+ const claims = shallowReactive(new Map());
+ const children = shallowReactive(new Set());
+ const owner = computed(() => firstInDocument(claims));
+ const display = computed(() => component() ?? firstUnhosted(children));
+ const settled = ref(false);
+ let settleTimer: ReturnType | undefined;
+ watch(
+ [resetKey, display],
+ () => {
+ settled.value = false;
+ clearTimeout(settleTimer);
+ settleTimer = setTimeout(() => (settled.value = true), SETTLE_MS);
+ },
+ { immediate: true },
+ );
+ const context: PageHeaderStatusContext = {
+ display,
+ unhosted: computed(() => (settled.value && owner.value === null ? display.value : null)),
+ owner,
+ claim: (id, el) => {
+ claims.set(id, el);
+ },
+ release: (id) => {
+ claims.delete(id);
+ },
+ adopt: (child) => {
+ children.add(child);
+ return () => {
+ children.delete(child);
+ };
+ },
+ };
+ const detach = parent ? parent.adopt(context) : adoptRoot(context);
+ provide(PAGE_HEADER_STATUS_KEY, context);
+ onBeforeUnmount(() => {
+ detach();
+ clearTimeout(settleTimer);
+ });
+ return context;
+}
+
+/** Claims a place for the provided status at `el` and returns what this host should render now. */
+export function usePageHeaderStatusHost(
+ el: Ref,
+ priority: PageHeaderStatusPriority = "page",
+): ComputedRef {
+ if (priority === "fallback") return computed(() => firstUnhosted(rootContexts));
+ const context = inject(PAGE_HEADER_STATUS_KEY, null);
+ const id = Symbol("page-header-status-host");
+ // Claims compare DOM position, so they are made once the host is in the document and dropped while kept-alive and hidden.
+ const claim = () => {
+ if (context && el.value) context.claim(id, el.value);
+ };
+ const release = () => context?.release(id);
+ onMounted(claim);
+ onActivated(claim);
+ onBeforeUnmount(release);
+ onDeactivated(release);
+ return computed(() => (context?.owner.value === id ? context.display.value : null));
+}
+
+function adoptRoot(context: PageHeaderStatusContext): () => void {
+ rootContexts.add(context);
+ return () => {
+ rootContexts.delete(context);
+ };
+}
+
+function firstUnhosted(contexts: Set): Component | null {
+ for (const context of contexts) {
+ if (context.unhosted.value) return context.unhosted.value;
+ }
+ return null;
+}
+
+// The first host in document order that is not inside a dialog or drawer.
+function firstInDocument(claims: Map): symbol | null {
+ let first: [symbol, Element] | null = null;
+ for (const [id, el] of claims) {
+ if (!el.isConnected || el.closest(OVERLAY_SELECTOR)) continue;
+ if (!first || first[1].compareDocumentPosition(el) & Node.DOCUMENT_POSITION_PRECEDING) {
+ first = [id, el];
+ }
+ }
+ return first?.[0] ?? null;
+}
diff --git a/web/src/lib/overlay/Popover/OPopover.vue b/web/src/lib/overlay/Popover/OPopover.vue
index 44de03e182..0175701c46 100644
--- a/web/src/lib/overlay/Popover/OPopover.vue
+++ b/web/src/lib/overlay/Popover/OPopover.vue
@@ -9,8 +9,8 @@
Shares ODropdown's overlay coordination so descendant overlays (OSelect /
OCombobox / nested dropdowns) opening INSIDE the popover never dismiss it.
- Props: open (v-model) | side | align | sideOffset | hideWhenDetached | modal | ariaLabel | contentClass | collisionPadding
- Slots: trigger | default
+ Props: open (v-model) | anchor | side | align | flipAlign | sideOffset | arrow | hideWhenDetached | modal | ariaLabel | contentClass | collisionPadding
+ Slots: trigger (unused when `anchor` is set) | default | arrow (paths in a 12x6 viewBox)
Emits: update:open | open-auto-focus
-->