mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-03 16:10:21 +00:00
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
913 lines
33 KiB
Vue
913 lines
33 KiB
Vue
<!-- 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>
|