mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-02 15:10:34 +01:00
Compare commits
5 Commits
feat/add-r
...
refactor/c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04e2caaaaf | ||
|
|
0963ff08cd | ||
|
|
e43aeb8e24 | ||
|
|
9074208b09 | ||
|
|
571e23910e |
@@ -64,16 +64,16 @@ web:
|
||||
settings:
|
||||
posthog:
|
||||
# Whether to enable PostHog in web.
|
||||
enabled: true
|
||||
enabled: false
|
||||
appcues:
|
||||
# Whether to enable Appcues in web.
|
||||
enabled: true
|
||||
enabled: false
|
||||
sentry:
|
||||
# Whether to enable Sentry in web.
|
||||
enabled: true
|
||||
enabled: false
|
||||
pylon:
|
||||
# Whether to enable Pylon in web.
|
||||
enabled: true
|
||||
enabled: false
|
||||
|
||||
##################### Cache #####################
|
||||
cache:
|
||||
|
||||
@@ -870,14 +870,6 @@ components:
|
||||
- timestampMillis
|
||||
- data
|
||||
type: object
|
||||
CloudintegrationtypesAssets:
|
||||
properties:
|
||||
dashboards:
|
||||
items:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesDashboard'
|
||||
nullable: true
|
||||
type: array
|
||||
type: object
|
||||
CloudintegrationtypesAzureAccountConfig:
|
||||
properties:
|
||||
deploymentRegion:
|
||||
@@ -1025,17 +1017,6 @@ components:
|
||||
- ingestionUrl
|
||||
- ingestionKey
|
||||
type: object
|
||||
CloudintegrationtypesDashboard:
|
||||
properties:
|
||||
definition:
|
||||
$ref: '#/components/schemas/DashboardtypesStorableDashboardData'
|
||||
description:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
type: object
|
||||
CloudintegrationtypesDataCollected:
|
||||
properties:
|
||||
logs:
|
||||
@@ -1209,7 +1190,7 @@ components:
|
||||
CloudintegrationtypesService:
|
||||
properties:
|
||||
assets:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAssets'
|
||||
$ref: '#/components/schemas/CloudintegrationtypesServiceAssets'
|
||||
cloudIntegrationService:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesCloudIntegrationService'
|
||||
dataCollected:
|
||||
@@ -1222,8 +1203,6 @@ components:
|
||||
type: string
|
||||
supportedSignals:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesSupportedSignals'
|
||||
telemetryCollectionStrategy:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesTelemetryCollectionStrategy'
|
||||
title:
|
||||
type: string
|
||||
required:
|
||||
@@ -1234,9 +1213,17 @@ components:
|
||||
- assets
|
||||
- supportedSignals
|
||||
- dataCollected
|
||||
- telemetryCollectionStrategy
|
||||
- cloudIntegrationService
|
||||
type: object
|
||||
CloudintegrationtypesServiceAssets:
|
||||
properties:
|
||||
dashboards:
|
||||
items:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesServiceDashboard'
|
||||
type: array
|
||||
required:
|
||||
- dashboards
|
||||
type: object
|
||||
CloudintegrationtypesServiceConfig:
|
||||
properties:
|
||||
aws:
|
||||
@@ -1244,6 +1231,15 @@ components:
|
||||
azure:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAzureServiceConfig'
|
||||
type: object
|
||||
CloudintegrationtypesServiceDashboard:
|
||||
properties:
|
||||
description:
|
||||
type: string
|
||||
integrationDashboard:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesStorableIntegrationDashboard'
|
||||
title:
|
||||
type: string
|
||||
type: object
|
||||
CloudintegrationtypesServiceID:
|
||||
enum:
|
||||
- alb
|
||||
@@ -1278,6 +1274,23 @@ components:
|
||||
- icon
|
||||
- enabled
|
||||
type: object
|
||||
CloudintegrationtypesStorableIntegrationDashboard:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
dashboardId:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
provider:
|
||||
type: string
|
||||
slug:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
type: object
|
||||
CloudintegrationtypesSupportedSignals:
|
||||
properties:
|
||||
logs:
|
||||
@@ -1285,13 +1298,6 @@ components:
|
||||
metrics:
|
||||
type: boolean
|
||||
type: object
|
||||
CloudintegrationtypesTelemetryCollectionStrategy:
|
||||
properties:
|
||||
aws:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAWSTelemetryCollectionStrategy'
|
||||
azure:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAzureTelemetryCollectionStrategy'
|
||||
type: object
|
||||
CloudintegrationtypesUpdatableAccount:
|
||||
properties:
|
||||
config:
|
||||
@@ -6572,6 +6578,15 @@ components:
|
||||
nullable: true
|
||||
type: array
|
||||
type: object
|
||||
SpantypesOtelSpanRef:
|
||||
properties:
|
||||
refType:
|
||||
type: string
|
||||
spanId:
|
||||
type: string
|
||||
traceId:
|
||||
type: string
|
||||
type: object
|
||||
SpantypesPostableSpanMapper:
|
||||
properties:
|
||||
config:
|
||||
@@ -6835,6 +6850,10 @@ components:
|
||||
type: string
|
||||
parent_span_id:
|
||||
type: string
|
||||
references:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesOtelSpanRef'
|
||||
type: array
|
||||
resource:
|
||||
additionalProperties:
|
||||
type: string
|
||||
@@ -6860,6 +6879,8 @@ components:
|
||||
type: string
|
||||
trace_state:
|
||||
type: string
|
||||
required:
|
||||
- references
|
||||
type: object
|
||||
TagtypesPostableTag:
|
||||
properties:
|
||||
|
||||
@@ -2457,33 +2457,6 @@ export interface CloudintegrationtypesAccountDTO {
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface DashboardtypesStorableDashboardDataDTO {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesDashboardDTO {
|
||||
definition?: DashboardtypesStorableDashboardDataDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesAssetsDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
dashboards?: CloudintegrationtypesDashboardDTO[] | null;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesAzureConnectionArtifactDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -2866,6 +2839,54 @@ export interface CloudintegrationtypesPostableAgentCheckInDTO {
|
||||
providerAccountId?: string;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesStorableIntegrationDashboardDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
dashboardId?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
provider?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
slug?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesServiceDashboardDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
integrationDashboard?: CloudintegrationtypesStorableIntegrationDashboardDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesServiceAssetsDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
dashboards: CloudintegrationtypesServiceDashboardDTO[];
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesSupportedSignalsDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
@@ -2877,13 +2898,8 @@ export interface CloudintegrationtypesSupportedSignalsDTO {
|
||||
metrics?: boolean;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesTelemetryCollectionStrategyDTO {
|
||||
aws?: CloudintegrationtypesAWSTelemetryCollectionStrategyDTO;
|
||||
azure?: CloudintegrationtypesAzureTelemetryCollectionStrategyDTO;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesServiceDTO {
|
||||
assets: CloudintegrationtypesAssetsDTO;
|
||||
assets: CloudintegrationtypesServiceAssetsDTO;
|
||||
cloudIntegrationService: CloudintegrationtypesCloudIntegrationServiceDTO | null;
|
||||
dataCollected: CloudintegrationtypesDataCollectedDTO;
|
||||
/**
|
||||
@@ -2899,7 +2915,6 @@ export interface CloudintegrationtypesServiceDTO {
|
||||
*/
|
||||
overview: string;
|
||||
supportedSignals: CloudintegrationtypesSupportedSignalsDTO;
|
||||
telemetryCollectionStrategy: CloudintegrationtypesTelemetryCollectionStrategyDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -3705,6 +3720,10 @@ export interface DashboardtypesCustomVariableSpecDTO {
|
||||
customValue: string;
|
||||
}
|
||||
|
||||
export interface DashboardtypesStorableDashboardDataDTO {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export enum DashboardtypesSourceDTO {
|
||||
user = 'user',
|
||||
system = 'system',
|
||||
@@ -7768,6 +7787,21 @@ export interface SpantypesGettableTraceAggregationsDTO {
|
||||
aggregations: SpantypesSpanAggregationResultDTO[];
|
||||
}
|
||||
|
||||
export interface SpantypesOtelSpanRefDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
refType?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
spanId?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
traceId?: string;
|
||||
}
|
||||
|
||||
export type SpantypesWaterfallSpanDTOAttributesAnyOf = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
@@ -7862,6 +7896,10 @@ export interface SpantypesWaterfallSpanDTO {
|
||||
* @type string
|
||||
*/
|
||||
parent_span_id?: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
references: SpantypesOtelSpanRefDTO[];
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Dot } from '@signozhq/icons';
|
||||
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 { Popover } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { AIAssistantEvents } from 'container/AIAssistant/events';
|
||||
@@ -109,7 +110,7 @@ function HeaderRightSection({
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<TooltipSimple title="Noz">
|
||||
<TooltipSimple title={NOZ_TOOLTIP_TITLE}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
|
||||
2
frontend/src/components/Noz/Noz.constants.ts
Normal file
2
frontend/src/components/Noz/Noz.constants.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
/** Shared hover copy for every Noz entry point (header, floating trigger, sidebar). */
|
||||
export const NOZ_TOOLTIP_TITLE = 'Noz, your AI teammate';
|
||||
@@ -14,9 +14,7 @@ export function ShiftHoldOverlayController({
|
||||
|
||||
const actions = createShortcutActions({
|
||||
navigate: noop,
|
||||
// Overlay is read-only — actions never fire — so we only need a no-op
|
||||
// that satisfies the typed signature.
|
||||
handleThemeChange: (): void => undefined,
|
||||
handleThemeChange: noop,
|
||||
});
|
||||
|
||||
const visible = useShiftHoldOverlay({
|
||||
|
||||
@@ -116,35 +116,17 @@ jest.mock('hooks/useNotifications', (): unknown => ({
|
||||
}));
|
||||
|
||||
// mock theme hook
|
||||
//
|
||||
// We spread jest.requireActual so additions to hooks/useDarkMode (new hooks,
|
||||
// re-exports, contexts) keep working in this test without needing the mock to
|
||||
// re-enumerate every export. We only override the hooks the palette actually
|
||||
// calls.
|
||||
jest.mock('hooks/useDarkMode', (): unknown => {
|
||||
const actual = jest.requireActual('hooks/useDarkMode');
|
||||
const useThemeModeMock = (): {
|
||||
jest.mock('hooks/useDarkMode', (): unknown => ({
|
||||
useThemeMode: (): {
|
||||
setAutoSwitch: jest.Mock;
|
||||
setTheme: jest.Mock;
|
||||
toggleTheme: jest.Mock;
|
||||
theme: string;
|
||||
autoSwitch: boolean;
|
||||
} => ({
|
||||
setAutoSwitch: jest.fn(),
|
||||
setTheme: jest.fn(),
|
||||
toggleTheme: jest.fn(),
|
||||
theme: 'dark',
|
||||
autoSwitch: false,
|
||||
});
|
||||
return {
|
||||
...actual,
|
||||
__esModule: true,
|
||||
default: useThemeModeMock,
|
||||
useThemeMode: useThemeModeMock,
|
||||
useIsDarkMode: (): boolean => true,
|
||||
useSystemTheme: (): 'dark' | 'light' => 'dark',
|
||||
};
|
||||
});
|
||||
}),
|
||||
}));
|
||||
|
||||
// mock updateUserPreference API and react-query mutation
|
||||
jest.mock('api/v1/user/preferences/name/update', (): jest.Mock => jest.fn());
|
||||
|
||||
@@ -21,8 +21,6 @@ import {
|
||||
useAIAssistantStore,
|
||||
} from 'container/AIAssistant/store/useAIAssistantStore';
|
||||
import { useThemeMode } from 'hooks/useDarkMode';
|
||||
import { ThemeMode } from 'hooks/useDarkMode/constant';
|
||||
import { useThemeSelection } from 'hooks/useDarkMode/useThemeSelection';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import history from 'lib/history';
|
||||
import { ROLES as UserRole } from 'types/roles';
|
||||
@@ -50,8 +48,7 @@ export function CmdKPalette({
|
||||
}): JSX.Element | null {
|
||||
const { open, setOpen } = useCmdK();
|
||||
|
||||
const { theme } = useThemeMode();
|
||||
const selectTheme = useThemeSelection();
|
||||
const { setAutoSwitch, setTheme, theme } = useThemeMode();
|
||||
const location = useLocation();
|
||||
const isAIAssistantEnabled = useIsAIAssistantEnabled();
|
||||
const startNewConversation = useAIAssistantStore(
|
||||
@@ -84,12 +81,14 @@ export function CmdKPalette({
|
||||
|
||||
useEffect(cmdKEffect, [setOpen]);
|
||||
|
||||
function handleThemeChange(value: ThemeMode): void {
|
||||
function handleThemeChange(value: string): void {
|
||||
logEvent('Account Settings: Theme Changed', { theme: value });
|
||||
// Close the palette inside the same flushSync batch as the theme change
|
||||
// so its dismissal is part of the captured "new" frame of the wipe;
|
||||
// otherwise the dialog would be visible in both snapshots and flicker.
|
||||
selectTheme(value, () => setOpen(false));
|
||||
if (value === 'auto') {
|
||||
setAutoSwitch(true);
|
||||
} else {
|
||||
setAutoSwitch(false);
|
||||
setTheme(value);
|
||||
}
|
||||
}
|
||||
|
||||
function onClickHandler(key: string): void {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { GlobalShortcutsName } from 'constants/shortcuts/globalShortcuts';
|
||||
import { THEME_MODE, ThemeMode } from 'hooks/useDarkMode/constant';
|
||||
import { THEME_MODE } from 'hooks/useDarkMode/constant';
|
||||
import {
|
||||
BarChart,
|
||||
BellDot,
|
||||
@@ -34,7 +34,7 @@ export type CmdAction = {
|
||||
|
||||
type ActionDeps = {
|
||||
navigate: (path: string) => void;
|
||||
handleThemeChange: (mode: ThemeMode) => void;
|
||||
handleThemeChange: (mode: string) => void;
|
||||
/**
|
||||
* Provided only when the AI Assistant feature is available for the current
|
||||
* tenant. When present, the palette surfaces an "Open AI Assistant" entry
|
||||
|
||||
@@ -5,6 +5,7 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import ROUTES from 'constants/routes';
|
||||
import Noz from 'components/Noz/Noz';
|
||||
import { NOZ_TOOLTIP_TITLE } from 'components/Noz/Noz.constants';
|
||||
|
||||
import { AIAssistantEvents, AIAssistantOpenSource } from '../events';
|
||||
import { normalizePage } from '../hooks/useAIAssistantAnalyticsContext';
|
||||
@@ -42,16 +43,15 @@ export default function AIAssistantTrigger(): JSX.Element | null {
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipSimple title="Noz">
|
||||
<TooltipSimple title={NOZ_TOOLTIP_TITLE}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
className={`${styles.trigger} noz-wave`}
|
||||
onClick={handleOpen}
|
||||
aria-label="Open Noz"
|
||||
>
|
||||
<Noz size={24} />
|
||||
</Button>
|
||||
prefix={<Noz size={24} />}
|
||||
/>
|
||||
</TooltipSimple>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,7 +55,6 @@ const buildServiceDetailsResponse = (
|
||||
},
|
||||
},
|
||||
},
|
||||
telemetryCollectionStrategy: { aws: {} },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -53,6 +53,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.aws-service-dashboard-item-disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.aws-service-dashboard-item-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import type { KeyboardEvent, MouseEvent } from 'react';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import {
|
||||
CloudintegrationtypesDashboardDTO,
|
||||
CloudintegrationtypesServiceDashboardDTO,
|
||||
CloudintegrationtypesServiceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
@@ -8,6 +10,9 @@ import { withBasePath } from 'utils/basePath';
|
||||
|
||||
import './ServiceDashboards.styles.scss';
|
||||
|
||||
const DISABLED_TOOLTIP =
|
||||
'Enable metrics collection for this service to view this dashboard.';
|
||||
|
||||
function ServiceDashboards({
|
||||
service,
|
||||
isInteractive = true,
|
||||
@@ -25,68 +30,85 @@ function ServiceDashboards({
|
||||
<div className="aws-service-dashboards">
|
||||
<div className="aws-service-dashboards-title">Dashboards</div>
|
||||
<div className="aws-service-dashboards-items">
|
||||
{dashboards.map((dashboard: CloudintegrationtypesDashboardDTO) => {
|
||||
if (!dashboard.id) {
|
||||
return null;
|
||||
}
|
||||
{dashboards.map(
|
||||
(dashboard: CloudintegrationtypesServiceDashboardDTO, index: number) => {
|
||||
const dashboardId = dashboard.integrationDashboard?.dashboardId;
|
||||
const isEnabled = Boolean(dashboardId) && isInteractive;
|
||||
const itemKey = dashboardId || `${dashboard.title}-${index}`;
|
||||
const dashboardUrl = dashboardId ? `/dashboard/${dashboardId}` : '';
|
||||
|
||||
const dashboardUrl = `/dashboard/${dashboard.id}`;
|
||||
const handleClick = (event: MouseEvent<HTMLDivElement>): void => {
|
||||
if (!isEnabled) {
|
||||
return;
|
||||
}
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
window.open(
|
||||
withBasePath(dashboardUrl),
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
);
|
||||
return;
|
||||
}
|
||||
safeNavigate(dashboardUrl);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={dashboard.id}
|
||||
className={`aws-service-dashboard-item ${
|
||||
isInteractive ? 'aws-service-dashboard-item-clickable' : ''
|
||||
}`}
|
||||
role={isInteractive ? 'button' : undefined}
|
||||
tabIndex={isInteractive ? 0 : -1}
|
||||
onClick={(event): void => {
|
||||
if (!isInteractive) {
|
||||
return;
|
||||
}
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
window.open(
|
||||
withBasePath(dashboardUrl),
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const handleAuxClick = (event: MouseEvent<HTMLDivElement>): void => {
|
||||
if (!isEnabled) {
|
||||
return;
|
||||
}
|
||||
if (event.button === 1) {
|
||||
window.open(
|
||||
withBasePath(dashboardUrl),
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
if (!isEnabled) {
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
safeNavigate(dashboardUrl);
|
||||
}}
|
||||
onAuxClick={(event): void => {
|
||||
if (!isInteractive) {
|
||||
return;
|
||||
}
|
||||
if (event.button === 1) {
|
||||
window.open(
|
||||
withBasePath(dashboardUrl),
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event): void => {
|
||||
if (!isInteractive) {
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
safeNavigate(dashboardUrl);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="aws-service-dashboard-item-content">
|
||||
<div className="aws-service-dashboard-item-title">
|
||||
{dashboard.title}
|
||||
</div>
|
||||
<div className="aws-service-dashboard-item-description">
|
||||
{dashboard.description}
|
||||
}
|
||||
};
|
||||
|
||||
const card = (
|
||||
<div
|
||||
className={`aws-service-dashboard-item ${
|
||||
isEnabled ? 'aws-service-dashboard-item-clickable' : ''
|
||||
} ${!dashboardId ? 'aws-service-dashboard-item-disabled' : ''}`}
|
||||
role={isEnabled ? 'button' : undefined}
|
||||
tabIndex={isEnabled ? 0 : -1}
|
||||
aria-disabled={!dashboardId}
|
||||
onClick={handleClick}
|
||||
onAuxClick={handleAuxClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div className="aws-service-dashboard-item-content">
|
||||
<div className="aws-service-dashboard-item-title">
|
||||
{dashboard.title}
|
||||
</div>
|
||||
<div className="aws-service-dashboard-item-description">
|
||||
{dashboard.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
|
||||
if (!dashboardId) {
|
||||
return (
|
||||
<TooltipSimple key={itemKey} title={DISABLED_TOOLTIP} arrow>
|
||||
{card}
|
||||
</TooltipSimple>
|
||||
);
|
||||
}
|
||||
|
||||
return <div key={itemKey}>{card}</div>;
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -12,8 +12,6 @@ import APIError from 'types/api/error';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
|
||||
const toggleThemeFunction = jest.fn();
|
||||
const setThemeFunction = jest.fn();
|
||||
const setAutoSwitchFunction = jest.fn();
|
||||
const logEventFunction = jest.fn();
|
||||
const copyToClipboardFn = jest.fn();
|
||||
const editUserFn = jest.fn();
|
||||
@@ -58,11 +56,9 @@ jest.mock('hooks/useDarkMode', () => ({
|
||||
useIsDarkMode: jest.fn(() => true),
|
||||
useSystemTheme: jest.fn(() => 'dark'),
|
||||
default: jest.fn(() => ({
|
||||
theme: 'dark',
|
||||
setTheme: setThemeFunction,
|
||||
toggleTheme: toggleThemeFunction,
|
||||
autoSwitch: false,
|
||||
setAutoSwitch: setAutoSwitchFunction,
|
||||
setAutoSwitch: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -138,8 +134,7 @@ describe('MySettings Flows', () => {
|
||||
fireEvent.click(lightOption);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setAutoSwitchFunction).toHaveBeenCalledWith(false);
|
||||
expect(setThemeFunction).toHaveBeenCalledWith('light');
|
||||
expect(toggleThemeFunction).toHaveBeenCalled();
|
||||
expect(logEventFunction).toHaveBeenCalledWith(
|
||||
'Account Settings: Theme Changed',
|
||||
{
|
||||
@@ -147,10 +142,6 @@ describe('MySettings Flows', () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Lock in that the new selectTheme flow does not call toggleTheme;
|
||||
// otherwise we'd double-flip on top of the explicit setTheme call.
|
||||
expect(toggleThemeFunction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,8 +9,6 @@ import updateUserPreference from 'api/v1/user/preferences/name/update';
|
||||
import { AxiosError } from 'axios';
|
||||
import { USER_PREFERENCES } from 'constants/userPreferences';
|
||||
import useThemeMode, { useIsDarkMode, useSystemTheme } from 'hooks/useDarkMode';
|
||||
import { THEME_MODE, ThemeMode } from 'hooks/useDarkMode/constant';
|
||||
import { useThemeSelection } from 'hooks/useDarkMode/useThemeSelection';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { MonitorCog, Moon, Sun } from '@signozhq/icons';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
@@ -26,10 +24,9 @@ import './MySettings.styles.scss';
|
||||
function MySettings(): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { userPreferences, updateUserPreferenceInContext } = useAppContext();
|
||||
const { autoSwitch } = useThemeMode();
|
||||
const { toggleTheme, autoSwitch, setAutoSwitch } = useThemeMode();
|
||||
const systemTheme = useSystemTheme();
|
||||
const { notifications } = useNotifications();
|
||||
const selectTheme = useThemeSelection();
|
||||
|
||||
const [sideNavPinned, setSideNavPinned] = useState(false);
|
||||
|
||||
@@ -62,7 +59,7 @@ function MySettings(): JSX.Element {
|
||||
<Moon data-testid="dark-theme-icon" size={12} /> Dark{' '}
|
||||
</div>
|
||||
),
|
||||
value: THEME_MODE.DARK,
|
||||
value: 'dark',
|
||||
},
|
||||
{
|
||||
label: (
|
||||
@@ -71,7 +68,7 @@ function MySettings(): JSX.Element {
|
||||
<Badge color="robin">Beta</Badge>
|
||||
</div>
|
||||
),
|
||||
value: THEME_MODE.LIGHT,
|
||||
value: 'light',
|
||||
},
|
||||
{
|
||||
label: (
|
||||
@@ -79,31 +76,46 @@ function MySettings(): JSX.Element {
|
||||
<MonitorCog size={12} data-testid="auto-theme-icon" /> System{' '}
|
||||
</div>
|
||||
),
|
||||
value: THEME_MODE.SYSTEM,
|
||||
value: 'auto',
|
||||
},
|
||||
];
|
||||
|
||||
const [theme, setTheme] = useState(() => {
|
||||
if (autoSwitch) {
|
||||
return THEME_MODE.SYSTEM;
|
||||
return 'auto';
|
||||
}
|
||||
return isDarkMode ? THEME_MODE.DARK : THEME_MODE.LIGHT;
|
||||
return isDarkMode ? 'dark' : 'light';
|
||||
});
|
||||
|
||||
const handleThemeChange = (value: string): void => {
|
||||
// ToggleGroupSimple items above are all THEME_MODE values, so narrowing
|
||||
// the string here is safe.
|
||||
const mode = value as ThemeMode;
|
||||
logEvent('Account Settings: Theme Changed', { theme: mode });
|
||||
selectTheme(mode);
|
||||
logEvent('Account Settings: Theme Changed', {
|
||||
theme: value,
|
||||
});
|
||||
setTheme(value);
|
||||
|
||||
if (value === 'auto') {
|
||||
setAutoSwitch(true);
|
||||
} else {
|
||||
setAutoSwitch(false);
|
||||
// Only toggle if the current theme is different from the target
|
||||
const targetIsDark = value === 'dark';
|
||||
if (targetIsDark !== isDarkMode) {
|
||||
toggleTheme();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (autoSwitch) {
|
||||
setTheme(THEME_MODE.SYSTEM);
|
||||
setTheme('auto');
|
||||
return;
|
||||
}
|
||||
setTheme(isDarkMode ? THEME_MODE.DARK : THEME_MODE.LIGHT);
|
||||
|
||||
if (isDarkMode) {
|
||||
setTheme('dark');
|
||||
} else {
|
||||
setTheme('light');
|
||||
}
|
||||
}, [autoSwitch, isDarkMode]);
|
||||
|
||||
const handleSideNavPinnedChange = (checked: boolean): void => {
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Pin, PinOff } from '@signozhq/icons';
|
||||
|
||||
import { SidebarItem } from '../sideNav.types';
|
||||
|
||||
import './NavItem.styles.scss';
|
||||
import './NavItem.styles.scss';
|
||||
|
||||
export default function NavItem({
|
||||
@@ -27,7 +26,7 @@ export default function NavItem({
|
||||
showIcon?: boolean;
|
||||
dataTestId?: string;
|
||||
}): JSX.Element {
|
||||
const { label, icon, isBeta, isNew, isEarlyAccess } = item;
|
||||
const { label, icon, isBeta, isNew, isEarlyAccess, tooltip } = item;
|
||||
|
||||
const handleTogglePinClick = (
|
||||
event: React.MouseEvent<SVGSVGElement, MouseEvent>,
|
||||
@@ -36,7 +35,7 @@ export default function NavItem({
|
||||
onTogglePin?.(item);
|
||||
};
|
||||
|
||||
return (
|
||||
const navItem = (
|
||||
<div
|
||||
className={cx(
|
||||
'nav-item',
|
||||
@@ -107,6 +106,15 @@ export default function NavItem({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Only non-pinnable items set `tooltip`; it would nest with the pin tooltip.
|
||||
return tooltip ? (
|
||||
<Tooltip title={tooltip} placement="right">
|
||||
{navItem}
|
||||
</Tooltip>
|
||||
) : (
|
||||
navItem
|
||||
);
|
||||
}
|
||||
|
||||
NavItem.defaultProps = {
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
} from './sideNav.types';
|
||||
import { Style } from '@signozhq/design-tokens';
|
||||
import Noz from 'components/Noz/Noz';
|
||||
import { NOZ_TOOLTIP_TITLE } from 'components/Noz/Noz.constants';
|
||||
|
||||
export const getStartedMenuItem = {
|
||||
key: ROUTES.GET_STARTED,
|
||||
@@ -97,6 +98,7 @@ export const aiAssistantMenuItem = {
|
||||
icon: <Noz size={16} />,
|
||||
itemKey: 'ai-assistant',
|
||||
isEarlyAccess: true,
|
||||
tooltip: NOZ_TOOLTIP_TITLE,
|
||||
};
|
||||
|
||||
export const shortcutMenuItem = {
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface SidebarItem {
|
||||
isBeta?: boolean;
|
||||
isNew?: boolean;
|
||||
isEarlyAccess?: boolean;
|
||||
/** Hover copy for the whole item row (e.g. Noz's early-access tagline). */
|
||||
tooltip?: ReactNode;
|
||||
isPinned?: boolean;
|
||||
children?: SidebarItem[];
|
||||
isExternal?: boolean;
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import { useThemeSelection } from '../useThemeSelection';
|
||||
|
||||
const setThemeMock = jest.fn();
|
||||
const setAutoSwitchMock = jest.fn();
|
||||
let themeValue = 'dark';
|
||||
let systemThemeValue: 'dark' | 'light' = 'light';
|
||||
|
||||
jest.mock('hooks/useDarkMode', () => ({
|
||||
__esModule: true,
|
||||
default: (): {
|
||||
theme: string;
|
||||
setTheme: jest.Mock;
|
||||
setAutoSwitch: jest.Mock;
|
||||
toggleTheme: jest.Mock;
|
||||
autoSwitch: boolean;
|
||||
} => ({
|
||||
theme: themeValue,
|
||||
setTheme: setThemeMock,
|
||||
setAutoSwitch: setAutoSwitchMock,
|
||||
toggleTheme: jest.fn(),
|
||||
autoSwitch: false,
|
||||
}),
|
||||
useThemeMode: (): {
|
||||
theme: string;
|
||||
setTheme: jest.Mock;
|
||||
setAutoSwitch: jest.Mock;
|
||||
toggleTheme: jest.Mock;
|
||||
autoSwitch: boolean;
|
||||
} => ({
|
||||
theme: themeValue,
|
||||
setTheme: setThemeMock,
|
||||
setAutoSwitch: setAutoSwitchMock,
|
||||
toggleTheme: jest.fn(),
|
||||
autoSwitch: false,
|
||||
}),
|
||||
useSystemTheme: (): 'dark' | 'light' => systemThemeValue,
|
||||
useIsDarkMode: (): boolean => themeValue === 'dark',
|
||||
}));
|
||||
|
||||
const canAnimateMock = jest.fn();
|
||||
const runTransitionMock = jest.fn();
|
||||
|
||||
jest.mock('utils/themeTransition', () => ({
|
||||
__esModule: true,
|
||||
canAnimateThemeTransition: (): boolean => canAnimateMock(),
|
||||
runThemeTransition: (cb: () => void): void => runTransitionMock(cb),
|
||||
}));
|
||||
|
||||
describe('useThemeSelection', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
themeValue = 'dark';
|
||||
systemThemeValue = 'light';
|
||||
canAnimateMock.mockReturnValue(false);
|
||||
// Default behaviour: invoke the applyChange callback synchronously.
|
||||
runTransitionMock.mockImplementation((cb: () => void) => cb());
|
||||
});
|
||||
|
||||
it('applies an explicit light theme without auto-switch', () => {
|
||||
themeValue = 'dark';
|
||||
const { result } = renderHook(() => useThemeSelection());
|
||||
|
||||
act(() => result.current('light'));
|
||||
|
||||
expect(setAutoSwitchMock).toHaveBeenCalledWith(false);
|
||||
expect(setThemeMock).toHaveBeenCalledWith('light');
|
||||
});
|
||||
|
||||
it('applies an explicit dark theme without auto-switch', () => {
|
||||
themeValue = 'light';
|
||||
const { result } = renderHook(() => useThemeSelection());
|
||||
|
||||
act(() => result.current('dark'));
|
||||
|
||||
expect(setAutoSwitchMock).toHaveBeenCalledWith(false);
|
||||
expect(setThemeMock).toHaveBeenCalledWith('dark');
|
||||
});
|
||||
|
||||
it('SYSTEM with a light system preference resolves to setTheme("light") + auto on', () => {
|
||||
themeValue = 'dark';
|
||||
systemThemeValue = 'light';
|
||||
const { result } = renderHook(() => useThemeSelection());
|
||||
|
||||
act(() => result.current('auto'));
|
||||
|
||||
expect(setAutoSwitchMock).toHaveBeenCalledWith(true);
|
||||
// Explicit resolved value is what keeps the wipe snapshot accurate;
|
||||
// see the comment in useThemeSelection for the failure mode.
|
||||
expect(setThemeMock).toHaveBeenCalledWith('light');
|
||||
});
|
||||
|
||||
it('SYSTEM with a dark system preference resolves to setTheme("dark") + auto on', () => {
|
||||
themeValue = 'light';
|
||||
systemThemeValue = 'dark';
|
||||
const { result } = renderHook(() => useThemeSelection());
|
||||
|
||||
act(() => result.current('auto'));
|
||||
|
||||
expect(setAutoSwitchMock).toHaveBeenCalledWith(true);
|
||||
expect(setThemeMock).toHaveBeenCalledWith('dark');
|
||||
});
|
||||
|
||||
it('invokes onApplied inside the same batch, after the state mutations', () => {
|
||||
themeValue = 'dark';
|
||||
const onApplied = jest.fn();
|
||||
const { result } = renderHook(() => useThemeSelection());
|
||||
|
||||
act(() => result.current('light', onApplied));
|
||||
|
||||
expect(onApplied).toHaveBeenCalledTimes(1);
|
||||
expect(setThemeMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
onApplied.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('routes through runThemeTransition when the dark↔light state actually flips', () => {
|
||||
themeValue = 'dark';
|
||||
canAnimateMock.mockReturnValue(true);
|
||||
const { result } = renderHook(() => useThemeSelection());
|
||||
|
||||
act(() => result.current('light'));
|
||||
|
||||
expect(runTransitionMock).toHaveBeenCalledTimes(1);
|
||||
expect(setThemeMock).toHaveBeenCalledWith('light');
|
||||
});
|
||||
|
||||
it('skips runThemeTransition when no dark↔light flip happens', () => {
|
||||
themeValue = 'dark';
|
||||
canAnimateMock.mockReturnValue(true);
|
||||
const { result } = renderHook(() => useThemeSelection());
|
||||
|
||||
act(() => result.current('dark'));
|
||||
|
||||
expect(runTransitionMock).not.toHaveBeenCalled();
|
||||
// applyChange still ran inline.
|
||||
expect(setThemeMock).toHaveBeenCalledWith('dark');
|
||||
});
|
||||
|
||||
it('skips runThemeTransition when SYSTEM resolves to the currently-rendered theme', () => {
|
||||
themeValue = 'dark';
|
||||
systemThemeValue = 'dark';
|
||||
canAnimateMock.mockReturnValue(true);
|
||||
const { result } = renderHook(() => useThemeSelection());
|
||||
|
||||
act(() => result.current('auto'));
|
||||
|
||||
expect(runTransitionMock).not.toHaveBeenCalled();
|
||||
expect(setAutoSwitchMock).toHaveBeenCalledWith(true);
|
||||
expect(setThemeMock).toHaveBeenCalledWith('dark');
|
||||
});
|
||||
|
||||
it('skips runThemeTransition when capability check is false even if the theme flips', () => {
|
||||
themeValue = 'dark';
|
||||
canAnimateMock.mockReturnValue(false);
|
||||
const { result } = renderHook(() => useThemeSelection());
|
||||
|
||||
act(() => result.current('light'));
|
||||
|
||||
expect(runTransitionMock).not.toHaveBeenCalled();
|
||||
expect(setThemeMock).toHaveBeenCalledWith('light');
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,4 @@ export const THEME_MODE = {
|
||||
LIGHT: 'light',
|
||||
DARK: 'dark',
|
||||
SYSTEM: 'auto',
|
||||
} as const;
|
||||
|
||||
export type ThemeMode = (typeof THEME_MODE)[keyof typeof THEME_MODE];
|
||||
};
|
||||
|
||||
@@ -18,13 +18,7 @@ import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
|
||||
import { THEME_MODE } from './constant';
|
||||
|
||||
export const ThemeContext = createContext<{
|
||||
theme: string;
|
||||
toggleTheme: () => void;
|
||||
autoSwitch: boolean;
|
||||
setAutoSwitch: Dispatch<SetStateAction<boolean>>;
|
||||
setTheme: Dispatch<SetStateAction<string>>;
|
||||
}>({
|
||||
export const ThemeContext = createContext({
|
||||
theme: THEME_MODE.DARK,
|
||||
toggleTheme: (): void => {},
|
||||
autoSwitch: false,
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
canAnimateThemeTransition,
|
||||
runThemeTransition,
|
||||
} from 'utils/themeTransition';
|
||||
|
||||
import useThemeMode, { useSystemTheme } from './index';
|
||||
import { THEME_MODE, ThemeMode } from './constant';
|
||||
|
||||
type SelectTheme = (value: ThemeMode, onApplied?: () => void) => void;
|
||||
|
||||
// Centralises the "apply a theme selection" flow used by MySettings and the
|
||||
// command palette: figures out whether the visible (dark↔light) theme is
|
||||
// actually flipping, applies the state change, and — when capable — wraps the
|
||||
// change in a left→right view-transition wipe.
|
||||
//
|
||||
// `value` is one of THEME_MODE.{LIGHT,DARK,SYSTEM}; `onApplied` runs inside the
|
||||
// same flushSync batch as the theme change (useful for, e.g., closing the
|
||||
// command palette so its dismissal is part of the captured "new" snapshot).
|
||||
export function useThemeSelection(): SelectTheme {
|
||||
const { theme, setTheme, setAutoSwitch } = useThemeMode();
|
||||
const systemTheme = useSystemTheme();
|
||||
|
||||
return useCallback<SelectTheme>(
|
||||
(value, onApplied) => {
|
||||
const currentIsDark = theme === THEME_MODE.DARK;
|
||||
|
||||
// When switching to SYSTEM, the visible theme flips iff the OS preference
|
||||
// differs from what we're currently rendering. For explicit LIGHT/DARK,
|
||||
// resolvedTargetIsDark is just (value === DARK).
|
||||
const resolvedTargetIsDark =
|
||||
value === THEME_MODE.SYSTEM
|
||||
? systemTheme === THEME_MODE.DARK
|
||||
: value === THEME_MODE.DARK;
|
||||
const isSystem = value === THEME_MODE.SYSTEM;
|
||||
|
||||
// Always push the resolved LIGHT/DARK through setTheme synchronously so
|
||||
// the View Transition snapshot reflects the new theme. If we relied on
|
||||
// ThemeProvider's effect (setAutoSwitch → re-render → effect →
|
||||
// setThemeState), the flip wouldn't be guaranteed to run inside this
|
||||
// flushSync batch and the wipe would capture old → old, then snap.
|
||||
const resolvedTheme = resolvedTargetIsDark
|
||||
? THEME_MODE.DARK
|
||||
: THEME_MODE.LIGHT;
|
||||
|
||||
// runThemeTransition needs a zero-arg callback, so this closure is
|
||||
// unavoidable. It allocates once per selection — cheap enough that
|
||||
// micro-optimising it would just obscure the flow.
|
||||
const apply = (): void => {
|
||||
setAutoSwitch(isSystem);
|
||||
setTheme(resolvedTheme);
|
||||
onApplied?.();
|
||||
};
|
||||
|
||||
const willFlipDarkMode = resolvedTargetIsDark !== currentIsDark;
|
||||
if (!willFlipDarkMode || !canAnimateThemeTransition()) {
|
||||
apply();
|
||||
return;
|
||||
}
|
||||
|
||||
runThemeTransition(apply);
|
||||
},
|
||||
[theme, systemTheme, setTheme, setAutoSwitch],
|
||||
);
|
||||
}
|
||||
|
||||
export default useThemeSelection;
|
||||
@@ -22,6 +22,7 @@ import styles from './AnalyticsPanel.module.scss';
|
||||
interface AnalyticsPanelProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onTabChange: (tab: string) => void;
|
||||
}
|
||||
|
||||
const PANEL_WIDTH = 350;
|
||||
@@ -32,6 +33,7 @@ const PANEL_MARGIN_BOTTOM = 50;
|
||||
function AnalyticsPanel({
|
||||
isOpen,
|
||||
onClose,
|
||||
onTabChange,
|
||||
}: AnalyticsPanelProps): JSX.Element | null {
|
||||
const aggregations = useTraceStore((s) => s.aggregations);
|
||||
const colorByFieldName = useTraceStore((s) => s.colorByField.name);
|
||||
@@ -118,7 +120,7 @@ function AnalyticsPanel({
|
||||
/>
|
||||
|
||||
<div className={styles.body}>
|
||||
<TabsRoot defaultValue="exec-time">
|
||||
<TabsRoot defaultValue="exec-time" onValueChange={onTabChange}>
|
||||
<TabsList variant="secondary">
|
||||
<TabsTrigger value="exec-time" variant="secondary">
|
||||
% exec time
|
||||
|
||||
@@ -31,7 +31,12 @@ import Events from 'container/SpanDetailsDrawer/Events/Events';
|
||||
import SpanLogs from 'container/SpanDetailsDrawer/SpanLogs/SpanLogs';
|
||||
import { useSpanContextLogs } from 'container/SpanDetailsDrawer/SpanLogs/useSpanContextLogs';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
TraceDetailEventKeys,
|
||||
TraceDetailEvents,
|
||||
} from 'pages/TraceDetailsV3/events';
|
||||
import { useMigratePinnedAttributes } from 'pages/TraceDetailsV3/hooks/useMigratePinnedAttributes';
|
||||
import { useTraceDetailLogEvent } from 'pages/TraceDetailsV3/hooks/useTraceDetailLogEvent';
|
||||
import {
|
||||
getSpanAttribute,
|
||||
getSpanDisplayData,
|
||||
@@ -86,6 +91,16 @@ function SpanDetailsContent({
|
||||
}): JSX.Element {
|
||||
const FIVE_MINUTES_IN_MS = 5 * 60 * 1000;
|
||||
const spanAttributeActions = useSpanAttributeActions();
|
||||
const logTraceEvent = useTraceDetailLogEvent('v3', selectedSpan.trace_id);
|
||||
const handleTabChange = useCallback(
|
||||
(tab: string): void => {
|
||||
logTraceEvent(TraceDetailEvents.SpanPanelTabChanged, {
|
||||
[TraceDetailEventKeys.Tab]: tab,
|
||||
[TraceDetailEventKeys.SpanId]: selectedSpan.span_id,
|
||||
});
|
||||
},
|
||||
[logTraceEvent, selectedSpan.span_id],
|
||||
);
|
||||
const percentile = useSpanPercentile(selectedSpan);
|
||||
const linkedSpans = useLinkedSpans((selectedSpan as any).references);
|
||||
|
||||
@@ -376,7 +391,7 @@ function SpanDetailsContent({
|
||||
|
||||
<div className={styles.tabsSection}>
|
||||
{/* Step 9: ContentTabs */}
|
||||
<TabsRoot defaultValue="overview">
|
||||
<TabsRoot defaultValue="overview" onValueChange={handleTabChange}>
|
||||
<TabsList variant="secondary">
|
||||
<TabsTrigger value="overview" variant="secondary">
|
||||
<Bookmark size={14} /> Overview
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import {
|
||||
@@ -29,6 +29,8 @@ import KeyValueLabel from 'periscope/components/KeyValueLabel';
|
||||
import { TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
|
||||
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
|
||||
import { useTraceStore } from '../stores/traceStore';
|
||||
import AnalyticsPanel from '../SpanDetailsPanel/AnalyticsPanel/AnalyticsPanel';
|
||||
import Filters from '../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters';
|
||||
@@ -90,11 +92,35 @@ function TraceDetailsHeader({
|
||||
const previewFields = useTraceStore((s) => s.previewFields);
|
||||
const setPreviewFields = useTraceStore((s) => s.setPreviewFields);
|
||||
|
||||
const logTraceEvent = useTraceDetailLogEvent('v3', traceID || '');
|
||||
const pageLoadedAtRef = useRef(Date.now());
|
||||
|
||||
const handleSwitchToOldView = useCallback((): void => {
|
||||
logTraceEvent(TraceDetailEvents.ViewSwitched, {
|
||||
[TraceDetailEventKeys.From]: 'v3',
|
||||
[TraceDetailEventKeys.To]: 'v2',
|
||||
[TraceDetailEventKeys.DwellMs]: Date.now() - pageLoadedAtRef.current,
|
||||
});
|
||||
setLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_PREFER_OLD_VIEW, 'true');
|
||||
const oldUrl = `/trace-old/${traceID}${window.location.search}`;
|
||||
history.replace(oldUrl);
|
||||
}, [traceID]);
|
||||
}, [traceID, logTraceEvent]);
|
||||
|
||||
const handleToggleAnalytics = useCallback((): void => {
|
||||
logTraceEvent(TraceDetailEvents.AnalyticsPanelToggled, {
|
||||
[TraceDetailEventKeys.Open]: !isAnalyticsOpen,
|
||||
});
|
||||
setIsAnalyticsOpen((prev) => !prev);
|
||||
}, [logTraceEvent, isAnalyticsOpen]);
|
||||
|
||||
const handleAnalyticsTabChange = useCallback(
|
||||
(tab: string): void => {
|
||||
logTraceEvent(TraceDetailEvents.AnalyticsTabChanged, {
|
||||
[TraceDetailEventKeys.Tab]: tab,
|
||||
});
|
||||
},
|
||||
[logTraceEvent],
|
||||
);
|
||||
|
||||
const handlePreviousBtnClick = useCallback((): void => {
|
||||
if (hasInAppHistory()) {
|
||||
@@ -167,7 +193,7 @@ function TraceDetailsHeader({
|
||||
size="icon"
|
||||
color="secondary"
|
||||
aria-label="Analytics"
|
||||
onClick={(): void => setIsAnalyticsOpen((prev) => !prev)}
|
||||
onClick={handleToggleAnalytics}
|
||||
>
|
||||
<ChartPie size={14} />
|
||||
</Button>
|
||||
@@ -245,6 +271,7 @@ function TraceDetailsHeader({
|
||||
<AnalyticsPanel
|
||||
isOpen={isAnalyticsOpen}
|
||||
onClose={(): void => setIsAnalyticsOpen(false)}
|
||||
onTabChange={handleAnalyticsTabChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
38
frontend/src/pages/TraceDetailsV3/events.ts
Normal file
38
frontend/src/pages/TraceDetailsV3/events.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export enum TraceDetailEvents {
|
||||
DataLoaded = 'Trace Detail: Data loaded',
|
||||
ViewSwitched = 'Trace Detail: View switched',
|
||||
FlameGraphToggled = 'Trace Detail: Flame graph toggled',
|
||||
WaterfallToggled = 'Trace Detail: Waterfall toggled',
|
||||
AnalyticsPanelToggled = 'Trace Detail: Analytics panel toggled',
|
||||
AnalyticsTabChanged = 'Trace Detail: Analytics tab changed',
|
||||
SpanPanelTabChanged = 'Trace Detail: Span panel tab changed',
|
||||
}
|
||||
|
||||
export enum TraceDetailEventKeys {
|
||||
// Injected on every event by useTraceDetailLogEvent
|
||||
View = 'view',
|
||||
TraceId = 'traceId',
|
||||
// Data loaded — trace shape
|
||||
TotalSpansCount = 'totalSpansCount',
|
||||
NumServices = 'numServices',
|
||||
TraceDurationMs = 'traceDurationMs',
|
||||
HadErrors = 'hadErrors',
|
||||
FlamegraphSampled = 'flamegraphSampled',
|
||||
// Data loaded — persisted settings
|
||||
SpanPanelVariant = 'spanPanelVariant',
|
||||
ColorByField = 'colorByField',
|
||||
PreviewFieldsCount = 'previewFieldsCount',
|
||||
EntryPreferOldView = 'entryPreferOldView',
|
||||
// View switched
|
||||
From = 'from',
|
||||
To = 'to',
|
||||
DwellMs = 'dwellMs',
|
||||
// Toggles / tabs
|
||||
Expanded = 'expanded',
|
||||
Open = 'open',
|
||||
Tab = 'tab',
|
||||
// Span panel tab changed
|
||||
SpanId = 'spanId',
|
||||
}
|
||||
|
||||
export type TraceDetailView = 'v2' | 'v3';
|
||||
@@ -0,0 +1,88 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import { TraceDetailEvents } from '../../events';
|
||||
import { useTraceDetailLogEvent } from '../useTraceDetailLogEvent';
|
||||
|
||||
const logEventMock = jest.fn();
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: unknown[]): void => logEventMock(...args),
|
||||
}));
|
||||
|
||||
describe('useTraceDetailLogEvent', () => {
|
||||
beforeEach(() => {
|
||||
logEventMock.mockClear();
|
||||
});
|
||||
|
||||
it('injects view and traceId on every event', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTraceDetailLogEvent('v3', 'trace-123'),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current(TraceDetailEvents.DataLoaded, { totalSpansCount: 42 });
|
||||
});
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledTimes(1);
|
||||
expect(logEventMock).toHaveBeenCalledWith(TraceDetailEvents.DataLoaded, {
|
||||
view: 'v3',
|
||||
traceId: 'trace-123',
|
||||
totalSpansCount: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it('injects view and traceId even when no attributes are passed', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTraceDetailLogEvent('v2', 'trace-456'),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current(TraceDetailEvents.ViewSwitched);
|
||||
});
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledWith(TraceDetailEvents.ViewSwitched, {
|
||||
view: 'v2',
|
||||
traceId: 'trace-456',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a stable callback identity and emits the latest traceId', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ traceId }) => useTraceDetailLogEvent('v3', traceId),
|
||||
{ initialProps: { traceId: 'trace-1' } },
|
||||
);
|
||||
|
||||
const firstIdentity = result.current;
|
||||
rerender({ traceId: 'trace-2' });
|
||||
|
||||
expect(result.current).toBe(firstIdentity);
|
||||
|
||||
act(() => {
|
||||
result.current(TraceDetailEvents.SpanPanelTabChanged, { spanId: 's1' });
|
||||
});
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
TraceDetailEvents.SpanPanelTabChanged,
|
||||
{
|
||||
view: 'v3',
|
||||
traceId: 'trace-2',
|
||||
spanId: 's1',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('never throws if logEvent throws (analytics must not break the UI)', () => {
|
||||
logEventMock.mockImplementationOnce(() => {
|
||||
throw new Error('network down');
|
||||
});
|
||||
const { result } = renderHook(() =>
|
||||
useTraceDetailLogEvent('v3', 'trace-123'),
|
||||
);
|
||||
|
||||
expect(() => {
|
||||
act(() => {
|
||||
result.current(TraceDetailEvents.DataLoaded);
|
||||
});
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
|
||||
import {
|
||||
TraceDetailEventKeys,
|
||||
TraceDetailEvents,
|
||||
TraceDetailView,
|
||||
} from '../events';
|
||||
|
||||
export type TraceDetailLogEvent = (
|
||||
event: TraceDetailEvents,
|
||||
attributes?: Record<string, unknown>,
|
||||
) => void;
|
||||
|
||||
export function useTraceDetailLogEvent(
|
||||
view: TraceDetailView,
|
||||
traceId: string,
|
||||
): TraceDetailLogEvent {
|
||||
const contextRef = useRef({ view, traceId });
|
||||
contextRef.current = { view, traceId };
|
||||
|
||||
return useCallback(
|
||||
(
|
||||
event: TraceDetailEvents,
|
||||
attributes: Record<string, unknown> = {},
|
||||
): void => {
|
||||
try {
|
||||
void logEvent(event, {
|
||||
[TraceDetailEventKeys.View]: contextRef.current.view,
|
||||
[TraceDetailEventKeys.TraceId]: contextRef.current.traceId,
|
||||
...attributes,
|
||||
});
|
||||
} catch {
|
||||
// No-op. Logging must never throw into the UI.
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,10 @@ import {
|
||||
} from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { COLOR_BY_FIELDS } from './constants';
|
||||
import { TraceDetailEventKeys, TraceDetailEvents } from './events';
|
||||
import { useTraceDetailLogEvent } from './hooks/useTraceDetailLogEvent';
|
||||
import TraceStoreSync from './stores/TraceStoreSync';
|
||||
import { useTraceStore } from './stores/traceStore';
|
||||
import { AGGREGATIONS } from './utils/aggregations';
|
||||
import { SpanDetailVariant } from './SpanDetailsPanel/constants';
|
||||
import SpanDetailsPanel from './SpanDetailsPanel/SpanDetailsPanel';
|
||||
@@ -56,6 +59,14 @@ function TraceDetailsV3(): JSX.Element {
|
||||
const selectedSpanId = urlQuery.get('spanId') || undefined;
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const logTraceEvent = useTraceDetailLogEvent('v3', traceId || '');
|
||||
// Tracks which traceId the load event already fired for, so navigating
|
||||
// between traces (the route component stays mounted) re-fires it once each.
|
||||
const dataLoadedFiredForRef = useRef('');
|
||||
const colorByField = useTraceStore((s) => s.colorByField);
|
||||
const previewFieldsCount = useTraceStore((s) => s.previewFields.length);
|
||||
const userPrefsReady = useTraceStore((s) => s.userPreferences !== null);
|
||||
|
||||
const handleSpanDetailsClose = useCallback((): void => {
|
||||
urlQuery.delete('spanId');
|
||||
safeNavigate({ search: urlQuery.toString() });
|
||||
@@ -154,6 +165,46 @@ function TraceDetailsV3(): JSX.Element {
|
||||
allSpansRef.current = allSpans;
|
||||
}, [allSpans]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!traceId ||
|
||||
dataLoadedFiredForRef.current === traceId ||
|
||||
!userPrefsReady
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const payload = traceData?.payload;
|
||||
if (!payload?.spans?.length) {
|
||||
return;
|
||||
}
|
||||
dataLoadedFiredForRef.current = traceId;
|
||||
const numServices = new Set(payload.spans.map((s) => s['service.name'])).size;
|
||||
logTraceEvent(TraceDetailEvents.DataLoaded, {
|
||||
[TraceDetailEventKeys.TotalSpansCount]: totalSpansCount,
|
||||
[TraceDetailEventKeys.NumServices]: numServices,
|
||||
[TraceDetailEventKeys.TraceDurationMs]:
|
||||
payload.endTimestampMillis - payload.startTimestampMillis,
|
||||
[TraceDetailEventKeys.HadErrors]: (payload.totalErrorSpansCount || 0) > 0,
|
||||
[TraceDetailEventKeys.FlamegraphSampled]:
|
||||
totalSpansCount > FLAMEGRAPH_SPAN_LIMIT,
|
||||
[TraceDetailEventKeys.SpanPanelVariant]:
|
||||
getLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION) ||
|
||||
SpanDetailVariant.DOCKED_RIGHT,
|
||||
[TraceDetailEventKeys.ColorByField]: colorByField.name,
|
||||
[TraceDetailEventKeys.PreviewFieldsCount]: previewFieldsCount,
|
||||
[TraceDetailEventKeys.EntryPreferOldView]:
|
||||
getLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_PREFER_OLD_VIEW) === 'true',
|
||||
});
|
||||
}, [
|
||||
traceId,
|
||||
userPrefsReady,
|
||||
traceData,
|
||||
totalSpansCount,
|
||||
colorByField,
|
||||
previewFieldsCount,
|
||||
logTraceEvent,
|
||||
]);
|
||||
|
||||
// Frontend mode: expand all parents by default when full data arrives
|
||||
useEffect(() => {
|
||||
if (isFullDataLoaded && allSpans.length > 0) {
|
||||
@@ -233,6 +284,12 @@ function TraceDetailsV3(): JSX.Element {
|
||||
const [activeKeys, setActiveKeys] = useState<string[]>(['flame', 'waterfall']);
|
||||
|
||||
const handleCollapseChange = (key: string): void => {
|
||||
logTraceEvent(
|
||||
key === 'flame'
|
||||
? TraceDetailEvents.FlameGraphToggled
|
||||
: TraceDetailEvents.WaterfallToggled,
|
||||
{ [TraceDetailEventKeys.Expanded]: !activeKeys.includes(key) },
|
||||
);
|
||||
setActiveKeys((prev) =>
|
||||
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ChevronDown, Copy } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple as Dropdown } from '@signozhq/ui/dropdown-menu';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { JsonView } from 'periscope/components/JsonView';
|
||||
import { PrettyView } from 'periscope/components/PrettyView';
|
||||
import { PrettyViewProps } from 'periscope/components/PrettyView';
|
||||
@@ -12,6 +13,8 @@ import './DataViewer.styles.scss';
|
||||
|
||||
type ViewMode = 'pretty' | 'json';
|
||||
|
||||
const VIEW_MODE_CHANGED_EVENT = 'Data Viewer: View mode changed';
|
||||
|
||||
const VIEW_MODE_OPTIONS: { label: string; value: ViewMode }[] = [
|
||||
{ label: 'Pretty', value: 'pretty' },
|
||||
{ label: 'JSON', value: 'json' },
|
||||
@@ -34,6 +37,20 @@ function DataViewer({
|
||||
|
||||
const jsonString = useMemo(() => JSON.stringify(data, null, 2), [data]);
|
||||
|
||||
const handleViewModeChange = (value: string): void => {
|
||||
const next = value as ViewMode;
|
||||
setViewMode(next);
|
||||
try {
|
||||
logEvent(VIEW_MODE_CHANGED_EVENT, {
|
||||
viewMode: next,
|
||||
path: window.location.pathname,
|
||||
drawerKey,
|
||||
});
|
||||
} catch {
|
||||
// No op
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = (): void => {
|
||||
const text = JSON.stringify(data, null, 2);
|
||||
setCopy(text);
|
||||
@@ -56,7 +73,7 @@ function DataViewer({
|
||||
{
|
||||
type: 'radio-group',
|
||||
value: viewMode,
|
||||
onChange: (value): void => setViewMode(value as ViewMode),
|
||||
onChange: handleViewModeChange,
|
||||
children: VIEW_MODE_OPTIONS.map((opt) => ({
|
||||
type: 'radio',
|
||||
key: opt.value,
|
||||
|
||||
@@ -827,22 +827,3 @@ body.ai-assistant-panel-open {
|
||||
--input-focus-outline-width: 0;
|
||||
--radius-2: 4px;
|
||||
}
|
||||
|
||||
// Scoped to .theme-wipe-active (toggled on <html> in runThemeTransition) so
|
||||
// these overrides don't leak into any unrelated view transitions added later.
|
||||
// We disable the default UA crossfade so the JS-driven clip-path wipe is the
|
||||
// only visible effect, and stack the new snapshot above the old.
|
||||
html.theme-wipe-active {
|
||||
&::view-transition-old(root),
|
||||
&::view-transition-new(root) {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
&::view-transition-old(root) {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&::view-transition-new(root) {
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
import {
|
||||
canAnimateThemeTransition,
|
||||
runThemeTransition,
|
||||
THEME_WIPE_ACTIVE_CLASS,
|
||||
} from '../themeTransition';
|
||||
|
||||
type StartVT = (cb: () => void) => {
|
||||
ready: Promise<void>;
|
||||
finished: Promise<void>;
|
||||
};
|
||||
|
||||
const installStartViewTransition = (impl?: StartVT): jest.Mock => {
|
||||
const defaultImpl: StartVT = (cb) => {
|
||||
cb();
|
||||
return { ready: Promise.resolve(), finished: Promise.resolve() };
|
||||
};
|
||||
const fn = jest.fn(impl ?? defaultImpl);
|
||||
Object.defineProperty(document, 'startViewTransition', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: fn,
|
||||
});
|
||||
return fn;
|
||||
};
|
||||
|
||||
const removeStartViewTransition = (): void => {
|
||||
Object.defineProperty(document, 'startViewTransition', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const setReducedMotion = (matches: boolean): void => {
|
||||
(window.matchMedia as jest.Mock) = jest
|
||||
.fn()
|
||||
.mockImplementation((query: string) => ({
|
||||
matches: query === '(prefers-reduced-motion: reduce)' ? matches : false,
|
||||
media: query,
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
}));
|
||||
};
|
||||
|
||||
describe('canAnimateThemeTransition', () => {
|
||||
afterEach(() => {
|
||||
removeStartViewTransition();
|
||||
});
|
||||
|
||||
it('returns false when document.startViewTransition is unavailable', () => {
|
||||
removeStartViewTransition();
|
||||
setReducedMotion(false);
|
||||
expect(canAnimateThemeTransition()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when prefers-reduced-motion is reduce', () => {
|
||||
installStartViewTransition();
|
||||
setReducedMotion(true);
|
||||
expect(canAnimateThemeTransition()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when API is supported and motion is allowed', () => {
|
||||
installStartViewTransition();
|
||||
setReducedMotion(false);
|
||||
expect(canAnimateThemeTransition()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runThemeTransition', () => {
|
||||
afterEach(() => {
|
||||
removeStartViewTransition();
|
||||
document.documentElement.classList.remove(THEME_WIPE_ACTIVE_CLASS);
|
||||
});
|
||||
|
||||
it('falls back to running applyChange directly when API is missing', () => {
|
||||
removeStartViewTransition();
|
||||
const applyChange = jest.fn();
|
||||
runThemeTransition(applyChange);
|
||||
expect(applyChange).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
document.documentElement.classList.contains(THEME_WIPE_ACTIVE_CLASS),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('invokes startViewTransition and runs applyChange inside its callback', () => {
|
||||
const startVT = installStartViewTransition();
|
||||
const applyChange = jest.fn();
|
||||
runThemeTransition(applyChange);
|
||||
expect(startVT).toHaveBeenCalledTimes(1);
|
||||
expect(applyChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('toggles the wipe-active class on <html> for the lifetime of the transition', async () => {
|
||||
let resolveFinished: () => void = (): void => {};
|
||||
installStartViewTransition((cb) => {
|
||||
cb();
|
||||
return {
|
||||
ready: Promise.resolve(),
|
||||
finished: new Promise<void>((resolve) => {
|
||||
resolveFinished = resolve;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
runThemeTransition(() => undefined);
|
||||
|
||||
expect(
|
||||
document.documentElement.classList.contains(THEME_WIPE_ACTIVE_CLASS),
|
||||
).toBe(true);
|
||||
|
||||
resolveFinished();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(
|
||||
document.documentElement.classList.contains(THEME_WIPE_ACTIVE_CLASS),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the wipe-active class through overlapping transitions', async () => {
|
||||
let resolveA: () => void = (): void => {};
|
||||
let resolveB: () => void = (): void => {};
|
||||
let callIndex = 0;
|
||||
installStartViewTransition((cb) => {
|
||||
cb();
|
||||
callIndex += 1;
|
||||
if (callIndex === 1) {
|
||||
return {
|
||||
ready: Promise.resolve(),
|
||||
finished: new Promise<void>((resolve) => {
|
||||
resolveA = resolve;
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ready: Promise.resolve(),
|
||||
finished: new Promise<void>((resolve) => {
|
||||
resolveB = resolve;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
runThemeTransition(() => undefined);
|
||||
runThemeTransition(() => undefined);
|
||||
|
||||
expect(
|
||||
document.documentElement.classList.contains(THEME_WIPE_ACTIVE_CLASS),
|
||||
).toBe(true);
|
||||
|
||||
// First transition finishes — class must stay because B is still in flight.
|
||||
resolveA();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(
|
||||
document.documentElement.classList.contains(THEME_WIPE_ACTIVE_CLASS),
|
||||
).toBe(true);
|
||||
|
||||
resolveB();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(
|
||||
document.documentElement.classList.contains(THEME_WIPE_ACTIVE_CLASS),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to applyChange and releases the class when startViewTransition throws before its callback runs', () => {
|
||||
installStartViewTransition(() => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
const applyChange = jest.fn();
|
||||
runThemeTransition(applyChange);
|
||||
expect(applyChange).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
document.documentElement.classList.contains(THEME_WIPE_ACTIVE_CLASS),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('does not double-invoke applyChange when startViewTransition throws after its callback runs', () => {
|
||||
installStartViewTransition((cb) => {
|
||||
cb();
|
||||
throw new Error('post-cb');
|
||||
});
|
||||
const applyChange = jest.fn();
|
||||
runThemeTransition(applyChange);
|
||||
expect(applyChange).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
document.documentElement.classList.contains(THEME_WIPE_ACTIVE_CLASS),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,115 +0,0 @@
|
||||
import { flushSync } from 'react-dom';
|
||||
|
||||
const WIPE_DURATION_MS = 400;
|
||||
const WIPE_EASING = 'ease-out';
|
||||
|
||||
// Toggled on <html> for the duration of the wipe so the CSS overrides
|
||||
// (animation: none on ::view-transition-{old,new}(root)) don't leak into
|
||||
// any future, unrelated view transitions in the app.
|
||||
export const THEME_WIPE_ACTIVE_CLASS = 'theme-wipe-active';
|
||||
|
||||
type ViewTransition = {
|
||||
ready: Promise<void>;
|
||||
finished: Promise<void>;
|
||||
};
|
||||
type DocumentWithVT = Document & {
|
||||
startViewTransition?: (callback: () => void) => ViewTransition;
|
||||
};
|
||||
|
||||
// Rapid theme switches cancel the in-flight transition and immediately start a
|
||||
// new one; if we removed the class on the first transition's settled promise,
|
||||
// we'd strip the CSS override mid-way through the next wipe and the user
|
||||
// would briefly see the UA crossfade. Refcount so the class only comes off
|
||||
// once every transition we started has settled.
|
||||
let wipeActiveRefCount = 0;
|
||||
const acquireWipeClass = (root: HTMLElement): void => {
|
||||
wipeActiveRefCount += 1;
|
||||
root.classList.add(THEME_WIPE_ACTIVE_CLASS);
|
||||
};
|
||||
const releaseWipeClass = (root: HTMLElement): void => {
|
||||
wipeActiveRefCount = Math.max(0, wipeActiveRefCount - 1);
|
||||
if (wipeActiveRefCount === 0) {
|
||||
root.classList.remove(THEME_WIPE_ACTIVE_CLASS);
|
||||
}
|
||||
};
|
||||
|
||||
// Identity of the transition we most recently started. Used to skip the
|
||||
// .animate() call on a stale transition whose .ready resolved after a newer
|
||||
// transition has already taken over the ::view-transition-new pseudo-element.
|
||||
let currentTransition: ViewTransition | null = null;
|
||||
|
||||
export function canAnimateThemeTransition(): boolean {
|
||||
const doc = document as DocumentWithVT;
|
||||
if (typeof doc.startViewTransition !== 'function') {
|
||||
return false;
|
||||
}
|
||||
return !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
}
|
||||
|
||||
// Runs `applyChange` inside a View Transition and wipes the new theme in from
|
||||
// left to right via a polygon clip-path on ::view-transition-new(root).
|
||||
// Callers should gate on canAnimateThemeTransition() first; this is a safe
|
||||
// no-animation fallback otherwise.
|
||||
export function runThemeTransition(applyChange: () => void): void {
|
||||
const doc = document as DocumentWithVT;
|
||||
if (!doc.startViewTransition) {
|
||||
applyChange();
|
||||
return;
|
||||
}
|
||||
|
||||
const root = document.documentElement;
|
||||
acquireWipeClass(root);
|
||||
|
||||
// Some Chromium versions throw if startViewTransition is called while
|
||||
// another transition is in setup. Track whether the callback ran so we
|
||||
// don't double-apply if the throw happens mid-callback.
|
||||
let applied = false;
|
||||
let transition: ViewTransition;
|
||||
try {
|
||||
transition = doc.startViewTransition(() => {
|
||||
applied = true;
|
||||
flushSync(applyChange);
|
||||
});
|
||||
} catch {
|
||||
releaseWipeClass(root);
|
||||
if (!applied) {
|
||||
applyChange();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
currentTransition = transition;
|
||||
|
||||
const from = 'polygon(0 0, 0 0, 0 100%, 0 100%)';
|
||||
const to = 'polygon(0 0, 100% 0, 100% 100%, 0 100%)';
|
||||
|
||||
transition.ready
|
||||
.then(() => {
|
||||
// If a newer transition has superseded this one between
|
||||
// startViewTransition() and `ready` resolving, the browser has
|
||||
// already cancelled our pseudo-element. Calling .animate() on it now
|
||||
// would race with the newer transition's own animation.
|
||||
if (currentTransition !== transition) {
|
||||
return;
|
||||
}
|
||||
root.animate(
|
||||
{ clipPath: [from, to] },
|
||||
{
|
||||
duration: WIPE_DURATION_MS,
|
||||
easing: WIPE_EASING,
|
||||
pseudoElement: '::view-transition-new(root)',
|
||||
},
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
// Transition cancelled — applyChange has already run.
|
||||
});
|
||||
|
||||
const cleanup = (): void => {
|
||||
if (currentTransition === transition) {
|
||||
currentTransition = null;
|
||||
}
|
||||
releaseWipeClass(root);
|
||||
};
|
||||
transition.finished.then(cleanup).catch(cleanup);
|
||||
}
|
||||
@@ -74,7 +74,7 @@ func (s *traceStore) GetTraceSpans(ctx context.Context, traceID string, summary
|
||||
events, status_message, status_code_string, kind_string, parent_span_id,
|
||||
flags, is_remote, trace_state, status_code,
|
||||
db_name, db_operation, http_method, http_url, http_host,
|
||||
external_http_method, external_http_url, response_status_code
|
||||
external_http_method, external_http_url, response_status_code, links as references
|
||||
FROM %s.%s
|
||||
WHERE trace_id=? AND ts_bucket_start>=? AND ts_bucket_start<=?
|
||||
ORDER BY timestamp ASC, name ASC`,
|
||||
@@ -130,7 +130,7 @@ func (s *traceStore) GetTraceSpansByIDs(ctx context.Context, traceID string, sta
|
||||
"events", "status_message", "status_code_string", "kind_string", "parent_span_id",
|
||||
"flags", "is_remote", "trace_state", "status_code",
|
||||
"db_name", "db_operation", "http_method", "http_url", "http_host",
|
||||
"external_http_method", "external_http_url", "response_status_code",
|
||||
"external_http_method", "external_http_url", "response_status_code", "links as references",
|
||||
)
|
||||
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
|
||||
ids := make([]any, len(spanIDs))
|
||||
|
||||
@@ -54,6 +54,12 @@ type Event struct {
|
||||
IsError bool `json:"isError,omitempty"`
|
||||
}
|
||||
|
||||
type OtelSpanRef struct {
|
||||
TraceId string `json:"traceId,omitempty"`
|
||||
SpanId string `json:"spanId,omitempty"`
|
||||
RefType string `json:"refType,omitempty"`
|
||||
}
|
||||
|
||||
// WaterfallSpan represents the span in waterfall response,
|
||||
// this uses snake_case keys for response as a special case since these
|
||||
// keys can be directly used to query spans and client need to know the actual fields.
|
||||
@@ -74,6 +80,7 @@ type WaterfallSpan struct {
|
||||
TimeUnix uint64 `json:"time_unix"`
|
||||
TraceID string `json:"trace_id"`
|
||||
TraceState string `json:"trace_state"`
|
||||
References []OtelSpanRef `json:"references" required:"true" nullable:"false"`
|
||||
|
||||
// Calculated fields https://signoz.io/docs/traces-management/guides/derived-fields-spans
|
||||
DBName string `json:"db_name,omitempty"`
|
||||
@@ -128,6 +135,7 @@ type StorableSpan struct {
|
||||
ExternalHTTPMethod string `ch:"external_http_method"`
|
||||
ExternalHTTPURL string `ch:"external_http_url"`
|
||||
ResponseStatusCode string `ch:"response_status_code"`
|
||||
References string `ch:"references"`
|
||||
}
|
||||
|
||||
// MinimalSpan with only the fields needed to build the parent-child tree.
|
||||
@@ -285,6 +293,14 @@ func (item *StorableSpan) UnmarshalledEvents() []Event {
|
||||
return events
|
||||
}
|
||||
|
||||
func (item *StorableSpan) UnmarshalledRefs() []OtelSpanRef {
|
||||
refs := []OtelSpanRef{}
|
||||
if err := json.Unmarshal([]byte(item.References), &refs); err != nil {
|
||||
return nil // skip malformed values
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
func (item *StorableSpan) ToWaterfallSpan(traceID string) *WaterfallSpan {
|
||||
resources := make(map[string]string)
|
||||
maps.Copy(resources, item.ResourcesString)
|
||||
@@ -318,6 +334,7 @@ func (item *StorableSpan) ToWaterfallSpan(traceID string) *WaterfallSpan {
|
||||
Children: make([]*WaterfallSpan, 0),
|
||||
TimeUnix: uint64(item.StartTime.UnixNano()),
|
||||
ServiceName: item.ServiceName,
|
||||
References: item.UnmarshalledRefs(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,16 +54,16 @@ func newConfig() factory.Config {
|
||||
Directory: "/etc/signoz/web",
|
||||
Settings: SettingsConfig{
|
||||
Posthog: PosthogConfig{
|
||||
Enabled: true,
|
||||
Enabled: false,
|
||||
},
|
||||
Appcues: AppcuesConfig{
|
||||
Enabled: true,
|
||||
Enabled: false,
|
||||
},
|
||||
Sentry: SentryConfig{
|
||||
Enabled: true,
|
||||
Enabled: false,
|
||||
},
|
||||
Pylon: PylonConfig{
|
||||
Enabled: true,
|
||||
Enabled: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user