Files
openobserve/web/src/components/Header.vue
T
Ashish Kolheandgithub-actions[bot] ea263b344f fix(ui): show RBAC denials once, in place, with the missing permission (#16131)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-11-01 06:51:01 +00:00

913 lines
33 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 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 <http://www.gnu.org/licenses/>.
-->
<template>
<div class="bg-surface-chrome-deeper flex h-10 w-full shrink-0 flex-nowrap items-center">
<!-- LEFT SIDE: Logo -->
<div class="flex shrink-0 items-center justify-start gap-1 ps-3 max-md:ps-1.5">
<OButton
variant="ghost"
size="icon-toolbar"
class="md:hidden"
data-test="header-mobile-nav-toggle"
:aria-label="t('components.navbar.mainNavigation')"
@click="$emit('toggle-mobile-nav')"
>
<OIcon name="menu" size="sm" class="size-5!" />
</OButton>
<!-- LOGO SECTION: Displays custom or default OpenObserve logo -->
<!-- Shows custom logo/text if configured in enterprise mode -->
<div
class="relative-position flex"
v-if="
(config.isEnterprise == 'true' &&
store.state.zoConfig.hasOwnProperty('custom_logo_text') &&
store.state.zoConfig.custom_logo_text != '') ||
(config.isEnterprise == 'true' &&
store.state.zoConfig.hasOwnProperty('custom_logo_img') &&
store.state.zoConfig.custom_logo_img != null) ||
(config.isEnterprise == 'true' &&
store.state.zoConfig.hasOwnProperty('custom_logo_dark_img') &&
store.state.zoConfig.custom_logo_dark_img != null)
"
>
<!-- Custom logo text -->
<a
v-if="
store.state.zoConfig.hasOwnProperty('custom_logo_text') &&
store.state.zoConfig?.custom_logo_text != ''
"
:href="homeUrl"
@click.prevent="goToHome"
class="me-2 flex cursor-pointer items-center p-0 text-xl font-bold font-semibold text-inherit no-underline"
>{{ store.state.zoConfig.custom_logo_text }}</a
>
<!-- Custom logo image - shows appropriate logo based on current theme -->
<a :href="homeUrl" @click.prevent="goToHome" class="inline-flex items-center">
<!-- Dark mode: Show dark logo, fallback to light logo -->
<img
:alt="t('common.customLogoAlt')"
v-if="
isDark &&
store.state.zoConfig.hasOwnProperty('custom_logo_dark_img') &&
store.state.zoConfig?.custom_logo_dark_img != null
"
:src="`data:image; base64, ` + store.state.zoConfig?.custom_logo_dark_img"
style="max-width: 9.375rem; max-height: 2rem"
/>
<!-- Light mode: Show light logo, fallback to dark logo -->
<img
:alt="t('common.customLogoAlt')"
v-else-if="
!isDark &&
store.state.zoConfig.hasOwnProperty('custom_logo_img') &&
store.state.zoConfig?.custom_logo_img != null
"
:src="`data:image; base64, ` + store.state.zoConfig?.custom_logo_img"
style="max-width: 9.375rem; max-height: 2rem"
/>
<!-- Fallback: Show whichever logo exists (dark or light) -->
<img
:alt="t('common.customLogoAlt')"
v-else-if="
store.state.zoConfig.hasOwnProperty('custom_logo_dark_img') &&
store.state.zoConfig?.custom_logo_dark_img != null
"
:src="`data:image; base64, ` + store.state.zoConfig?.custom_logo_dark_img"
style="max-width: 9.375rem; max-height: 2rem"
/>
<img
:alt="t('common.customLogoAlt')"
v-else-if="
store.state.zoConfig.hasOwnProperty('custom_logo_img') &&
store.state.zoConfig?.custom_logo_img != null
"
:src="`data:image; base64, ` + store.state.zoConfig?.custom_logo_img"
style="max-width: 9.375rem; max-height: 2rem"
/>
</a>
<!-- OpenObserve logo (shown alongside custom logo if configured) -->
<div
v-if="store.state.zoConfig.custom_hide_self_logo == false"
class="relative inline-flex min-h-10 items-center"
>
<a :href="homeUrl" @click.prevent="goToHome" class="inline-flex items-center">
<img
data-test="header-openobserve-logo"
class="openobserve-logo block h-8 max-w-37.5 cursor-pointer transition-opacity duration-200 hover:opacity-80"
:src="
getImageURL(
isDark
? 'images/common/openobserve_latest_dark_2.svg'
: 'images/common/openobserve_latest_light_2.svg',
)
"
:alt="raw('OpenObserve')"
/>
</a>
</div>
</div>
<!-- Default OpenObserve logo (when no custom logo) -->
<div v-else class="relative-position relative inline-flex min-h-10 items-center">
<a :href="homeUrl" @click.prevent="goToHome" class="inline-flex items-center">
<img
data-test="header-openobserve-logo"
class="openobserve-logo block h-8 max-w-37.5 cursor-pointer transition-opacity duration-200 hover:opacity-80"
:src="
getImageURL(
isDark
? 'images/common/openobserve_latest_dark_2.svg'
: 'images/common/openobserve_latest_light_2.svg',
)
"
:alt="raw('OpenObserve')"
/>
</a>
</div>
</div>
<!-- end left side -->
<!-- CENTER: elastic spacer so the right-side controls stay right-aligned. -->
<div class="min-w-0 flex-1" />
<!-- RIGHT SIDE: Controls -->
<div class="flex shrink-0 items-center justify-end gap-1 pe-3">
<!-- QUOTA WARNING SECTION: Shows warning when quota threshold is reached -->
<div
class="me-4 flex items-center gap-1 max-md:hidden"
v-if="store.state.organizationData.quotaThresholdMsg"
>
<div type="warning" icon="cloud" class="bg-status-warning-bg rounded-default inline p-1.25">
<OIcon name="warning" size="xs" class="text-warning" />{{
store.state.organizationData.quotaThresholdMsg
}}
</div>
<OButton
variant="secondary"
size="sm"
class="m-1"
@click="router.replace('/billings/plans')"
>
{{ t("menu.upgradeToProPlan") }}
</OButton>
</div>
<!-- HEADER MENU: Contains all header navigation and user controls -->
<div class="header-menu flex items-center gap-x-2">
<!-- INGESTION QUOTA WARNING: Shows when 85%+ of ingestion limit is used -->
<OButton
v-if="config.isEnterprise == 'true' && store.state.zoConfig.ingestion_quota_used >= 85"
variant="ghost"
size="icon-toolbar"
data-test="ingestion-quota-warning-icon"
>
<OIcon
name="warning"
size="sm"
class="opacity-60"
:style="{ color: ingestionQuotaColor }"
/>
<OTooltip
side="top"
align="center"
:content="t('common.warningIngestionQuota', { percent: ingestionQuotaPercentage })"
/>
</OButton>
<OPageHeaderStatus fallback />
<!-- EDITION BADGE / UPGRADE BUTTON -->
<!-- Enterprise/Cloud: ghost-muted badge (informational, opens about dialog) -->
<!-- Open Source: primary CTA to drive upgrades -->
<OButton
:variant="
config.isEnterprise === 'true' || config.isCloud === 'true'
? 'outline-primary'
: 'primary'
"
size="xs"
class="max-md:hidden"
data-test="upgrade-to-enterprise-btn"
@click="openEnterpriseDialog"
>
<template #icon-left>
<OIcon
:name="
config.isEnterprise === 'true' || config.isCloud === 'true'
? 'verified'
: 'card-giftcard'
"
size="sm"
/>
</template>
{{ enterpriseButtonText }}
</OButton>
<!-- ORGANIZATION SELECTOR: Dropdown to switch between organizations -->
<OrganizationSelector
:organizations="organizations"
:current="userClickedOrg"
@select="handleOrgSelection"
/>
<div class="header-utility-icons flex items-center gap-x-2">
<!-- AI CHAT TOGGLE: Enterprise/Cloud feature to toggle AI chat panel.
Leads the utility-icon cluster, set off by a separator from the
org selector so it reads as the primary action in this group.
Shown locked (not hidden) in builds that don't unlock it. -->
<template v-if="aiButtonVisible">
<OButton
variant="ghost"
size="icon-toolbar"
@click="toggleAIChat"
data-test="menu-link-ai-item"
:aria-label="t('menu.aiAssistant')"
class="group text-ai-accent! hover:shadow-ai-accent/35 dark:shadow-ai-accent/20 dark:hover:shadow-ai-accent/35 [background:var(--color-gradient-ai-subtle)]! [transition:background_0.3s_ease,box-shadow_0.3s_ease,color_0.3s_ease] hover:text-white! hover:shadow-md hover:[background:var(--color-gradient-ai)]! dark:text-white! dark:shadow-md dark:hover:shadow-md"
:class="store.state.isAiChatEnabled ? 'ai-btn-active' : ''"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<img
alt=""
:src="getBtnLogo"
class="h-5 w-5 shrink-0 [transition:transform_0.6s_ease] group-hover:rotate-180 group-hover:brightness-0 group-hover:invert group-hover:[transition:filter_0.3s_ease]"
/>
<span
v-if="!aiAccess.allowed"
aria-hidden="true"
class="bg-surface-overlay border-surface-base absolute -top-1 -right-1 flex h-3.5 w-3.5 items-center justify-center rounded-full border shadow-sm"
>
<OIcon name="lock" size="xs" class="text-warning-600 size-2.5!" />
</span>
<OTooltip
v-if="aiAccess.allowed"
side="bottom"
align="center"
:content="t('menu.aiAssistant')"
shortcut-id="aiChatToggle"
/>
</OButton>
</template>
<span
v-else-if="aiSlotPending"
aria-hidden="true"
class="size-[1.875rem] shrink-0"
data-test="header-ai-slot-placeholder"
/>
<!-- THEME SWITCHER: Toggle between light and dark mode -->
<ThemeSwitcher class="max-md:hidden"></ThemeSwitcher>
<!-- SLACK COMMUNITY LINK -->
<OButton
variant="ghost"
size="icon-toolbar"
class="max-md:hidden"
data-test="menu-link-slack-item"
:aria-label="raw('Slack')"
@click="openSlack"
>
<component :is="slackIcon" class="size-5 shrink-0" />
<OTooltip side="top" align="center" :content="raw('Slack')" />
</OButton>
<!-- HELP MENU: Contains links to docs, API, and about page -->
<ODropdown side="bottom" align="end">
<template #trigger>
<OButton
variant="ghost"
size="icon-toolbar"
class="max-md:hidden"
data-test="menu-link-help-item"
:aria-label="t('menu.help')"
>
<OIcon name="help-outline" size="sm" class="size-5!" />
<OTooltip side="top" align="center" :content="t('menu.help')" />
</OButton>
</template>
<div class="header-menu-bar min-w-62.5">
<!-- OpenAPI link (only for non-cloud deployments) -->
<template
v-if="
config.isCloud !== 'true' &&
!store.state.zoConfig?.custom_hide_menus?.split(',')?.includes('openapi')
"
>
<ODropdownItem
data-test="menu-link-openapi-item"
@select="navigateToOpenAPI(zoBackendUrl)"
>
{{ raw("OpenAPI") }}
</ODropdownItem>
<ODropdownSeparator />
</template>
<!-- Documentation link -->
<ODropdownItem data-test="menu-link-docs-item" @select="navigateToDocs()">
{{ t(`menu.docs`) }}
</ODropdownItem>
<ODropdownSeparator />
<!-- Keyboard shortcuts -->
<ODropdownItem
data-test="menu-link-shortcuts-item"
shortcut-id="openCheatsheet"
@select="openShortcuts"
>
{{ t("menu.keyboardShortcuts") }}
</ODropdownItem>
<ODropdownSeparator />
<!-- About page link -->
<ODropdownItem data-test="menu-link-about-item" @select="goToAbout">
{{ t(`menu.about`) }}
</ODropdownItem>
</div>
</ODropdown>
<!-- USER PROFILE MENU: Profile, language, theme, and logout -->
<ODropdown
side="bottom"
align="end"
@update:open="
(open) => {
if (!open) showLanguageSubmenu = false;
}
"
>
<template #trigger>
<OButton
variant="ghost"
size="icon-toolbar"
data-test="header-my-account-profile-icon"
:aria-label="
user.given_name ? user.given_name + ' ' + user.family_name : user.email
"
>
<OIcon :name="user.picture ? user.picture : 'person'" size="sm" class="size-5!" />
<OTooltip
side="top"
align="center"
:content="user.given_name ? user.given_name + ' ' + user.family_name : user.email"
/>
</OButton>
</template>
<div class="header-menu-bar min-w-62.5">
<!-- User information (non-clickable info row) -->
<div class="flex items-center gap-3 px-3 py-2">
<OIcon :name="user.picture ? user.picture : 'person'" size="xs" />
<span class="truncate text-sm">{{
user.given_name ? user.given_name + " " + user.family_name : user.email
}}</span>
</div>
<ODropdownSeparator />
<template v-if="showMyOncall">
<ODropdownItem data-test="header-my-oncall" @select="goToMyOncall">
<template #icon-left>
<OIcon size="xs" name="person" class="padding-none" />
</template>
{{ t("oncall.mineTitle") }}
</ODropdownItem>
<ODropdownSeparator />
</template>
<!-- A menuitem cannot hold menuitems, so the list is the row's sibling inside one positioned wrapper. -->
<div class="relative" role="none">
<ODropdownItem
data-test="header-language-submenu-trigger"
class="focus-visible:ring-focus-ring-accent focus-visible:ring-2 focus-visible:ring-inset"
aria-haspopup="menu"
:aria-expanded="showLanguageSubmenu"
@select="onLanguageRowSelect"
@keydown="onLanguageRowKeydown"
>
<template #icon-left>
<OIcon size="xs" name="language" class="padding-none" />
</template>
<span class="flex-1 whitespace-nowrap">{{ t("menu.language") }}</span>
<span class="inline-flex items-center gap-1.5 whitespace-nowrap opacity-75">
<img
v-if="selectedLanguage.icon && selectedLanguage.icon.startsWith('img:')"
:src="selectedLanguage.icon.slice(4)"
:alt="selectedLanguage.label"
class="rounded-default inline-block h-3 w-4 shrink-0 object-cover"
/>
<OIcon
v-else-if="selectedLanguage.icon"
size="xs"
:name="selectedLanguage.icon"
class="padding-none"
/>
<span>{{ selectedLanguage.label }}</span>
</span>
<OIcon size="xs" name="chevron-right" />
</ODropdownItem>
<div
v-if="showLanguageSubmenu"
ref="languageListRef"
role="group"
:aria-label="t('menu.language')"
class="rounded-default bg-dropdown-bg shadow-overlay max-lg:border-dropdown-border absolute end-full top-0 z-9999 me-1 min-w-50 border border-transparent py-1 max-lg:static max-lg:me-0 max-lg:mt-1 max-lg:w-full max-lg:min-w-0 max-lg:shadow-none"
data-test="language-dropdown-item"
@keydown="onLanguageListKeydown"
>
<ODropdownItem
v-for="lang in langList"
:key="lang.code"
:data-test="`language-dropdown-item-${lang.code}`"
:class="{ 'font-semibold': selectedLanguage.code === lang.code }"
@select="
changeLanguage(lang);
showLanguageSubmenu = false;
"
>
<img
v-if="lang.icon && lang.icon.startsWith('img:')"
:src="lang.icon.slice(4)"
:alt="lang.label"
class="rounded-default inline-block h-3 w-4 shrink-0 object-cover"
/>
<OIcon v-else-if="lang.icon" size="xs" :name="lang.icon" />
<span class="flex-1">{{ lang.label }}</span>
<OIcon v-if="selectedLanguage.code === lang.code" size="xs" name="check" />
</ODropdownItem>
</div>
</div>
<ODropdownSeparator />
<!-- Theme management -->
<ODropdownItem
data-test="menu-link-predefined-themes-item"
@select="openPredefinedThemes"
>
<template #icon-left>
<OIcon size="xs" name="color-lens" class="padding-none" />
</template>
{{ t("common.manageTheme") }}
</ODropdownItem>
<ODropdownSeparator />
<template v-if="linkedAccountsVisible">
<ODropdownItem
data-test="menu-link-linked-accounts-item"
@select="showLinkedAccounts = true"
>
<template #icon-left>
<OIcon size="xs" name="link" class="padding-none" />
</template>
{{ t("menu.linkedAccounts") }}
</ODropdownItem>
<ODropdownSeparator />
</template>
<!-- Logout -->
<ODropdownItem
data-test="menu-link-logout-item"
variant="destructive"
@select="signout"
>
<template #icon-left>
<OIcon size="xs" name="exit-to-app" class="padding-none" />
</template>
{{ t("menu.signOut") }}
</ODropdownItem>
</div>
</ODropdown>
</div>
</div>
</div>
<!-- end right side -->
<!-- Enterprise Upgrade Dialog -->
<EnterpriseUpgradeDialog v-model="showEnterpriseDialog" />
<LinkedAccountsDialog v-if="linkedAccountsVisible" v-model:open="showLinkedAccounts" />
</div>
</template>
<script lang="ts">
import { defineComponent, PropType, computed, nextTick, ref } from "vue";
import { useRouter } from "vue-router";
import { raw, useI18nTyped } from "@/types/i18n";
import { useTheme } from "@/composables/useTheme";
import { useEnterpriseUpgradeDialog } from "@/composables/useEnterpriseUpgradeDialog";
import useBreakpoint from "@/composables/useBreakpoint";
import ThemeSwitcher from "./ThemeSwitcher.vue";
import EnterpriseUpgradeDialog from "./EnterpriseUpgradeDialog.vue";
import OrganizationSelector from "./OrganizationSelector.vue";
import LinkedAccountsDialog from "./settings/LinkedAccountsDialog.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OPageHeaderStatus from "@/lib/core/PageHeader/OPageHeaderStatus.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import { checkFeatureAccess } from "@/utils/enterpriseFeatures";
import { readSlackOrgConfig } from "@/utils/slackSettings";
import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue";
import ODropdownItem from "@/lib/overlay/Dropdown/ODropdownItem.vue";
import ODropdownSeparator from "@/lib/overlay/Dropdown/ODropdownSeparator.vue";
import ODropdownGroup from "@/lib/overlay/Dropdown/ODropdownGroup.vue";
import { isGateOpen, useNavGateContext } from "@/lib/core/Navbar/useNavGateContext";
import { getImageURL } from "@/utils/zincutils";
import { chartColor } from "@/utils/chartTheme";
export default defineComponent({
name: "HeaderComponent",
components: {
OPageHeaderStatus,
ThemeSwitcher,
EnterpriseUpgradeDialog,
OrganizationSelector,
LinkedAccountsDialog,
OButton,
OIcon,
OTooltip,
ODropdown,
ODropdownItem,
ODropdownSeparator,
ODropdownGroup,
},
props: {
// Store instance
store: {
type: Object as PropType<any>,
required: true,
},
// Router instance
router: {
type: Object as PropType<any>,
required: true,
},
// Configuration object
config: {
type: Object as PropType<any>,
required: true,
},
// Current user information
user: {
type: Object as PropType<any>,
required: true,
},
// Slack icon component
slackIcon: {
type: Object as PropType<any>,
required: true,
},
// Backend URL
zoBackendUrl: {
type: String,
required: true,
},
// List of available languages
langList: {
type: Array as PropType<any[]>,
required: true,
},
// Currently selected language
selectedLanguage: {
type: Object as PropType<any>,
required: true,
},
// Selected organization
selectedOrg: {
type: Object as PropType<any>,
required: true,
},
// User clicked organization (for UI sync)
userClickedOrg: {
type: Object as PropType<any>,
required: true,
},
// Full list of organizations for the selector
organizations: {
type: Array as PropType<any[]>,
required: true,
},
// AI button hover state
isHovered: {
type: Boolean,
required: true,
},
// AI button logo based on state
getBtnLogo: {
type: String,
required: true,
},
},
emits: [
"update:selectedOrg",
"update:isHovered",
"updateOrganization",
"goToHome",
"goToAbout",
"toggleAIChat",
"openSlack",
"navigateToOpenAPI",
"navigateToDocs",
"changeLanguage",
"openPredefinedThemes",
"openShortcuts",
"toggle-mobile-nav",
"signout",
],
setup(props, { emit }) {
const { t } = useI18nTyped();
const router = useRouter();
const { isDark } = useTheme();
const homeUrl = computed(() => {
if (!router) return "/";
return router.resolve({
path: "/",
query: {
org_identifier: props.store.state.selectedOrganization?.identifier,
},
}).href;
});
// Enterprise upgrade dialog state — a module-level singleton (see the
// composable) so a locked feature's tooltip link elsewhere in the app can
// open the SAME dialog instance this header button does.
const { isOpen: showEnterpriseDialog, open: openEnterpriseDialog } =
useEnterpriseUpgradeDialog();
const showLanguageSubmenu = ref(false);
const languageListRef = ref<HTMLElement | null>(null);
const { lgUp } = useBreakpoint();
const languageItems = (): HTMLElement[] =>
Array.from(
languageListRef.value?.querySelectorAll<HTMLElement>(
'[data-test^="language-dropdown-item-"]',
) ?? [],
);
const focusSelectedLanguage = async () => {
await nextTick();
const items = languageItems();
const selected = items.find(
(el) => el.dataset.test === `language-dropdown-item-${props.selectedLanguage.code}`,
);
(selected ?? items[0])?.focus();
};
const focusLanguageRow = () => {
const row = languageListRef.value?.parentElement?.querySelector<HTMLElement>(
'[data-test="header-language-submenu-trigger"]',
);
row?.focus();
};
const closeLanguageList = () => {
focusLanguageRow();
showLanguageSubmenu.value = false;
};
// preventDefault keeps the profile menu open; reka closes it on any unprevented select.
const onLanguageRowSelect = (event: Event) => {
event.preventDefault();
showLanguageSubmenu.value = !showLanguageSubmenu.value;
if (showLanguageSubmenu.value) focusSelectedLanguage();
};
// Either horizontal arrow opens: the chevron points inline-end while the list opens inline-start.
const onLanguageRowKeydown = (event: KeyboardEvent) => {
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
event.preventDefault();
event.stopPropagation();
showLanguageSubmenu.value = true;
focusSelectedLanguage();
};
const onLanguageListKeydown = (event: KeyboardEvent) => {
const items = languageItems();
const index = items.indexOf(document.activeElement as HTMLElement);
const awayKey = document.documentElement.dir === "rtl" ? "ArrowLeft" : "ArrowRight";
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
const step = event.key === "ArrowDown" ? 1 : -1;
items[(index + step + items.length) % items.length]?.focus();
} else if (event.key === "Escape") {
closeLanguageList();
} else if (event.key === awayKey) {
// Below lg the list sits inline under its row, so there is nothing to the side to return to.
if (!lgUp.value) return;
closeLanguageList();
} else {
return;
}
event.preventDefault();
event.stopPropagation();
};
// AI chat toggle — shown locked (not hidden) in builds that don't unlock
// it. Same `ai_enabled`-is-an-admin-preference-not-an-upsell-target
// reasoning as QueryEditor.vue's floating AI icon: an entitled org that's
// turned AI off still never sees the button at all.
//
// NOT `buildFeatureGateContext()` — that reads the `@/aws-exports`
// singleton directly, but this component takes edition info as its own
// `config` PROP (so a host can inject it / tests can override it) rather
// than importing the module. Build the context from the prop instead.
const aiAccess = computed(() =>
checkFeatureAccess("aiAssistant", {
isEnterprise: props.config.isEnterprise === "true",
isCloud: props.config.isCloud === "true",
rbac: props.store.state.zoConfig?.rbac_enabled !== false,
}),
);
const aiButtonVisible = computed(
() => !aiAccess.value.allowed || Boolean(props.store.state.zoConfig.ai_enabled),
);
const aiSlotPending = computed(
() => aiAccess.value.allowed && !props.store.state.zoConfig?.version,
);
// The edition gate and the org's runtime switch both have to pass, as for Settings › Slack.
const linkedAccountsVisible = computed(
() =>
checkFeatureAccess("slack", {
isEnterprise: props.config.isEnterprise === "true",
isCloud: props.config.isCloud === "true",
rbac: props.store.state.zoConfig?.rbac_enabled !== false,
}).allowed && readSlackOrgConfig(props.store.state.zoConfig).enabled,
);
const showLinkedAccounts = ref(false);
// Computed property for enterprise button text based on deployment type
const enterpriseButtonText = computed(() => {
const isEnterprise = props.config.isEnterprise === "true";
const isCloud = props.config.isCloud === "true";
if (isCloud) {
return t("about.header_button.cloud_features", { product: raw("OpenObserve") });
} else if (isEnterprise) {
return t("about.header_button.enterprise_edition");
} else {
return t("about.header_button.get_enterprise_free");
}
});
// Computed property for ingestion quota percentage
const ingestionQuotaPercentage = computed(() => {
return Math.ceil(props.store.state.zoConfig.ingestion_quota_used * 100) / 100 || 0;
});
// Computed property for ingestion quota warning color
const ingestionQuotaColor = computed(() => {
return props.store.state.zoConfig.ingestion_quota_used >= 95
? chartColor("--color-status-negative")
: chartColor("--color-status-warning-text");
});
// Event handlers that emit to parent component
const updateOrganization = () => {
emit("updateOrganization");
};
const goToHome = () => {
emit("goToHome");
};
const goToAbout = () => {
emit("goToAbout");
};
const toggleAIChat = () => {
if (!aiAccess.value.allowed) {
openEnterpriseDialog();
return;
}
emit("toggleAIChat");
};
const openSlack = () => {
emit("openSlack");
};
const navigateToOpenAPI = (url: string) => {
emit("navigateToOpenAPI", url);
};
const navigateToDocs = () => {
emit("navigateToDocs");
};
const changeLanguage = (lang: any) => {
emit("changeLanguage", lang);
};
const openPredefinedThemes = () => {
emit("openPredefinedThemes");
};
const openShortcuts = () => {
emit("openShortcuts");
};
const signout = () => {
emit("signout");
};
// Handle mouse hover events for AI button
const handleMouseEnter = () => {
emit("update:isHovered", true);
};
const handleMouseLeave = () => {
emit("update:isHovered", false);
};
// Handle organization selection from the OrganizationSelector menu
const handleOrgSelection = (org: any) => {
emit("update:selectedOrg", org);
emit("updateOrganization");
};
const navGateContext = useNavGateContext();
// Waits for an explicit `true`: the gate is open before the full config loads, and unmounting the first menu item then drops keyboard focus.
const showMyOncall = computed(
() =>
props.store.state.zoConfig?.oncall_enabled === true &&
isGateOpen(navGateContext.value, "oncall") &&
props.router.hasRoute("onCallMine"),
);
const goToMyOncall = () => {
// eslint-disable-next-line vue/no-mutating-props -- vue-router's push navigates; the rule mistakes it for Array#push.
props.router.push({
name: "onCallMine",
query: { org_identifier: props.store.state.selectedOrganization.identifier },
});
};
return {
isDark,
t,
raw,
getImageURL,
enterpriseButtonText,
aiSlotPending,
ingestionQuotaPercentage,
ingestionQuotaColor,
showEnterpriseDialog,
showLanguageSubmenu,
languageListRef,
onLanguageRowSelect,
onLanguageRowKeydown,
onLanguageListKeydown,
updateOrganization,
goToHome,
homeUrl,
goToAbout,
toggleAIChat,
aiAccess,
aiButtonVisible,
linkedAccountsVisible,
showLinkedAccounts,
openSlack,
navigateToOpenAPI,
navigateToDocs,
changeLanguage,
openPredefinedThemes,
openShortcuts,
signout,
handleMouseEnter,
handleMouseLeave,
handleOrgSelection,
openEnterpriseDialog,
showMyOncall,
goToMyOncall,
};
},
});
</script>
<style scoped>
/* keep(lib-override:o2-button): dark-only, element-scoped re-point of OButton's
ghost-primary tokens for the navbar upgrade CTA. The custom properties are READ by OButton's
own internal DOM, so they have to be declared on the button element itself —
there is no utility that sets them. Scoping appends [data-v] to the
[data-test] compound, which is OButton's root and therefore carries this
component's scope id. Was a global in styles/utilities.css (W1.d). */
.dark [data-test="upgrade-to-enterprise-btn"] {
--color-button-ghost-primary-active-bg: var(--color-primary-900);
--color-button-ghost-primary-text: var(--color-primary-200);
}
</style>