mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-23 11:50:42 +01:00
Compare commits
25 Commits
chore/chec
...
bottom-str
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
121d245d5b | ||
|
|
d1facefee0 | ||
|
|
0455ad5761 | ||
|
|
7212ab304f | ||
|
|
7114811f1b | ||
|
|
187daf82e7 | ||
|
|
a9109e4841 | ||
|
|
57be30c472 | ||
|
|
b3aa231afa | ||
|
|
b0c53b5cd0 | ||
|
|
20942356b8 | ||
|
|
7d948e6087 | ||
|
|
6ccb268b30 | ||
|
|
df6bae6d57 | ||
|
|
1a7f19cc3d | ||
|
|
7d7200ead6 | ||
|
|
ad457804e9 | ||
|
|
733a1fbb73 | ||
|
|
9f20158225 | ||
|
|
9833797abe | ||
|
|
52995b252e | ||
|
|
deb7854b44 | ||
|
|
f7c47408e9 | ||
|
|
1af45169d5 | ||
|
|
0c2a874e07 |
@@ -12,13 +12,14 @@ import { CmdKPalette } from 'components/cmdKPalette/cmdKPalette';
|
||||
import NotFound from 'components/NotFound';
|
||||
import { ShiftHoldOverlayController } from 'components/ShiftOverlay/ShiftHoldOverlayController';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import ROUTES from 'constants/routes';
|
||||
import AppLayout from 'container/AppLayout';
|
||||
import Hex from 'crypto-js/enc-hex';
|
||||
import HmacSHA256 from 'crypto-js/hmac-sha256';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
|
||||
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { StatusCodes } from 'http-status-codes';
|
||||
@@ -58,7 +59,6 @@ function App(): JSX.Element {
|
||||
isFetchingActiveLicense,
|
||||
activeLicenseFetchError,
|
||||
userFetchError,
|
||||
featureFlagsFetchError,
|
||||
isLoggedIn: isLoggedInState,
|
||||
featureFlags,
|
||||
org,
|
||||
@@ -66,6 +66,8 @@ function App(): JSX.Element {
|
||||
} = useAppContext();
|
||||
const [routes, setRoutes] = useState<AppRoutes[]>(defaultRoutes);
|
||||
const isAIAssistantEnabled = useIsAIAssistantEnabled();
|
||||
const isSavedViewEnabled = useSavedViewEnabled();
|
||||
const chatSupport = useChatSupport();
|
||||
|
||||
const { hostname } = window.location;
|
||||
const [pathname, setPathname] = useState(history.location.pathname);
|
||||
@@ -253,7 +255,9 @@ function App(): JSX.Element {
|
||||
}, [isDarkMode]);
|
||||
|
||||
useEffect(() => {
|
||||
// The bottom strip carries Support, so the floating bubble goes entirely.
|
||||
if (
|
||||
isSavedViewEnabled ||
|
||||
pathname === ROUTES.ONBOARDING ||
|
||||
pathname.startsWith('/public/dashboard/') ||
|
||||
pathname === '/ai-assistant' ||
|
||||
@@ -263,71 +267,32 @@ function App(): JSX.Element {
|
||||
} else {
|
||||
window.Pylon?.('showChatBubble');
|
||||
}
|
||||
}, [pathname]);
|
||||
}, [pathname, isSavedViewEnabled]);
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
// Identity for the Pylon widget. Whether this user gets Pylon at all is
|
||||
// `useChatSupport`'s call — this only fills in who they are.
|
||||
useEffect(() => {
|
||||
// feature flag shouldn't be loading and featureFlags or fetchError any one of this should be true indicating that req is complete
|
||||
// licenses should also be present. there is no check for licenses for loading and error as that is mandatory if not present then routing
|
||||
// to something went wrong which would ideally need a reload.
|
||||
if (
|
||||
!isFetchingFeatureFlags &&
|
||||
(featureFlags || featureFlagsFetchError) &&
|
||||
activeLicense &&
|
||||
trialInfo
|
||||
) {
|
||||
let isChatSupportEnabled = false;
|
||||
let isPremiumSupportEnabled = false;
|
||||
if (featureFlags && featureFlags.length > 0) {
|
||||
isChatSupportEnabled =
|
||||
featureFlags.find((flag) => flag.name === FeatureKeys.CHAT_SUPPORT)
|
||||
?.active || false;
|
||||
|
||||
isPremiumSupportEnabled =
|
||||
featureFlags.find((flag) => flag.name === FeatureKeys.PREMIUM_SUPPORT)
|
||||
?.active || false;
|
||||
}
|
||||
const showAddCreditCardModal =
|
||||
!isPremiumSupportEnabled && !trialInfo?.trialConvertedToSubscription;
|
||||
|
||||
if (
|
||||
isLoggedInState &&
|
||||
isChatSupportEnabled &&
|
||||
!showAddCreditCardModal &&
|
||||
(isCloudUser || isEnterpriseSelfHostedUser) &&
|
||||
window.signozBootData?.settings?.pylon?.enabled
|
||||
) {
|
||||
const email = user.email || '';
|
||||
const secret = window.signozBootData?.settings?.pylon?.identitySecret || '';
|
||||
let emailHash = '';
|
||||
|
||||
if (email && secret) {
|
||||
emailHash = HmacSHA256(email, Hex.parse(secret)).toString(Hex);
|
||||
}
|
||||
|
||||
window.pylon = {
|
||||
chat_settings: {
|
||||
app_id: window.signozBootData?.settings?.pylon?.appId,
|
||||
email: user.email,
|
||||
name: user.displayName || user.email,
|
||||
email_hash: emailHash,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (chatSupport !== ChatSupportState.Pylon) {
|
||||
return;
|
||||
}
|
||||
}, [
|
||||
isLoggedInState,
|
||||
user,
|
||||
pathname,
|
||||
trialInfo?.trialConvertedToSubscription,
|
||||
featureFlags,
|
||||
isFetchingFeatureFlags,
|
||||
featureFlagsFetchError,
|
||||
activeLicense,
|
||||
trialInfo,
|
||||
isCloudUser,
|
||||
isEnterpriseSelfHostedUser,
|
||||
]);
|
||||
|
||||
const email = user.email || '';
|
||||
const secret = window.signozBootData?.settings?.pylon?.identitySecret || '';
|
||||
let emailHash = '';
|
||||
|
||||
if (email && secret) {
|
||||
emailHash = HmacSHA256(email, Hex.parse(secret)).toString(Hex);
|
||||
}
|
||||
|
||||
window.pylon = {
|
||||
chat_settings: {
|
||||
app_id: window.signozBootData?.settings?.pylon?.appId,
|
||||
email: user.email,
|
||||
name: user.displayName || user.email,
|
||||
email_hash: emailHash,
|
||||
},
|
||||
};
|
||||
}, [chatSupport, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFetchingUser && isCloudUser && user && user.email) {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useMutation } from 'react-query';
|
||||
import { Button, Modal } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { createSubscription } from 'api/generated/services/subscriptions';
|
||||
import type { CreateSubscription201 } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { SubscriptionCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
|
||||
import { CreditCard, X } from '@signozhq/icons';
|
||||
import APIError from 'types/api/error';
|
||||
import { getBaseUrl } from 'utils/basePath';
|
||||
|
||||
interface AddCreditCardModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onAddCreditCard?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown to trial users who have not added a card, in place of chat support.
|
||||
* Submitting creates the subscription and opens the returned billing URL.
|
||||
*/
|
||||
function AddCreditCardModal({
|
||||
open,
|
||||
onClose,
|
||||
onAddCreditCard,
|
||||
}: AddCreditCardModalProps): JSX.Element {
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const handleBillingOnSuccess = (data: CreateSubscription201): void => {
|
||||
if (data?.data?.redirectURL) {
|
||||
const newTab = document.createElement('a');
|
||||
newTab.href = data.data.redirectURL;
|
||||
newTab.target = '_blank';
|
||||
newTab.rel = 'noopener noreferrer';
|
||||
newTab.click();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBillingOnError = (error: APIError): void => {
|
||||
notifications.error({
|
||||
message: error.getErrorCode(),
|
||||
description: error.getErrorMessage(),
|
||||
});
|
||||
};
|
||||
|
||||
const { mutate: updateCreditCard, isLoading: isLoadingBilling } = useMutation(
|
||||
createSubscription,
|
||||
{ onSuccess: handleBillingOnSuccess, onError: handleBillingOnError },
|
||||
);
|
||||
|
||||
const handleAddCreditCard = (): void => {
|
||||
onAddCreditCard?.();
|
||||
updateCreditCard({ url: getBaseUrl() });
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="add-credit-card-modal"
|
||||
title={<span className="title">Add Credit Card for Chat Support</span>}
|
||||
open={open}
|
||||
closable
|
||||
onCancel={onClose}
|
||||
destroyOnClose
|
||||
footer={[
|
||||
<Button
|
||||
key="cancel"
|
||||
onClick={onClose}
|
||||
className="cancel-btn"
|
||||
icon={<X size={16} />}
|
||||
>
|
||||
Cancel
|
||||
</Button>,
|
||||
<AuthZTooltip
|
||||
key="submit"
|
||||
checks={[SubscriptionCreatePermission]}
|
||||
withPortal={false}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CreditCard size={16} />}
|
||||
size="middle"
|
||||
loading={isLoadingBilling}
|
||||
disabled={isLoadingBilling}
|
||||
onClick={handleAddCreditCard}
|
||||
className="add-credit-card-btn"
|
||||
>
|
||||
Add Credit Card
|
||||
</Button>
|
||||
</AuthZTooltip>,
|
||||
]}
|
||||
>
|
||||
<Typography.Text className="add-credit-card-text">
|
||||
You're currently on <span className="highlight-text">Trial plan</span>.
|
||||
Add a credit card to access SigNoz chat support to your workspace.
|
||||
</Typography.Text>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
AddCreditCardModal.defaultProps = { onAddCreditCard: undefined };
|
||||
|
||||
export default AddCreditCardModal;
|
||||
@@ -1,63 +1,15 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation } from 'react-query';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Button, Modal } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Button } from 'antd';
|
||||
import AddCreditCardModal from 'components/AddCreditCardModal/AddCreditCardModal';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { createSubscription } from 'api/generated/services/subscriptions';
|
||||
import type { CreateSubscription201 } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { SubscriptionCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
|
||||
import { CreditCard, MessageSquareText, X } from '@signozhq/icons';
|
||||
import APIError from 'types/api/error';
|
||||
import { getBaseUrl } from 'utils/basePath';
|
||||
import { MessageSquareText } from '@signozhq/icons';
|
||||
|
||||
export default function ChatSupportGateway(): JSX.Element {
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const [isAddCreditCardModalOpen, setIsAddCreditCardModalOpen] =
|
||||
useState(false);
|
||||
|
||||
const handleBillingOnSuccess = (data: CreateSubscription201): void => {
|
||||
if (data?.data?.redirectURL) {
|
||||
const newTab = document.createElement('a');
|
||||
newTab.href = data.data.redirectURL;
|
||||
newTab.target = '_blank';
|
||||
newTab.rel = 'noopener noreferrer';
|
||||
newTab.click();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBillingOnError = (error: APIError): void => {
|
||||
notifications.error({
|
||||
message: error.getErrorCode(),
|
||||
description: error.getErrorMessage(),
|
||||
});
|
||||
};
|
||||
|
||||
const { mutate: updateCreditCard, isLoading: isLoadingBilling } = useMutation(
|
||||
createSubscription,
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
handleBillingOnSuccess(data);
|
||||
},
|
||||
onError: handleBillingOnError,
|
||||
},
|
||||
);
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const handleAddCreditCard = (): void => {
|
||||
logEvent('Add Credit card modal: Clicked', {
|
||||
source: `chat support icon`,
|
||||
page: pathname,
|
||||
});
|
||||
|
||||
updateCreditCard({
|
||||
url: getBaseUrl(),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="chat-support-gateway">
|
||||
@@ -76,47 +28,16 @@ export default function ChatSupportGateway(): JSX.Element {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Add Credit Card Modal */}
|
||||
<Modal
|
||||
className="add-credit-card-modal"
|
||||
title={<span className="title">Add Credit Card for Chat Support</span>}
|
||||
<AddCreditCardModal
|
||||
open={isAddCreditCardModalOpen}
|
||||
closable
|
||||
onCancel={(): void => setIsAddCreditCardModalOpen(false)}
|
||||
destroyOnClose
|
||||
footer={[
|
||||
<Button
|
||||
key="cancel"
|
||||
onClick={(): void => setIsAddCreditCardModalOpen(false)}
|
||||
className="cancel-btn"
|
||||
icon={<X size={16} />}
|
||||
>
|
||||
Cancel
|
||||
</Button>,
|
||||
<AuthZTooltip
|
||||
key="submit"
|
||||
checks={[SubscriptionCreatePermission]}
|
||||
withPortal={false}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CreditCard size={16} />}
|
||||
size="middle"
|
||||
loading={isLoadingBilling}
|
||||
disabled={isLoadingBilling}
|
||||
onClick={handleAddCreditCard}
|
||||
className="add-credit-card-btn"
|
||||
>
|
||||
Add Credit Card
|
||||
</Button>
|
||||
</AuthZTooltip>,
|
||||
]}
|
||||
>
|
||||
<Typography.Text className="add-credit-card-text">
|
||||
You're currently on <span className="highlight-text">Trial plan</span>
|
||||
. Add a credit card to access SigNoz chat support to your workspace.
|
||||
</Typography.Text>
|
||||
</Modal>
|
||||
onClose={(): void => setIsAddCreditCardModalOpen(false)}
|
||||
onAddCreditCard={(): void => {
|
||||
logEvent('Add Credit card modal: Clicked', {
|
||||
source: `chat support icon`,
|
||||
page: pathname,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation } from 'react-query';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Button, Modal, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import AddCreditCardModal from 'components/AddCreditCardModal/AddCreditCardModal';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { createSubscription } from 'api/generated/services/subscriptions';
|
||||
import type { CreateSubscription201 } from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { SubscriptionCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
|
||||
import { defaultTo } from 'lodash-es';
|
||||
import { CircleHelp, CreditCard, X } from '@signozhq/icons';
|
||||
import { CircleHelp } from '@signozhq/icons';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import APIError from 'types/api/error';
|
||||
import { getBaseUrl } from 'utils/basePath';
|
||||
|
||||
import './LaunchChatSupport.styles.scss';
|
||||
|
||||
@@ -41,7 +33,6 @@ function LaunchChatSupport({
|
||||
chatMessageDisabled = false,
|
||||
}: LaunchChatSupportProps): JSX.Element | null {
|
||||
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
|
||||
const { notifications } = useNotifications();
|
||||
const {
|
||||
trialInfo,
|
||||
featureFlags,
|
||||
@@ -119,43 +110,12 @@ function LaunchChatSupport({
|
||||
}
|
||||
};
|
||||
|
||||
const handleBillingOnSuccess = (data: CreateSubscription201): void => {
|
||||
if (data?.data?.redirectURL) {
|
||||
const newTab = document.createElement('a');
|
||||
newTab.href = data.data.redirectURL;
|
||||
newTab.target = '_blank';
|
||||
newTab.rel = 'noopener noreferrer';
|
||||
newTab.click();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBillingOnError = (error: APIError): void => {
|
||||
notifications.error({
|
||||
message: error.getErrorCode(),
|
||||
description: error.getErrorMessage(),
|
||||
});
|
||||
};
|
||||
|
||||
const { mutate: updateCreditCard, isLoading: isLoadingBilling } = useMutation(
|
||||
createSubscription,
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
handleBillingOnSuccess(data);
|
||||
},
|
||||
onError: handleBillingOnError,
|
||||
},
|
||||
);
|
||||
|
||||
const handleAddCreditCard = (): void => {
|
||||
logEvent('Add Credit card modal: Clicked', {
|
||||
source: `facing issues button`,
|
||||
page: pathname,
|
||||
...attributes,
|
||||
});
|
||||
|
||||
updateCreditCard({
|
||||
url: getBaseUrl(),
|
||||
});
|
||||
};
|
||||
|
||||
return isCloudUserVal && isChatSupportEnabled ? ( // Note: we would need to move this condition to license based in future
|
||||
@@ -175,47 +135,11 @@ function LaunchChatSupport({
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Add Credit Card Modal */}
|
||||
<Modal
|
||||
className="add-credit-card-modal"
|
||||
title={<span className="title">Add Credit Card for Chat Support</span>}
|
||||
<AddCreditCardModal
|
||||
open={isAddCreditCardModalOpen}
|
||||
closable
|
||||
onCancel={(): void => setIsAddCreditCardModalOpen(false)}
|
||||
destroyOnClose
|
||||
footer={[
|
||||
<Button
|
||||
key="cancel"
|
||||
onClick={(): void => setIsAddCreditCardModalOpen(false)}
|
||||
className="cancel-btn"
|
||||
icon={<X size={16} />}
|
||||
>
|
||||
Cancel
|
||||
</Button>,
|
||||
<AuthZTooltip
|
||||
key="submit"
|
||||
checks={[SubscriptionCreatePermission]}
|
||||
withPortal={false}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CreditCard size={16} />}
|
||||
size="middle"
|
||||
loading={isLoadingBilling}
|
||||
disabled={isLoadingBilling}
|
||||
onClick={handleAddCreditCard}
|
||||
className="add-credit-card-btn"
|
||||
>
|
||||
Add Credit Card
|
||||
</Button>
|
||||
</AuthZTooltip>,
|
||||
]}
|
||||
>
|
||||
<Typography.Text className="add-credit-card-text">
|
||||
You're currently on <span className="highlight-text">Trial plan</span>
|
||||
. Add a credit card to access SigNoz chat support to your workspace.
|
||||
</Typography.Text>
|
||||
</Modal>
|
||||
onClose={(): void => setIsAddCreditCardModalOpen(false)}
|
||||
onAddCreditCard={handleAddCreditCard}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
.quick-filters-settings-container {
|
||||
flex: 0 0 0;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// The one `overflow: hidden` in the chain. Ancestors (RouteTab, AppLayout)
|
||||
// only hand height down; each pane below owns its own scroll.
|
||||
.layout {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// Positioned so overlays (settings drawer) paint above the content pane
|
||||
// without changing this pane's layout width.
|
||||
.filters {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
// Bounded box for the OverlayScrollbar inside it (`.overlay-scrollbar` is
|
||||
// `height: 100%`), which owns the scrolling.
|
||||
.content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { ComponentProps, ReactNode } from 'react';
|
||||
import cx from 'classnames';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
|
||||
import QuickFilters from '../QuickFilters';
|
||||
|
||||
import styles from './QuickFiltersLayout.module.scss';
|
||||
|
||||
// Same optionality as `<QuickFilters />` in JSX (honours its defaultProps).
|
||||
type QuickFiltersElementProps = JSX.LibraryManagedAttributes<
|
||||
typeof QuickFilters,
|
||||
ComponentProps<typeof QuickFilters>
|
||||
>;
|
||||
|
||||
export interface QuickFiltersLayoutProps {
|
||||
quickFilterProps: QuickFiltersElementProps;
|
||||
showFilters: boolean;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
testId?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function QuickFiltersLayout({
|
||||
quickFilterProps,
|
||||
showFilters,
|
||||
className,
|
||||
contentClassName,
|
||||
testId,
|
||||
children,
|
||||
}: QuickFiltersLayoutProps): JSX.Element {
|
||||
return (
|
||||
<div className={cx(styles.layout, className)} data-testid={testId}>
|
||||
{showFilters && (
|
||||
<aside
|
||||
className={styles.filters}
|
||||
data-testid="quick-filters-layout-filters"
|
||||
>
|
||||
<QuickFilters {...quickFilterProps} />
|
||||
</aside>
|
||||
)}
|
||||
<section
|
||||
className={cx(styles.content, contentClassName)}
|
||||
data-testid="quick-filters-layout-content"
|
||||
>
|
||||
<OverlayScrollbar>
|
||||
<div>{children}</div>
|
||||
</OverlayScrollbar>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default QuickFiltersLayout;
|
||||
@@ -0,0 +1,79 @@
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import { QuickFiltersSource } from '../../types';
|
||||
import QuickFiltersLayout from '../QuickFiltersLayout';
|
||||
|
||||
jest.mock('../QuickFiltersLayout.module.scss', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
layout: 'layout',
|
||||
filters: 'filters',
|
||||
content: 'content',
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../../QuickFilters', () => ({
|
||||
__esModule: true,
|
||||
default: ({ source }: { source: string }): JSX.Element => (
|
||||
<div data-testid="quick-filters">{source}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const quickFilterProps = {
|
||||
source: QuickFiltersSource.TRACES_EXPLORER,
|
||||
handleFilterVisibilityChange: jest.fn(),
|
||||
};
|
||||
|
||||
describe('QuickFiltersLayout', () => {
|
||||
it('renders QuickFilters with the given props inside the filters pane', () => {
|
||||
render(
|
||||
<QuickFiltersLayout showFilters quickFilterProps={quickFilterProps}>
|
||||
<div>content</div>
|
||||
</QuickFiltersLayout>,
|
||||
);
|
||||
|
||||
const filtersPane = screen.getByTestId('quick-filters-layout-filters');
|
||||
expect(filtersPane).toContainElement(screen.getByTestId('quick-filters'));
|
||||
expect(screen.getByTestId('quick-filters')).toHaveTextContent(
|
||||
QuickFiltersSource.TRACES_EXPLORER,
|
||||
);
|
||||
expect(screen.getByTestId('quick-filters-layout-content')).toHaveTextContent(
|
||||
'content',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not render the filters pane when showFilters is false', () => {
|
||||
render(
|
||||
<QuickFiltersLayout showFilters={false} quickFilterProps={quickFilterProps}>
|
||||
<div>content</div>
|
||||
</QuickFiltersLayout>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('quick-filters-layout-filters'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('quick-filters')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('merges classNames onto the root and content panes', () => {
|
||||
render(
|
||||
<QuickFiltersLayout
|
||||
showFilters
|
||||
quickFilterProps={quickFilterProps}
|
||||
className="page-root"
|
||||
contentClassName="page-content"
|
||||
testId="page"
|
||||
>
|
||||
<div>content</div>
|
||||
</QuickFiltersLayout>,
|
||||
);
|
||||
|
||||
const root = screen.getByTestId('page');
|
||||
expect(root).toHaveClass('layout', 'page-root');
|
||||
expect(screen.getByTestId('quick-filters-layout-content')).toHaveClass(
|
||||
'content',
|
||||
'page-content',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -6,27 +6,12 @@
|
||||
left: 0;
|
||||
z-index: 999;
|
||||
width: 342px;
|
||||
height: 100%;
|
||||
background: var(--l1-background);
|
||||
transition: width 0.05s ease-in-out;
|
||||
overflow: hidden;
|
||||
color: var(--l1-foreground);
|
||||
|
||||
&.qf-logs-explorer {
|
||||
height: calc(100vh - 45px);
|
||||
}
|
||||
|
||||
&.qf-exceptions {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
&.qf-api-monitoring {
|
||||
height: calc(100vh - 45px);
|
||||
}
|
||||
|
||||
&.qf-traces-explorer {
|
||||
height: calc(100vh - 45px);
|
||||
}
|
||||
|
||||
&.hidden {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
38
frontend/src/components/RouteTab/RouteTab.module.scss
Normal file
38
frontend/src/components/RouteTab/RouteTab.module.scss
Normal file
@@ -0,0 +1,38 @@
|
||||
// Hands the parent's height down to the active pane and lets the pane scroll
|
||||
// its own content, so TopNav and the tab bar stay put. Child combinators only
|
||||
// (nested Tabs must not be caught).
|
||||
.routeTab {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.routeTab > :global(.ant-tabs-content-holder) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.routeTab > :global(.ant-tabs-content-holder) > :global(.ant-tabs-content) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.routeTab
|
||||
> :global(.ant-tabs-content-holder)
|
||||
> :global(.ant-tabs-content)
|
||||
> :global(.ant-tabs-tabpane-active) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.routeTab
|
||||
> :global(.ant-tabs-content-holder)
|
||||
> :global(.ant-tabs-content)
|
||||
> :global(.ant-tabs-tabpane-active)
|
||||
> :global(.overlay-scrollbar) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -5,6 +5,11 @@ import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
import RouteTab from './index';
|
||||
import { RouteTabProps } from './types';
|
||||
|
||||
jest.mock('./RouteTab.module.scss', () => ({
|
||||
__esModule: true,
|
||||
default: { routeTab: 'routeTab' },
|
||||
}));
|
||||
|
||||
function DummyComponent1(): JSX.Element {
|
||||
return <div>Dummy Component 1</div>;
|
||||
}
|
||||
@@ -74,6 +79,36 @@ describe('RouteTab component', () => {
|
||||
expect(history.location.pathname).toBe('/tab2');
|
||||
});
|
||||
|
||||
it('applies the layout class alongside a custom className', () => {
|
||||
const history = createMemoryHistory();
|
||||
const { container } = render(
|
||||
<Router history={history}>
|
||||
<RouteTab
|
||||
history={history}
|
||||
routes={testRoutes}
|
||||
activeKey="Tab1"
|
||||
className="custom-tabs"
|
||||
/>
|
||||
</Router>,
|
||||
);
|
||||
expect(container.querySelector('.ant-tabs')).toHaveClass(
|
||||
'routeTab',
|
||||
'custom-tabs',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the active tab content inside an overlay scrollbar', () => {
|
||||
const history = createMemoryHistory();
|
||||
const { container } = render(
|
||||
<Router history={history}>
|
||||
<RouteTab history={history} routes={testRoutes} activeKey="Tab1" />
|
||||
</Router>,
|
||||
);
|
||||
expect(
|
||||
container.querySelector('.ant-tabs-tabpane-active > .overlay-scrollbar'),
|
||||
).toHaveTextContent('Dummy Component 1');
|
||||
});
|
||||
|
||||
it('calls onChangeHandler on tab change', () => {
|
||||
const onChangeHandler = jest.fn();
|
||||
const history = createMemoryHistory();
|
||||
|
||||
@@ -5,20 +5,32 @@ import {
|
||||
useParams,
|
||||
} from 'react-router-dom';
|
||||
import { Tabs, TabsProps } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
|
||||
import { RouteTabProps } from './types';
|
||||
|
||||
import styles from './RouteTab.module.scss';
|
||||
|
||||
interface Params {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Each pane scrolls its own content inside an OverlayScrollbar, so the tab bar
|
||||
* stays put. Mounted as the page root the pane is bounded to the viewport; inside
|
||||
* a plain block wrapper the scroller is inert and the page scrolls as usual.
|
||||
* Pane content that needs a bounded box must size itself with `height: 100%`
|
||||
* (the scroller's viewport is block flow, so `flex: 1` has no effect there).
|
||||
*/
|
||||
function RouteTab({
|
||||
routes,
|
||||
activeKey,
|
||||
onChangeHandler,
|
||||
history,
|
||||
showRightSection,
|
||||
className,
|
||||
...rest
|
||||
}: RouteTabProps & TabsProps): JSX.Element {
|
||||
const params = useParams<Params>();
|
||||
@@ -50,11 +62,16 @@ function RouteTab({
|
||||
label: name,
|
||||
key,
|
||||
tabKey: route,
|
||||
children: <Component />,
|
||||
children: (
|
||||
<OverlayScrollbar>
|
||||
<Component />
|
||||
</OverlayScrollbar>
|
||||
),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
className={cx(styles.routeTab, className)}
|
||||
onChange={onChange}
|
||||
destroyInactiveTabPane
|
||||
activeKey={currentRoute?.key || activeKey}
|
||||
|
||||
@@ -47,4 +47,5 @@ export enum LOCALSTORAGE {
|
||||
DASHBOARDS_LIST_VIEWS = 'DASHBOARDS_LIST_VIEWS',
|
||||
DASHBOARD_V2_PANEL_COLUMN_WIDTHS = 'DASHBOARD_V2_PANEL_COLUMN_WIDTHS',
|
||||
LLM_ATTRIBUTE_MAPPING_TEST_SPAN = 'LLM_ATTRIBUTE_MAPPING_TEST_SPAN',
|
||||
SAVED_VIEW_ENABLED = 'SAVED_VIEW_ENABLED',
|
||||
}
|
||||
|
||||
12
frontend/src/container/AllError/StripInfo/StripInfo.tsx
Normal file
12
frontend/src/container/AllError/StripInfo/StripInfo.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import StripTypography from 'container/BottomStrip/components/StripTypography/StripTypography';
|
||||
import { pluralize } from 'utils/pluralize';
|
||||
|
||||
interface StripInfoProps {
|
||||
count: number;
|
||||
}
|
||||
|
||||
function StripInfo({ count }: StripInfoProps): JSX.Element {
|
||||
return <StripTypography>{pluralize(count, 'exception')}</StripTypography>;
|
||||
}
|
||||
|
||||
export default StripInfo;
|
||||
@@ -0,0 +1,23 @@
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import StripInfo from '../StripInfo';
|
||||
|
||||
describe('StripInfo', () => {
|
||||
it('shows how many exceptions matched', () => {
|
||||
const { getByText } = render(<StripInfo count={42} />);
|
||||
|
||||
expect(getByText('42 exceptions')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('says exception, not exceptions, when there is one', () => {
|
||||
const { getByText } = render(<StripInfo count={1} />);
|
||||
|
||||
expect(getByText('1 exception')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows zero when nothing matched', () => {
|
||||
const { getByText } = render(<StripInfo count={0} />);
|
||||
|
||||
expect(getByText('0 exceptions')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,9 @@ import { FilterConfirmProps } from 'antd/lib/table/interface';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import getAll from 'api/errors/getAll';
|
||||
import getErrorCounts from 'api/errors/getErrorCounts';
|
||||
import { useBottomStripLeft } from 'container/BottomStrip/useBottomStripLeft';
|
||||
|
||||
import StripInfo from './StripInfo/StripInfo';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
@@ -160,6 +163,11 @@ function AllErrors(): JSX.Element {
|
||||
},
|
||||
]);
|
||||
|
||||
const exceptionCount = errorCountResponse.data?.payload ?? 0;
|
||||
useBottomStripLeft(
|
||||
useMemo(() => <StripInfo count={exceptionCount} />, [exceptionCount]),
|
||||
);
|
||||
|
||||
const isFetching = isErrorsFetching || errorCountResponse.isFetching;
|
||||
useEffect(() => {
|
||||
setIsFetching(isFetching);
|
||||
|
||||
@@ -1,23 +1,15 @@
|
||||
.api-monitoring-page {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
.api-monitoring-explorer {
|
||||
.api-quick-filters-header {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
border-right: 1px solid var(--l1-border);
|
||||
|
||||
.api-quick-filter-left-section {
|
||||
width: 0%;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
.api-quick-filters-header {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
border-right: 1px solid var(--l1-border);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
}
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.api-module-right-section {
|
||||
@@ -161,16 +153,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.filter-visible {
|
||||
.api-quick-filter-left-section {
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.api-module-right-section {
|
||||
width: calc(100% - 260px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.no-filtered-domains-message-container {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useEffect } from 'react';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
|
||||
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
@@ -20,20 +19,21 @@ function Explorer(): JSX.Element {
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<div className={cx('api-monitoring-page', 'filter-visible')}>
|
||||
<section className="api-quick-filter-left-section">
|
||||
<QuickFilters
|
||||
className="qf-api-monitoring"
|
||||
source={QuickFiltersSource.API_MONITORING}
|
||||
signal={SignalType.API_MONITORING}
|
||||
showFilterCollapse={false}
|
||||
showQueryName={false}
|
||||
handleFilterVisibilityChange={(): void => {}}
|
||||
useFieldApis={quickFilterFieldApis}
|
||||
/>
|
||||
</section>
|
||||
<QuickFiltersLayout
|
||||
className="api-monitoring-explorer"
|
||||
showFilters
|
||||
quickFilterProps={{
|
||||
className: 'qf-api-monitoring',
|
||||
source: QuickFiltersSource.API_MONITORING,
|
||||
signal: SignalType.API_MONITORING,
|
||||
showFilterCollapse: false,
|
||||
showQueryName: false,
|
||||
handleFilterVisibilityChange: (): void => {},
|
||||
useFieldApis: quickFilterFieldApis,
|
||||
}}
|
||||
>
|
||||
<DomainList />
|
||||
</div>
|
||||
</QuickFiltersLayout>
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,6 +53,10 @@
|
||||
z-index: 0;
|
||||
background: var(--l1-background);
|
||||
|
||||
// Column so the bottom strip sits under the scrolling content, not inside it.
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&.full-screen-content {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -70,7 +74,9 @@
|
||||
|
||||
.chat-support-gateway {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
|
||||
// UI belongs in the bounded layout, not in another offset here.
|
||||
bottom: calc(20px + var(--bottom-strip-height, 0px));
|
||||
right: 20px;
|
||||
z-index: 1000;
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ import { USER_PREFERENCES } from 'constants/userPreferences';
|
||||
import AIAssistantModal from 'container/AIAssistant/AIAssistantModal';
|
||||
import AIAssistantPanel from 'container/AIAssistant/AIAssistantPanel';
|
||||
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
|
||||
import BottomStrip from 'container/BottomStrip';
|
||||
import SideNav from 'container/SideNav';
|
||||
import TopNav from 'container/TopNav';
|
||||
import dayjs from 'dayjs';
|
||||
@@ -51,6 +52,7 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
|
||||
import useTabVisibility from 'hooks/useTabFocus';
|
||||
import history from 'lib/history';
|
||||
import { isNull } from 'lodash-es';
|
||||
@@ -402,6 +404,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
}, [pathname]);
|
||||
|
||||
const isToDisplayLayout = isLoggedIn;
|
||||
const isSavedViewEnabled = useSavedViewEnabled();
|
||||
|
||||
const routeKey = useMemo(() => getRouteKey(pathname), [pathname]);
|
||||
const pageTitle = t(routeKey);
|
||||
@@ -868,6 +871,10 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
</OverlayScrollbar>
|
||||
</LayoutContent>
|
||||
</Sentry.ErrorBoundary>
|
||||
|
||||
{isSavedViewEnabled && isToDisplayLayout && !renderFullScreen && (
|
||||
<BottomStrip />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoggedIn && isAIAssistantEnabled && (
|
||||
@@ -878,7 +885,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{showAddCreditCardModal && <ChatSupportGateway />}
|
||||
{showAddCreditCardModal && !isSavedViewEnabled && <ChatSupportGateway />}
|
||||
{showChangelogModal && changelog && (
|
||||
<ChangelogModal changelog={changelog} onClose={toggleChangelogModal} />
|
||||
)}
|
||||
|
||||
@@ -12,8 +12,12 @@ export const Layout = styled(LayoutComponent)`
|
||||
}
|
||||
`;
|
||||
|
||||
// Takes the height left in `.app-content` after the bottom strip.
|
||||
// `min-height: 0` is not needed right now, overlayscrollbars already sets
|
||||
// `overflow: auto` here. Kept so this does not break if that goes away.
|
||||
export const LayoutContent = styled(LayoutComponent.Content)`
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
}
|
||||
|
||||
36
frontend/src/container/BottomStrip/AskNoz/AskNoz.module.scss
Normal file
36
frontend/src/container/BottomStrip/AskNoz/AskNoz.module.scss
Normal file
@@ -0,0 +1,36 @@
|
||||
.askNoz {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.badge {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
line-height: 0;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.pulseDot {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 0;
|
||||
animation: askNozDotPulse 1.5s ease-in-out infinite;
|
||||
transform: scale(0.8);
|
||||
margin-right: -12px;
|
||||
}
|
||||
|
||||
@keyframes askNozDotPulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
transform: scale(0.82);
|
||||
}
|
||||
}
|
||||
76
frontend/src/container/BottomStrip/AskNoz/AskNoz.tsx
Normal file
76
frontend/src/container/BottomStrip/AskNoz/AskNoz.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import Noz from 'components/Noz/Noz';
|
||||
import { NOZ_TOOLTIP_TITLE } from 'components/Noz/Noz.constants';
|
||||
import { selectPendingUserInputStreamCount } from 'container/AIAssistant/store/pendingInputSelectors';
|
||||
import {
|
||||
openAIAssistant,
|
||||
useAIAssistantStore,
|
||||
} from 'container/AIAssistant/store/useAIAssistantStore';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { Dot } from '@signozhq/icons';
|
||||
|
||||
import styles from './AskNoz.module.scss';
|
||||
|
||||
/**
|
||||
* Opens the Noz drawer, matching the header entry it replaces. Cmd+K opens the
|
||||
* modal instead; this is deliberately the drawer.
|
||||
*
|
||||
* Carries the header's pending badge: when Noz is blocked on the user
|
||||
* (`awaiting_approval` / `awaiting_clarification`) a dot pulses. Without it,
|
||||
* hiding the header button would remove a notification rather than move it.
|
||||
*/
|
||||
function AskNoz(): JSX.Element | null {
|
||||
const { pathname } = useLocation();
|
||||
const isAIAssistantEnabled = useIsAIAssistantEnabled();
|
||||
const isDrawerOpen = useAIAssistantStore((state) => state.isDrawerOpen);
|
||||
const isModalOpen = useAIAssistantStore((state) => state.isModalOpen);
|
||||
const pendingUserInputCount = useAIAssistantStore(
|
||||
selectPendingUserInputStreamCount,
|
||||
);
|
||||
|
||||
// Noz is already on screen in the modal, so the "needs you" dot would be noise.
|
||||
const showPendingBadge = pendingUserInputCount > 0 && !isModalOpen;
|
||||
|
||||
// The drawer does not render on the Noz full page, so the button would be inert.
|
||||
const isAIAssistantPage = pathname.startsWith(ROUTES.AI_ASSISTANT_BASE);
|
||||
|
||||
if (!isAIAssistantEnabled || isDrawerOpen || isAIAssistantPage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.askNoz} data-testid="bottom-strip-ask-noz">
|
||||
{showPendingBadge && (
|
||||
<span className={styles.badge} aria-hidden>
|
||||
<span className={styles.pulseDot}>
|
||||
<Dot size={36} />
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<TooltipSimple title={NOZ_TOOLTIP_TITLE}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
className="noz-wave"
|
||||
prefix={<Noz size={16} />}
|
||||
onClick={(): void => openAIAssistant()}
|
||||
aria-label={
|
||||
showPendingBadge
|
||||
? `Ask Noz, ${pendingUserInputCount} ${
|
||||
pendingUserInputCount === 1 ? 'action needs' : 'actions need'
|
||||
} your response`
|
||||
: 'Ask Noz'
|
||||
}
|
||||
>
|
||||
Ask Noz
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AskNoz;
|
||||
@@ -0,0 +1,133 @@
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
import { openAIAssistant } from 'container/AIAssistant/store/useAIAssistantStore';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import AskNoz from '../AskNoz';
|
||||
|
||||
jest.mock('hooks/useIsAIAssistantEnabled');
|
||||
jest.mock('container/AIAssistant/store/useAIAssistantStore', () => ({
|
||||
openAIAssistant: jest.fn(),
|
||||
useAIAssistantStore: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockEnabled = useIsAIAssistantEnabled as jest.MockedFunction<
|
||||
typeof useIsAIAssistantEnabled
|
||||
>;
|
||||
const mockOpen = openAIAssistant as jest.MockedFunction<typeof openAIAssistant>;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { useAIAssistantStore } = jest.requireMock(
|
||||
'container/AIAssistant/store/useAIAssistantStore',
|
||||
) as { useAIAssistantStore: jest.Mock };
|
||||
|
||||
/** The component reads the store through three separate selector calls. */
|
||||
function mockStore({
|
||||
isDrawerOpen = false,
|
||||
isModalOpen = false,
|
||||
pendingCount = 0,
|
||||
}: {
|
||||
isDrawerOpen?: boolean;
|
||||
isModalOpen?: boolean;
|
||||
pendingCount?: number;
|
||||
} = {}): void {
|
||||
const state = { isDrawerOpen, isModalOpen, streams: {} };
|
||||
useAIAssistantStore.mockImplementation((selector: (s: unknown) => unknown) => {
|
||||
const picked = selector(state);
|
||||
// `selectPendingUserInputStreamCount` walks `streams`, which is empty here,
|
||||
// so stand in the count we want to assert against.
|
||||
return typeof picked === 'number' ? pendingCount : picked;
|
||||
});
|
||||
}
|
||||
|
||||
// `TooltipSimple` replaces its trigger's props, so the testId lives on the
|
||||
// wrapper and the button itself is reached by role — there is only ever one.
|
||||
const SLOT = 'bottom-strip-ask-noz';
|
||||
|
||||
describe('AskNoz', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockEnabled.mockReturnValue(true);
|
||||
mockStore();
|
||||
});
|
||||
|
||||
describe('visibility', () => {
|
||||
it('renders when the assistant is enabled', () => {
|
||||
const { getByRole } = render(<AskNoz />);
|
||||
|
||||
expect(getByRole('button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing when the assistant is disabled', () => {
|
||||
mockEnabled.mockReturnValue(false);
|
||||
|
||||
const { queryByTestId } = render(<AskNoz />);
|
||||
|
||||
expect(queryByTestId(SLOT)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing while the drawer is open', () => {
|
||||
mockStore({ isDrawerOpen: true });
|
||||
|
||||
const { queryByTestId } = render(<AskNoz />);
|
||||
|
||||
expect(queryByTestId(SLOT)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing on the Noz full page, where the drawer does not mount', () => {
|
||||
const { queryByTestId } = render(<AskNoz />, undefined, {
|
||||
initialRoute: '/ai-assistant/some-conversation-id',
|
||||
});
|
||||
|
||||
expect(queryByTestId(SLOT)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('what it does', () => {
|
||||
it('opens the drawer on click', () => {
|
||||
const { getByRole } = render(<AskNoz />);
|
||||
|
||||
fireEvent.click(getByRole('button'));
|
||||
|
||||
expect(mockOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pending badge', () => {
|
||||
it('announces the count when Noz is waiting on the user', () => {
|
||||
mockStore({ pendingCount: 2 });
|
||||
|
||||
const { getByRole } = render(<AskNoz />);
|
||||
|
||||
expect(getByRole('button')).toHaveAttribute(
|
||||
'aria-label',
|
||||
'Ask Noz, 2 actions need your response',
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the singular for one pending action', () => {
|
||||
mockStore({ pendingCount: 1 });
|
||||
|
||||
const { getByRole } = render(<AskNoz />);
|
||||
|
||||
expect(getByRole('button')).toHaveAttribute(
|
||||
'aria-label',
|
||||
'Ask Noz, 1 action needs your response',
|
||||
);
|
||||
});
|
||||
|
||||
it('stays quiet when nothing is pending', () => {
|
||||
const { getByRole } = render(<AskNoz />);
|
||||
|
||||
expect(getByRole('button')).toHaveAttribute('aria-label', 'Ask Noz');
|
||||
});
|
||||
|
||||
it('stays quiet while the modal is open, Noz is already on screen', () => {
|
||||
mockStore({ isModalOpen: true, pendingCount: 3 });
|
||||
|
||||
const { getByRole } = render(<AskNoz />);
|
||||
|
||||
expect(getByRole('button')).toHaveAttribute('aria-label', 'Ask Noz');
|
||||
});
|
||||
});
|
||||
});
|
||||
46
frontend/src/container/BottomStrip/BottomStrip.module.scss
Normal file
46
frontend/src/container/BottomStrip/BottomStrip.module.scss
Normal file
@@ -0,0 +1,46 @@
|
||||
.strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
|
||||
flex-shrink: 0;
|
||||
height: var(--bottom-strip-height);
|
||||
padding: 0 12px;
|
||||
|
||||
background: var(--l2-background);
|
||||
border-top: 1px solid var(--l2-border);
|
||||
|
||||
--button-font-size: 12px;
|
||||
|
||||
// font styles
|
||||
font-family: var(--font-family-sf-mono, monospace);
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-none);
|
||||
|
||||
// Above page content, below the body-portalled overlays that are meant to
|
||||
// cover the strip.
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.left,
|
||||
.right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.left {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
// Temporary placeholder for the left slot. Replaced later.
|
||||
.version {
|
||||
color: var(--l2-foreground);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
29
frontend/src/container/BottomStrip/LeftSlot/LeftSlot.tsx
Normal file
29
frontend/src/container/BottomStrip/LeftSlot/LeftSlot.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import StripTypography from '../components/StripTypography/StripTypography';
|
||||
import { useBottomStripStore } from '../store/useBottomStripStore';
|
||||
|
||||
function LeftSlot(): JSX.Element | null {
|
||||
const { versionData } = useAppContext();
|
||||
const left = useBottomStripStore((state) => state.left);
|
||||
const ownerId = useBottomStripStore((state) => state.ownerId);
|
||||
const version = versionData?.version?.trim();
|
||||
const versionNode = version ? (
|
||||
<StripTypography>{version}</StripTypography>
|
||||
) : null;
|
||||
|
||||
if (!left) {
|
||||
return versionNode;
|
||||
}
|
||||
|
||||
return (
|
||||
// Keyed so a page whose node threw does not leave the boundary latched on
|
||||
// the fallback for every page after it.
|
||||
<Sentry.ErrorBoundary key={ownerId} fallback={<>{versionNode}</>}>
|
||||
{left}
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
export default LeftSlot;
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import AddCreditCardModal from 'components/AddCreditCardModal/AddCreditCardModal';
|
||||
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
|
||||
import { MessageSquareText } from '@signozhq/icons';
|
||||
|
||||
function SupportButton(): JSX.Element | null {
|
||||
const chatSupport = useChatSupport();
|
||||
const [isAddCreditCardModalOpen, setIsAddCreditCardModalOpen] =
|
||||
useState(false);
|
||||
|
||||
if (chatSupport === ChatSupportState.Unavailable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleClick = (): void => {
|
||||
if (chatSupport === ChatSupportState.NeedsCard) {
|
||||
setIsAddCreditCardModalOpen(true);
|
||||
return;
|
||||
}
|
||||
window.Pylon?.('show');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<MessageSquareText size={16} />}
|
||||
onClick={handleClick}
|
||||
testId="bottom-strip-support"
|
||||
>
|
||||
Support
|
||||
</Button>
|
||||
|
||||
<AddCreditCardModal
|
||||
open={isAddCreditCardModalOpen}
|
||||
onClose={(): void => setIsAddCreditCardModalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default SupportButton;
|
||||
@@ -0,0 +1,81 @@
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import SupportButton from '../SupportButton';
|
||||
|
||||
jest.mock('hooks/useChatSupport', () => ({
|
||||
...jest.requireActual('hooks/useChatSupport'),
|
||||
useChatSupport: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockChatSupport = useChatSupport as jest.MockedFunction<
|
||||
typeof useChatSupport
|
||||
>;
|
||||
|
||||
const BUTTON = 'bottom-strip-support';
|
||||
const MODAL_TITLE = 'Add Credit Card for Chat Support';
|
||||
|
||||
describe('SupportButton', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
window.Pylon = jest.fn() as never;
|
||||
});
|
||||
|
||||
describe('when Pylon is available', () => {
|
||||
beforeEach(() => mockChatSupport.mockReturnValue(ChatSupportState.Pylon));
|
||||
|
||||
it('shows the button', () => {
|
||||
const { getByTestId } = render(<SupportButton />);
|
||||
|
||||
expect(getByTestId(BUTTON)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the Pylon widget on click', () => {
|
||||
const { getByTestId } = render(<SupportButton />);
|
||||
|
||||
fireEvent.click(getByTestId(BUTTON));
|
||||
|
||||
expect(window.Pylon).toHaveBeenCalledWith('show');
|
||||
});
|
||||
|
||||
it('does not open the credit card modal', () => {
|
||||
const { getByTestId, queryByText } = render(<SupportButton />);
|
||||
|
||||
fireEvent.click(getByTestId(BUTTON));
|
||||
|
||||
expect(queryByText(MODAL_TITLE)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the user needs a card', () => {
|
||||
beforeEach(() => mockChatSupport.mockReturnValue(ChatSupportState.NeedsCard));
|
||||
|
||||
it('shows the same button', () => {
|
||||
const { getByTestId } = render(<SupportButton />);
|
||||
|
||||
expect(getByTestId(BUTTON)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the credit card modal on click, not Pylon', () => {
|
||||
const { getByTestId, getByText } = render(<SupportButton />);
|
||||
|
||||
fireEvent.click(getByTestId(BUTTON));
|
||||
|
||||
expect(getByText(MODAL_TITLE)).toBeInTheDocument();
|
||||
expect(window.Pylon).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when support is unavailable', () => {
|
||||
beforeEach(() =>
|
||||
mockChatSupport.mockReturnValue(ChatSupportState.Unavailable),
|
||||
);
|
||||
|
||||
it('renders nothing at all', () => {
|
||||
const { queryByTestId } = render(<SupportButton />);
|
||||
|
||||
expect(queryByTestId(BUTTON)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import { type ReactNode, useState } from 'react';
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
import { render } from 'tests/test-utils';
|
||||
import { Info } from 'types/api/v1/version/get';
|
||||
|
||||
import BottomStrip, {
|
||||
BOTTOM_STRIP_HEIGHT,
|
||||
BOTTOM_STRIP_HEIGHT_VAR,
|
||||
BOTTOM_STRIP_ON_CLASS,
|
||||
} from '..';
|
||||
import { useBottomStripStore } from '../store/useBottomStripStore';
|
||||
import { useBottomStripLeft } from '../useBottomStripLeft';
|
||||
|
||||
/** Stands in for a page that puts something on the left of the strip. */
|
||||
function Page({ children }: { children: ReactNode }): null {
|
||||
useBottomStripLeft(children);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A page whose value changes without needing `rerender`. */
|
||||
function ChangingPage(): JSX.Element {
|
||||
const [count, setCount] = useState(600);
|
||||
|
||||
useBottomStripLeft(`${count} traces`);
|
||||
|
||||
return (
|
||||
<button type="button" onClick={(): void => setCount(42)}>
|
||||
change
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** A page node that blows up while the strip renders it. */
|
||||
function Boom(): JSX.Element {
|
||||
throw new Error('bad left node');
|
||||
}
|
||||
|
||||
const VERSION = 'v0.134.67';
|
||||
const versionData: Info = { version: VERSION, ee: 'Y', setupCompleted: true };
|
||||
const withVersion = { appContextOverrides: { versionData } };
|
||||
|
||||
describe('BottomStrip', () => {
|
||||
// The store is module level, so it outlives each test.
|
||||
beforeEach(() => {
|
||||
useBottomStripStore.setState({ left: null, ownerId: null });
|
||||
});
|
||||
|
||||
it('publishes the body class and height property while mounted', () => {
|
||||
const { unmount } = render(<BottomStrip />);
|
||||
|
||||
expect(document.body.classList.contains(BOTTOM_STRIP_ON_CLASS)).toBe(true);
|
||||
expect(document.body.style.getPropertyValue(BOTTOM_STRIP_HEIGHT_VAR)).toBe(
|
||||
`${BOTTOM_STRIP_HEIGHT}px`,
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(document.body.classList.contains(BOTTOM_STRIP_ON_CLASS)).toBe(false);
|
||||
expect(document.body.style.getPropertyValue(BOTTOM_STRIP_HEIGHT_VAR)).toBe(
|
||||
'',
|
||||
);
|
||||
});
|
||||
|
||||
// The string is whatever the Go build injected, so it is rendered untouched —
|
||||
// same as SideNav. Release tags carry the "v", local builds do not.
|
||||
it.each([['v0.134.67'], ['main-64f1c2a']])(
|
||||
'renders the build version %p exactly as given',
|
||||
(version) => {
|
||||
const { getByText } = render(<BottomStrip />, undefined, {
|
||||
appContextOverrides: {
|
||||
versionData: { version, ee: 'Y', setupCompleted: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByText(version)).toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it('renders the strip without a version when none is available', () => {
|
||||
const { getByTestId } = render(<BottomStrip />, undefined, {
|
||||
appContextOverrides: { versionData: null },
|
||||
});
|
||||
|
||||
const strip = getByTestId('bottom-strip');
|
||||
|
||||
expect(strip).toBeInTheDocument();
|
||||
expect(strip).toHaveTextContent('');
|
||||
});
|
||||
|
||||
// `tests/test-utils` builds its wrapper around the first `ui`, so `rerender`
|
||||
// re-renders the original tree. These drive change through state and through
|
||||
// separate trees instead, which the module-level store lets them share.
|
||||
describe('left slot', () => {
|
||||
it('shows what the page put there instead of the version', () => {
|
||||
render(<Page>600 traces</Page>);
|
||||
const { getByText, queryByText } = render(
|
||||
<BottomStrip />,
|
||||
undefined,
|
||||
withVersion,
|
||||
);
|
||||
|
||||
expect(getByText('600 traces')).toBeInTheDocument();
|
||||
expect(queryByText(VERSION)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the version once the page is gone', () => {
|
||||
const page = render(<Page>600 traces</Page>);
|
||||
const { getByText } = render(<BottomStrip />, undefined, withVersion);
|
||||
|
||||
page.unmount();
|
||||
|
||||
expect(getByText(VERSION)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates when the page changes what it shows', () => {
|
||||
render(<ChangingPage />);
|
||||
const { getByText, getByRole } = render(
|
||||
<BottomStrip />,
|
||||
undefined,
|
||||
withVersion,
|
||||
);
|
||||
|
||||
expect(getByText('600 traces')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(getByRole('button', { name: 'change' }));
|
||||
|
||||
expect(getByText('42 traces')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the version when the page node throws', () => {
|
||||
// React logs the caught error, which is noise here.
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
render(
|
||||
<Page>
|
||||
<Boom />
|
||||
</Page>,
|
||||
);
|
||||
const { getByTestId, getByText } = render(
|
||||
<BottomStrip />,
|
||||
undefined,
|
||||
withVersion,
|
||||
);
|
||||
|
||||
expect(getByTestId('bottom-strip')).toBeInTheDocument();
|
||||
expect(getByText(VERSION)).toBeInTheDocument();
|
||||
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
it('keeps the new page value when the old page unmounts after it', () => {
|
||||
// Navigation order: the next page mounts before the last one unmounts,
|
||||
// so without the owner guard the outgoing page wipes the incoming value.
|
||||
const pageA = render(<Page>page A</Page>);
|
||||
render(<Page>page B</Page>);
|
||||
const { getByText } = render(<BottomStrip />, undefined, withVersion);
|
||||
|
||||
pageA.unmount();
|
||||
|
||||
expect(getByText('page B')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
.separator {
|
||||
flex-shrink: 0;
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
background: var(--l2-border);
|
||||
}
|
||||
|
||||
// Strip items hide themselves by rendering null, which would otherwise leave a
|
||||
// separator dangling at an edge or two of them side by side. Collapsing them
|
||||
// here keeps the call sites free of visibility plumbing.
|
||||
.separator:first-child,
|
||||
.separator:last-child,
|
||||
.separator + .separator {
|
||||
display: none;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import styles from './StripSeparator.module.scss';
|
||||
|
||||
function StripSeparator(): JSX.Element {
|
||||
return <span className={styles.separator} aria-hidden />;
|
||||
}
|
||||
|
||||
export default StripSeparator;
|
||||
@@ -0,0 +1,10 @@
|
||||
.stripTypography {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
color: var(--l2-foreground);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './StripTypography.module.scss';
|
||||
|
||||
interface StripTypographyProps {
|
||||
children: ReactNode;
|
||||
/** Leading icon, aligned and spaced for you. Same shape as `Button`'s. */
|
||||
prefix?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the strip renders goes through this: the version, a plain count, or
|
||||
* an icon with a key and value. It owns the strip's type and alignment and
|
||||
* nothing else — how a consumer colours its own content is theirs.
|
||||
*/
|
||||
function StripTypography({
|
||||
children,
|
||||
prefix,
|
||||
className,
|
||||
}: StripTypographyProps): JSX.Element {
|
||||
return (
|
||||
<span className={cx(styles.stripTypography, className)}>
|
||||
{prefix}
|
||||
<Typography.Text as="span">{children}</Typography.Text>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
StripTypography.defaultProps = { prefix: undefined, className: undefined };
|
||||
|
||||
export default StripTypography;
|
||||
43
frontend/src/container/BottomStrip/index.tsx
Normal file
43
frontend/src/container/BottomStrip/index.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useLayoutEffect } from 'react';
|
||||
|
||||
import AskNoz from './AskNoz/AskNoz';
|
||||
import LeftSlot from './LeftSlot/LeftSlot';
|
||||
import StripSeparator from './components/StripSeparator/StripSeparator';
|
||||
import SupportButton from './SupportButton/SupportButton';
|
||||
|
||||
import styles from './BottomStrip.module.scss';
|
||||
|
||||
export const BOTTOM_STRIP_HEIGHT = 24;
|
||||
|
||||
export const BOTTOM_STRIP_ON_CLASS = 'bottom-strip-on';
|
||||
export const BOTTOM_STRIP_HEIGHT_VAR = '--bottom-strip-height';
|
||||
|
||||
function BottomStrip(): JSX.Element {
|
||||
useLayoutEffect(() => {
|
||||
document.body.classList.add(BOTTOM_STRIP_ON_CLASS);
|
||||
document.body.style.setProperty(
|
||||
BOTTOM_STRIP_HEIGHT_VAR,
|
||||
`${BOTTOM_STRIP_HEIGHT}px`,
|
||||
);
|
||||
|
||||
return (): void => {
|
||||
document.body.classList.remove(BOTTOM_STRIP_ON_CLASS);
|
||||
document.body.style.removeProperty(BOTTOM_STRIP_HEIGHT_VAR);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.strip} data-testid="bottom-strip">
|
||||
<div className={styles.left}>
|
||||
<LeftSlot />
|
||||
</div>
|
||||
<div className={styles.right}>
|
||||
<AskNoz />
|
||||
<StripSeparator />
|
||||
<SupportButton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BottomStrip;
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface BottomStripState {
|
||||
/** What the strip shows on the left, or null to fall back to the version. */
|
||||
left: ReactNode | null;
|
||||
/** Which page owns the current value — see `clearLeft`. */
|
||||
ownerId: string | null;
|
||||
setLeft: (ownerId: string, left: ReactNode) => void;
|
||||
clearLeft: (ownerId: string) => void;
|
||||
}
|
||||
|
||||
export const useBottomStripStore = create<BottomStripState>()((set, get) => ({
|
||||
left: null,
|
||||
ownerId: null,
|
||||
setLeft: (ownerId, left): void => set({ left, ownerId }),
|
||||
// Only the current owner may clear. On a plain route swap React runs the old
|
||||
// page's cleanup before the new page's effect, so this is moot, but two
|
||||
// consumers can be mounted at once (a page under a drawer): the one that set
|
||||
// last owns the slot, and the other unmounting must not wipe it.
|
||||
clearLeft: (ownerId): void => {
|
||||
if (get().ownerId === ownerId) {
|
||||
set({ left: null, ownerId: null });
|
||||
}
|
||||
},
|
||||
}));
|
||||
23
frontend/src/container/BottomStrip/useBottomStripLeft.ts
Normal file
23
frontend/src/container/BottomStrip/useBottomStripLeft.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { type ReactNode, useEffect, useId } from 'react';
|
||||
|
||||
import { useBottomStripStore } from './store/useBottomStripStore';
|
||||
|
||||
/**
|
||||
* Puts `node` on the left of the bottom strip for as long as the calling page is
|
||||
* mounted. Pass null to show nothing and let the version through.
|
||||
*
|
||||
* There is no refresh API by design: a page that refetches re-renders, which
|
||||
* produces a new node, which re-runs this effect. Wrap the node in `useMemo`
|
||||
* keyed on the values it shows, or the store is written on every render.
|
||||
*/
|
||||
export function useBottomStripLeft(node: ReactNode | null): void {
|
||||
const ownerId = useId();
|
||||
const setLeft = useBottomStripStore((state) => state.setLeft);
|
||||
const clearLeft = useBottomStripStore((state) => state.clearLeft);
|
||||
|
||||
useEffect(() => {
|
||||
setLeft(ownerId, node);
|
||||
|
||||
return (): void => clearLeft(ownerId);
|
||||
}, [node, ownerId, setLeft, clearLeft]);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
.create-alert-v2-footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
|
||||
// UI belongs in the bounded layout, not in another offset here.
|
||||
bottom: var(--bottom-strip-height, 0px);
|
||||
left: 63px;
|
||||
right: 0;
|
||||
background-color: var(--l1-background);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
.explorer-options-container {
|
||||
position: fixed;
|
||||
bottom: 0px;
|
||||
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
|
||||
// UI belongs in the bounded layout, not in another offset here.
|
||||
bottom: var(--bottom-strip-height, 0px);
|
||||
left: calc(50% + 240px);
|
||||
transform: translate(calc(-50% - 120px), 0);
|
||||
transition: left 0.2s linear;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
.explorer-option-droppable-container {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
|
||||
// UI belongs in the bounded layout, not in another offset here.
|
||||
bottom: var(--bottom-strip-height, 0px);
|
||||
width: -webkit-fill-available;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
.home-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import {
|
||||
@@ -26,6 +26,8 @@ import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
import { useBottomStripLeft } from 'container/BottomStrip/useBottomStripLeft';
|
||||
import StripInfo from 'container/Home/StripInfo/StripInfo';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
@@ -64,6 +66,8 @@ const homeInterval = 30 * 60 * 1000;
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export default function Home(): JSX.Element {
|
||||
useBottomStripLeft(useMemo(() => <StripInfo />, []));
|
||||
|
||||
const { user } = useAppContext();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
15
frontend/src/container/Home/StripInfo/StripInfo.tsx
Normal file
15
frontend/src/container/Home/StripInfo/StripInfo.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useGetAlerts } from 'api/generated/services/alerts';
|
||||
import StripTypography from 'container/BottomStrip/components/StripTypography/StripTypography';
|
||||
import { pluralize } from 'utils/pluralize';
|
||||
|
||||
function StripInfo(): JSX.Element {
|
||||
// Firing instances, not rules, matching the triggered alerts page. Home's own
|
||||
// rules query sets `cacheTime: 0`, so it cannot be shared.
|
||||
const { data } = useGetAlerts();
|
||||
|
||||
const count = data?.data?.length ?? 0;
|
||||
|
||||
return <StripTypography>{pluralize(count, 'alert')} firing</StripTypography>;
|
||||
}
|
||||
|
||||
export default StripInfo;
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useGetAlerts } from 'api/generated/services/alerts';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import StripInfo from '../StripInfo';
|
||||
|
||||
jest.mock('api/generated/services/alerts', () => ({
|
||||
useGetAlerts: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockUseGetAlerts = useGetAlerts as jest.Mock;
|
||||
|
||||
describe('StripInfo', () => {
|
||||
it('counts the firing alert instances', () => {
|
||||
mockUseGetAlerts.mockReturnValue({ data: { data: [{}, {}, {}] } });
|
||||
|
||||
const { getByText } = render(<StripInfo />);
|
||||
|
||||
expect(getByText('3 alerts firing')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('says alert, not alerts, when only one is firing', () => {
|
||||
mockUseGetAlerts.mockReturnValue({ data: { data: [{}] } });
|
||||
|
||||
const { getByText } = render(<StripInfo />);
|
||||
|
||||
expect(getByText('1 alert firing')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows zero before the response lands', () => {
|
||||
mockUseGetAlerts.mockReturnValue({ data: undefined });
|
||||
|
||||
const { getByText } = render(<StripInfo />);
|
||||
|
||||
expect(getByText('0 alerts firing')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -65,8 +65,6 @@
|
||||
}
|
||||
|
||||
.trace-explorer-page {
|
||||
display: flex;
|
||||
|
||||
// Meant to fix the query builder colors
|
||||
--input-background: var(--l2-background);
|
||||
--input-hover-background: var(--l2-background);
|
||||
@@ -75,32 +73,8 @@
|
||||
--input-hover-border-color: var(--internal-ant-border-color-hover);
|
||||
--input-focus-border-color: var(--internal-ant-border-color-hover);
|
||||
|
||||
.filter {
|
||||
width: 260px;
|
||||
height: 100%;
|
||||
min-height: 100vh;
|
||||
|
||||
border-right: 0px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background-color: var(--l1-background);
|
||||
|
||||
> .ant-card-body {
|
||||
padding: 0;
|
||||
width: 258px;
|
||||
}
|
||||
}
|
||||
|
||||
.trace-explorer {
|
||||
width: 100%;
|
||||
background: var(--l1-background);
|
||||
|
||||
> .ant-card-body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
border-color: var(--l1-border);
|
||||
}
|
||||
.trace-explorer.filters-expanded {
|
||||
width: calc(100% - 260px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Card } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
|
||||
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
@@ -188,26 +186,21 @@ function Explorer(): JSX.Element {
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<div
|
||||
<QuickFiltersLayout
|
||||
className="trace-explorer-page"
|
||||
data-testid="llm-observability-explorer"
|
||||
testId="llm-observability-explorer"
|
||||
showFilters={isOpen}
|
||||
quickFilterProps={{
|
||||
className: 'qf-traces-explorer',
|
||||
source: QuickFiltersSource.AI_OBSERVABILITY,
|
||||
signal: SignalType.AI_OBSERVABILITY,
|
||||
useFieldApis: quickFiltersFieldApis,
|
||||
handleFilterVisibilityChange: (): void => {
|
||||
setOpen(!isOpen);
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Card className="filter" hidden={!isOpen}>
|
||||
<QuickFilters
|
||||
className="qf-traces-explorer"
|
||||
source={QuickFiltersSource.AI_OBSERVABILITY}
|
||||
signal={SignalType.AI_OBSERVABILITY}
|
||||
useFieldApis={quickFiltersFieldApis}
|
||||
handleFilterVisibilityChange={(): void => {
|
||||
setOpen(!isOpen);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
<div
|
||||
className={cx('trace-explorer', {
|
||||
'filters-expanded': isOpen,
|
||||
})}
|
||||
>
|
||||
<div className="trace-explorer">
|
||||
<div className="trace-explorer-header">
|
||||
<Toolbar
|
||||
showAutoRefresh
|
||||
@@ -291,7 +284,7 @@ function Explorer(): JSX.Element {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</QuickFiltersLayout>
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
.licenses-page {
|
||||
max-height: 100vh;
|
||||
overflow: hidden;
|
||||
|
||||
.licenses-page-header {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
background: var(--l1-background);
|
||||
@@ -32,7 +29,6 @@
|
||||
|
||||
.licenses-page-content {
|
||||
flex: 1;
|
||||
height: calc(100vh - 48px);
|
||||
background: var(--l1-background);
|
||||
padding: 10px 8px;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
height: calc(100vh - 62px);
|
||||
flex: 1;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import StripTypography from 'container/BottomStrip/components/StripTypography/StripTypography';
|
||||
import { pluralize } from 'utils/pluralize';
|
||||
|
||||
interface StripInfoProps {
|
||||
filteredCount: number;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
function StripInfo({ filteredCount, totalCount }: StripInfoProps): JSX.Element {
|
||||
return (
|
||||
<StripTypography>
|
||||
{filteredCount} of {pluralize(totalCount, 'rule')}
|
||||
</StripTypography>
|
||||
);
|
||||
}
|
||||
|
||||
export default StripInfo;
|
||||
@@ -0,0 +1,23 @@
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import StripInfo from '../StripInfo';
|
||||
|
||||
describe('StripInfo', () => {
|
||||
it('shows how many rules the filters left', () => {
|
||||
const { getByText } = render(<StripInfo filteredCount={3} totalCount={12} />);
|
||||
|
||||
expect(getByText('3 of 12 rules')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('says rule, not rules, when there is only one', () => {
|
||||
const { getByText } = render(<StripInfo filteredCount={1} totalCount={1} />);
|
||||
|
||||
expect(getByText('1 of 1 rule')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows zero when the filters match nothing', () => {
|
||||
const { getByText } = render(<StripInfo filteredCount={0} totalCount={12} />);
|
||||
|
||||
expect(getByText('0 of 12 rules')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import NoResultsEmptyState from 'components/Alerts/NoResultsEmptyState';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { useCalculatedPageSize } from 'components/TanStackTableView/useCalculatedPageSize';
|
||||
import { useTableParams } from 'components/TanStackTableView/useTableParams';
|
||||
import { useBottomStripLeft } from 'container/BottomStrip/useBottomStripLeft';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useUrlSearchState } from 'hooks/useUrlSearchState';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
@@ -20,6 +21,7 @@ import { ALERT_RULES_PARAMS, useAlertRulesFilters } from './hooks';
|
||||
import styles from './ListAlertRules.module.scss';
|
||||
import { getAlertRuleColumns } from './table.config';
|
||||
import type { AlertRule } from './types';
|
||||
import StripInfo from './StripInfo/StripInfo';
|
||||
import { useAlertRulesData } from './useAlertRulesData';
|
||||
import { useAlertRulesHandlers } from './useAlertRulesHandlers';
|
||||
|
||||
@@ -69,6 +71,18 @@ function ListAlertRules(): JSX.Element {
|
||||
const { filteredRules, isFetching, isError, allRules, refetch } =
|
||||
useAlertRulesData(orderBy, debouncedSearch, filterValues ?? []);
|
||||
|
||||
useBottomStripLeft(
|
||||
useMemo(
|
||||
() => (
|
||||
<StripInfo
|
||||
filteredCount={filteredRules.length}
|
||||
totalCount={allRules.length}
|
||||
/>
|
||||
),
|
||||
[filteredRules.length, allRules.length],
|
||||
),
|
||||
);
|
||||
|
||||
const { handleEdit, handleNewAlert, handleRowClick, handleRowClickNewTab } =
|
||||
useAlertRulesHandlers(allRules.length);
|
||||
|
||||
|
||||
@@ -1,18 +1,7 @@
|
||||
.meter-explorer-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
.meter-explorer-quick-filters-section {
|
||||
width: 280px;
|
||||
border-right: 1px solid var(--l1-border);
|
||||
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.meter-explorer-content-section {
|
||||
width: 100%;
|
||||
// Clearance for the fixed ExplorerOptions bar.
|
||||
padding-bottom: 80px;
|
||||
|
||||
// Meant to fix the query builder colors
|
||||
--input-background: var(--l2-background);
|
||||
@@ -83,14 +72,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.quick-filters-open {
|
||||
.meter-explorer-content-section {
|
||||
width: calc(100% - 280px);
|
||||
}
|
||||
}
|
||||
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
.dashboards-and-alerts-popover-container {
|
||||
|
||||
@@ -3,9 +3,8 @@ import { useQueryClient } from 'react-query';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
|
||||
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import { initialQueryMeterWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
@@ -121,29 +120,21 @@ function Explorer(): JSX.Element {
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<div
|
||||
className={cx('meter-explorer-container', {
|
||||
'quick-filters-open': showQuickFilters,
|
||||
})}
|
||||
<QuickFiltersLayout
|
||||
className="meter-explorer-container"
|
||||
showFilters={showQuickFilters}
|
||||
quickFilterProps={{
|
||||
className: 'qf-meter-explorer',
|
||||
source: QuickFiltersSource.METER_EXPLORER,
|
||||
signal: SignalType.METER_EXPLORER,
|
||||
showFilterCollapse: true,
|
||||
showQueryName: false,
|
||||
handleFilterVisibilityChange: (): void => {
|
||||
setShowQuickFilters(!showQuickFilters);
|
||||
},
|
||||
useFieldApis: quickFilterFieldApis,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cx('meter-explorer-quick-filters-section', {
|
||||
hidden: !showQuickFilters,
|
||||
})}
|
||||
>
|
||||
<QuickFilters
|
||||
className="qf-meter-explorer"
|
||||
source={QuickFiltersSource.METER_EXPLORER}
|
||||
signal={SignalType.METER_EXPLORER}
|
||||
showFilterCollapse
|
||||
showQueryName={false}
|
||||
handleFilterVisibilityChange={(): void => {
|
||||
setShowQuickFilters(!showQuickFilters);
|
||||
}}
|
||||
useFieldApis={quickFilterFieldApis}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="meter-explorer-content-section">
|
||||
<div className="meter-explorer-explore-content">
|
||||
<div className="explore-header">
|
||||
@@ -196,7 +187,7 @@ function Explorer(): JSX.Element {
|
||||
splitedQueries={splitedQueries}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</QuickFiltersLayout>
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -60,9 +60,6 @@
|
||||
.metrics-table-container {
|
||||
padding-bottom: 48px;
|
||||
.ant-table {
|
||||
margin-left: -16px;
|
||||
margin-right: -16px;
|
||||
|
||||
.ant-table-thead > tr > th {
|
||||
padding: 12px;
|
||||
font-weight: 500;
|
||||
@@ -184,7 +181,9 @@
|
||||
|
||||
.ant-pagination {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
// Lifted above the bottom strip. Don't extend this pattern — new
|
||||
// fixed-bottom UI belongs in the bounded layout, not in another offset here.
|
||||
bottom: var(--bottom-strip-height, 0px);
|
||||
width: calc(100% - 54px);
|
||||
background: var(--l1-background);
|
||||
padding: 16px;
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import { ENTITY_VERSION_V4 } from 'constants/app';
|
||||
import { MAX_RPS_LIMIT } from 'constants/global';
|
||||
import { useBottomStripLeft } from 'container/BottomStrip/useBottomStripLeft';
|
||||
import ResourceAttributesFilter from 'container/ResourceAttributesFilter';
|
||||
import { useGetQueriesRange } from 'hooks/queryBuilder/useGetQueriesRange';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
@@ -20,6 +21,7 @@ import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { getTotalRPS } from 'utils/services';
|
||||
|
||||
import { getColumns } from '../Columns/ServiceColumn';
|
||||
import StripInfo from '../StripInfo/StripInfo';
|
||||
import { ServiceMetricsTableProps } from '../types';
|
||||
import { getServiceListFromQuery } from '../utils';
|
||||
|
||||
@@ -67,6 +69,10 @@ function ServiceMetricTable({
|
||||
[isLoading, queries, topLevelOperations],
|
||||
);
|
||||
|
||||
useBottomStripLeft(
|
||||
useMemo(() => <StripInfo count={services.length} />, [services.length]),
|
||||
);
|
||||
|
||||
const { search } = useLocation();
|
||||
const tableColumns = useMemo(() => getColumns(search, true), [search]);
|
||||
const [RPS, setRPS] = useState(0);
|
||||
|
||||
@@ -6,7 +6,10 @@ import localStorageSet from 'api/browser/localstorage/set';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { SKIP_ONBOARDING } from 'constants/onboarding';
|
||||
import useErrorNotification from 'hooks/useErrorNotification';
|
||||
import { useBottomStripLeft } from 'container/BottomStrip/useBottomStripLeft';
|
||||
import { useQueryService } from 'hooks/useQueryService';
|
||||
|
||||
import StripInfo from '../StripInfo/StripInfo';
|
||||
import useResourceAttribute from 'hooks/useResourceAttribute';
|
||||
import {
|
||||
convertRawQueriesToTraceSelectedTags,
|
||||
@@ -42,6 +45,10 @@ function ServiceTraces(): JSX.Element {
|
||||
|
||||
const services = data || [];
|
||||
|
||||
useBottomStripLeft(
|
||||
useMemo(() => <StripInfo count={services.length} />, [services.length]),
|
||||
);
|
||||
|
||||
const [skipOnboarding, setSkipOnboarding] = useState(
|
||||
localStorageGet(SKIP_ONBOARDING) === 'true',
|
||||
);
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import StripTypography from 'container/BottomStrip/components/StripTypography/StripTypography';
|
||||
import { pluralize } from 'utils/pluralize';
|
||||
|
||||
interface StripInfoProps {
|
||||
count: number;
|
||||
}
|
||||
|
||||
function StripInfo({ count }: StripInfoProps): JSX.Element {
|
||||
return <StripTypography>{pluralize(count, 'service')}</StripTypography>;
|
||||
}
|
||||
|
||||
export default StripInfo;
|
||||
@@ -0,0 +1,23 @@
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import StripInfo from '../StripInfo';
|
||||
|
||||
describe('StripInfo', () => {
|
||||
it('shows how many services are listed', () => {
|
||||
const { getByText } = render(<StripInfo count={18} />);
|
||||
|
||||
expect(getByText('18 services')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('says service, not services, when there is one', () => {
|
||||
const { getByText } = render(<StripInfo count={1} />);
|
||||
|
||||
expect(getByText('1 service')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows zero when there are none', () => {
|
||||
const { getByText } = render(<StripInfo count={0} />);
|
||||
|
||||
expect(getByText('0 services')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
height: calc(100vh - 62px);
|
||||
flex: 1;
|
||||
min-height: 400px;
|
||||
padding-top: var(--spacing-8);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
.version-container {
|
||||
max-height: 100vh;
|
||||
overflow: hidden;
|
||||
|
||||
.version-page-header {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
background: var(--l1-background);
|
||||
|
||||
148
frontend/src/hooks/__tests__/useChatSupport.test.tsx
Normal file
148
frontend/src/hooks/__tests__/useChatSupport.test.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
jest.mock('providers/App/App');
|
||||
jest.mock('hooks/useGetTenantLicense');
|
||||
|
||||
const mockAppContext = useAppContext as jest.MockedFunction<
|
||||
typeof useAppContext
|
||||
>;
|
||||
const mockLicense = useGetTenantLicense as jest.MockedFunction<
|
||||
typeof useGetTenantLicense
|
||||
>;
|
||||
|
||||
const flag = (name: FeatureKeys, active: boolean): Record<string, unknown> => ({
|
||||
name,
|
||||
active,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
});
|
||||
|
||||
function setup({
|
||||
chatSupport = true,
|
||||
premiumSupport = false,
|
||||
trialConverted = false,
|
||||
isCloudUser = true,
|
||||
isEnterpriseSelfHostedUser = false,
|
||||
isLoggedIn = true,
|
||||
pylonEnabled = true,
|
||||
isFetchingFeatureFlags = false,
|
||||
activeLicense = {} as unknown,
|
||||
trialInfo = {} as unknown,
|
||||
} = {}): void {
|
||||
window.signozBootData = {
|
||||
settings: { pylon: { enabled: pylonEnabled } },
|
||||
} as never;
|
||||
|
||||
mockAppContext.mockReturnValue({
|
||||
featureFlags: [
|
||||
flag(FeatureKeys.CHAT_SUPPORT, chatSupport),
|
||||
flag(FeatureKeys.PREMIUM_SUPPORT, premiumSupport),
|
||||
],
|
||||
isFetchingFeatureFlags,
|
||||
featureFlagsFetchError: null,
|
||||
trialInfo: trialInfo && { trialConvertedToSubscription: trialConverted },
|
||||
isLoggedIn,
|
||||
activeLicense,
|
||||
} as never);
|
||||
|
||||
mockLicense.mockReturnValue({
|
||||
isCloudUser,
|
||||
isEnterpriseSelfHostedUser,
|
||||
} as never);
|
||||
}
|
||||
|
||||
const state = (): ChatSupportState =>
|
||||
renderHook(() => useChatSupport()).result.current;
|
||||
|
||||
describe('useChatSupport', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
describe('pylon', () => {
|
||||
it('hands off to Pylon for a cloud user past trial', () => {
|
||||
setup({ trialConverted: true });
|
||||
|
||||
expect(state()).toBe(ChatSupportState.Pylon);
|
||||
});
|
||||
|
||||
it('hands off to Pylon for enterprise self-hosted', () => {
|
||||
setup({
|
||||
trialConverted: true,
|
||||
isCloudUser: false,
|
||||
isEnterpriseSelfHostedUser: true,
|
||||
});
|
||||
|
||||
expect(state()).toBe(ChatSupportState.Pylon);
|
||||
});
|
||||
|
||||
it('hands off to Pylon when premium support is on, card or not', () => {
|
||||
setup({ premiumSupport: true, trialConverted: false });
|
||||
|
||||
expect(state()).toBe(ChatSupportState.Pylon);
|
||||
});
|
||||
|
||||
it('offers nothing when Pylon is not configured server side', () => {
|
||||
setup({ trialConverted: true, pylonEnabled: false });
|
||||
|
||||
expect(state()).toBe(ChatSupportState.Unavailable);
|
||||
});
|
||||
});
|
||||
|
||||
describe('needsCard', () => {
|
||||
it('offers the card flow to a cloud user still on trial', () => {
|
||||
setup({ trialConverted: false, premiumSupport: false });
|
||||
|
||||
expect(state()).toBe(ChatSupportState.NeedsCard);
|
||||
});
|
||||
|
||||
it('offers nothing to a non-cloud user needing a card', () => {
|
||||
setup({
|
||||
trialConverted: false,
|
||||
isCloudUser: false,
|
||||
isEnterpriseSelfHostedUser: true,
|
||||
});
|
||||
|
||||
expect(state()).toBe(ChatSupportState.Unavailable);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unavailable', () => {
|
||||
it('offers nothing without the chat support flag', () => {
|
||||
setup({ chatSupport: false });
|
||||
|
||||
expect(state()).toBe(ChatSupportState.Unavailable);
|
||||
});
|
||||
|
||||
it('offers nothing when logged out', () => {
|
||||
setup({ isLoggedIn: false });
|
||||
|
||||
expect(state()).toBe(ChatSupportState.Unavailable);
|
||||
});
|
||||
|
||||
it('offers nothing while the flags are still loading', () => {
|
||||
setup({ isFetchingFeatureFlags: true });
|
||||
|
||||
expect(state()).toBe(ChatSupportState.Unavailable);
|
||||
});
|
||||
|
||||
it('offers nothing before the licence has loaded', () => {
|
||||
setup({ activeLicense: null });
|
||||
|
||||
expect(state()).toBe(ChatSupportState.Unavailable);
|
||||
});
|
||||
|
||||
it('offers nothing on a tenant that is neither cloud nor enterprise', () => {
|
||||
setup({
|
||||
trialConverted: true,
|
||||
isCloudUser: false,
|
||||
isEnterpriseSelfHostedUser: false,
|
||||
});
|
||||
|
||||
expect(state()).toBe(ChatSupportState.Unavailable);
|
||||
});
|
||||
});
|
||||
});
|
||||
70
frontend/src/hooks/useChatSupport.ts
Normal file
70
frontend/src/hooks/useChatSupport.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export enum ChatSupportState {
|
||||
/** Pylon is configured for this user — hand off to the widget. */
|
||||
Pylon = 'pylon',
|
||||
/** Trial without a card — offer the Add Credit Card flow instead. */
|
||||
NeedsCard = 'needsCard',
|
||||
/** No support entry at all. */
|
||||
Unavailable = 'unavailable',
|
||||
}
|
||||
|
||||
export function useChatSupport(): ChatSupportState {
|
||||
const {
|
||||
featureFlags,
|
||||
isFetchingFeatureFlags,
|
||||
featureFlagsFetchError,
|
||||
trialInfo,
|
||||
isLoggedIn,
|
||||
activeLicense,
|
||||
} = useAppContext();
|
||||
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
|
||||
|
||||
return useMemo(() => {
|
||||
const isReady =
|
||||
!isFetchingFeatureFlags &&
|
||||
(featureFlags || featureFlagsFetchError) &&
|
||||
activeLicense &&
|
||||
trialInfo;
|
||||
if (!isReady || !isLoggedIn) {
|
||||
return ChatSupportState.Unavailable;
|
||||
}
|
||||
|
||||
const flag = (name: FeatureKeys): boolean =>
|
||||
featureFlags?.find((f) => f.name === name)?.active || false;
|
||||
|
||||
if (!flag(FeatureKeys.CHAT_SUPPORT)) {
|
||||
return ChatSupportState.Unavailable;
|
||||
}
|
||||
|
||||
const needsCard =
|
||||
!flag(FeatureKeys.PREMIUM_SUPPORT) &&
|
||||
!trialInfo?.trialConvertedToSubscription;
|
||||
|
||||
if (needsCard) {
|
||||
// The credit card flow is cloud-only
|
||||
return isCloudUser
|
||||
? ChatSupportState.NeedsCard
|
||||
: ChatSupportState.Unavailable;
|
||||
}
|
||||
|
||||
const pylonConfigured = Boolean(
|
||||
window.signozBootData?.settings?.pylon?.enabled,
|
||||
);
|
||||
return (isCloudUser || isEnterpriseSelfHostedUser) && pylonConfigured
|
||||
? ChatSupportState.Pylon
|
||||
: ChatSupportState.Unavailable;
|
||||
}, [
|
||||
activeLicense,
|
||||
featureFlags,
|
||||
featureFlagsFetchError,
|
||||
isCloudUser,
|
||||
isEnterpriseSelfHostedUser,
|
||||
isFetchingFeatureFlags,
|
||||
isLoggedIn,
|
||||
trialInfo,
|
||||
]);
|
||||
}
|
||||
11
frontend/src/hooks/useSavedViewEnabled.ts
Normal file
11
frontend/src/hooks/useSavedViewEnabled.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function useSavedViewEnabled(): boolean {
|
||||
const [isEnabled] = useState(
|
||||
() => getLocalStorageKey(LOCALSTORAGE.SAVED_VIEW_ENABLED) === 'true',
|
||||
);
|
||||
|
||||
return isEnabled;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useHistory, useLocation, useParams } from 'react-router-dom';
|
||||
|
||||
import logEvent from 'api/common/logEvent';
|
||||
@@ -10,6 +10,9 @@ import { normalizePage } from 'container/AIAssistant/hooks/useAIAssistantAnalyti
|
||||
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
|
||||
import { VariantContext } from 'container/AIAssistant/VariantContext';
|
||||
import Noz from 'components/Noz/Noz';
|
||||
import { useBottomStripLeft } from 'container/BottomStrip/useBottomStripLeft';
|
||||
|
||||
import StripInfo from './StripInfo/StripInfo';
|
||||
|
||||
import styles from './AIAssistantPage.module.scss';
|
||||
import ConversationsList from 'container/AIAssistant/components/ConversationsList';
|
||||
@@ -41,6 +44,8 @@ export default function AIAssistantPage(): JSX.Element {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useBottomStripLeft(useMemo(() => <StripInfo />, []));
|
||||
|
||||
const conversations = useAIAssistantStore((s) => s.conversations);
|
||||
const activeConversationId = useAIAssistantStore(
|
||||
(s) => s.activeConversationId,
|
||||
|
||||
15
frontend/src/pages/AIAssistantPage/StripInfo/StripInfo.tsx
Normal file
15
frontend/src/pages/AIAssistantPage/StripInfo/StripInfo.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
|
||||
import StripTypography from 'container/BottomStrip/components/StripTypography/StripTypography';
|
||||
import { pluralize } from 'utils/pluralize';
|
||||
|
||||
function StripInfo(): JSX.Element {
|
||||
const conversations = useAIAssistantStore((state) => state.conversations);
|
||||
|
||||
const count = Object.values(conversations).filter(
|
||||
(conversation) => !conversation.archived,
|
||||
).length;
|
||||
|
||||
return <StripTypography>{pluralize(count, 'conversation')}</StripTypography>;
|
||||
}
|
||||
|
||||
export default StripInfo;
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import StripInfo from '../StripInfo';
|
||||
|
||||
function seed(conversations: Record<string, unknown>): void {
|
||||
useAIAssistantStore.setState({ conversations } as never);
|
||||
}
|
||||
|
||||
describe('StripInfo', () => {
|
||||
it('counts only the conversations that are not archived', () => {
|
||||
seed({
|
||||
a: { id: 'a' },
|
||||
b: { id: 'b' },
|
||||
c: { id: 'c', archived: true },
|
||||
});
|
||||
|
||||
const { getByText } = render(<StripInfo />);
|
||||
|
||||
expect(getByText('2 conversations')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('says one conversation, not 1 conversations', () => {
|
||||
seed({ a: { id: 'a' } });
|
||||
|
||||
const { getByText } = render(<StripInfo />);
|
||||
|
||||
expect(getByText('1 conversation')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows zero when there are none', () => {
|
||||
seed({});
|
||||
|
||||
const { getByText } = render(<StripInfo />);
|
||||
|
||||
expect(getByText('0 conversations')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,29 @@
|
||||
.alerts-container {
|
||||
// Hands the page height down to the active tab so its content can bound itself
|
||||
// instead of guessing with 100vh. Child combinators only, nested Tabs
|
||||
// (Configuration) must not be caught.
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
> .ant-tabs-content-holder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
> .ant-tabs-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
> .ant-tabs-tabpane-active {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.top-level-tab.periscope-tab {
|
||||
padding: 2px 0;
|
||||
}
|
||||
@@ -40,5 +65,9 @@
|
||||
|
||||
.alert-rules-container {
|
||||
margin-top: 10px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
.all-errors-page {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
.all-errors-quick-filter-section {
|
||||
width: 0%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.all-errors-right-section {
|
||||
.right-toolbar-actions-container {
|
||||
display: flex;
|
||||
@@ -18,14 +11,4 @@
|
||||
.ant-tabs {
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
&.filter-visible {
|
||||
.all-errors-quick-filter-section {
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.all-errors-right-section {
|
||||
width: calc(100% - 260px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,11 @@ import { Filter } from '@signozhq/icons';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import cx from 'classnames';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
|
||||
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import RouteTab from 'components/RouteTab';
|
||||
import TypicalOverlayScrollbar from 'components/TypicalOverlayScrollbar/TypicalOverlayScrollbar';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
|
||||
import ResourceAttributesFilterV2 from 'container/ResourceAttributeFilterV2/ResourceAttributesFilterV2';
|
||||
@@ -59,63 +57,52 @@ function AllErrors(): JSX.Element {
|
||||
const quickFilterFieldApis = useSignalFieldApis();
|
||||
|
||||
return (
|
||||
<div className={cx('all-errors-page', showFilters ? 'filter-visible' : '')}>
|
||||
{showFilters && (
|
||||
<section className={cx('all-errors-quick-filter-section')}>
|
||||
<QuickFilters
|
||||
className="qf-exceptions"
|
||||
source={QuickFiltersSource.EXCEPTIONS}
|
||||
signal={SignalType.EXCEPTIONS}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
useFieldApis={quickFilterFieldApis}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
<section
|
||||
className={cx(
|
||||
'all-errors-right-section',
|
||||
showFilters ? 'filter-visible' : '',
|
||||
)}
|
||||
>
|
||||
<TypicalOverlayScrollbar>
|
||||
<>
|
||||
<Toolbar
|
||||
showAutoRefresh={false}
|
||||
leftActions={
|
||||
!showFilters ? (
|
||||
<Tooltip title="Show Filters">
|
||||
<Button onClick={handleFilterVisibilityChange} className="filter-btn">
|
||||
<Filter size="md" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : undefined
|
||||
}
|
||||
rightActions={
|
||||
<div className="right-toolbar-actions-container">
|
||||
<RightToolbarActions
|
||||
onStageRunQuery={handleRunQuery}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
/>
|
||||
<HeaderRightSection
|
||||
enableAnnouncements={false}
|
||||
enableShare
|
||||
enableFeedback
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
<QuickFiltersLayout
|
||||
className="all-errors-page"
|
||||
contentClassName="all-errors-right-section"
|
||||
showFilters={showFilters}
|
||||
quickFilterProps={{
|
||||
className: 'qf-exceptions',
|
||||
source: QuickFiltersSource.EXCEPTIONS,
|
||||
signal: SignalType.EXCEPTIONS,
|
||||
handleFilterVisibilityChange,
|
||||
useFieldApis: quickFilterFieldApis,
|
||||
}}
|
||||
>
|
||||
<Toolbar
|
||||
showAutoRefresh={false}
|
||||
leftActions={
|
||||
!showFilters ? (
|
||||
<Tooltip title="Show Filters">
|
||||
<Button onClick={handleFilterVisibilityChange} className="filter-btn">
|
||||
<Filter size="md" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : undefined
|
||||
}
|
||||
rightActions={
|
||||
<div className="right-toolbar-actions-container">
|
||||
<RightToolbarActions
|
||||
onStageRunQuery={handleRunQuery}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
/>
|
||||
<ResourceAttributesFilterV2 />
|
||||
<RouteTab
|
||||
routes={routes}
|
||||
activeKey={pathname}
|
||||
history={history}
|
||||
showRightSection={false}
|
||||
<HeaderRightSection
|
||||
enableAnnouncements={false}
|
||||
enableShare
|
||||
enableFeedback
|
||||
/>
|
||||
</>
|
||||
</TypicalOverlayScrollbar>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<ResourceAttributesFilterV2 />
|
||||
<RouteTab
|
||||
routes={routes}
|
||||
activeKey={pathname}
|
||||
history={history}
|
||||
showRightSection={false}
|
||||
/>
|
||||
</QuickFiltersLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
.api-monitoring-page {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
.ant-tabs {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ant-tabs-nav {
|
||||
padding: 0 16px;
|
||||
margin-bottom: 0px;
|
||||
@@ -15,22 +8,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.ant-tabs-content-holder {
|
||||
display: flex;
|
||||
|
||||
.ant-tabs-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.ant-tabs-tabpane {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@@ -13,9 +13,12 @@ function ApiMonitoringPage(): JSX.Element {
|
||||
const routes: TabRoutes[] = [Explorer];
|
||||
|
||||
return (
|
||||
<div className="api-monitoring-page">
|
||||
<RouteTab routes={routes} activeKey={pathname} history={history} />
|
||||
</div>
|
||||
<RouteTab
|
||||
className="api-monitoring-page"
|
||||
routes={routes}
|
||||
activeKey={pathname}
|
||||
history={history}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
|
||||
// UI belongs in the bounded layout, not in another offset here.
|
||||
bottom: var(--bottom-strip-height, 0px);
|
||||
left: 0;
|
||||
width: 100%;
|
||||
z-index: 100;
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
.infra-monitoring-module-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
.ant-tabs {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ant-tabs-nav {
|
||||
padding: 0 8px;
|
||||
margin-bottom: 0px;
|
||||
@@ -17,22 +8,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.ant-tabs-content-holder {
|
||||
display: flex;
|
||||
|
||||
.ant-tabs-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.ant-tabs-tabpane {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@@ -13,8 +13,11 @@ export default function InfrastructureMonitoringPage(): JSX.Element {
|
||||
const routes: TabRoutes[] = [Hosts, Kubernetes];
|
||||
|
||||
return (
|
||||
<div className="infra-monitoring-module-container">
|
||||
<RouteTab routes={routes} activeKey={pathname} history={history} />
|
||||
</div>
|
||||
<RouteTab
|
||||
className="infra-monitoring-module-container"
|
||||
routes={routes}
|
||||
activeKey={pathname}
|
||||
history={history}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,4 @@
|
||||
.logs-module-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.ant-tabs {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ant-tabs-nav {
|
||||
padding: 0 16px;
|
||||
margin-bottom: 0px;
|
||||
@@ -20,25 +8,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.ant-tabs-content-holder {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
|
||||
.ant-tabs-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.ant-tabs-tabpane {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@@ -13,8 +13,11 @@ export default function LogsModulePage(): JSX.Element {
|
||||
const routes: TabRoutes[] = [logsExplorer, logsPipelines, logSaveView];
|
||||
|
||||
return (
|
||||
<div className="logs-module-container">
|
||||
<RouteTab routes={routes} activeKey={pathname} history={history} />
|
||||
</div>
|
||||
<RouteTab
|
||||
className="logs-module-container"
|
||||
routes={routes}
|
||||
activeKey={pathname}
|
||||
history={history}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
.messaging-queues-module-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
.ant-tabs {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ant-tabs-nav {
|
||||
padding: 0 8px;
|
||||
margin-bottom: 0px;
|
||||
@@ -17,22 +8,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.ant-tabs-content-holder {
|
||||
display: flex;
|
||||
|
||||
.ant-tabs-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.ant-tabs-tabpane {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@@ -68,8 +68,11 @@ export default function MessagingQueuesMainPage(): JSX.Element {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="messaging-queues-module-container">
|
||||
<RouteTab routes={routes} activeKey={pathname} history={history} />
|
||||
</div>
|
||||
<RouteTab
|
||||
className="messaging-queues-module-container"
|
||||
routes={routes}
|
||||
activeKey={pathname}
|
||||
history={history}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,14 +14,13 @@ function MeterExplorerPage(): JSX.Element {
|
||||
const routes: TabRoutes[] = [Meter, Explorer, Views];
|
||||
|
||||
return (
|
||||
<div className="meter-explorer-page">
|
||||
<RouteTab
|
||||
routes={routes}
|
||||
activeKey={pathname}
|
||||
history={history}
|
||||
defaultActiveKey={ROUTES.METER}
|
||||
/>
|
||||
</div>
|
||||
<RouteTab
|
||||
className="meter-explorer-page"
|
||||
routes={routes}
|
||||
activeKey={pathname}
|
||||
history={history}
|
||||
defaultActiveKey={ROUTES.METER}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
.metrics-explorer-page {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
.ant-tabs {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ant-tabs-nav {
|
||||
padding-left: 16px;
|
||||
margin-bottom: 0px;
|
||||
@@ -18,20 +9,7 @@
|
||||
}
|
||||
|
||||
.ant-tabs-content-holder {
|
||||
display: flex;
|
||||
padding: 16px;
|
||||
|
||||
.ant-tabs-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.ant-tabs-tabpane {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
|
||||
@@ -42,9 +42,12 @@ function MetricsExplorerPage(): JSX.Element {
|
||||
useShareBuilderUrl({ defaultValue: defaultQuery });
|
||||
|
||||
return (
|
||||
<div className="metrics-explorer-page">
|
||||
<RouteTab routes={routes} activeKey={pathname} history={history} />
|
||||
</div>
|
||||
<RouteTab
|
||||
className="metrics-explorer-page"
|
||||
routes={routes}
|
||||
activeKey={pathname}
|
||||
history={history}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
.support-page-container {
|
||||
max-height: 100vh;
|
||||
overflow: hidden;
|
||||
|
||||
.support-page-header {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
background: var(--l1-background);
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.hasErrors {
|
||||
color: var(--destructive);
|
||||
}
|
||||
37
frontend/src/pages/TraceDetailsV3/StripInfo/StripInfo.tsx
Normal file
37
frontend/src/pages/TraceDetailsV3/StripInfo/StripInfo.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import StripSeparator from 'container/BottomStrip/components/StripSeparator/StripSeparator';
|
||||
import StripTypography from 'container/BottomStrip/components/StripTypography/StripTypography';
|
||||
import cx from 'classnames';
|
||||
import { ChartNoAxesGantt, TriangleAlert } from '@signozhq/icons';
|
||||
|
||||
import styles from './StripInfo.module.scss';
|
||||
|
||||
interface StripInfoProps {
|
||||
totalSpansCount: number;
|
||||
totalErrorSpansCount: number;
|
||||
}
|
||||
|
||||
function StripInfo({
|
||||
totalSpansCount,
|
||||
totalErrorSpansCount,
|
||||
}: StripInfoProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<StripTypography prefix={<ChartNoAxesGantt size={13} />}>
|
||||
Spans: {totalSpansCount}
|
||||
</StripTypography>
|
||||
<StripSeparator />
|
||||
<StripTypography
|
||||
prefix={
|
||||
<TriangleAlert
|
||||
size={13}
|
||||
className={cx({ [styles.hasErrors]: totalErrorSpansCount > 0 })}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Errors: {totalErrorSpansCount}
|
||||
</StripTypography>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default StripInfo;
|
||||
@@ -0,0 +1,23 @@
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import StripInfo from '../StripInfo';
|
||||
|
||||
describe('StripInfo', () => {
|
||||
it('shows the span and error counts', () => {
|
||||
const { getByText } = render(
|
||||
<StripInfo totalSpansCount={600} totalErrorSpansCount={4} />,
|
||||
);
|
||||
|
||||
expect(getByText('Spans: 600')).toBeInTheDocument();
|
||||
expect(getByText('Errors: 4')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows zero counts rather than hiding them', () => {
|
||||
const { getByText } = render(
|
||||
<StripInfo totalSpansCount={0} totalErrorSpansCount={0} />,
|
||||
);
|
||||
|
||||
expect(getByText('Spans: 0')).toBeInTheDocument();
|
||||
expect(getByText('Errors: 0')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
.root {
|
||||
height: calc(100vh);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@ import { Collapse } from 'antd';
|
||||
import { useDetailsPanel } from 'components/DetailsPanel';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { useBottomStripLeft } from 'container/BottomStrip/useBottomStripLeft';
|
||||
import useGetTraceV4 from 'hooks/trace/useGetTraceV4';
|
||||
import StripInfo from './StripInfo/StripInfo';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
@@ -144,6 +146,19 @@ function TraceDetailsV3(): JSX.Element {
|
||||
|
||||
const allSpans = traceData?.payload?.spans || [];
|
||||
const totalSpansCount = traceData?.payload?.totalSpansCount || 0;
|
||||
const totalErrorSpansCount = traceData?.payload?.totalErrorSpansCount || 0;
|
||||
|
||||
useBottomStripLeft(
|
||||
useMemo(
|
||||
() => (
|
||||
<StripInfo
|
||||
totalSpansCount={totalSpansCount}
|
||||
totalErrorSpansCount={totalErrorSpansCount}
|
||||
/>
|
||||
),
|
||||
[totalSpansCount, totalErrorSpansCount],
|
||||
),
|
||||
);
|
||||
const isFullDataLoaded =
|
||||
totalSpansCount > 0 && totalSpansCount <= allSpans.length;
|
||||
|
||||
@@ -441,7 +456,10 @@ function TraceDetailsV3(): JSX.Element {
|
||||
})}
|
||||
>
|
||||
<TriangleAlert size={13} />
|
||||
Errors: {traceData.payload.totalErrorSpansCount ?? 0}
|
||||
Errors:{' '}
|
||||
{traceData.payload.totalErrorSpansCount ?? (
|
||||
<span className="translate-safe">{0}</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
@@ -65,8 +65,6 @@
|
||||
}
|
||||
|
||||
.trace-explorer-page {
|
||||
display: flex;
|
||||
|
||||
// Meant to fix the query builder colors
|
||||
--input-background: var(--l2-background);
|
||||
--input-hover-background: var(--l2-background);
|
||||
@@ -75,32 +73,8 @@
|
||||
--input-hover-border-color: var(--internal-ant-border-color-hover);
|
||||
--input-focus-border-color: var(--internal-ant-border-color-hover);
|
||||
|
||||
.filter {
|
||||
width: 260px;
|
||||
height: 100%;
|
||||
min-height: 100vh;
|
||||
|
||||
border-right: 0px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background-color: var(--l1-background);
|
||||
|
||||
> .ant-card-body {
|
||||
padding: 0;
|
||||
width: 258px;
|
||||
}
|
||||
}
|
||||
|
||||
.trace-explorer {
|
||||
width: 100%;
|
||||
background: var(--l1-background);
|
||||
|
||||
> .ant-card-body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
border-color: var(--l1-border);
|
||||
}
|
||||
.trace-explorer.filters-expanded {
|
||||
width: calc(100% - 260px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Card } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
|
||||
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
@@ -261,23 +259,20 @@ function TracesExplorer(): JSX.Element {
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<div className="trace-explorer-page">
|
||||
<Card className="filter" hidden={!isOpen}>
|
||||
<QuickFilters
|
||||
className="qf-traces-explorer"
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
signal={SignalType.TRACES}
|
||||
handleFilterVisibilityChange={(): void => {
|
||||
setOpen(!isOpen);
|
||||
}}
|
||||
useFieldApis={quickFilterFieldApis}
|
||||
/>
|
||||
</Card>
|
||||
<div
|
||||
className={cx('trace-explorer', {
|
||||
'filters-expanded': isOpen,
|
||||
})}
|
||||
>
|
||||
<QuickFiltersLayout
|
||||
className="trace-explorer-page"
|
||||
showFilters={isOpen}
|
||||
quickFilterProps={{
|
||||
className: 'qf-traces-explorer',
|
||||
source: QuickFiltersSource.TRACES_EXPLORER,
|
||||
signal: SignalType.TRACES,
|
||||
handleFilterVisibilityChange: (): void => {
|
||||
setOpen(!isOpen);
|
||||
},
|
||||
useFieldApis: quickFilterFieldApis,
|
||||
}}
|
||||
>
|
||||
<div className="trace-explorer">
|
||||
<div className="trace-explorer-header">
|
||||
<Toolbar
|
||||
showAutoRefresh
|
||||
@@ -369,7 +364,7 @@ function TracesExplorer(): JSX.Element {
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</QuickFiltersLayout>
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
.traces-funnel-details {
|
||||
display: flex;
|
||||
// 45px -> height of the tab bar
|
||||
height: calc(100vh - 45px);
|
||||
height: 100%;
|
||||
|
||||
&__steps-config {
|
||||
flex-shrink: 0;
|
||||
width: 600px;
|
||||
border-right: 1px solid var(--l1-border);
|
||||
// Positioning context for the absolute .steps-footer.
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
// Scoped here so the modal usage of FunnelConfiguration on trace details
|
||||
// stays in normal flow.
|
||||
.funnel-configuration {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
&__steps-results {
|
||||
width: 100%;
|
||||
|
||||
@@ -4,14 +4,17 @@
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
&.funnel-details-page {
|
||||
height: calc(
|
||||
100vh - 170px
|
||||
); // 64px bottom bar + 61px configuration header + 45px page navbar
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
// .steps-footer is absolute against the config column, so its 64px is
|
||||
// reserved rather than laid out.
|
||||
margin-bottom: 64px;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&__header {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -25,16 +25,15 @@ function TracesModulePage(): JSX.Element {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="traces-module-container">
|
||||
<RouteTab
|
||||
routes={routes}
|
||||
activeKey={
|
||||
pathname.includes(ROUTES.TRACES_FUNNELS) ? ROUTES.TRACES_FUNNELS : pathname
|
||||
}
|
||||
history={history}
|
||||
onChangeHandler={handleTabChange}
|
||||
/>
|
||||
</div>
|
||||
<RouteTab
|
||||
className="traces-module-container"
|
||||
routes={routes}
|
||||
activeKey={
|
||||
pathname.includes(ROUTES.TRACES_FUNNELS) ? ROUTES.TRACES_FUNNELS : pathname
|
||||
}
|
||||
history={history}
|
||||
onChangeHandler={handleTabChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -813,6 +813,12 @@ body.ai-assistant-panel-open {
|
||||
}
|
||||
}
|
||||
|
||||
body.bottom-strip-on {
|
||||
.PylonChat-chatWindowFrameContainer {
|
||||
bottom: var(--bottom-strip-height, 0px) !important;
|
||||
}
|
||||
}
|
||||
|
||||
[role='tab'] {
|
||||
color: var(--foreground) !important;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user