mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-05 16:10:43 +00:00
Co-authored-by: ktx-vaidehi <vaidehi.akhani@kiara.tech> Co-authored-by: ktx-vaidehi <134508096+ktx-vaidehi@users.noreply.github.com>
1486 lines
51 KiB
Vue
1486 lines
51 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="[
|
|
store.state.printMode === true ? 'printMode' : '',
|
|
'o2-app-root',
|
|
'w-full',
|
|
'transition-[width]',
|
|
'duration-300',
|
|
'ease-[ease]',
|
|
'min-h-screen',
|
|
'h-screen',
|
|
'flex',
|
|
'flex-col',
|
|
]"
|
|
>
|
|
<header class="o2-app-header shrink-0" :class="store.state.printMode === true ? 'hidden' : ''">
|
|
<!-- Every bar that sits above the toolbar, in one measured wrapper so
|
|
`--navbar-height` accounts for whichever of them is actually showing.
|
|
The wrapper always renders — an unconditional ref keeps the observer
|
|
attached even when nothing is on screen yet. -->
|
|
<div ref="announcementBarRef">
|
|
<!-- Webinar announcement bar: shown above toolbar for cloud users -->
|
|
<div
|
|
v-if="config.isCloud === 'true'"
|
|
class="bg-button-primary text-button-primary-foreground text-center"
|
|
>
|
|
<WebinarBanner variant="header" />
|
|
</div>
|
|
|
|
<!-- Operator-authored announcement bars (enterprise) -->
|
|
<AnnouncementBanner v-if="config.isEnterprise === 'true'" />
|
|
</div>
|
|
|
|
<!-- Header component containing logo, navigation, and user controls -->
|
|
<AppHeader
|
|
:store="store"
|
|
:router="router"
|
|
:config="config"
|
|
:user="user"
|
|
:slack-icon="slackIcon"
|
|
:zo-backend-url="zoBackendUrl"
|
|
:lang-list="langList"
|
|
:selected-language="selectedLanguage"
|
|
:selected-org="selectedOrg"
|
|
:user-clicked-org="userClickedOrg"
|
|
:organizations="orgOptions"
|
|
:is-hovered="isHovered"
|
|
:get-btn-logo="getBtnLogo"
|
|
@update:selected-org="selectedOrg = $event"
|
|
@update:is-hovered="isHovered = $event"
|
|
@update-organization="updateOrganization"
|
|
@go-to-home="goToHome"
|
|
@go-to-about="goToAbout"
|
|
@toggleAIChat="toggleAIChat"
|
|
@open-slack="openSlack"
|
|
@navigateToOpenAPI="navigateToOpenAPI"
|
|
@navigate-to-docs="navigateToDocs"
|
|
@change-language="changeLanguage"
|
|
@open-predefined-themes="openPredefinedThemes"
|
|
@open-shortcuts="openShortcutsList"
|
|
@signout="signout"
|
|
/>
|
|
</header>
|
|
|
|
<div class="flex min-h-0 flex-1">
|
|
<ONavbar
|
|
v-if="store.state.printMode !== true"
|
|
:links-list="navLinks"
|
|
:mini-mode="miniMode"
|
|
:visible="leftDrawerOpen"
|
|
@menu-hover="handleMenuHover"
|
|
/>
|
|
|
|
<div class="flex h-full min-h-0 min-w-0 flex-1">
|
|
<!-- Main Panel -->
|
|
<main
|
|
data-test="main-content"
|
|
class="bg-surface-chrome-deeper flex min-h-0 flex-col pe-2 pb-2"
|
|
:style="{
|
|
width: !store.state.isAiChatEnabled
|
|
? '100%'
|
|
: store.state.isAiChatExpanded
|
|
? '50%'
|
|
: '75%',
|
|
}"
|
|
>
|
|
<!-- Content card — all pages render inside this. The border stays present in both
|
|
themes (transparent in light) so toggling dark mode can't shift page content by 1px. -->
|
|
<div
|
|
class="bg-surface-base rounded-surface flex min-h-0 flex-1 flex-col overflow-hidden border shadow-md"
|
|
:class="isDark ? 'border-border-default' : 'border-transparent'"
|
|
>
|
|
<div
|
|
v-if="isLoading"
|
|
:key="store.state.selectedOrganization?.identifier"
|
|
class="o2-content-scroll h-full flex-1 overflow-y-auto"
|
|
>
|
|
<router-view v-slot="{ Component }">
|
|
<component :is="Component" class="h-full" @sendToAiChat="sendToAiChat" />
|
|
</router-view>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
<!-- Right Panel (AI Chat - unified for both general and context-specific usage) -->
|
|
<aside
|
|
v-show="store.state.isAiChatEnabled && isLoading"
|
|
class="o2-sidebar o2-sidebar-right bg-surface-chrome-deeper sticky top-[var(--navbar-height,2.25rem)] shrink-0 self-start overflow-y-auto"
|
|
:class="[
|
|
isDark ? 'dark-mode-chat-container' : 'light-mode-chat-container',
|
|
{ 'o2-sidebar--expanded': store.state.isAiChatExpanded },
|
|
// The chat is a floating card in both modes — match the main content
|
|
// card's right/bottom gap (+ rounded-surface corners) so they read as
|
|
// the same card. Expanding only widens it; it never overlays the header.
|
|
'pe-2 pb-2',
|
|
]"
|
|
:style="[
|
|
{
|
|
height: 'calc(100vh - var(--navbar-height, 2.25rem))',
|
|
maxWidth: '100%',
|
|
},
|
|
// Full-screen just widens the panel (25% → 50%) beside the content —
|
|
// same top position + height, so the main header stays visible.
|
|
store.state.isAiChatExpanded
|
|
? { width: '50%', minWidth: '18.75rem' }
|
|
: { width: '25%', minWidth: '4.688rem' },
|
|
]"
|
|
>
|
|
<O2AIChat
|
|
:header-height="40"
|
|
:is-open="store.state.isAiChatEnabled"
|
|
@close="closeChat"
|
|
:aiChatInputContext="aiChatInputContext"
|
|
:appendMode="aiChatAppendMode"
|
|
:aiChatPayload="aiChatPayload"
|
|
/>
|
|
</aside>
|
|
</div>
|
|
</div>
|
|
|
|
<ODialog
|
|
data-test="main-layout-get-started-dialog"
|
|
v-model:open="showGetStarted"
|
|
size="full"
|
|
:show-close="false"
|
|
>
|
|
<GetStarted @removeFirstTimeLogin="removeFirstTimeLogin" />
|
|
</ODialog>
|
|
<CommunitySlackInvite />
|
|
<PredefinedThemes />
|
|
<ShortcutCheatsheet v-model:open="showShortcuts" />
|
|
</div>
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
import ONavbar from "@/lib/core/Navbar/ONavbar.vue";
|
|
import type { NavItem } from "@/lib/core/Navbar/ONavbar.types";
|
|
import AppHeader from "../components/Header.vue";
|
|
import { raw, useI18nTyped } from "@/types/i18n";
|
|
import {
|
|
useLocalCurrentUser,
|
|
useLocalOrganization,
|
|
useLocalUserInfo,
|
|
getImageURL,
|
|
invalidateLoginData,
|
|
getDueDays,
|
|
trialPeriodAllowedPath,
|
|
emptyDataAllowedPaths,
|
|
} from "../utils/zincutils";
|
|
|
|
import {
|
|
ref,
|
|
defineComponent,
|
|
KeepAlive,
|
|
computed,
|
|
onMounted,
|
|
onUnmounted,
|
|
watch,
|
|
markRaw,
|
|
nextTick,
|
|
onBeforeMount,
|
|
} from "vue";
|
|
import { useStore } from "vuex";
|
|
import { useTheme } from "@/composables/useTheme";
|
|
import { useRouter, RouterView } from "vue-router";
|
|
import config from "../aws-exports";
|
|
|
|
import { setLanguage } from "../utils/cookies";
|
|
import { getLocale } from "../locales";
|
|
|
|
import MainLayoutOpenSourceMixin from "@/mixins/mainLayout.mixin";
|
|
import MainLayoutCloudMixin from "@/enterprise/mixins/mainLayout.mixin";
|
|
|
|
import configService from "@/services/config";
|
|
import ThemeSwitcher from "../components/ThemeSwitcher.vue";
|
|
import PredefinedThemes from "../components/PredefinedThemes.vue";
|
|
import { usePredefinedThemes } from "@/composables/usePredefinedThemes";
|
|
import GetStarted from "@/components/login/GetStarted.vue";
|
|
import CommunitySlackInvite from "@/components/CommunitySlackInvite.vue";
|
|
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
|
|
import SlackIcon from "@/components/icons/SlackIcon.vue";
|
|
import ManagementIcon from "@/components/icons/ManagementIcon.vue";
|
|
import organizations from "@/services/organizations";
|
|
import useStreams from "@/composables/useStreams";
|
|
import { openobserveRum } from "@openobserve/browser-rum";
|
|
import useSearchWebSocket from "@/composables/useSearchWebSocket";
|
|
import O2AIChat from "@/components/O2AIChat.vue";
|
|
import WebinarBanner from "@/components/WebinarBanner.vue";
|
|
import AnnouncementBanner from "@/components/announcements/AnnouncementBanner.vue";
|
|
import useRoutePrefetch from "@/composables/useRoutePrefetch";
|
|
import { toast, dismissAll } from "@/lib/feedback/Toast/useToast";
|
|
import { useShortcuts } from "@/lib/vue-shortcut-manager";
|
|
import { ShortcutCheatsheet } from "@/lib/vue-shortcut-manager";
|
|
import { useHomeDashboard } from "@/composables/useHomeDashboard";
|
|
|
|
let mainLayoutMixin: any = null;
|
|
if (config.isCloud == "true") {
|
|
mainLayoutMixin = MainLayoutCloudMixin;
|
|
} else {
|
|
mainLayoutMixin = MainLayoutOpenSourceMixin;
|
|
}
|
|
|
|
export default defineComponent({
|
|
name: "MainLayout",
|
|
mixins: [mainLayoutMixin],
|
|
components: {
|
|
AppHeader,
|
|
WebinarBanner,
|
|
AnnouncementBanner,
|
|
"keep-alive": KeepAlive,
|
|
ONavbar,
|
|
"router-view": RouterView,
|
|
SlackIcon,
|
|
ManagementIcon,
|
|
ThemeSwitcher,
|
|
PredefinedThemes,
|
|
O2AIChat,
|
|
ShortcutCheatsheet,
|
|
GetStarted,
|
|
CommunitySlackInvite,
|
|
ODialog,
|
|
},
|
|
methods: {
|
|
navigateToDocs() {
|
|
let docURL = "https://openobserve.ai/docs";
|
|
if (this.config.isEnterprise == "true" && this.store.state.zoConfig.custom_docs_url != "") {
|
|
docURL = this.store.state.zoConfig.custom_docs_url;
|
|
}
|
|
window.open(docURL, "_blank");
|
|
},
|
|
navigateToOpenAPI(zoBackendUrl: string) {
|
|
window.open(zoBackendUrl + "/swagger/index.html", "_blank");
|
|
},
|
|
signout() {
|
|
this.closeSocket();
|
|
|
|
// AI chat streams outlive their component by design, so navigating away
|
|
// doesn't kill an answer. Logout has to stop them explicitly — otherwise
|
|
// they keep streaming and writing chat history after sign-out.
|
|
window.dispatchEvent(new Event("o2:abort-ai-streams"));
|
|
|
|
// Clear any open notifications so they don't carry over past logout.
|
|
dismissAll();
|
|
|
|
// Stop session replay recording on logout
|
|
if (this.store.state.zoConfig?.rum?.enabled) {
|
|
openobserveRum.stopSessionReplayRecording();
|
|
}
|
|
|
|
// Always call backend logout to clear auth cookies (auth_tokens, auth_ext)
|
|
// before clearing local state — prevents stale credentials from causing
|
|
// 401 errors on the next login attempt (see #10900)
|
|
invalidateLoginData();
|
|
|
|
this.store.dispatch("logout");
|
|
|
|
useLocalCurrentUser("", true);
|
|
useLocalUserInfo("", true);
|
|
|
|
this.$router.push("/logout");
|
|
},
|
|
goToHome() {
|
|
this.$router.push({
|
|
path: "/",
|
|
query: {
|
|
org_identifier: this.store.state.selectedOrganization.identifier,
|
|
},
|
|
});
|
|
},
|
|
goToAbout() {
|
|
this.$router.push({
|
|
path: "/about",
|
|
query: {
|
|
org_identifier: this.store.state.selectedOrganization.identifier,
|
|
},
|
|
});
|
|
},
|
|
changeLanguage(item: { code: string; label: string }) {
|
|
setLanguage(item.code);
|
|
window.location.reload();
|
|
},
|
|
},
|
|
setup() {
|
|
const store: any = useStore();
|
|
const { isDark } = useTheme();
|
|
const router: any = useRouter();
|
|
const { t } = useI18nTyped();
|
|
const miniMode = ref(false);
|
|
const zoBackendUrl = store.state.API_ENDPOINT;
|
|
const isLoading = ref(false);
|
|
|
|
const { getStreams, resetStreams } = useStreams(t);
|
|
const { closeSocket } = useSearchWebSocket();
|
|
const { isOpen: isPredefinedThemesOpen, toggleThemes } = usePredefinedThemes();
|
|
const { prefetchRoute } = useRoutePrefetch();
|
|
|
|
const isMonacoEditorLoaded = ref(false);
|
|
const showGetStarted = ref((localStorage.getItem("isFirstTimeLogin") ?? "false") === "true");
|
|
const isHovered = ref(false);
|
|
const aiChatInputContext = ref("");
|
|
const aiChatAppendMode = ref(true);
|
|
const aiChatPayload = ref<{
|
|
text: string;
|
|
autoSend: boolean;
|
|
id: number;
|
|
} | null>(null);
|
|
let customOrganization = Object.prototype.hasOwnProperty.call(
|
|
router.currentRoute.value.query,
|
|
"org_identifier",
|
|
)
|
|
? router.currentRoute.value.query.org_identifier
|
|
: undefined;
|
|
const selectedOrg = ref(store.state.selectedOrganization);
|
|
const userClickedOrg = ref(store.state.selectedOrganization);
|
|
const isIncidentsEnabled = computed(() => {
|
|
return (
|
|
(config.isEnterprise == "true" || config.isCloud == "true") &&
|
|
store.state.zoConfig.incidents_enabled
|
|
);
|
|
});
|
|
|
|
// Workflows — enterprise/cloud only (FD3). Build-time gate, no runtime flag.
|
|
// Enterprise/cloud build AND the backend `/config` flag `workflows_enabled`
|
|
// (enterprise `O2_WORKFLOWS_ENABLED`). Reactive so the menu picks it up
|
|
// regardless of whether the config response arrived before or after mount.
|
|
// `=== true`, not truthy: /config is fetched without await, so the flag is
|
|
// briefly undefined and the entry must stay hidden rather than flash in.
|
|
const isWorkflowsEnabled = computed(
|
|
() =>
|
|
(config.isEnterprise == "true" || config.isCloud == "true") &&
|
|
store.state.zoConfig?.workflows_enabled === true,
|
|
);
|
|
|
|
// Backend `/config` flag `online_evals_enabled` — controlled by
|
|
// enterprise `O2_EVAL_ENABLED`. Reactive so the menu picks it up regardless
|
|
// of whether the config response arrived before or after this component
|
|
// mounted.
|
|
const isOnlineEvalsEnabled = computed(() => {
|
|
return (
|
|
(config.isEnterprise == "true" || config.isCloud == "true") &&
|
|
Boolean(store.state.zoConfig?.online_evals_enabled)
|
|
);
|
|
});
|
|
|
|
// Backend `/config` flag `synthetics_enabled` — `ZO_SYNTHETICS_ENABLED`, and
|
|
// no longer an enterprise build check: synthetics ships in OSS, and only the
|
|
// private-agent path behind it is enterprise. Reactive so the menu picks it
|
|
// up regardless of whether the config response arrived before or after mount.
|
|
const isSyntheticsEnabled = computed(() => {
|
|
return Boolean(store.state.zoConfig?.synthetics_enabled);
|
|
});
|
|
|
|
// Real entries carry `identifier`; the placeholder literal only sets label/value.
|
|
const orgOptions = ref<Array<{ identifier?: string; [key: string]: unknown }>>([
|
|
{ label: Number, value: String },
|
|
]);
|
|
let slackURL = "https://short.openobserve.ai/community";
|
|
if (config.isEnterprise == "true" && store.state.zoConfig.custom_slack_url) {
|
|
slackURL = store.state.zoConfig.custom_slack_url;
|
|
}
|
|
|
|
let user = store.state.userInfo;
|
|
|
|
var linksList = ref<NavItem[]>([
|
|
{
|
|
title: t("menu.home"),
|
|
icon: "home",
|
|
link: "/",
|
|
exact: true,
|
|
name: "home",
|
|
},
|
|
{
|
|
title: t("menu.search"),
|
|
icon: "search",
|
|
link: "/logs",
|
|
name: "logs",
|
|
},
|
|
{
|
|
title: t("menu.metrics"),
|
|
icon: "bar-chart",
|
|
link: "/metrics",
|
|
name: "metrics",
|
|
},
|
|
{
|
|
title: t("menu.traces"),
|
|
icon: "account-tree",
|
|
link: "/traces",
|
|
name: "traces",
|
|
},
|
|
{
|
|
title: raw("RUM"),
|
|
icon: "devices",
|
|
link: "/rum",
|
|
name: "rum",
|
|
},
|
|
{
|
|
title: t("menu.dashboard"),
|
|
icon: "dashboard",
|
|
link: "/dashboards",
|
|
name: "dashboards",
|
|
},
|
|
{
|
|
title: t("menu.index"),
|
|
icon: "window",
|
|
link: "/streams",
|
|
name: "streams",
|
|
},
|
|
{
|
|
title: t("menu.alerts"),
|
|
icon: "shield-alert-outline",
|
|
link: "/alerts",
|
|
name: "alertList",
|
|
},
|
|
// Directly after Alerts, since an SLO is what an SLO alert burns against.
|
|
{
|
|
title: t("menu.slos"),
|
|
icon: "target",
|
|
link: "/slos",
|
|
name: "sloList",
|
|
},
|
|
{
|
|
title: t("menu.ingestion"),
|
|
icon: "data-plus-line",
|
|
link: "/ingestion",
|
|
name: "ingestion",
|
|
},
|
|
{
|
|
title: t("menu.iam"),
|
|
icon: "manage-accounts",
|
|
link: "/iam",
|
|
display: store.state?.currentuser?.role == "admin" ? true : false,
|
|
name: "iam",
|
|
},
|
|
{
|
|
title: t("menu.settings"),
|
|
icon: "settings",
|
|
link: "/settings",
|
|
name: "settings",
|
|
},
|
|
]);
|
|
|
|
// Reveal the rail only once its item list is settled — true immediately when
|
|
// config is cached, else set when getConfig() resolves. Avoids config-driven
|
|
// tiles popping in and shifting the layout.
|
|
const menuReady = ref(
|
|
!!(
|
|
store.state.zoConfig &&
|
|
Object.prototype.hasOwnProperty.call(store.state.zoConfig, "version") &&
|
|
store.state.zoConfig.version != ""
|
|
),
|
|
);
|
|
const navLinks = computed(() => (menuReady.value ? linksList.value : []));
|
|
|
|
const langList = [
|
|
{
|
|
label: raw("English"),
|
|
code: "en-us",
|
|
},
|
|
{
|
|
label: raw("Türkçe"),
|
|
code: "tr-turk",
|
|
},
|
|
{
|
|
label: raw("简体中文"),
|
|
code: "zh-cn",
|
|
},
|
|
{
|
|
label: raw("繁體中文"),
|
|
code: "zh-tw",
|
|
},
|
|
{
|
|
label: raw("Français"),
|
|
code: "fr",
|
|
},
|
|
{
|
|
label: raw("Español"),
|
|
code: "es",
|
|
},
|
|
{
|
|
label: raw("Deutsch"),
|
|
code: "de",
|
|
},
|
|
{
|
|
label: raw("Italiano"),
|
|
code: "it",
|
|
},
|
|
{
|
|
label: raw("日本語"),
|
|
code: "ja",
|
|
},
|
|
{
|
|
label: raw("한국어"),
|
|
code: "ko",
|
|
},
|
|
{
|
|
label: raw("Nederlands"),
|
|
code: "nl",
|
|
},
|
|
{
|
|
label: raw("Português"),
|
|
code: "pt",
|
|
},
|
|
{
|
|
label: raw("Русский"),
|
|
code: "ru",
|
|
},
|
|
{
|
|
label: raw("Polski"),
|
|
code: "pl",
|
|
},
|
|
{
|
|
label: raw("Tiếng Việt"),
|
|
code: "vi",
|
|
},
|
|
];
|
|
|
|
onBeforeMount(() => {
|
|
try {
|
|
const url = new URL(window.location.href);
|
|
const localOrg: any = useLocalOrganization();
|
|
if (
|
|
Object.keys(localOrg.value).length == 0 &&
|
|
url.searchParams.get("org_identifier") != null
|
|
) {
|
|
localOrg.value = {
|
|
identifier: url.searchParams.get("org_identifier"),
|
|
user_email: store.state.userInfo.email,
|
|
};
|
|
|
|
selectedOrg.value = localOrg.value;
|
|
useLocalOrganization(localOrg.value);
|
|
store.dispatch("setSelectedOrganization", localOrg.value);
|
|
}
|
|
} catch (error) {
|
|
console.error("Error in onBeforeMount:", error);
|
|
}
|
|
});
|
|
|
|
// Measured rather than assumed. The strip now holds two independent bars
|
|
// (the cloud webinar promo and any number of operator-authored banners),
|
|
// each of which wraps its own text, so no fixed value can describe it — the
|
|
// old `isWebinarBannerVisible` two-value calc only ever fit one bar of one
|
|
// line. WebinarBanner still dispatches that flag; nothing reads it for
|
|
// height any more.
|
|
const announcementBarRef = ref<HTMLElement | null>(null);
|
|
let announcementBarObserver: ResizeObserver | null = null;
|
|
|
|
const setNavbarHeight = (barHeightPx: number) => {
|
|
const barHeightRem = barHeightPx / 16;
|
|
document.documentElement.style.setProperty("--navbar-height", `${2.5 + barHeightRem}rem`);
|
|
};
|
|
|
|
onMounted(() => {
|
|
setNavbarHeight(announcementBarRef.value?.offsetHeight ?? 0);
|
|
|
|
if (announcementBarRef.value && typeof ResizeObserver !== "undefined") {
|
|
announcementBarObserver = new ResizeObserver(([entry]) => {
|
|
setNavbarHeight(entry.contentRect.height);
|
|
});
|
|
announcementBarObserver.observe(announcementBarRef.value);
|
|
}
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
announcementBarObserver?.disconnect();
|
|
announcementBarObserver = null;
|
|
});
|
|
|
|
const needsFullConfig = () =>
|
|
!Object.prototype.hasOwnProperty.call(store.state.zoConfig, "version") ||
|
|
store.state.zoConfig.version == "";
|
|
|
|
// Which org the full config was fetched for — the response carries
|
|
// org-scoped fields (e.g. per-user permission flags), so an org switch
|
|
// must refetch even when a config is already loaded.
|
|
let fullConfigOrg = "";
|
|
|
|
// The full config endpoint is authenticated and org-scoped; on a fresh
|
|
// session the org can resolve after this component mounts.
|
|
watch(
|
|
() => store.state.selectedOrganization?.identifier,
|
|
(identifier) => {
|
|
if (identifier && (needsFullConfig() || identifier !== fullConfigOrg)) {
|
|
getConfig();
|
|
}
|
|
},
|
|
);
|
|
|
|
onMounted(async () => {
|
|
filterMenus();
|
|
|
|
// TODO OK : Clean get config functions which sets rum user and functions menu. Move it to common method.
|
|
if (needsFullConfig()) {
|
|
getConfig();
|
|
} else {
|
|
if (config.isCloud == "false") {
|
|
linksList.value = mainLayoutMixin.setup().leftNavigationLinks(linksList, t);
|
|
filterMenus();
|
|
}
|
|
menuReady.value = true;
|
|
await nextTick();
|
|
// if rum enabled then setUser to capture session details.
|
|
if (store.state.zoConfig.rum?.enabled) {
|
|
setRumUser();
|
|
}
|
|
}
|
|
});
|
|
|
|
const updateIncidentsMenu = () => {
|
|
if (isIncidentsEnabled.value) {
|
|
const alertIndex = linksList.value.findIndex((link) => link.name === "alertList");
|
|
|
|
const incidentExists = linksList.value.some((link) => link.name === "incidentList");
|
|
|
|
if (alertIndex !== -1 && !incidentExists) {
|
|
linksList.value.splice(alertIndex + 1, 0, {
|
|
title: t("menu.incidents"),
|
|
icon: "notifications-active",
|
|
link: "/incidents",
|
|
name: "incidentList",
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
// Insert the Workflows entry after Alerts. Idempotent.
|
|
const updateWorkflowsMenu = () => {
|
|
const existingIndex = linksList.value.findIndex((link) => link.name === "workflows");
|
|
|
|
if (isWorkflowsEnabled.value) {
|
|
if (existingIndex !== -1) return;
|
|
|
|
const anchor = linksList.value.findIndex((link) => link.name === "alertList");
|
|
if (anchor === -1) return;
|
|
|
|
linksList.value.splice(anchor + 1, 0, {
|
|
title: t("menu.workflows"),
|
|
icon: "schema",
|
|
link: "/workflows",
|
|
name: "workflows",
|
|
});
|
|
} else if (existingIndex !== -1) {
|
|
// The entry must be REMOVED, not just skipped: the menu is rebuilt on
|
|
// org switch and `workflows_enabled` can differ per deployment, so an
|
|
// add-only guard would leave a stale entry behind.
|
|
linksList.value.splice(existingIndex, 1);
|
|
}
|
|
};
|
|
|
|
// If `/config` resolves after this component mounted (or the flag flips),
|
|
// keep the menu in sync — same contract as the other flag-driven entries.
|
|
watch(isWorkflowsEnabled, () => updateWorkflowsMenu(), { immediate: false });
|
|
const splitterModel = ref(100);
|
|
const selectedLanguage: any = langList.find((l) => l.code == getLocale()) || langList[0];
|
|
|
|
// Insert / remove the AI Observability menu entry based on the live config
|
|
// flag. Position: directly after Traces. Idempotent — safe to call from
|
|
// multiple lifecycle hooks.
|
|
const updateAIObservabilityMenu = () => {
|
|
const existingIndex = linksList.value.findIndex(
|
|
(link: any) => link.name === "aiObservability",
|
|
);
|
|
|
|
if (isOnlineEvalsEnabled.value) {
|
|
if (existingIndex !== -1) return;
|
|
const tracesIndex = linksList.value.findIndex((link: any) => link.name === "traces");
|
|
const insertAt = tracesIndex === -1 ? linksList.value.length : tracesIndex + 1;
|
|
linksList.value.splice(insertAt, 0, {
|
|
title: t("menu.aiObservability"),
|
|
icon: "auto-awesome",
|
|
link: "/ai",
|
|
name: "aiObservability",
|
|
});
|
|
} else if (existingIndex !== -1) {
|
|
linksList.value.splice(existingIndex, 1);
|
|
}
|
|
};
|
|
|
|
// If `/config` resolves after this component mounted (or if the flag
|
|
// ever flips at runtime), keep the menu in sync.
|
|
watch(isOnlineEvalsEnabled, () => updateAIObservabilityMenu(), { immediate: false });
|
|
|
|
const updateSyntheticMenu = () => {
|
|
const existingIndex = linksList.value.findIndex((l: any) => l.name === "synthetics");
|
|
|
|
if (!isSyntheticsEnabled.value) {
|
|
if (existingIndex !== -1) linksList.value.splice(existingIndex, 1);
|
|
return;
|
|
}
|
|
if (existingIndex !== -1) return;
|
|
|
|
const incidentIndex = linksList.value.findIndex((l: any) => l.name === "incidentList");
|
|
const alertIndex = linksList.value.findIndex((l: any) => l.name === "alertList");
|
|
const insertAt =
|
|
incidentIndex !== -1
|
|
? incidentIndex + 1
|
|
: alertIndex !== -1
|
|
? alertIndex + 1
|
|
: linksList.value.length;
|
|
|
|
linksList.value.splice(insertAt, 0, {
|
|
title: t("menu.synthetic"),
|
|
icon: "radar",
|
|
link: "/synthetics",
|
|
name: "synthetics",
|
|
});
|
|
};
|
|
|
|
// Keep the menu in sync if /config resolves after mount.
|
|
watch(isSyntheticsEnabled, () => updateSyntheticMenu(), {
|
|
immediate: false,
|
|
});
|
|
|
|
const filterMenus = () => {
|
|
updateIncidentsMenu();
|
|
updateWorkflowsMenu();
|
|
updateSyntheticMenu();
|
|
updateAIObservabilityMenu();
|
|
|
|
const disableMenus = new Set(
|
|
store.state.zoConfig?.custom_hide_menus?.split(",")?.filter((val: string) => val?.trim()) ||
|
|
[],
|
|
);
|
|
|
|
store.dispatch("setHiddenMenus", disableMenus);
|
|
|
|
linksList.value = linksList.value.filter((link: any) => {
|
|
const hide = link.hide === undefined ? false : link.hide;
|
|
|
|
return !disableMenus.has(link.name) && !hide;
|
|
});
|
|
};
|
|
|
|
// additional links based on environment and conditions
|
|
if (config.isCloud == "true") {
|
|
linksList.value = mainLayoutMixin.setup().leftNavigationLinks(linksList, t);
|
|
filterMenus();
|
|
} else {
|
|
linksList.value.splice(7, 0, {
|
|
title: t("menu.report"),
|
|
icon: "description",
|
|
link: "/reports",
|
|
name: "reports",
|
|
});
|
|
filterMenus();
|
|
}
|
|
|
|
//orgIdentifier query param exists then clear the localstorage and store.
|
|
if (store.state.selectedOrganization != null) {
|
|
if (
|
|
mainLayoutMixin.setup().customOrganization != undefined &&
|
|
mainLayoutMixin.setup().customOrganization != store.state.selectedOrganization?.identifier
|
|
) {
|
|
useLocalOrganization("");
|
|
store.dispatch("setSelectedOrganization", {});
|
|
}
|
|
}
|
|
|
|
const updateOrganization = async () => {
|
|
resetStreams();
|
|
store.dispatch("logs/resetLogs");
|
|
store.dispatch("setIsDataIngested", false);
|
|
const orgIdentifier = selectedOrg.value.identifier;
|
|
const queryParams =
|
|
router.currentRoute.value.path.indexOf(".logs") > -1 ? router.currentRoute.value.query : {};
|
|
router.push({
|
|
path: router.currentRoute.value.path,
|
|
query: {
|
|
...queryParams,
|
|
org_identifier: orgIdentifier,
|
|
},
|
|
});
|
|
useLocalOrganization(selectedOrg.value);
|
|
store.dispatch("setSelectedOrganization", { ...selectedOrg.value });
|
|
// setSelectedOrganization();
|
|
// if (
|
|
// config.isCloud &&
|
|
// selectedOrg.value.subscription_type == config.freePlan
|
|
// ) {
|
|
// await billings
|
|
// .list_subscription(selectedOrg.value.identifier)
|
|
// .then(async (res: any) => {
|
|
// if (res.data.data.length == 0) {
|
|
// router.push({ name: "plans" });
|
|
// } else if (
|
|
// res.data.data.CustomerBillingObj.customer_id == null ||
|
|
// res.data.data.CustomerBillingObj.customer_id == ""
|
|
// ) {
|
|
// router.push({ name: "plans" });
|
|
// } else {
|
|
// await verifyStreamExist(selectedOrg.value);
|
|
// }
|
|
// });
|
|
// } else {
|
|
if (
|
|
Object.prototype.hasOwnProperty.call(
|
|
store.state.zoConfig,
|
|
"restricted_routes_on_empty_data",
|
|
) &&
|
|
store.state.zoConfig.restricted_routes_on_empty_data == true &&
|
|
store.state.organizationData.isDataIngested == false
|
|
) {
|
|
await verifyStreamExist(selectedOrg.value);
|
|
}
|
|
};
|
|
|
|
const verifyStreamExist = async (selectedOrgData: any) => {
|
|
await getStreams("", false).then((response: any) => {
|
|
store.dispatch("setSelectedOrganization", {
|
|
...selectedOrgData,
|
|
});
|
|
if (response.list.length == 0) {
|
|
store.dispatch("setIsDataIngested", false);
|
|
// IAM is org-setup, not data consumption — don't bounce out of IAM
|
|
// screens just because no streams exist yet. General Settings is exempt
|
|
// because it hosts the Danger Zone: switching to an empty org must still
|
|
// leave the admin able to delete it. Mirrors the routeGuard exemptions —
|
|
// General only, not the rest of the Settings tree.
|
|
const currentPath = router.currentRoute.value.path || "";
|
|
if (
|
|
currentPath.indexOf("/iam") !== -1 ||
|
|
emptyDataAllowedPaths.indexOf(currentPath.replace(/\/$/, "")) !== -1
|
|
) {
|
|
return;
|
|
}
|
|
toast({
|
|
variant: "warning",
|
|
message: t("toastMessages.layouts.ingestionNotStarted"),
|
|
timeout: 5000,
|
|
});
|
|
router.push({ name: "ingestion" });
|
|
} else {
|
|
store.dispatch("setIsDataIngested", true);
|
|
}
|
|
});
|
|
};
|
|
|
|
const setSelectedOrganization = async () => {
|
|
try {
|
|
customOrganization = Object.prototype.hasOwnProperty.call(
|
|
router.currentRoute.value.query,
|
|
"org_identifier",
|
|
)
|
|
? router.currentRoute.value.query.org_identifier
|
|
: "";
|
|
let tempDefaultOrg = {};
|
|
let localOrgFlag = false;
|
|
const url = new URL(window.location.href);
|
|
|
|
// If the org the user is currently on (URL or stored) is no longer in the
|
|
// available list, it is being deleted (the backend hides deleting orgs from
|
|
// this list). Warn the user, then fall through to default-org selection and
|
|
// redirect home so the stale org_identifier query param is dropped.
|
|
const intendedOrgId =
|
|
customOrganization || (useLocalOrganization()?.value?.identifier ?? "");
|
|
const orgs = store.state.organizations || [];
|
|
if (
|
|
intendedOrgId &&
|
|
orgs.length > 0 &&
|
|
!orgs.some((o: any) => o.identifier === intendedOrgId)
|
|
) {
|
|
toast({
|
|
variant: "warning",
|
|
message: t("organization.orgBeingDeletedSwitching"),
|
|
});
|
|
// Clear stale selection so the logic below picks the default org.
|
|
customOrganization = "";
|
|
useLocalOrganization("");
|
|
selectedOrg.value = {};
|
|
store.dispatch("setSelectedOrganization", {});
|
|
if (router.currentRoute.value.query.org_identifier) {
|
|
router.replace({ path: "/", query: {} });
|
|
}
|
|
}
|
|
if (store.state.organizations?.length > 0) {
|
|
const localOrg: any = useLocalOrganization();
|
|
if (
|
|
Object.keys(localOrg.value).length == 0 &&
|
|
url.searchParams.get("org_identifier") != null
|
|
) {
|
|
localOrg.value = {
|
|
identifier: url.searchParams.get("org_identifier"),
|
|
user_email: store.state.userInfo.email,
|
|
};
|
|
}
|
|
orgOptions.value = store.state.organizations
|
|
.map(
|
|
(data: {
|
|
id: any;
|
|
name: any;
|
|
type: any;
|
|
identifier: any;
|
|
UserObj: any;
|
|
ingest_threshold: number;
|
|
search_threshold: number;
|
|
CustomerBillingObj: { subscription_type: string; note: string };
|
|
status: string;
|
|
}) => {
|
|
const optiondata: any = {
|
|
label: data.name,
|
|
id: data.id,
|
|
identifier: data.identifier,
|
|
user_email: store.state.userInfo.email,
|
|
ingest_threshold: data.ingest_threshold,
|
|
search_threshold: data.search_threshold,
|
|
subscription_type: Object.prototype.hasOwnProperty.call(
|
|
data,
|
|
"CustomerBillingObj",
|
|
)
|
|
? data.CustomerBillingObj.subscription_type
|
|
: "",
|
|
status: data.status,
|
|
note: Object.prototype.hasOwnProperty.call(data, "CustomerBillingObj")
|
|
? data.CustomerBillingObj.note
|
|
: "",
|
|
};
|
|
|
|
if (
|
|
config.isCloud == "true" &&
|
|
localOrg.value?.identifier == data?.identifier &&
|
|
(customOrganization == "" || customOrganization == undefined)
|
|
) {
|
|
// localOrg.value.subscription_type =
|
|
// data.CustomerBillingObj.subscription_type;
|
|
// useLocalOrganization(localOrg.value);
|
|
useLocalOrganization(optiondata);
|
|
}
|
|
|
|
if (
|
|
localOrg.value.identifier == data.identifier ||
|
|
url.searchParams.get("org_identifier") == data.identifier
|
|
) {
|
|
localOrgFlag = true;
|
|
}
|
|
|
|
if (
|
|
(Object.keys(selectedOrg.value).length == 0 &&
|
|
data.type == "default" &&
|
|
store.state.userInfo.email == data.UserObj.email &&
|
|
(customOrganization == "" || customOrganization == undefined)) ||
|
|
(store.state.organizations?.length == 1 &&
|
|
(customOrganization == "" || customOrganization == undefined))
|
|
) {
|
|
selectedOrg.value = localOrg.value ? localOrg.value : optiondata;
|
|
useLocalOrganization(optiondata);
|
|
store.dispatch("setSelectedOrganization", optiondata);
|
|
} else if (data.identifier == customOrganization) {
|
|
selectedOrg.value = optiondata;
|
|
useLocalOrganization(optiondata);
|
|
store.dispatch("setSelectedOrganization", optiondata);
|
|
}
|
|
|
|
if (data.type == "default") {
|
|
tempDefaultOrg = optiondata;
|
|
}
|
|
|
|
return optiondata;
|
|
},
|
|
)
|
|
.sort((a: any, b: any) => a.label.localeCompare(b.label));
|
|
}
|
|
|
|
if (localOrgFlag == false) {
|
|
selectedOrg.value = tempDefaultOrg;
|
|
useLocalOrganization(tempDefaultOrg);
|
|
store.dispatch("setSelectedOrganization", tempDefaultOrg);
|
|
}
|
|
|
|
if (Object.keys(selectedOrg.value).length == 0 && store.state.organizations.length > 0) {
|
|
let data = store.state.organizations[0];
|
|
let optiondata = {
|
|
label: data.name,
|
|
id: data.id,
|
|
identifier: data.identifier,
|
|
user_email: store.state.userInfo.email,
|
|
ingest_threshold: data.ingest_threshold,
|
|
search_threshold: data.search_threshold,
|
|
subscription_type: Object.prototype.hasOwnProperty.call(data, "CustomerBillingObj")
|
|
? data.CustomerBillingObj.subscription_type
|
|
: "",
|
|
status: data.status,
|
|
note: Object.prototype.hasOwnProperty.call(data, "CustomerBillingObj")
|
|
? data.CustomerBillingObj.note
|
|
: "",
|
|
};
|
|
selectedOrg.value = optiondata;
|
|
useLocalOrganization(optiondata);
|
|
store.dispatch("setSelectedOrganization", optiondata);
|
|
}
|
|
|
|
if (router.currentRoute.value.query.action == "subscribe") {
|
|
router.push({
|
|
name: "plans",
|
|
query: {
|
|
org_identifier: selectedOrg.value.identifier,
|
|
},
|
|
});
|
|
}
|
|
|
|
if (
|
|
Object.keys(selectedOrg.value).length > 0 &&
|
|
selectedOrg.value.identifier != "" &&
|
|
selectedOrg.value.identifier != undefined
|
|
) {
|
|
await getOrganizationSettings();
|
|
isLoading.value = true;
|
|
}
|
|
} catch (error) {
|
|
console.error("Error in setSelectedOrganization:", error);
|
|
}
|
|
};
|
|
|
|
// get organizations settings on first load and identifier change
|
|
const getOrganizationSettings = async () => {
|
|
// Default settings to use if API call fails
|
|
const defaultSettings = {
|
|
scrape_interval: 15,
|
|
span_id_field_name: "spanId",
|
|
trace_id_field_name: "traceId",
|
|
toggle_ingestion_logs: false,
|
|
usage_stream_enabled: false,
|
|
enable_websocket_search: false,
|
|
enable_streaming_search: false,
|
|
streaming_aggregation_enabled: false,
|
|
free_trial_expiry: "",
|
|
light_mode_theme_color: undefined,
|
|
dark_mode_theme_color: undefined,
|
|
claim_parser_function: "",
|
|
org_storage_enabled: false,
|
|
};
|
|
|
|
try {
|
|
//get organizations settings
|
|
const orgSettings: any = await organizations.get_organization_settings(
|
|
store.state?.selectedOrganization?.identifier,
|
|
);
|
|
|
|
//set settings in store
|
|
//scrape interval will be in number
|
|
store.dispatch("setOrganizationSettings", {
|
|
scrape_interval:
|
|
orgSettings?.data?.data?.scrape_interval ?? defaultSettings.scrape_interval,
|
|
span_id_field_name:
|
|
orgSettings?.data?.data?.span_id_field_name ?? defaultSettings.span_id_field_name,
|
|
trace_id_field_name:
|
|
orgSettings?.data?.data?.trace_id_field_name ?? defaultSettings.trace_id_field_name,
|
|
toggle_ingestion_logs:
|
|
orgSettings?.data?.data?.toggle_ingestion_logs ?? defaultSettings.toggle_ingestion_logs,
|
|
usage_stream_enabled:
|
|
orgSettings?.data?.data?.usage_stream_enabled ?? defaultSettings.usage_stream_enabled,
|
|
enable_websocket_search:
|
|
orgSettings?.data?.data?.enable_websocket_search ??
|
|
defaultSettings.enable_websocket_search,
|
|
enable_streaming_search:
|
|
orgSettings?.data?.data?.enable_streaming_search ??
|
|
defaultSettings.enable_streaming_search,
|
|
streaming_aggregation_enabled:
|
|
orgSettings?.data?.data?.streaming_aggregation_enabled ??
|
|
defaultSettings.streaming_aggregation_enabled,
|
|
free_trial_expiry:
|
|
orgSettings?.data?.data?.free_trial_expiry ?? defaultSettings.free_trial_expiry,
|
|
light_mode_theme_color: orgSettings?.data?.data?.light_mode_theme_color,
|
|
dark_mode_theme_color: orgSettings?.data?.data?.dark_mode_theme_color,
|
|
claim_parser_function:
|
|
orgSettings?.data?.data?.claim_parser_function ?? defaultSettings.claim_parser_function,
|
|
cross_links: orgSettings?.data?.data?.cross_links ?? [],
|
|
org_storage_enabled:
|
|
orgSettings?.data?.data?.org_storage_enabled ?? defaultSettings.org_storage_enabled,
|
|
});
|
|
|
|
// Load the org's home dashboard (settings/v2 KV) alongside the legacy org
|
|
// settings so it's available on boot and every org switch.
|
|
await useHomeDashboard(t).load(store.state?.selectedOrganization?.identifier);
|
|
|
|
if (
|
|
orgSettings?.data?.data?.free_trial_expiry != null &&
|
|
orgSettings?.data?.data?.free_trial_expiry != ""
|
|
) {
|
|
const trialDueDays = getDueDays(orgSettings?.data?.data?.free_trial_expiry);
|
|
if (
|
|
trialDueDays <= 0 &&
|
|
trialPeriodAllowedPath.indexOf(router.currentRoute.value.name) == -1
|
|
) {
|
|
router.push({
|
|
name: "plans",
|
|
query: {
|
|
org_identifier: selectedOrg.value.identifier,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
} catch (error: any) {
|
|
// Handle permission errors gracefully (403 = Forbidden)
|
|
if (error?.response?.status === 403) {
|
|
console.warn("Organization settings access denied (403). Using default settings.");
|
|
// Set default settings when access is denied
|
|
store.dispatch("setOrganizationSettings", defaultSettings);
|
|
} else {
|
|
console.error("Error in getOrganizationSettings:", error);
|
|
// Still set defaults for other errors to prevent undefined values
|
|
store.dispatch("setOrganizationSettings", defaultSettings);
|
|
}
|
|
}
|
|
return;
|
|
};
|
|
|
|
/**
|
|
* Get the full authenticated configuration from the backend. The
|
|
* unauthenticated bootstrap fetched in main.ts carries only the login-page
|
|
* fields; everything menu- and feature-related arrives here.
|
|
* @throws {Error} If the request fails.
|
|
*/
|
|
const FULL_CONFIG_RETRY_DELAYS_MS = [2000, 5000, 15000];
|
|
|
|
const getConfig = async (attempt = 0) => {
|
|
const orgIdentifier = store.state.selectedOrganization?.identifier;
|
|
if (!orgIdentifier) {
|
|
// No org yet on a fresh session — the selectedOrganization watcher
|
|
// retries as soon as one is resolved. Fail open meanwhile: a user whose
|
|
// org never resolves (org-list failure, zero orgs) must still see the
|
|
// base menu, not a blank shell.
|
|
menuReady.value = true;
|
|
return;
|
|
}
|
|
await configService
|
|
.get_config_full(orgIdentifier)
|
|
.then(async (res: any) => {
|
|
if (config.isCloud == "false") {
|
|
linksList.value = mainLayoutMixin.setup().leftNavigationLinks(linksList, t);
|
|
}
|
|
|
|
store.dispatch("setConfig", res.data);
|
|
fullConfigOrg = orgIdentifier;
|
|
await nextTick();
|
|
|
|
filterMenus();
|
|
menuReady.value = true;
|
|
// if rum enabled then setUser to capture session details.
|
|
if (res.data.rum.enabled) {
|
|
setRumUser();
|
|
}
|
|
// The empty-data → /ingestion redirect depends on
|
|
// restricted_routes_on_empty_data, which only arrives with the full
|
|
// config — the first navigation ran before it landed, so re-check now.
|
|
if (
|
|
res.data.restricted_routes_on_empty_data === true &&
|
|
store.state.organizationData.isDataIngested === false
|
|
) {
|
|
await verifyStreamExist(store.state.selectedOrganization);
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
console.error("Failed to load the full configuration:", error);
|
|
// Fail open: reveal the base menu even if the config never resolves.
|
|
menuReady.value = true;
|
|
// Session replay must not be lost to a failed config fetch — the rum
|
|
// settings already arrived with the unauthenticated bootstrap.
|
|
if (store.state.zoConfig?.rum?.enabled) {
|
|
setRumUser();
|
|
}
|
|
// A transient failure (expired session being refreshed, rolling
|
|
// deploy) would otherwise strand the session on the bootstrap subset.
|
|
if (attempt < FULL_CONFIG_RETRY_DELAYS_MS.length) {
|
|
setTimeout(() => {
|
|
if (needsFullConfig()) {
|
|
getConfig(attempt + 1);
|
|
}
|
|
}, FULL_CONFIG_RETRY_DELAYS_MS[attempt]);
|
|
}
|
|
});
|
|
};
|
|
|
|
if (config.isCloud == "true") {
|
|
mainLayoutMixin.setup().getDefaultOrganization(store);
|
|
}
|
|
|
|
const setRumUser = () => {
|
|
if (store.state.zoConfig?.rum?.enabled == true) {
|
|
const userInfo = store.state.userInfo;
|
|
// Set user information first
|
|
openobserveRum.setUser({
|
|
name: userInfo.given_name + " " + userInfo.family_name,
|
|
email: userInfo.email,
|
|
});
|
|
// Start session replay recording after user is identified
|
|
// This handles cases where user refreshes the page or accesses app directly
|
|
openobserveRum.startSessionReplayRecording({ force: true });
|
|
}
|
|
};
|
|
|
|
const prefetch = () => {
|
|
const href = "/web/assets/editor.api.v1.js";
|
|
const existingLink = document.querySelector(`link[rel="prefetch"][href="${href}"]`);
|
|
|
|
if (!existingLink) {
|
|
// Create a new link element
|
|
isMonacoEditorLoaded.value = true;
|
|
const link = document.createElement("link");
|
|
link.rel = "prefetch";
|
|
link.href = href;
|
|
document.head.appendChild(link);
|
|
}
|
|
};
|
|
|
|
const expandMenu = () => {
|
|
// miniMode.value = false;
|
|
if (!isMonacoEditorLoaded.value) prefetch();
|
|
};
|
|
|
|
const openSlack = () => {
|
|
window.open(slackURL, "_blank");
|
|
};
|
|
|
|
const toggleAIChat = () => {
|
|
// ai_enabled arrives with the async /config response, so it can't gate registration.
|
|
if (!store.state.zoConfig.ai_enabled) return;
|
|
// On the home page, switch to the AI tab instead of opening the side panel
|
|
if (router.currentRoute.value.name === "home") {
|
|
window.dispatchEvent(new CustomEvent("o2:home-switch-tab", { detail: "ai" }));
|
|
return;
|
|
}
|
|
if (!store.state.isAiChatEnabled) {
|
|
// Closed → Open inline sidebar
|
|
store.dispatch("setIsAiChatEnabled", true);
|
|
store.dispatch("setIsAiChatExpanded", false);
|
|
} else if (!store.state.isAiChatExpanded) {
|
|
// Inline sidebar → Close
|
|
store.dispatch("setIsAiChatEnabled", false);
|
|
store.dispatch("setIsAiChatExpanded", false);
|
|
} else {
|
|
// Expanded overlay → Back to inline sidebar
|
|
store.dispatch("setIsAiChatExpanded", false);
|
|
}
|
|
window.dispatchEvent(new Event("resize"));
|
|
};
|
|
|
|
const closeChat = () => {
|
|
store.dispatch("setIsAiChatEnabled", false);
|
|
store.dispatch("setIsAiChatExpanded", false);
|
|
window.dispatchEvent(new Event("resize"));
|
|
};
|
|
|
|
const getBtnLogo = computed(() => {
|
|
if (isDark.value) {
|
|
return getImageURL("images/common/ai_icon_dark.svg");
|
|
}
|
|
|
|
if (isHovered.value) {
|
|
return getImageURL("images/common/ai_icon_dark.svg");
|
|
}
|
|
|
|
return getImageURL("images/common/ai_icon_gradient.svg");
|
|
});
|
|
//this will be the function used to cancel the get started dialog and remove the isFirstTimeLogin from local storage
|
|
//this will be called from the get started component whenever users clicks on the submit button
|
|
const removeFirstTimeLogin = (val: boolean) => {
|
|
showGetStarted.value = val;
|
|
localStorage.removeItem("isFirstTimeLogin");
|
|
};
|
|
|
|
const sendToAiChat = (value: any, append: boolean = true, autoSend: boolean = false) => {
|
|
if (!store.state.isAiChatEnabled) {
|
|
store.dispatch("setIsAiChatEnabled", true);
|
|
}
|
|
|
|
// Support object payload { query, autoSend } from OverviewTab
|
|
let text = value;
|
|
let shouldAutoSend = autoSend;
|
|
if (value && typeof value === "object" && "query" in value) {
|
|
text = value.query;
|
|
shouldAutoSend = value.autoSend ?? false;
|
|
}
|
|
|
|
aiChatAppendMode.value = append;
|
|
|
|
// Deliver text + autoSend atomically in a single object so O2AIChat
|
|
// watcher always sees both values together — no timing race.
|
|
aiChatPayload.value = { text, autoSend: shouldAutoSend, id: Date.now() };
|
|
|
|
// Keep legacy aiChatInputContext in sync for other callers
|
|
aiChatInputContext.value = "";
|
|
nextTick(() => {
|
|
aiChatInputContext.value = text;
|
|
nextTick(() => {
|
|
aiChatInputContext.value = "";
|
|
});
|
|
});
|
|
};
|
|
|
|
const openPredefinedThemes = () => {
|
|
toggleThemes();
|
|
};
|
|
|
|
/**
|
|
* Prefetch route module on menu hover
|
|
* @param routePath - The route path from the menu link
|
|
*/
|
|
const handleMenuHover = (routePath: string) => {
|
|
prefetchRoute(routePath);
|
|
};
|
|
|
|
// Sync the user-clicked org with the selected org
|
|
watch(
|
|
selectedOrg,
|
|
(newVal) => {
|
|
userClickedOrg.value = newVal;
|
|
},
|
|
{ immediate: true },
|
|
);
|
|
|
|
// Home page has its own inline AI tab (see toggleAIChat's home special-case
|
|
// above), so the sidebar chat panel is redundant there — close it on
|
|
// arrival so we don't show both the sidebar and the home AI tab at once.
|
|
watch(
|
|
() => router.currentRoute.value.name,
|
|
(routeName) => {
|
|
if (routeName === "home" && store.state.isAiChatEnabled) {
|
|
closeChat();
|
|
}
|
|
},
|
|
);
|
|
|
|
const showShortcuts = ref(false);
|
|
const openShortcutsList = () => {
|
|
showShortcuts.value = true;
|
|
};
|
|
|
|
// ── Global shortcuts: AI Chat ─────────────────────────────────────────
|
|
// O2 AI is enterprise-only: on OSS the Ctrl+B binding must not exist at all.
|
|
if (config.isEnterprise == "true") {
|
|
useShortcuts([{ id: "aiChatToggle", handler: () => toggleAIChat() }]);
|
|
}
|
|
|
|
return {
|
|
isDark,
|
|
t,
|
|
router,
|
|
store,
|
|
config,
|
|
announcementBarRef,
|
|
langList,
|
|
selectedLanguage,
|
|
linksList,
|
|
navLinks,
|
|
selectedOrg,
|
|
orgOptions,
|
|
leftDrawerOpen: true,
|
|
miniMode,
|
|
user,
|
|
zoBackendUrl,
|
|
isLoading,
|
|
getImageURL,
|
|
updateOrganization,
|
|
setSelectedOrganization,
|
|
getOrganizationSettings,
|
|
resetStreams,
|
|
prefetch,
|
|
expandMenu,
|
|
slackIcon: markRaw(SlackIcon),
|
|
openSlack,
|
|
settings: "settings",
|
|
closeSocket,
|
|
splitterModel,
|
|
toggleAIChat,
|
|
closeChat,
|
|
getBtnLogo,
|
|
isHovered,
|
|
showGetStarted,
|
|
removeFirstTimeLogin,
|
|
sendToAiChat,
|
|
aiChatInputContext,
|
|
aiChatAppendMode,
|
|
aiChatPayload,
|
|
userClickedOrg,
|
|
verifyStreamExist,
|
|
filterMenus,
|
|
getConfig,
|
|
setRumUser,
|
|
openPredefinedThemes,
|
|
showShortcuts,
|
|
openShortcutsList,
|
|
isPredefinedThemesOpen,
|
|
handleMenuHover,
|
|
};
|
|
},
|
|
computed: {
|
|
changeOrganization() {
|
|
return this.store?.state?.organizations;
|
|
},
|
|
changeOrganizationIdentifier() {
|
|
return this.store?.state?.selectedOrganization?.identifier;
|
|
},
|
|
forceFetchOrganization() {
|
|
return this.router?.currentRoute?.value?.query?.update_org;
|
|
},
|
|
changeUserInfo() {
|
|
return this.store?.state?.userInfo;
|
|
},
|
|
},
|
|
watch: {
|
|
forceFetchOrganization() {
|
|
mainLayoutMixin.setup().getDefaultOrganization(this.store);
|
|
},
|
|
changeOrganization: {
|
|
handler() {
|
|
this.setSelectedOrganization();
|
|
},
|
|
deep: true,
|
|
immediate: true,
|
|
},
|
|
async changeOrganizationIdentifier() {
|
|
this.isLoading = false;
|
|
this.resetStreams();
|
|
// Clear notifications from the previous org — they no longer apply.
|
|
dismissAll();
|
|
this.store.dispatch("setOrganizationPasscode", "");
|
|
this.store.dispatch("resetOrganizationData", {});
|
|
|
|
// Clear temporary theme colors when switching organizations
|
|
// This ensures each org shows its own theme colors without preview colors from another org
|
|
this.store.commit("clearTempThemeColors");
|
|
|
|
await this.getOrganizationSettings();
|
|
|
|
this.isLoading = true;
|
|
// Find the matching organization from orgOptions
|
|
const matchingOrg = this.orgOptions.find(
|
|
(org) => org.identifier === this.store.state.selectedOrganization.identifier,
|
|
);
|
|
|
|
if (matchingOrg) {
|
|
this.selectedOrg = matchingOrg;
|
|
}
|
|
},
|
|
},
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* keep(print): This layout's root is the ONLY writer of `.printMode` (store.state.printMode,
|
|
above), so the rule can only ever fire on descendants of that root — but
|
|
`.hideOnPrintMode` is placed by other components (VariableAdHocValueSelector,
|
|
pipeline/PipelineEditor, Dashboards/ViewDashboard) that render through
|
|
<router-view> and so do not carry this scope id. :deep() pierces to them while
|
|
keeping the ancestor condition scoped to the owner. */
|
|
.printMode :deep(.hideOnPrintMode) {
|
|
display: none;
|
|
}
|
|
</style>
|