mirror of
https://github.com/SigNoz/signoz.git
synced 2026-04-10 14:10:22 +01:00
Compare commits
1 Commits
issue_4203
...
feat/trace
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4cd6a9687 |
@@ -3309,7 +3309,7 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesPostableAccount'
|
||||
responses:
|
||||
"201":
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -3322,7 +3322,7 @@ paths:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: Created
|
||||
description: OK
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
@@ -3683,11 +3683,6 @@ paths:
|
||||
provider
|
||||
operationId: ListServicesMetadata
|
||||
parameters:
|
||||
- in: query
|
||||
name: cloud_integration_id
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
- in: path
|
||||
name: cloud_provider
|
||||
required: true
|
||||
@@ -3740,11 +3735,6 @@ paths:
|
||||
description: This endpoint gets a service for the specified cloud provider
|
||||
operationId: GetService
|
||||
parameters:
|
||||
- in: query
|
||||
name: cloud_integration_id
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
- in: path
|
||||
name: cloud_provider
|
||||
required: true
|
||||
|
||||
@@ -139,10 +139,12 @@
|
||||
"react-helmet-async": "1.3.0",
|
||||
"react-hook-form": "7.71.2",
|
||||
"react-i18next": "^11.16.1",
|
||||
"react-json-tree": "0.20.0",
|
||||
"react-lottie": "1.2.10",
|
||||
"react-markdown": "8.0.7",
|
||||
"react-query": "3.39.3",
|
||||
"react-redux": "^7.2.2",
|
||||
"react-rnd": "10.5.3",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-router-dom-v5-compat": "6.27.0",
|
||||
"react-syntax-highlighter": "15.5.0",
|
||||
|
||||
@@ -28,7 +28,7 @@ import type {
|
||||
CloudintegrationtypesPostableAgentCheckInDTO,
|
||||
CloudintegrationtypesUpdatableAccountDTO,
|
||||
CloudintegrationtypesUpdatableServiceDTO,
|
||||
CreateAccount201,
|
||||
CreateAccount200,
|
||||
CreateAccountPathParameters,
|
||||
DisconnectAccountPathParameters,
|
||||
GetAccount200,
|
||||
@@ -36,12 +36,10 @@ import type {
|
||||
GetConnectionCredentials200,
|
||||
GetConnectionCredentialsPathParameters,
|
||||
GetService200,
|
||||
GetServiceParams,
|
||||
GetServicePathParameters,
|
||||
ListAccounts200,
|
||||
ListAccountsPathParameters,
|
||||
ListServicesMetadata200,
|
||||
ListServicesMetadataParams,
|
||||
ListServicesMetadataPathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
UpdateAccountPathParameters,
|
||||
@@ -262,7 +260,7 @@ export const createAccount = (
|
||||
cloudintegrationtypesPostableAccountDTO: BodyType<CloudintegrationtypesPostableAccountDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateAccount201>({
|
||||
return GeneratedAPIInstance<CreateAccount200>({
|
||||
url: `/api/v1/cloud_integrations/${cloudProvider}/accounts`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -942,25 +940,19 @@ export const invalidateGetConnectionCredentials = async (
|
||||
*/
|
||||
export const listServicesMetadata = (
|
||||
{ cloudProvider }: ListServicesMetadataPathParameters,
|
||||
params?: ListServicesMetadataParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<ListServicesMetadata200>({
|
||||
url: `/api/v1/cloud_integrations/${cloudProvider}/services`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListServicesMetadataQueryKey = (
|
||||
{ cloudProvider }: ListServicesMetadataPathParameters,
|
||||
params?: ListServicesMetadataParams,
|
||||
) => {
|
||||
return [
|
||||
`/api/v1/cloud_integrations/${cloudProvider}/services`,
|
||||
...(params ? [params] : []),
|
||||
] as const;
|
||||
export const getListServicesMetadataQueryKey = ({
|
||||
cloudProvider,
|
||||
}: ListServicesMetadataPathParameters) => {
|
||||
return [`/api/v1/cloud_integrations/${cloudProvider}/services`] as const;
|
||||
};
|
||||
|
||||
export const getListServicesMetadataQueryOptions = <
|
||||
@@ -968,7 +960,6 @@ export const getListServicesMetadataQueryOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>
|
||||
>(
|
||||
{ cloudProvider }: ListServicesMetadataPathParameters,
|
||||
params?: ListServicesMetadataParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listServicesMetadata>>,
|
||||
@@ -980,12 +971,11 @@ export const getListServicesMetadataQueryOptions = <
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ??
|
||||
getListServicesMetadataQueryKey({ cloudProvider }, params);
|
||||
queryOptions?.queryKey ?? getListServicesMetadataQueryKey({ cloudProvider });
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof listServicesMetadata>>
|
||||
> = ({ signal }) => listServicesMetadata({ cloudProvider }, params, signal);
|
||||
> = ({ signal }) => listServicesMetadata({ cloudProvider }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
@@ -1013,7 +1003,6 @@ export function useListServicesMetadata<
|
||||
TError = ErrorType<RenderErrorResponseDTO>
|
||||
>(
|
||||
{ cloudProvider }: ListServicesMetadataPathParameters,
|
||||
params?: ListServicesMetadataParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listServicesMetadata>>,
|
||||
@@ -1024,7 +1013,6 @@ export function useListServicesMetadata<
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListServicesMetadataQueryOptions(
|
||||
{ cloudProvider },
|
||||
params,
|
||||
options,
|
||||
);
|
||||
|
||||
@@ -1043,11 +1031,10 @@ export function useListServicesMetadata<
|
||||
export const invalidateListServicesMetadata = async (
|
||||
queryClient: QueryClient,
|
||||
{ cloudProvider }: ListServicesMetadataPathParameters,
|
||||
params?: ListServicesMetadataParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListServicesMetadataQueryKey({ cloudProvider }, params) },
|
||||
{ queryKey: getListServicesMetadataQueryKey({ cloudProvider }) },
|
||||
options,
|
||||
);
|
||||
|
||||
@@ -1060,24 +1047,21 @@ export const invalidateListServicesMetadata = async (
|
||||
*/
|
||||
export const getService = (
|
||||
{ cloudProvider, serviceId }: GetServicePathParameters,
|
||||
params?: GetServiceParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetService200>({
|
||||
url: `/api/v1/cloud_integrations/${cloudProvider}/services/${serviceId}`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetServiceQueryKey = (
|
||||
{ cloudProvider, serviceId }: GetServicePathParameters,
|
||||
params?: GetServiceParams,
|
||||
) => {
|
||||
export const getGetServiceQueryKey = ({
|
||||
cloudProvider,
|
||||
serviceId,
|
||||
}: GetServicePathParameters) => {
|
||||
return [
|
||||
`/api/v1/cloud_integrations/${cloudProvider}/services/${serviceId}`,
|
||||
...(params ? [params] : []),
|
||||
] as const;
|
||||
};
|
||||
|
||||
@@ -1086,7 +1070,6 @@ export const getGetServiceQueryOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>
|
||||
>(
|
||||
{ cloudProvider, serviceId }: GetServicePathParameters,
|
||||
params?: GetServiceParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getService>>,
|
||||
@@ -1098,12 +1081,11 @@ export const getGetServiceQueryOptions = <
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ??
|
||||
getGetServiceQueryKey({ cloudProvider, serviceId }, params);
|
||||
queryOptions?.queryKey ?? getGetServiceQueryKey({ cloudProvider, serviceId });
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getService>>> = ({
|
||||
signal,
|
||||
}) => getService({ cloudProvider, serviceId }, params, signal);
|
||||
}) => getService({ cloudProvider, serviceId }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
@@ -1129,7 +1111,6 @@ export function useGetService<
|
||||
TError = ErrorType<RenderErrorResponseDTO>
|
||||
>(
|
||||
{ cloudProvider, serviceId }: GetServicePathParameters,
|
||||
params?: GetServiceParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getService>>,
|
||||
@@ -1140,7 +1121,6 @@ export function useGetService<
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetServiceQueryOptions(
|
||||
{ cloudProvider, serviceId },
|
||||
params,
|
||||
options,
|
||||
);
|
||||
|
||||
@@ -1159,11 +1139,10 @@ export function useGetService<
|
||||
export const invalidateGetService = async (
|
||||
queryClient: QueryClient,
|
||||
{ cloudProvider, serviceId }: GetServicePathParameters,
|
||||
params?: GetServiceParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetServiceQueryKey({ cloudProvider, serviceId }, params) },
|
||||
{ queryKey: getGetServiceQueryKey({ cloudProvider, serviceId }) },
|
||||
options,
|
||||
);
|
||||
|
||||
|
||||
@@ -3589,7 +3589,7 @@ export type ListAccounts200 = {
|
||||
export type CreateAccountPathParameters = {
|
||||
cloudProvider: string;
|
||||
};
|
||||
export type CreateAccount201 = {
|
||||
export type CreateAccount200 = {
|
||||
data: CloudintegrationtypesGettableAccountWithConnectionArtifactDTO;
|
||||
/**
|
||||
* @type string
|
||||
@@ -3647,14 +3647,6 @@ export type GetConnectionCredentials200 = {
|
||||
export type ListServicesMetadataPathParameters = {
|
||||
cloudProvider: string;
|
||||
};
|
||||
export type ListServicesMetadataParams = {
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
cloud_integration_id?: string;
|
||||
};
|
||||
|
||||
export type ListServicesMetadata200 = {
|
||||
data: CloudintegrationtypesGettableServicesMetadataDTO;
|
||||
/**
|
||||
@@ -3667,14 +3659,6 @@ export type GetServicePathParameters = {
|
||||
cloudProvider: string;
|
||||
serviceId: string;
|
||||
};
|
||||
export type GetServiceParams = {
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
cloud_integration_id?: string;
|
||||
};
|
||||
|
||||
export type GetService200 = {
|
||||
data: CloudintegrationtypesServiceDTO;
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
.details-header {
|
||||
// ghost + secondary missing hover bg token in @signozhq/button
|
||||
--button-ghost-hover-background: var(--l3-background);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
height: 36px;
|
||||
background: var(--l2-background);
|
||||
|
||||
&__icon-btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--l1-foreground);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { X } from '@signozhq/icons';
|
||||
|
||||
import './DetailsHeader.styles.scss';
|
||||
|
||||
export interface HeaderAction {
|
||||
key: string;
|
||||
component: ReactNode; // check later if we can use direct btn itself or not.
|
||||
}
|
||||
|
||||
export interface DetailsHeaderProps {
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
actions?: HeaderAction[];
|
||||
closePosition?: 'left' | 'right';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function DetailsHeader({
|
||||
title,
|
||||
onClose,
|
||||
actions,
|
||||
closePosition = 'right',
|
||||
className,
|
||||
}: DetailsHeaderProps): JSX.Element {
|
||||
const closeButton = (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="details-header__icon-btn"
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`details-header ${className || ''}`}>
|
||||
{closePosition === 'left' && closeButton}
|
||||
|
||||
<span className="details-header__title">{title}</span>
|
||||
|
||||
{actions && (
|
||||
<div className="details-header__actions">
|
||||
{actions.map((action) => (
|
||||
<div key={action.key}>{action.component}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{closePosition === 'right' && closeButton}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DetailsHeader;
|
||||
@@ -0,0 +1,7 @@
|
||||
.details-panel-drawer {
|
||||
&__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
36
frontend/src/components/DetailsPanel/DetailsPanelDrawer.tsx
Normal file
36
frontend/src/components/DetailsPanel/DetailsPanelDrawer.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { DrawerWrapper } from '@signozhq/drawer';
|
||||
|
||||
import './DetailsPanelDrawer.styles.scss';
|
||||
|
||||
interface DetailsPanelDrawerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function DetailsPanelDrawer({
|
||||
isOpen,
|
||||
onClose,
|
||||
children,
|
||||
className,
|
||||
}: DetailsPanelDrawerProps): JSX.Element {
|
||||
return (
|
||||
<DrawerWrapper
|
||||
open={isOpen}
|
||||
onOpenChange={(open): void => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
direction="right"
|
||||
type="panel"
|
||||
showOverlay={false}
|
||||
allowOutsideClick
|
||||
className={`details-panel-drawer ${className || ''}`}
|
||||
content={<div className="details-panel-drawer__body">{children}</div>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default DetailsPanelDrawer;
|
||||
8
frontend/src/components/DetailsPanel/index.ts
Normal file
8
frontend/src/components/DetailsPanel/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type {
|
||||
DetailsHeaderProps,
|
||||
HeaderAction,
|
||||
} from './DetailsHeader/DetailsHeader';
|
||||
export { default as DetailsHeader } from './DetailsHeader/DetailsHeader';
|
||||
export { default as DetailsPanelDrawer } from './DetailsPanelDrawer';
|
||||
export type { DetailsPanelState, UseDetailsPanelOptions } from './types';
|
||||
export { default as useDetailsPanel } from './useDetailsPanel';
|
||||
10
frontend/src/components/DetailsPanel/types.ts
Normal file
10
frontend/src/components/DetailsPanel/types.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export interface DetailsPanelState {
|
||||
isOpen: boolean;
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export interface UseDetailsPanelOptions {
|
||||
entityId: string | undefined;
|
||||
onClose?: () => void;
|
||||
}
|
||||
29
frontend/src/components/DetailsPanel/useDetailsPanel.ts
Normal file
29
frontend/src/components/DetailsPanel/useDetailsPanel.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { DetailsPanelState, UseDetailsPanelOptions } from './types';
|
||||
|
||||
function useDetailsPanel({
|
||||
entityId,
|
||||
onClose,
|
||||
}: UseDetailsPanelOptions): DetailsPanelState {
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const prevEntityIdRef = useRef<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
const currentId = entityId || '';
|
||||
if (currentId && currentId !== prevEntityIdRef.current) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
prevEntityIdRef.current = currentId;
|
||||
}, [entityId]);
|
||||
|
||||
const open = useCallback(() => setIsOpen(true), []);
|
||||
const close = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
onClose?.();
|
||||
}, [onClose]);
|
||||
|
||||
return { isOpen, open, close };
|
||||
}
|
||||
|
||||
export default useDetailsPanel;
|
||||
@@ -0,0 +1,54 @@
|
||||
.data-viewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
// Toolbar — view mode switcher + copy button
|
||||
&__toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
&__mode-select {
|
||||
min-width: 90px;
|
||||
height: 28px !important;
|
||||
|
||||
.ant-select-selector {
|
||||
border-radius: 4px !important;
|
||||
border-color: var(--l2-border) !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__copy-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--l2-foreground);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--l1-foreground);
|
||||
background: var(--l3-background);
|
||||
}
|
||||
}
|
||||
|
||||
// Shared content container — no scroll, each view handles its own
|
||||
&__content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
67
frontend/src/periscope/components/DataViewer/DataViewer.tsx
Normal file
67
frontend/src/periscope/components/DataViewer/DataViewer.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Copy } from '@signozhq/icons';
|
||||
// TODO: Replace antd Select with @signozhq/ui component when moving to design library
|
||||
import { Select } from 'antd';
|
||||
import { JsonView } from 'periscope/components/JsonView';
|
||||
import { PrettyView } from 'periscope/components/PrettyView';
|
||||
import { PrettyViewProps } from 'periscope/components/PrettyView';
|
||||
|
||||
import { copyToClipboard } from './utils';
|
||||
|
||||
import './DataViewer.styles.scss';
|
||||
|
||||
type ViewMode = 'pretty' | 'json';
|
||||
|
||||
export interface DataViewerProps {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
data: Record<string, any>;
|
||||
drawerKey?: string;
|
||||
prettyViewProps?: Omit<PrettyViewProps, 'data' | 'drawerKey'>;
|
||||
}
|
||||
|
||||
function DataViewer({
|
||||
data,
|
||||
drawerKey = 'default',
|
||||
prettyViewProps,
|
||||
}: DataViewerProps): JSX.Element {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('pretty');
|
||||
|
||||
const jsonString = useMemo(() => JSON.stringify(data, null, 2), [data]);
|
||||
|
||||
return (
|
||||
<div className="data-viewer">
|
||||
<div className="data-viewer__toolbar">
|
||||
<Select
|
||||
className="data-viewer__mode-select"
|
||||
size="small"
|
||||
value={viewMode}
|
||||
onChange={(value: ViewMode): void => setViewMode(value)}
|
||||
options={[
|
||||
{ label: 'Pretty', value: 'pretty' },
|
||||
{ label: 'JSON', value: 'json' },
|
||||
]}
|
||||
getPopupContainer={(trigger): HTMLElement =>
|
||||
trigger.parentElement || document.body
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="data-viewer__copy-btn"
|
||||
onClick={(): void => copyToClipboard(data)}
|
||||
aria-label="Copy JSON"
|
||||
>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="data-viewer__content">
|
||||
{viewMode === 'pretty' && (
|
||||
<PrettyView data={data} drawerKey={drawerKey} {...prettyViewProps} />
|
||||
)}
|
||||
{viewMode === 'json' && <JsonView data={jsonString} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DataViewer;
|
||||
2
frontend/src/periscope/components/DataViewer/index.ts
Normal file
2
frontend/src/periscope/components/DataViewer/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { DataViewerProps } from './DataViewer';
|
||||
export { default as DataViewer } from './DataViewer';
|
||||
12
frontend/src/periscope/components/DataViewer/utils.ts
Normal file
12
frontend/src/periscope/components/DataViewer/utils.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { toast } from '@signozhq/sonner';
|
||||
|
||||
export function copyToClipboard(value: unknown): void {
|
||||
const text =
|
||||
typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value);
|
||||
// eslint-disable-next-line no-restricted-properties
|
||||
navigator.clipboard.writeText(text);
|
||||
toast.success('Copied to clipboard', {
|
||||
richColors: true,
|
||||
position: 'top-right',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
.floating-panel {
|
||||
z-index: 999;
|
||||
background: var(--l1-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
overflow: hidden;
|
||||
|
||||
// Inner div fills the Rnd-controlled size
|
||||
&__inner {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// Drag handle — any child with this class becomes the drag target
|
||||
.floating-panel__drag-handle {
|
||||
cursor: move;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { ComponentProps } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Rnd } from 'react-rnd';
|
||||
|
||||
import './FloatingPanel.styles.scss';
|
||||
|
||||
type EnableResizing = ComponentProps<typeof Rnd>['enableResizing'];
|
||||
|
||||
export interface FloatingPanelProps {
|
||||
isOpen: boolean;
|
||||
children: React.ReactNode;
|
||||
defaultPosition?: { x: number; y: number };
|
||||
width?: number;
|
||||
height?: number;
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
enableResizing?: EnableResizing;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function FloatingPanel({
|
||||
isOpen,
|
||||
children,
|
||||
defaultPosition,
|
||||
width = 560,
|
||||
height = 600,
|
||||
minWidth = 400,
|
||||
minHeight = 300,
|
||||
enableResizing,
|
||||
className,
|
||||
}: FloatingPanelProps): JSX.Element | null {
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const initialPosition = defaultPosition || {
|
||||
x: window.innerWidth - width - 24,
|
||||
y: 80,
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<Rnd
|
||||
default={{
|
||||
x: initialPosition.x,
|
||||
y: initialPosition.y,
|
||||
width,
|
||||
height,
|
||||
}}
|
||||
dragHandleClassName="floating-panel__drag-handle"
|
||||
minWidth={minWidth}
|
||||
minHeight={minHeight}
|
||||
onDrag={(_e, d): void | false => {
|
||||
const HEADER_HEIGHT = 40;
|
||||
// Top: don't allow header to go above viewport
|
||||
if (d.y < 0) {
|
||||
return false;
|
||||
}
|
||||
// Left: don't allow panel to go off-screen left
|
||||
if (d.x < 0) {
|
||||
return false;
|
||||
}
|
||||
// Bottom: only header needs to be visible
|
||||
if (d.y > window.innerHeight - HEADER_HEIGHT) {
|
||||
return false;
|
||||
}
|
||||
// Right: at least the close button (~40px) stays visible
|
||||
if (d.x > window.innerWidth - 40) {
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
className={`floating-panel ${className || ''}`}
|
||||
enableResizing={enableResizing}
|
||||
>
|
||||
<div className="floating-panel__inner">{children}</div>
|
||||
</Rnd>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export default FloatingPanel;
|
||||
2
frontend/src/periscope/components/FloatingPanel/index.ts
Normal file
2
frontend/src/periscope/components/FloatingPanel/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { FloatingPanelProps } from './FloatingPanel';
|
||||
export { default as FloatingPanel } from './FloatingPanel';
|
||||
@@ -0,0 +1,22 @@
|
||||
.json-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
|
||||
&__footer {
|
||||
flex-shrink: 0;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-top: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
&__wrap-toggle {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-left: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
78
frontend/src/periscope/components/JsonView/JsonView.tsx
Normal file
78
frontend/src/periscope/components/JsonView/JsonView.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useState } from 'react';
|
||||
import MEditor, { EditorProps, Monaco } from '@monaco-editor/react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Switch, Typography } from 'antd';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import './JsonView.styles.scss';
|
||||
|
||||
export interface JsonViewProps {
|
||||
data: string;
|
||||
height?: string;
|
||||
}
|
||||
|
||||
const editorOptions: EditorProps['options'] = {
|
||||
automaticLayout: true,
|
||||
readOnly: true,
|
||||
wordWrap: 'on',
|
||||
minimap: { enabled: false },
|
||||
fontWeight: 400,
|
||||
fontFamily: 'SF Mono, Geist Mono, Fira Code, monospace',
|
||||
fontSize: 12,
|
||||
lineHeight: '18px',
|
||||
colorDecorators: true,
|
||||
scrollBeyondLastLine: false,
|
||||
decorationsOverviewRuler: false,
|
||||
scrollbar: { vertical: 'hidden', horizontal: 'hidden' },
|
||||
folding: false,
|
||||
};
|
||||
|
||||
function setEditorTheme(monaco: Monaco): void {
|
||||
monaco.editor.defineTheme('signoz-dark', {
|
||||
base: 'vs-dark',
|
||||
inherit: true,
|
||||
rules: [
|
||||
{ token: 'string.key.json', foreground: Color.BG_VANILLA_400 },
|
||||
{ token: 'string.value.json', foreground: Color.BG_ROBIN_400 },
|
||||
],
|
||||
colors: {
|
||||
'editor.background': '#00000000', // transparent
|
||||
},
|
||||
fontFamily: 'SF Mono, Geist Mono, Fira Code, monospace',
|
||||
fontSize: 12,
|
||||
fontWeight: 'normal',
|
||||
lineHeight: 18,
|
||||
letterSpacing: -0.06,
|
||||
});
|
||||
}
|
||||
|
||||
function JsonView({ data, height = '575px' }: JsonViewProps): JSX.Element {
|
||||
const [isWrapWord, setIsWrapWord] = useState(true);
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
return (
|
||||
<div className="json-view">
|
||||
<MEditor
|
||||
value={data}
|
||||
language="json"
|
||||
options={{ ...editorOptions, wordWrap: isWrapWord ? 'on' : 'off' }}
|
||||
onChange={(): void => {}}
|
||||
height={height}
|
||||
theme={isDarkMode ? 'signoz-dark' : 'light'}
|
||||
beforeMount={setEditorTheme}
|
||||
/>
|
||||
<div className="json-view__footer">
|
||||
<div className="json-view__wrap-toggle">
|
||||
<Typography.Text>Wrap text</Typography.Text>
|
||||
<Switch
|
||||
checked={isWrapWord}
|
||||
onChange={(checked): void => setIsWrapWord(checked)}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default JsonView;
|
||||
2
frontend/src/periscope/components/JsonView/index.ts
Normal file
2
frontend/src/periscope/components/JsonView/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { JsonViewProps } from './JsonView';
|
||||
export { default as JsonView } from './JsonView';
|
||||
@@ -1,43 +1,67 @@
|
||||
.key-value-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 2px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
&--row {
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&--column {
|
||||
flex-direction: column;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
|
||||
.key-value-label__key {
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.48px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.key-value-label__value {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
&__key,
|
||||
&__value {
|
||||
padding: 1px 6px;
|
||||
padding: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.005em;
|
||||
&,
|
||||
&:hover {
|
||||
color: var(--text-vanilla-400);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&__key {
|
||||
background: var(--bg-ink-400);
|
||||
background: var(--l2-background);
|
||||
border-radius: 2px 0 0 2px;
|
||||
}
|
||||
&__value {
|
||||
background: var(--bg-slate-400);
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.key-value-label {
|
||||
border-color: var(--bg-vanilla-400);
|
||||
&__key,
|
||||
&__value {
|
||||
color: var(--text-ink-400);
|
||||
}
|
||||
&__key {
|
||||
background: var(--bg-vanilla-300);
|
||||
}
|
||||
&__value {
|
||||
background: var(--bg-vanilla-200);
|
||||
&__value {
|
||||
background: var(--l3-background);
|
||||
|
||||
&--clickable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,61 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Tooltip } from 'antd';
|
||||
|
||||
import TrimmedText from '../TrimmedText/TrimmedText';
|
||||
|
||||
import './KeyValueLabel.styles.scss';
|
||||
|
||||
// Rethink this component later
|
||||
type KeyValueLabelProps = {
|
||||
badgeKey: string | React.ReactNode;
|
||||
badgeValue: string;
|
||||
badgeValue: string | React.ReactNode;
|
||||
direction?: 'row' | 'column';
|
||||
maxCharacters?: number;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
export default function KeyValueLabel({
|
||||
badgeKey,
|
||||
badgeValue,
|
||||
direction = 'row',
|
||||
maxCharacters = 20,
|
||||
onClick,
|
||||
}: KeyValueLabelProps): JSX.Element | null {
|
||||
const isUrl = useMemo(() => /^https?:\/\//.test(badgeValue), [badgeValue]);
|
||||
|
||||
if (!badgeKey || !badgeValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderValue = (): JSX.Element => {
|
||||
if (typeof badgeValue !== 'string') {
|
||||
return <div className="key-value-label__value">{badgeValue}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip title={badgeValue}>
|
||||
<div
|
||||
className={`key-value-label__value ${
|
||||
onClick ? 'key-value-label__value--clickable' : ''
|
||||
}`}
|
||||
onClick={onClick}
|
||||
role={onClick ? 'button' : undefined}
|
||||
tabIndex={onClick ? 0 : undefined}
|
||||
onKeyDown={
|
||||
onClick
|
||||
? (e): void => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
onClick();
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<TrimmedText text={badgeValue} maxCharacters={maxCharacters} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="key-value-label">
|
||||
<div className={`key-value-label key-value-label--${direction}`}>
|
||||
<div className="key-value-label__key">
|
||||
{typeof badgeKey === 'string' ? (
|
||||
<TrimmedText text={badgeKey} maxCharacters={maxCharacters} />
|
||||
@@ -31,26 +63,13 @@ export default function KeyValueLabel({
|
||||
badgeKey
|
||||
)}
|
||||
</div>
|
||||
{isUrl ? (
|
||||
<a
|
||||
href={badgeValue}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="key-value-label__value"
|
||||
>
|
||||
<TrimmedText text={badgeValue} maxCharacters={maxCharacters} />
|
||||
</a>
|
||||
) : (
|
||||
<Tooltip title={badgeValue}>
|
||||
<div className="key-value-label__value">
|
||||
<TrimmedText text={badgeValue} maxCharacters={maxCharacters} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{renderValue()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
KeyValueLabel.defaultProps = {
|
||||
maxCharacters: 20,
|
||||
direction: 'row',
|
||||
onClick: undefined,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
.pretty-view {
|
||||
padding: 0;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&__search-input {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
margin-bottom: 8px;
|
||||
width: 100%;
|
||||
border: 1px solid var(--l2-border) !important;
|
||||
border-radius: 4px;
|
||||
font-family: 'SF Mono', 'Geist Mono', 'Fira Code', monospace !important;
|
||||
font-size: 12px !important;
|
||||
line-height: 18px !important;
|
||||
background: var(--l1-background) !important;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--primary) !important;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Font spec: SF Mono, 12px, 400, 18px line-height, -0.5% letter-spacing
|
||||
font-family: 'SF Mono', 'Geist Mono', 'Fira Code', monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.06px;
|
||||
|
||||
// Override react-json-tree inline styles
|
||||
ul {
|
||||
margin: 0 !important;
|
||||
padding-left: 16px !important;
|
||||
list-style: none !important;
|
||||
background-color: transparent !important;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
> ul,
|
||||
&__pinned > ul {
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
|
||||
// Force font on all tree elements
|
||||
li,
|
||||
span,
|
||||
label {
|
||||
font-family: inherit !important;
|
||||
font-size: inherit !important;
|
||||
line-height: inherit !important;
|
||||
letter-spacing: inherit !important;
|
||||
}
|
||||
|
||||
// Fix react-json-tree text-indent wrapping
|
||||
li {
|
||||
text-indent: 0 !important;
|
||||
margin-left: 0 !important;
|
||||
padding-top: 2px !important;
|
||||
padding-bottom: 2px !important;
|
||||
}
|
||||
|
||||
.pretty-view__actions {
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
// Leaf node row — hover highlights only this row
|
||||
&__row {
|
||||
border-radius: 2px;
|
||||
display: flex !important;
|
||||
align-items: baseline;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--l3-background);
|
||||
|
||||
.pretty-view__actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Push actions to the right edge
|
||||
> span {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Nested node (object/array) — hover only on the label line, not children
|
||||
&__nested-row {
|
||||
> label,
|
||||
> span:not(ul span) {
|
||||
border-radius: 2px;
|
||||
padding: 1px 2px;
|
||||
display: inline !important; // keep item string inline with label
|
||||
}
|
||||
|
||||
// Highlight label + item string on hover, show actions
|
||||
&:hover > label,
|
||||
&:hover > label + span {
|
||||
background-color: var(--l3-background);
|
||||
}
|
||||
|
||||
&:hover > label + span .pretty-view__actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
// In nested rows, value-row should not take full width
|
||||
.pretty-view__value-row {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
// Value + actions wrapper (from valueRenderer / getItemString)
|
||||
&__value-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
// In leaf rows, value-row takes full width to push ... to the right
|
||||
&__row &__value-row {
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// ... actions button — hidden by default, shown on row hover
|
||||
&__actions {
|
||||
opacity: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
color: var(--l2-foreground);
|
||||
transition: opacity 0.1s ease;
|
||||
padding: 0 2px;
|
||||
|
||||
&:hover {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
// Pinned items section
|
||||
&__pinned {
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
padding-bottom: 8px;
|
||||
margin-bottom: 8px;
|
||||
padding-left: 0px;
|
||||
|
||||
li {
|
||||
margin-left: 0 !important;
|
||||
padding-left: 0 !important;
|
||||
display: flex !important;
|
||||
align-items: baseline;
|
||||
}
|
||||
}
|
||||
|
||||
&__pinned-header {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--l3-foreground);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
&__pinned-label {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
&__pinned-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-robin-400);
|
||||
cursor: pointer;
|
||||
fill: var(--text-robin-400);
|
||||
transition: fill 0.1s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-robin-300);
|
||||
fill: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
300
frontend/src/periscope/components/PrettyView/PrettyView.tsx
Normal file
300
frontend/src/periscope/components/PrettyView/PrettyView.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { JSONTree, KeyPath } from 'react-json-tree';
|
||||
import { Copy, Ellipsis, Pin, PinOff } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import type { MenuProps } from 'antd';
|
||||
// TODO: Replace antd Dropdown with @signozhq/ui component when moving to design library
|
||||
import { Dropdown } from 'antd';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import { darkTheme, lightTheme, themeExtension } from './constants';
|
||||
import usePinnedFields from './hooks/usePinnedFields';
|
||||
import useSearchFilter, { filterTree } from './hooks/useSearchFilter';
|
||||
import {
|
||||
copyToClipboard,
|
||||
keyPathToDisplayString,
|
||||
keyPathToForward,
|
||||
serializeKeyPath,
|
||||
} from './utils';
|
||||
|
||||
import './PrettyView.styles.scss';
|
||||
|
||||
export interface FieldContext {
|
||||
fieldKey: string;
|
||||
fieldKeyPath: (string | number)[];
|
||||
fieldValue: unknown;
|
||||
isNested: boolean;
|
||||
}
|
||||
|
||||
export interface PrettyViewAction {
|
||||
key: string;
|
||||
label: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
onClick: (context: FieldContext) => void;
|
||||
}
|
||||
|
||||
export interface PrettyViewProps {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
data: Record<string, any>;
|
||||
actions?: PrettyViewAction[];
|
||||
searchable?: boolean;
|
||||
showPinned?: boolean;
|
||||
drawerKey?: string;
|
||||
}
|
||||
|
||||
function PrettyView({
|
||||
data,
|
||||
actions,
|
||||
searchable = true,
|
||||
showPinned = false,
|
||||
drawerKey = 'default',
|
||||
}: PrettyViewProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { searchQuery, setSearchQuery, filteredData } = useSearchFilter(data);
|
||||
const {
|
||||
isPinned,
|
||||
togglePin,
|
||||
pinnedEntries,
|
||||
pinnedData,
|
||||
displayKeyToForwardPath,
|
||||
} = usePinnedFields(data, drawerKey);
|
||||
|
||||
const filteredPinnedData = useMemo(() => {
|
||||
const trimmed = searchQuery.trim();
|
||||
if (!trimmed) {
|
||||
return pinnedData;
|
||||
}
|
||||
return filterTree(pinnedData, trimmed) || {};
|
||||
}, [pinnedData, searchQuery]);
|
||||
|
||||
const theme = useMemo(
|
||||
() => ({
|
||||
extend: isDarkMode ? darkTheme : lightTheme,
|
||||
...themeExtension,
|
||||
}),
|
||||
[isDarkMode],
|
||||
);
|
||||
|
||||
const shouldExpandNodeInitially = useCallback(
|
||||
(
|
||||
_keyPath: readonly (string | number)[],
|
||||
_data: unknown,
|
||||
level: number,
|
||||
): boolean => level < 5,
|
||||
[],
|
||||
);
|
||||
|
||||
const buildMenuItems = useCallback(
|
||||
(context: FieldContext): MenuProps['items'] => {
|
||||
// todo: drive dropdown through config.
|
||||
const copyItem = {
|
||||
key: 'copy',
|
||||
label: 'Copy',
|
||||
icon: <Copy size={12} />,
|
||||
onClick: (): void => {
|
||||
copyToClipboard(context.fieldValue);
|
||||
},
|
||||
};
|
||||
|
||||
const items: NonNullable<MenuProps['items']> = [copyItem];
|
||||
|
||||
// Pin action only for leaf nodes
|
||||
if (!context.isNested) {
|
||||
// Resolve the correct forward path — pinned tree uses display keys
|
||||
// which don't match the original serialized path
|
||||
const resolvedPath =
|
||||
displayKeyToForwardPath[context.fieldKey] || context.fieldKeyPath;
|
||||
const serialized = serializeKeyPath(resolvedPath);
|
||||
const pinned = isPinned(serialized);
|
||||
|
||||
items.push({
|
||||
key: 'pin',
|
||||
label: pinned ? 'Unpin field' : 'Pin field',
|
||||
icon: pinned ? <PinOff size={12} /> : <Pin size={12} />,
|
||||
onClick: (): void => {
|
||||
togglePin(resolvedPath);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (actions && actions.length > 0) {
|
||||
//todo: why this divider?
|
||||
items.push({ type: 'divider' as const, key: 'divider' });
|
||||
actions.forEach((action) => {
|
||||
items.push({
|
||||
key: action.key,
|
||||
label: action.label,
|
||||
icon: action.icon,
|
||||
onClick: (): void => {
|
||||
action.onClick(context);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
},
|
||||
[actions, isPinned, togglePin, displayKeyToForwardPath],
|
||||
);
|
||||
|
||||
const renderWithActions = useCallback(
|
||||
({
|
||||
content,
|
||||
fieldKey,
|
||||
fieldKeyPath,
|
||||
value,
|
||||
isNested,
|
||||
}: {
|
||||
content: React.ReactNode;
|
||||
fieldKey: string;
|
||||
fieldKeyPath: (string | number)[];
|
||||
value: unknown;
|
||||
isNested: boolean;
|
||||
}): React.ReactNode => {
|
||||
const context: FieldContext = {
|
||||
fieldKey,
|
||||
fieldKeyPath,
|
||||
fieldValue: value,
|
||||
isNested,
|
||||
};
|
||||
const menuItems = buildMenuItems(context);
|
||||
return (
|
||||
<span className="pretty-view__value-row">
|
||||
<span>{content}</span>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: menuItems,
|
||||
onClick: (e): void => {
|
||||
e.domEvent.stopPropagation();
|
||||
},
|
||||
}}
|
||||
trigger={['click']}
|
||||
placement="bottomLeft"
|
||||
getPopupContainer={(trigger): HTMLElement =>
|
||||
trigger.parentElement || document.body
|
||||
}
|
||||
>
|
||||
<span
|
||||
className="pretty-view__actions"
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<Ellipsis size={12} />
|
||||
</span>
|
||||
</Dropdown>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
[buildMenuItems],
|
||||
);
|
||||
|
||||
const getItemString = useCallback(
|
||||
(
|
||||
_nodeType: string,
|
||||
nodeData: unknown,
|
||||
itemType: React.ReactNode,
|
||||
itemString: string,
|
||||
keyPath: KeyPath,
|
||||
): // eslint-disable-next-line max-params
|
||||
React.ReactNode => {
|
||||
const forwardPath = keyPathToForward(keyPath);
|
||||
return renderWithActions({
|
||||
content: (
|
||||
<>
|
||||
{itemType} {itemString}
|
||||
</>
|
||||
),
|
||||
fieldKey: keyPathToDisplayString(keyPath),
|
||||
fieldKeyPath: forwardPath,
|
||||
value: nodeData,
|
||||
isNested: true,
|
||||
});
|
||||
},
|
||||
[renderWithActions],
|
||||
);
|
||||
|
||||
const valueRenderer = useCallback(
|
||||
(
|
||||
valueAsString: unknown,
|
||||
value: unknown,
|
||||
...keyPath: KeyPath
|
||||
): React.ReactNode => {
|
||||
const forwardPath = keyPathToForward(keyPath);
|
||||
return renderWithActions({
|
||||
content: String(valueAsString),
|
||||
fieldKey: keyPathToDisplayString(keyPath),
|
||||
fieldKeyPath: forwardPath,
|
||||
value,
|
||||
isNested: typeof value === 'object' && value !== null,
|
||||
});
|
||||
},
|
||||
[renderWithActions],
|
||||
);
|
||||
|
||||
const pinnedLabelRenderer = useCallback(
|
||||
(keyPath: KeyPath): React.ReactNode => {
|
||||
const displayKey = String(keyPath[0]);
|
||||
const entry = pinnedEntries.find((e) => e.displayKey === displayKey);
|
||||
return (
|
||||
<span className="pretty-view__pinned-label">
|
||||
<Pin
|
||||
size={12}
|
||||
className="pretty-view__pinned-icon"
|
||||
onClick={(): void => {
|
||||
if (entry) {
|
||||
togglePin(entry.forwardPath);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>{displayKey}</span>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
[togglePin, pinnedEntries],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="pretty-view">
|
||||
{searchable && (
|
||||
<Input
|
||||
className="pretty-view__search-input"
|
||||
type="text"
|
||||
placeholder="Search for a field..."
|
||||
value={searchQuery}
|
||||
onChange={(e): void => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showPinned && Object.keys(filteredPinnedData).length > 0 && (
|
||||
<div className="pretty-view__pinned">
|
||||
<div className="pretty-view__pinned-header">PINNED ITEMS</div>
|
||||
<JSONTree
|
||||
key={`pinned-${searchQuery}`}
|
||||
data={filteredPinnedData}
|
||||
theme={theme}
|
||||
invertTheme={false}
|
||||
hideRoot
|
||||
shouldExpandNodeInitially={shouldExpandNodeInitially}
|
||||
valueRenderer={valueRenderer}
|
||||
getItemString={getItemString}
|
||||
labelRenderer={pinnedLabelRenderer}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<JSONTree
|
||||
key={searchQuery}
|
||||
data={filteredData}
|
||||
theme={theme}
|
||||
invertTheme={false}
|
||||
hideRoot
|
||||
shouldExpandNodeInitially={shouldExpandNodeInitially}
|
||||
valueRenderer={valueRenderer}
|
||||
getItemString={getItemString}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PrettyView;
|
||||
62
frontend/src/periscope/components/PrettyView/constants.ts
Normal file
62
frontend/src/periscope/components/PrettyView/constants.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
// Dark theme — SigNoz design palette
|
||||
export const darkTheme = {
|
||||
scheme: 'signoz-dark',
|
||||
author: 'signoz',
|
||||
base00: 'transparent',
|
||||
base01: '#161922',
|
||||
base02: '#1d212d',
|
||||
base03: '#62687C',
|
||||
base04: '#ADB4C2',
|
||||
base05: '#ADB4C2',
|
||||
base06: '#ADB4C2',
|
||||
base07: '#ADB4C2',
|
||||
base08: '#EA6D71',
|
||||
base09: '#7CEDBD',
|
||||
base0A: '#7CEDBD',
|
||||
base0B: '#ADB4C2',
|
||||
base0C: '#23C4F8',
|
||||
base0D: '#95ACFB',
|
||||
base0E: '#95ACFB',
|
||||
base0F: '#AD7F58',
|
||||
};
|
||||
|
||||
// Light theme
|
||||
export const lightTheme = {
|
||||
scheme: 'signoz-light',
|
||||
author: 'signoz',
|
||||
base00: 'transparent',
|
||||
base01: '#F9F9FB',
|
||||
base02: '#EFF0F3',
|
||||
base03: '#80828D',
|
||||
base04: '#62636C',
|
||||
base05: '#62636C',
|
||||
base06: '#62636C',
|
||||
base07: '#1E1F24',
|
||||
base08: '#E5484D',
|
||||
base09: '#168757',
|
||||
base0A: '#168757',
|
||||
base0B: '#62636C',
|
||||
base0C: '#157594',
|
||||
base0D: '#2F48A0',
|
||||
base0E: '#2F48A0',
|
||||
base0F: '#684C35',
|
||||
};
|
||||
|
||||
export const themeExtension = {
|
||||
value: ({
|
||||
style,
|
||||
}: {
|
||||
style: Record<string, unknown>;
|
||||
}): { style: Record<string, unknown>; className: string } => ({
|
||||
style: { ...style },
|
||||
className: 'pretty-view__row',
|
||||
}),
|
||||
nestedNode: ({
|
||||
style,
|
||||
}: {
|
||||
style: Record<string, unknown>;
|
||||
}): { style: Record<string, unknown>; className: string } => ({
|
||||
style: { ...style },
|
||||
className: 'pretty-view__nested-row',
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
deserializeKeyPath,
|
||||
keyPathToDisplayString,
|
||||
resolveValueByKeys,
|
||||
serializeKeyPath,
|
||||
} from '../utils';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type AnyRecord = Record<string, any>;
|
||||
|
||||
const STORAGE_PREFIX = 'pinnedFields';
|
||||
|
||||
function loadFromStorage(storageKey: string): string[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveToStorage(storageKey: string, keys: string[]): void {
|
||||
localStorage.setItem(storageKey, JSON.stringify(keys));
|
||||
}
|
||||
|
||||
export interface PinnedEntry {
|
||||
serializedKey: string;
|
||||
displayKey: string;
|
||||
forwardPath: (string | number)[];
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
export interface UsePinnedFieldsReturn {
|
||||
isPinned: (serializedKey: string) => boolean;
|
||||
togglePin: (forwardPath: (string | number)[]) => void;
|
||||
pinnedEntries: PinnedEntry[];
|
||||
pinnedData: AnyRecord;
|
||||
displayKeyToForwardPath: Record<string, (string | number)[]>;
|
||||
}
|
||||
|
||||
function usePinnedFields(
|
||||
data: AnyRecord,
|
||||
drawerKey: string,
|
||||
): UsePinnedFieldsReturn {
|
||||
const storageKey = `${STORAGE_PREFIX}:${drawerKey}`;
|
||||
|
||||
// Stored as serialized keyPath arrays (JSON strings)
|
||||
const [pinnedSerializedKeys, setPinnedSerializedKeys] = useState<Set<string>>(
|
||||
() => new Set(loadFromStorage(storageKey)),
|
||||
);
|
||||
|
||||
const togglePin = useCallback(
|
||||
(forwardPath: (string | number)[]): void => {
|
||||
const serialized = serializeKeyPath(forwardPath);
|
||||
setPinnedSerializedKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(serialized)) {
|
||||
next.delete(serialized);
|
||||
} else {
|
||||
next.add(serialized);
|
||||
}
|
||||
saveToStorage(storageKey, Array.from(next));
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[storageKey],
|
||||
);
|
||||
|
||||
const isPinned = useCallback(
|
||||
(serializedKey: string): boolean => pinnedSerializedKeys.has(serializedKey),
|
||||
[pinnedSerializedKeys],
|
||||
);
|
||||
|
||||
const pinnedEntries = useMemo(
|
||||
(): PinnedEntry[] =>
|
||||
Array.from(pinnedSerializedKeys)
|
||||
.map((serializedKey) => {
|
||||
const forwardPath = deserializeKeyPath(serializedKey);
|
||||
if (!forwardPath) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
serializedKey,
|
||||
displayKey: keyPathToDisplayString(
|
||||
[...forwardPath].reverse() as readonly (string | number)[],
|
||||
),
|
||||
forwardPath,
|
||||
value: resolveValueByKeys(data, forwardPath),
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(entry): entry is PinnedEntry =>
|
||||
entry !== null && entry.value !== undefined,
|
||||
),
|
||||
[pinnedSerializedKeys, data],
|
||||
);
|
||||
|
||||
// Flat object for the pinned JSONTree — use display key as the object key
|
||||
const pinnedData = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
pinnedEntries.map((entry) => [entry.displayKey, entry.value]),
|
||||
),
|
||||
[pinnedEntries],
|
||||
);
|
||||
|
||||
// Map from display key to original forward path — for unpin from pinned tree
|
||||
const displayKeyToForwardPath = useMemo(
|
||||
(): Record<string, (string | number)[]> =>
|
||||
Object.fromEntries(
|
||||
pinnedEntries.map((entry) => [entry.displayKey, entry.forwardPath]),
|
||||
),
|
||||
[pinnedEntries],
|
||||
);
|
||||
|
||||
return {
|
||||
isPinned,
|
||||
togglePin,
|
||||
pinnedEntries,
|
||||
pinnedData,
|
||||
displayKeyToForwardPath,
|
||||
};
|
||||
}
|
||||
|
||||
export default usePinnedFields;
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type AnyRecord = Record<string, any>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type AnyValue = any;
|
||||
|
||||
function isLeaf(value: unknown): boolean {
|
||||
return value === null || value === undefined || typeof value !== 'object';
|
||||
}
|
||||
|
||||
function matchesQuery(text: string, lowerQuery: string): boolean {
|
||||
return text.toLowerCase().includes(lowerQuery);
|
||||
}
|
||||
|
||||
// Filter a single value (leaf, array, or object) against the query.
|
||||
// Returns the filtered value or null if no match.
|
||||
function filterValue(value: AnyValue, query: string): AnyValue | null {
|
||||
if (isLeaf(value)) {
|
||||
return matchesQuery(String(value), query.toLowerCase()) ? value : null;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return filterArray(value, query);
|
||||
}
|
||||
|
||||
return filterTree(value, query);
|
||||
}
|
||||
|
||||
// Recursively filter an array, keeping only elements that match
|
||||
function filterArray(arr: AnyValue[], query: string): AnyValue[] | null {
|
||||
const results = arr
|
||||
.map((item) => filterValue(item, query))
|
||||
.filter((item) => item !== null);
|
||||
|
||||
return results.length > 0 ? results : null;
|
||||
}
|
||||
|
||||
// Recursively filter the data tree, keeping only branches with matching keys or values
|
||||
export function filterTree(obj: AnyRecord, query: string): AnyRecord | null {
|
||||
const result: AnyRecord = {};
|
||||
const lowerQuery = query.toLowerCase();
|
||||
let hasMatch = false;
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (matchesQuery(key, lowerQuery)) {
|
||||
result[key] = value;
|
||||
hasMatch = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const filtered = filterValue(value, query);
|
||||
if (filtered !== null) {
|
||||
result[key] = filtered;
|
||||
hasMatch = true;
|
||||
}
|
||||
}
|
||||
|
||||
return hasMatch ? result : null;
|
||||
}
|
||||
|
||||
interface UseSearchFilterReturn {
|
||||
searchQuery: string;
|
||||
setSearchQuery: (query: string) => void;
|
||||
filteredData: AnyRecord;
|
||||
}
|
||||
|
||||
function useSearchFilter(data: AnyRecord): UseSearchFilterReturn {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
const trimmed = searchQuery.trim();
|
||||
if (!trimmed) {
|
||||
return data;
|
||||
}
|
||||
return filterTree(data, trimmed) || {};
|
||||
}, [data, searchQuery]);
|
||||
|
||||
return { searchQuery, setSearchQuery, filteredData };
|
||||
}
|
||||
|
||||
export default useSearchFilter;
|
||||
2
frontend/src/periscope/components/PrettyView/index.ts
Normal file
2
frontend/src/periscope/components/PrettyView/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { PrettyViewProps } from './PrettyView';
|
||||
export { default as PrettyView } from './PrettyView';
|
||||
58
frontend/src/periscope/components/PrettyView/utils.ts
Normal file
58
frontend/src/periscope/components/PrettyView/utils.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { toast } from '@signozhq/sonner';
|
||||
|
||||
export function copyToClipboard(value: unknown): void {
|
||||
const text =
|
||||
typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value);
|
||||
// eslint-disable-next-line no-restricted-properties
|
||||
navigator.clipboard.writeText(text);
|
||||
toast.success('Copied to clipboard', {
|
||||
richColors: true,
|
||||
position: 'top-right',
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve a value from a nested object using an array of keys (not dot-notation)
|
||||
// e.g. resolveValueByKeys({ tagMap: { 'cloud.account.id': 'x' } }, ['tagMap', 'cloud.account.id']) → 'x'
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function resolveValueByKeys(
|
||||
data: Record<string, any>,
|
||||
keys: (string | number)[],
|
||||
): unknown {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return keys.reduce((obj: any, key) => obj?.[key], data);
|
||||
}
|
||||
|
||||
// Convert react-json-tree's reversed keyPath to forward order
|
||||
// e.g. ['cloud.account.id', 'tagMap'] → ['tagMap', 'cloud.account.id']
|
||||
export function keyPathToForward(
|
||||
keyPath: readonly (string | number)[],
|
||||
): (string | number)[] {
|
||||
return [...keyPath].reverse();
|
||||
}
|
||||
|
||||
// Display-friendly string for a keyPath
|
||||
// e.g. ['tagMap', 'cloud.account.id'] → 'tagMap.cloud.account.id'
|
||||
export function keyPathToDisplayString(
|
||||
keyPath: readonly (string | number)[],
|
||||
): string {
|
||||
return [...keyPath].reverse().join('.');
|
||||
}
|
||||
|
||||
// Serialize keyPath for storage/comparison (JSON stringified array)
|
||||
export function serializeKeyPath(keyPath: (string | number)[]): string {
|
||||
return JSON.stringify(keyPath);
|
||||
}
|
||||
|
||||
export function deserializeKeyPath(
|
||||
serialized: string,
|
||||
): (string | number)[] | null {
|
||||
try {
|
||||
const parsed = JSON.parse(serialized);
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
.resizable-box {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&--disabled {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
&__content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__handle {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
background: var(--l2-border);
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
&--vertical {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4px;
|
||||
cursor: row-resize;
|
||||
}
|
||||
|
||||
&--horizontal {
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
import './ResizableBox.styles.scss';
|
||||
|
||||
export interface ResizableBoxProps {
|
||||
children: React.ReactNode;
|
||||
direction?: 'vertical' | 'horizontal';
|
||||
defaultHeight?: number;
|
||||
minHeight?: number;
|
||||
maxHeight?: number;
|
||||
defaultWidth?: number;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
onResize?: (size: number) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function ResizableBox({
|
||||
children,
|
||||
direction = 'vertical',
|
||||
defaultHeight = 200,
|
||||
minHeight = 50,
|
||||
maxHeight = Infinity,
|
||||
defaultWidth = 200,
|
||||
minWidth = 50,
|
||||
maxWidth = Infinity,
|
||||
onResize,
|
||||
disabled = false,
|
||||
className,
|
||||
}: ResizableBoxProps): JSX.Element {
|
||||
const isHorizontal = direction === 'horizontal';
|
||||
const [size, setSize] = useState(isHorizontal ? defaultWidth : defaultHeight);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent): void => {
|
||||
e.preventDefault();
|
||||
const startPos = isHorizontal ? e.clientX : e.clientY;
|
||||
const startSize = size;
|
||||
const min = isHorizontal ? minWidth : minHeight;
|
||||
const max = isHorizontal ? maxWidth : maxHeight;
|
||||
|
||||
const onMouseMove = (moveEvent: MouseEvent): void => {
|
||||
const currentPos = isHorizontal ? moveEvent.clientX : moveEvent.clientY;
|
||||
const delta = currentPos - startPos;
|
||||
const newSize = Math.min(max, Math.max(min, startSize + delta));
|
||||
setSize(newSize);
|
||||
onResize?.(newSize);
|
||||
};
|
||||
|
||||
const onMouseUp = (): void => {
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
|
||||
document.body.style.cursor = isHorizontal ? 'col-resize' : 'row-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
},
|
||||
[size, isHorizontal, minWidth, maxWidth, minHeight, maxHeight, onResize],
|
||||
);
|
||||
|
||||
const containerStyle = disabled
|
||||
? undefined
|
||||
: isHorizontal
|
||||
? { width: size }
|
||||
: { height: size };
|
||||
const handleClass = `resizable-box__handle resizable-box__handle--${direction}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`resizable-box ${disabled ? 'resizable-box--disabled' : ''} ${
|
||||
className || ''
|
||||
}`}
|
||||
style={containerStyle}
|
||||
>
|
||||
<div className="resizable-box__content">{children}</div>
|
||||
{!disabled && <div className={handleClass} onMouseDown={handleMouseDown} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ResizableBox;
|
||||
2
frontend/src/periscope/components/ResizableBox/index.ts
Normal file
2
frontend/src/periscope/components/ResizableBox/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { ResizableBoxProps } from './ResizableBox';
|
||||
export { default as ResizableBox } from './ResizableBox';
|
||||
@@ -6538,6 +6538,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.0.tgz#d774355e41f372d5350a4d0714abb48194a489c3"
|
||||
integrity sha512-t7dhREVv6dbNj0q17X12j7yDG4bD/DHYX7o5/DbDxobP0HnGPgpRz2Ej77aL7TZT3DSw13fqUTj8J4mMnqa7WA==
|
||||
|
||||
"@types/lodash@^4.17.0", "@types/lodash@^4.17.15":
|
||||
version "4.17.24"
|
||||
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.24.tgz#4ae334fc62c0e915ca8ed8e35dcc6d4eeb29215f"
|
||||
integrity sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==
|
||||
|
||||
"@types/mdast@^3.0.0":
|
||||
version "3.0.12"
|
||||
resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-3.0.12.tgz#beeb511b977c875a5b0cc92eab6fcac2f0895514"
|
||||
@@ -8952,7 +8957,7 @@ color-string@^1.9.0:
|
||||
color-name "^1.0.0"
|
||||
simple-swizzle "^0.2.2"
|
||||
|
||||
color@^4.2.1:
|
||||
color@^4.2.1, color@^4.2.3:
|
||||
version "4.2.3"
|
||||
resolved "https://registry.npmjs.org/color/-/color-4.2.3.tgz"
|
||||
integrity sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==
|
||||
@@ -9384,6 +9389,11 @@ csstype@^3.1.2:
|
||||
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81"
|
||||
integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==
|
||||
|
||||
csstype@^3.1.3:
|
||||
version "3.2.3"
|
||||
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a"
|
||||
integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==
|
||||
|
||||
"d3-array@1 - 3", "d3-array@2 - 3", "d3-array@2.10.0 - 3":
|
||||
version "3.2.3"
|
||||
resolved "https://registry.npmjs.org/d3-array/-/d3-array-3.2.3.tgz"
|
||||
@@ -16819,6 +16829,11 @@ rc-virtual-list@^3.11.1, rc-virtual-list@^3.5.1, rc-virtual-list@^3.5.2:
|
||||
rc-resize-observer "^1.0.0"
|
||||
rc-util "^5.36.0"
|
||||
|
||||
re-resizable@^6.11.2:
|
||||
version "6.11.2"
|
||||
resolved "https://registry.yarnpkg.com/re-resizable/-/re-resizable-6.11.2.tgz#2e8f7119ca3881d5b5aea0ffa014a80e5c1252b3"
|
||||
integrity sha512-2xI2P3OHs5qw7K0Ud1aLILK6MQxW50TcO+DetD9eIV58j84TqYeHoZcL9H4GXFXXIh7afhH8mv5iUCXII7OW7A==
|
||||
|
||||
react-addons-update@15.6.3:
|
||||
version "15.6.3"
|
||||
resolved "https://registry.yarnpkg.com/react-addons-update/-/react-addons-update-15.6.3.tgz#c449c309154024d04087b206d0400e020547b313"
|
||||
@@ -16826,6 +16841,16 @@ react-addons-update@15.6.3:
|
||||
dependencies:
|
||||
object-assign "^4.1.0"
|
||||
|
||||
react-base16-styling@^0.10.0:
|
||||
version "0.10.0"
|
||||
resolved "https://registry.yarnpkg.com/react-base16-styling/-/react-base16-styling-0.10.0.tgz#5d5f019bd4dc5870c3e92fd9d5410533a0bbb0c6"
|
||||
integrity sha512-H1k2eFB6M45OaiRru3PBXkuCcn2qNmx+gzLb4a9IPMR7tMH8oBRXU5jGbPDYG1Hz+82d88ED0vjR8BmqU3pQdg==
|
||||
dependencies:
|
||||
"@types/lodash" "^4.17.0"
|
||||
color "^4.2.3"
|
||||
csstype "^3.1.3"
|
||||
lodash-es "^4.17.21"
|
||||
|
||||
react-beautiful-dnd@13.1.1:
|
||||
version "13.1.1"
|
||||
resolved "https://registry.yarnpkg.com/react-beautiful-dnd/-/react-beautiful-dnd-13.1.1.tgz#b0f3087a5840920abf8bb2325f1ffa46d8c4d0a2"
|
||||
@@ -16890,6 +16915,14 @@ react-draggable@^4.0.0, react-draggable@^4.0.3:
|
||||
clsx "^1.1.1"
|
||||
prop-types "^15.8.1"
|
||||
|
||||
react-draggable@^4.5.0:
|
||||
version "4.5.0"
|
||||
resolved "https://registry.yarnpkg.com/react-draggable/-/react-draggable-4.5.0.tgz#0b274ccb6965fcf97ed38fcf7e3cc223bc48cdf5"
|
||||
integrity sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==
|
||||
dependencies:
|
||||
clsx "^2.1.1"
|
||||
prop-types "^15.8.1"
|
||||
|
||||
react-error-boundary@4.0.11:
|
||||
version "4.0.11"
|
||||
resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-4.0.11.tgz#36bf44de7746714725a814630282fee83a7c9a1c"
|
||||
@@ -16980,6 +17013,14 @@ react-is@^18.3.1:
|
||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e"
|
||||
integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==
|
||||
|
||||
react-json-tree@0.20.0:
|
||||
version "0.20.0"
|
||||
resolved "https://registry.yarnpkg.com/react-json-tree/-/react-json-tree-0.20.0.tgz#1e7fd26f093bd49d733f80dd9b6d40142e09f7c9"
|
||||
integrity sha512-h+f9fUNAxzBx1rbrgUF7+zSWKGHDtt2VPYLErIuB0JyKGnWgFMM21ksqQyb3EXwXNnoMW2rdE5kuAaubgGOx2Q==
|
||||
dependencies:
|
||||
"@types/lodash" "^4.17.15"
|
||||
react-base16-styling "^0.10.0"
|
||||
|
||||
react-kapsule@^2.5:
|
||||
version "2.5.7"
|
||||
resolved "https://registry.yarnpkg.com/react-kapsule/-/react-kapsule-2.5.7.tgz#dcd957ae8e897ff48055fc8ff48ed04ebe3c5bd2"
|
||||
@@ -17099,6 +17140,15 @@ react-resizable@^3.0.4:
|
||||
prop-types "15.x"
|
||||
react-draggable "^4.0.3"
|
||||
|
||||
react-rnd@10.5.3:
|
||||
version "10.5.3"
|
||||
resolved "https://registry.yarnpkg.com/react-rnd/-/react-rnd-10.5.3.tgz#f8c484034276a561e8aa2fbf6a94580596621013"
|
||||
integrity sha512-s/sIT3pGZnQ+57egijkTp9mizjIWrJz68Pq6yd+F/wniFY3IriML18dUXnQe/HP9uMiJ+9MAp44hljG99fZu6Q==
|
||||
dependencies:
|
||||
re-resizable "^6.11.2"
|
||||
react-draggable "^4.5.0"
|
||||
tslib "2.6.2"
|
||||
|
||||
react-router-dom-v5-compat@6.27.0:
|
||||
version "6.27.0"
|
||||
resolved "https://registry.yarnpkg.com/react-router-dom-v5-compat/-/react-router-dom-v5-compat-6.27.0.tgz#19429aefa130ee151e6f4487d073d919ec22d458"
|
||||
@@ -19184,6 +19234,11 @@ tsconfig-paths@^4.2.0:
|
||||
minimist "^1.2.6"
|
||||
strip-bom "^3.0.0"
|
||||
|
||||
tslib@2.6.2, tslib@^2.0.0:
|
||||
version "2.6.2"
|
||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae"
|
||||
integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==
|
||||
|
||||
tslib@2.7.0:
|
||||
version "2.7.0"
|
||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.7.0.tgz#d9b40c5c40ab59e8738f297df3087bf1a2690c01"
|
||||
@@ -19194,11 +19249,6 @@ tslib@^1.14.1, tslib@^1.8.1:
|
||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
|
||||
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
|
||||
|
||||
tslib@^2.0.0:
|
||||
version "2.6.2"
|
||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae"
|
||||
integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==
|
||||
|
||||
tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.0:
|
||||
version "2.5.0"
|
||||
resolved "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz"
|
||||
|
||||
@@ -41,7 +41,7 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||
RequestContentType: "application/json",
|
||||
Response: new(citypes.GettableAccountWithConnectionArtifact),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
@@ -138,7 +138,6 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||
Summary: "List services metadata",
|
||||
Description: "This endpoint lists the services metadata for the specified cloud provider",
|
||||
Request: nil,
|
||||
RequestQuery: new(citypes.ListServicesMetadataParams),
|
||||
RequestContentType: "",
|
||||
Response: new(citypes.GettableServicesMetadata),
|
||||
ResponseContentType: "application/json",
|
||||
@@ -159,7 +158,6 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||
Summary: "Get service",
|
||||
Description: "This endpoint gets a service for the specified cloud provider",
|
||||
Request: nil,
|
||||
RequestQuery: new(citypes.GetServiceParams),
|
||||
RequestContentType: "",
|
||||
Response: new(citypes.Service),
|
||||
ResponseContentType: "application/json",
|
||||
|
||||
@@ -419,7 +419,6 @@ func readAsRaw(rows driver.Rows, queryName string) (*qbtypes.RawData, error) {
|
||||
|
||||
rr.Data[name] = val
|
||||
}
|
||||
mergeSpanAttributeColumns(rr.Data)
|
||||
outRows = append(outRows, &rr)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
@@ -432,48 +431,6 @@ func readAsRaw(rows driver.Rows, queryName string) (*qbtypes.RawData, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// mergeSpanAttributeColumns merges the typed ClickHouse span attribute columns
|
||||
// (attributes_string, attributes_number, attributes_bool, resources_string) into
|
||||
// unified "attributes" and "resource_attributes" keys, removing the raw columns.
|
||||
// It is a no-op if none of the raw columns are present.
|
||||
func mergeSpanAttributeColumns(data map[string]any) {
|
||||
attrStr, hasStr := data["attributes_string"]
|
||||
attrNum, hasNum := data["attributes_number"]
|
||||
attrBool, hasBool := data["attributes_bool"]
|
||||
// todo(nitya): move to resource json
|
||||
resStr, hasRes := data["resources_string"]
|
||||
|
||||
if !hasStr && !hasNum && !hasBool && !hasRes {
|
||||
return
|
||||
}
|
||||
|
||||
attributes := make(map[string]any)
|
||||
if m, ok := attrStr.(map[string]string); ok {
|
||||
for k, v := range m {
|
||||
attributes[k] = v
|
||||
}
|
||||
}
|
||||
if m, ok := attrNum.(map[string]float64); ok {
|
||||
for k, v := range m {
|
||||
attributes[k] = v
|
||||
}
|
||||
}
|
||||
if m, ok := attrBool.(map[string]bool); ok {
|
||||
for k, v := range m {
|
||||
attributes[k] = v
|
||||
}
|
||||
}
|
||||
delete(data, "attributes_string")
|
||||
delete(data, "attributes_number")
|
||||
delete(data, "attributes_bool")
|
||||
data["attributes"] = attributes
|
||||
|
||||
if m, ok := resStr.(map[string]string); ok {
|
||||
data["resource"] = m
|
||||
}
|
||||
delete(data, "resources_string")
|
||||
}
|
||||
|
||||
// numericAsFloat converts numeric types to float64 efficiently.
|
||||
func numericAsFloat(v any) float64 {
|
||||
switch x := v.(type) {
|
||||
|
||||
@@ -1,50 +1,6 @@
|
||||
package telemetrytraces
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
// Internal Columns
|
||||
SpanTimestampBucketStartColumn = "ts_bucket_start"
|
||||
SpanResourceFingerPrintColumn = "resource_fingerprint"
|
||||
|
||||
// Intrinsic Columns
|
||||
SpanTimestampColumn = "timestamp"
|
||||
SpanTraceIDColumn = "trace_id"
|
||||
SpanSpanIDColumn = "span_id"
|
||||
SpanTraceStateColumn = "trace_state"
|
||||
SpanParentSpanIDColumn = "parent_span_id"
|
||||
SpanFlagsColumn = "flags"
|
||||
SpanNameColumn = "name"
|
||||
SpanKindColumn = "kind"
|
||||
SpanKindStringColumn = "kind_string"
|
||||
SpanDurationNanoColumn = "duration_nano"
|
||||
SpanStatusCodeColumn = "status_code"
|
||||
SpanStatusMessageColumn = "status_message"
|
||||
SpanStatusCodeStringColumn = "status_code_string"
|
||||
SpanEventsColumn = "events"
|
||||
SpanLinksColumn = "links"
|
||||
|
||||
// Calculated Columns
|
||||
SpanResponseStatusCodeColumn = "response_status_code"
|
||||
SpanExternalHTTPURLColumn = "external_http_url"
|
||||
SpanHTTPURLColumn = "http_url"
|
||||
SpanExternalHTTPMethodColumn = "external_http_method"
|
||||
SpanHTTPMethodColumn = "http_method"
|
||||
SpanHTTPHostColumn = "http_host"
|
||||
SpanDBNameColumn = "db_name"
|
||||
SpanDBOperationColumn = "db_operation"
|
||||
SpanHasErrorColumn = "has_error"
|
||||
SpanIsRemoteColumn = "is_remote"
|
||||
|
||||
// Contextual Columns
|
||||
SpanAttributesStringColumn = "attributes_string"
|
||||
SpanAttributesNumberColumn = "attributes_number"
|
||||
SpanAttributesBoolColumn = "attributes_bool"
|
||||
SpanResourcesStringColumn = "resources_string"
|
||||
)
|
||||
import "github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
|
||||
var (
|
||||
IntrinsicFields = map[string]telemetrytypes.TelemetryFieldKey{
|
||||
|
||||
@@ -78,16 +78,6 @@ func TestGetFieldKeyName(t *testing.T) {
|
||||
expectedResult: "multiIf(resource.`deployment.environment` IS NOT NULL, resource.`deployment.environment`::String, `resource_string_deployment$$environment_exists`==true, `resource_string_deployment$$environment`, NULL)",
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "Contextual map column - attributes_string without span context does not short-circuit",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: SpanAttributesStringColumn,
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
expectedResult: "attributes_string['attributes_string']",
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "Non-existent column",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
"golang.org/x/exp/maps"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -84,12 +86,40 @@ func (b *traceQueryStatementBuilder) Build(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
isSelectFieldsEmpty := false
|
||||
/*
|
||||
Adding a tech debt note here:
|
||||
This piece of code is a hot fix and should be removed once we close issue: engineering-pod/issues/3622
|
||||
*/
|
||||
/*
|
||||
-------------------------------- Start of tech debt ----------------------------
|
||||
*/
|
||||
if requestType == qbtypes.RequestTypeRaw {
|
||||
// we are expanding here to ensure that all the conflicts are taken care in adjustKeys
|
||||
// i.e if there is a conflict we strip away context of the key in adjustKeys
|
||||
query, isSelectFieldsEmpty = b.expandRawSelectFields(query)
|
||||
|
||||
selectedFields := query.SelectFields
|
||||
|
||||
if len(selectedFields) == 0 {
|
||||
sortedKeys := maps.Keys(DefaultFields)
|
||||
slices.Sort(sortedKeys)
|
||||
for _, key := range sortedKeys {
|
||||
selectedFields = append(selectedFields, DefaultFields[key])
|
||||
}
|
||||
query.SelectFields = selectedFields
|
||||
}
|
||||
|
||||
selectFieldKeys := []string{}
|
||||
for _, field := range selectedFields {
|
||||
selectFieldKeys = append(selectFieldKeys, field.Name)
|
||||
}
|
||||
|
||||
for _, x := range []string{"timestamp", "span_id", "trace_id"} {
|
||||
if !slices.Contains(selectFieldKeys, x) {
|
||||
query.SelectFields = append(query.SelectFields, DefaultFields[x])
|
||||
}
|
||||
}
|
||||
}
|
||||
/*
|
||||
-------------------------------- End of tech debt ----------------------------
|
||||
*/
|
||||
|
||||
query = b.adjustKeys(ctx, keys, query, requestType)
|
||||
|
||||
@@ -98,7 +128,7 @@ func (b *traceQueryStatementBuilder) Build(
|
||||
|
||||
switch requestType {
|
||||
case qbtypes.RequestTypeRaw:
|
||||
return b.buildListQuery(ctx, q, query, start, end, keys, variables, isSelectFieldsEmpty)
|
||||
return b.buildListQuery(ctx, q, query, start, end, keys, variables)
|
||||
case qbtypes.RequestTypeTimeSeries:
|
||||
return b.buildTimeSeriesQuery(ctx, q, query, start, end, keys, variables)
|
||||
case qbtypes.RequestTypeScalar:
|
||||
@@ -262,7 +292,6 @@ func (b *traceQueryStatementBuilder) buildListQuery(
|
||||
start, end uint64,
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
isSelectFieldsEmpty bool,
|
||||
) (*qbtypes.Statement, error) {
|
||||
|
||||
var (
|
||||
@@ -277,6 +306,7 @@ func (b *traceQueryStatementBuilder) buildListQuery(
|
||||
cteArgs = append(cteArgs, args)
|
||||
}
|
||||
|
||||
// TODO: should we deprecate `SelectFields` and return everything from a span like we do for logs?
|
||||
for _, field := range query.SelectFields {
|
||||
colExpr, err := b.fm.ColumnExpressionFor(ctx, start, end, &field, keys)
|
||||
if err != nil {
|
||||
@@ -285,13 +315,6 @@ func (b *traceQueryStatementBuilder) buildListQuery(
|
||||
sb.SelectMore(colExpr)
|
||||
}
|
||||
|
||||
if isSelectFieldsEmpty {
|
||||
sb.SelectMore(SpanAttributesStringColumn)
|
||||
sb.SelectMore(SpanAttributesNumberColumn)
|
||||
sb.SelectMore(SpanAttributesBoolColumn)
|
||||
sb.SelectMore(SpanResourcesStringColumn)
|
||||
}
|
||||
|
||||
// From table
|
||||
sb.From(fmt.Sprintf("%s.%s", DBName, SpanIndexV3TableName))
|
||||
|
||||
@@ -815,52 +838,3 @@ func (b *traceQueryStatementBuilder) buildResourceFilterCTE(
|
||||
variables,
|
||||
)
|
||||
}
|
||||
|
||||
// expandRawSelectFields populates SelectFields for raw (list view) queries.
|
||||
// It must be called before adjustKeys so that normalization runs over the full set.
|
||||
// Returns the updated query and whether the original SelectFields was empty (i.e. full expansion was performed).
|
||||
func (b *traceQueryStatementBuilder) expandRawSelectFields(query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]) (qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation], bool) {
|
||||
wasEmpty := len(query.SelectFields) == 0
|
||||
selectFields := []telemetrytypes.TelemetryFieldKey{
|
||||
{Name: SpanTimestampColumn, FieldContext: telemetrytypes.FieldContextSpan},
|
||||
{Name: SpanTraceIDColumn, FieldContext: telemetrytypes.FieldContextSpan},
|
||||
{Name: SpanSpanIDColumn, FieldContext: telemetrytypes.FieldContextSpan},
|
||||
}
|
||||
if wasEmpty {
|
||||
// Select all intrinsic columns
|
||||
selectFields = append(selectFields, telemetrytypes.TelemetryFieldKey{Name: SpanTraceStateColumn, FieldContext: telemetrytypes.FieldContextSpan})
|
||||
selectFields = append(selectFields, telemetrytypes.TelemetryFieldKey{Name: SpanParentSpanIDColumn, FieldContext: telemetrytypes.FieldContextSpan})
|
||||
selectFields = append(selectFields, telemetrytypes.TelemetryFieldKey{Name: SpanFlagsColumn, FieldContext: telemetrytypes.FieldContextSpan})
|
||||
selectFields = append(selectFields, telemetrytypes.TelemetryFieldKey{Name: SpanNameColumn, FieldContext: telemetrytypes.FieldContextSpan})
|
||||
selectFields = append(selectFields, telemetrytypes.TelemetryFieldKey{Name: SpanKindColumn, FieldContext: telemetrytypes.FieldContextSpan})
|
||||
selectFields = append(selectFields, telemetrytypes.TelemetryFieldKey{Name: SpanKindStringColumn, FieldContext: telemetrytypes.FieldContextSpan})
|
||||
selectFields = append(selectFields, telemetrytypes.TelemetryFieldKey{Name: SpanDurationNanoColumn, FieldContext: telemetrytypes.FieldContextSpan})
|
||||
selectFields = append(selectFields, telemetrytypes.TelemetryFieldKey{Name: SpanStatusCodeColumn, FieldContext: telemetrytypes.FieldContextSpan})
|
||||
selectFields = append(selectFields, telemetrytypes.TelemetryFieldKey{Name: SpanStatusMessageColumn, FieldContext: telemetrytypes.FieldContextSpan})
|
||||
selectFields = append(selectFields, telemetrytypes.TelemetryFieldKey{Name: SpanStatusCodeStringColumn, FieldContext: telemetrytypes.FieldContextSpan})
|
||||
selectFields = append(selectFields, telemetrytypes.TelemetryFieldKey{Name: SpanEventsColumn, FieldContext: telemetrytypes.FieldContextSpan})
|
||||
selectFields = append(selectFields, telemetrytypes.TelemetryFieldKey{Name: SpanLinksColumn, FieldContext: telemetrytypes.FieldContextSpan})
|
||||
|
||||
// select all calculated columns
|
||||
selectFields = append(selectFields, telemetrytypes.TelemetryFieldKey{Name: SpanResponseStatusCodeColumn, FieldContext: telemetrytypes.FieldContextSpan})
|
||||
selectFields = append(selectFields, telemetrytypes.TelemetryFieldKey{Name: SpanExternalHTTPURLColumn, FieldContext: telemetrytypes.FieldContextSpan})
|
||||
selectFields = append(selectFields, telemetrytypes.TelemetryFieldKey{Name: SpanHTTPURLColumn, FieldContext: telemetrytypes.FieldContextSpan})
|
||||
selectFields = append(selectFields, telemetrytypes.TelemetryFieldKey{Name: SpanExternalHTTPMethodColumn, FieldContext: telemetrytypes.FieldContextSpan})
|
||||
selectFields = append(selectFields, telemetrytypes.TelemetryFieldKey{Name: SpanHTTPMethodColumn, FieldContext: telemetrytypes.FieldContextSpan})
|
||||
selectFields = append(selectFields, telemetrytypes.TelemetryFieldKey{Name: SpanHTTPHostColumn, FieldContext: telemetrytypes.FieldContextSpan})
|
||||
selectFields = append(selectFields, telemetrytypes.TelemetryFieldKey{Name: SpanDBNameColumn, FieldContext: telemetrytypes.FieldContextSpan})
|
||||
selectFields = append(selectFields, telemetrytypes.TelemetryFieldKey{Name: SpanDBOperationColumn, FieldContext: telemetrytypes.FieldContextSpan})
|
||||
selectFields = append(selectFields, telemetrytypes.TelemetryFieldKey{Name: SpanHasErrorColumn, FieldContext: telemetrytypes.FieldContextSpan})
|
||||
selectFields = append(selectFields, telemetrytypes.TelemetryFieldKey{Name: SpanIsRemoteColumn, FieldContext: telemetrytypes.FieldContextSpan})
|
||||
} else {
|
||||
for _, field := range query.SelectFields {
|
||||
// TODO(tvats): If a user specifies attribute.timestamp in the select fields, this loop will basically ignore it, as we already added a field by default. This can be fixed once we close https://github.com/SigNoz/engineering-pod/issues/3693
|
||||
if field.Name == SpanTimestampColumn || field.Name == SpanTraceIDColumn || field.Name == SpanSpanIDColumn {
|
||||
continue
|
||||
}
|
||||
selectFields = append(selectFields, field)
|
||||
}
|
||||
}
|
||||
query.SelectFields = selectFields
|
||||
return query, wasEmpty
|
||||
}
|
||||
|
||||
@@ -436,7 +436,7 @@ func TestStatementBuilderListQuery(t *testing.T) {
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp AS `timestamp`, trace_id AS `trace_id`, span_id AS `span_id`, name AS `name`, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) AS `service.name`, duration_nano AS `duration_nano`, `attribute_number_cart$$items_count` AS `cart.items_count` FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT name AS `name`, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) AS `service.name`, duration_nano AS `duration_nano`, `attribute_number_cart$$items_count` AS `cart.items_count`, timestamp AS `timestamp`, span_id AS `span_id`, trace_id AS `trace_id` FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
},
|
||||
expectedErr: nil,
|
||||
@@ -465,7 +465,7 @@ func TestStatementBuilderListQuery(t *testing.T) {
|
||||
Limit: 10,
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp AS `timestamp`, trace_id AS `trace_id`, span_id AS `span_id`, trace_state AS `trace_state`, parent_span_id AS `parent_span_id`, flags AS `flags`, name AS `name`, kind AS `kind`, kind_string AS `kind_string`, duration_nano AS `duration_nano`, status_code AS `status_code`, status_message AS `status_message`, status_code_string AS `status_code_string`, events AS `events`, links AS `links`, response_status_code AS `response_status_code`, external_http_url AS `external_http_url`, http_url AS `http_url`, external_http_method AS `external_http_method`, http_method AS `http_method`, http_host AS `http_host`, db_name AS `db_name`, db_operation AS `db_operation`, has_error AS `has_error`, is_remote AS `is_remote`, attributes_string, attributes_number, attributes_bool, resources_string FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? ORDER BY attributes_string['user.id'] AS `user.id` desc LIMIT ?",
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT duration_nano AS `duration_nano`, name AS `name`, response_status_code AS `response_status_code`, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) AS `service.name`, span_id AS `span_id`, timestamp AS `timestamp`, trace_id AS `trace_id` FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? ORDER BY attributes_string['user.id'] AS `user.id` desc LIMIT ?",
|
||||
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
},
|
||||
expectedErr: nil,
|
||||
@@ -509,7 +509,7 @@ func TestStatementBuilderListQuery(t *testing.T) {
|
||||
Limit: 10,
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp AS `timestamp`, trace_id AS `trace_id`, span_id AS `span_id`, name AS `name`, resource_string_service$$name AS `serviceName`, duration_nano AS `durationNano`, http_method AS `httpMethod`, response_status_code AS `responseStatusCode` FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT name AS `name`, resource_string_service$$name AS `serviceName`, duration_nano AS `durationNano`, http_method AS `httpMethod`, response_status_code AS `responseStatusCode`, timestamp AS `timestamp`, span_id AS `span_id`, trace_id AS `trace_id` FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
},
|
||||
expectedErr: nil,
|
||||
@@ -553,7 +553,7 @@ func TestStatementBuilderListQuery(t *testing.T) {
|
||||
Limit: 10,
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp AS `timestamp`, trace_id AS `trace_id`, span_id AS `span_id`, name AS `name`, resource_string_service$$name AS `serviceName`, duration_nano AS `durationNano`, http_method AS `httpMethod`, multiIf(toString(`attribute_string_mixed$$materialization$$key`) != '', toString(`attribute_string_mixed$$materialization$$key`), toString(multiIf(resource.`mixed.materialization.key` IS NOT NULL, resource.`mixed.materialization.key`::String, mapContains(resources_string, 'mixed.materialization.key'), resources_string['mixed.materialization.key'], NULL)) != '', toString(multiIf(resource.`mixed.materialization.key` IS NOT NULL, resource.`mixed.materialization.key`::String, mapContains(resources_string, 'mixed.materialization.key'), resources_string['mixed.materialization.key'], NULL)), NULL) AS `mixed.materialization.key` FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT name AS `name`, resource_string_service$$name AS `serviceName`, duration_nano AS `durationNano`, http_method AS `httpMethod`, multiIf(toString(`attribute_string_mixed$$materialization$$key`) != '', toString(`attribute_string_mixed$$materialization$$key`), toString(multiIf(resource.`mixed.materialization.key` IS NOT NULL, resource.`mixed.materialization.key`::String, mapContains(resources_string, 'mixed.materialization.key'), resources_string['mixed.materialization.key'], NULL)) != '', toString(multiIf(resource.`mixed.materialization.key` IS NOT NULL, resource.`mixed.materialization.key`::String, mapContains(resources_string, 'mixed.materialization.key'), resources_string['mixed.materialization.key'], NULL)), NULL) AS `mixed.materialization.key`, timestamp AS `timestamp`, span_id AS `span_id`, trace_id AS `trace_id` FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
},
|
||||
expectedErr: nil,
|
||||
@@ -598,7 +598,7 @@ func TestStatementBuilderListQuery(t *testing.T) {
|
||||
Limit: 10,
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp AS `timestamp`, trace_id AS `trace_id`, span_id AS `span_id`, name AS `name`, resource_string_service$$name AS `serviceName`, duration_nano AS `durationNano`, http_method AS `httpMethod`, `attribute_string_mixed$$materialization$$key` AS `mixed.materialization.key` FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT name AS `name`, resource_string_service$$name AS `serviceName`, duration_nano AS `durationNano`, http_method AS `httpMethod`, `attribute_string_mixed$$materialization$$key` AS `mixed.materialization.key`, timestamp AS `timestamp`, span_id AS `span_id`, trace_id AS `trace_id` FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
},
|
||||
expectedErr: nil,
|
||||
@@ -706,7 +706,7 @@ func TestStatementBuilderListQueryWithCorruptData(t *testing.T) {
|
||||
Limit: 10,
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp AS `timestamp`, trace_id AS `trace_id`, span_id AS `span_id`, trace_state AS `trace_state`, parent_span_id AS `parent_span_id`, flags AS `flags`, name AS `name`, kind AS `kind`, kind_string AS `kind_string`, duration_nano AS `duration_nano`, status_code AS `status_code`, status_message AS `status_message`, status_code_string AS `status_code_string`, events AS `events`, links AS `links`, response_status_code AS `response_status_code`, external_http_url AS `external_http_url`, http_url AS `http_url`, external_http_method AS `external_http_method`, http_method AS `http_method`, http_host AS `http_host`, db_name AS `db_name`, db_operation AS `db_operation`, has_error AS `has_error`, is_remote AS `is_remote`, attributes_string, attributes_number, attributes_bool, resources_string FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT duration_nano AS `duration_nano`, name AS `name`, response_status_code AS `response_status_code`, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) AS `service.name`, span_id AS `span_id`, timestamp AS `timestamp`, trace_id AS `trace_id` FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
},
|
||||
expectedErr: nil,
|
||||
@@ -739,7 +739,7 @@ func TestStatementBuilderListQueryWithCorruptData(t *testing.T) {
|
||||
}},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp AS `timestamp`, trace_id AS `trace_id`, span_id AS `span_id`, trace_state AS `trace_state`, parent_span_id AS `parent_span_id`, flags AS `flags`, name AS `name`, kind AS `kind`, kind_string AS `kind_string`, duration_nano AS `duration_nano`, status_code AS `status_code`, status_message AS `status_message`, status_code_string AS `status_code_string`, events AS `events`, links AS `links`, response_status_code AS `response_status_code`, external_http_url AS `external_http_url`, http_url AS `http_url`, external_http_method AS `external_http_method`, http_method AS `http_method`, http_host AS `http_host`, db_name AS `db_name`, db_operation AS `db_operation`, has_error AS `has_error`, is_remote AS `is_remote`, attributes_string, attributes_number, attributes_bool, resources_string FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? ORDER BY timestamp AS `timestamp` asc LIMIT ?",
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT duration_nano AS `duration_nano`, name AS `name`, response_status_code AS `response_status_code`, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) AS `service.name`, span_id AS `span_id`, timestamp AS `timestamp`, trace_id AS `trace_id` FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? ORDER BY timestamp AS `timestamp` asc LIMIT ?",
|
||||
Args: []any{uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
},
|
||||
expectedErr: nil,
|
||||
|
||||
@@ -62,10 +62,6 @@ type GettableServicesMetadata struct {
|
||||
Services []*ServiceMetadata `json:"services" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
type ListServicesMetadataParams struct {
|
||||
CloudIntegrationID valuer.UUID `query:"cloud_integration_id" required:"false"`
|
||||
}
|
||||
|
||||
// Service represents a cloud integration service with its definition,
|
||||
// cloud integration service is non nil only when the service entry exists in DB with ANY config (enabled or disabled).
|
||||
type Service struct {
|
||||
@@ -73,10 +69,6 @@ type Service struct {
|
||||
CloudIntegrationService *CloudIntegrationService `json:"cloudIntegrationService" required:"true" nullable:"true"`
|
||||
}
|
||||
|
||||
type GetServiceParams struct {
|
||||
CloudIntegrationID valuer.UUID `query:"cloud_integration_id" required:"false"`
|
||||
}
|
||||
|
||||
type UpdatableService struct {
|
||||
Config *ServiceConfig `json:"config" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
@@ -490,24 +490,25 @@ def test_traces_list(
|
||||
"name": "A",
|
||||
"signal": "traces",
|
||||
"disabled": False,
|
||||
"selectFields": [
|
||||
{"name": "span_id"},
|
||||
{"name": "span.timestamp"},
|
||||
{"name": "trace_id"},
|
||||
],
|
||||
"order": [{"key": {"name": "timestamp"}, "direction": "desc"}],
|
||||
"limit": 1,
|
||||
},
|
||||
},
|
||||
HTTPStatus.OK,
|
||||
lambda x: [
|
||||
x[3].duration_nano,
|
||||
x[3].name,
|
||||
x[3].response_status_code,
|
||||
x[3].service_name,
|
||||
x[3].span_id,
|
||||
format_timestamp(x[3].timestamp),
|
||||
x[3].trace_id,
|
||||
], # type: Callable[[List[Traces]], List[Any]]
|
||||
),
|
||||
# Case 2: order by attribute timestamp field which is there in attributes as well
|
||||
# attribute.timestamp gets adjusted to span.timestamp
|
||||
# This should break but it doesn't because attribute.timestamp gets adjusted to timestamp
|
||||
# because of default trace.timestamp gets added by default and bug in field mapper picks
|
||||
# instrinsic field
|
||||
pytest.param(
|
||||
{
|
||||
"type": "builder_query",
|
||||
@@ -515,11 +516,6 @@ def test_traces_list(
|
||||
"name": "A",
|
||||
"signal": "traces",
|
||||
"disabled": False,
|
||||
"selectFields": [
|
||||
{"name": "span_id"},
|
||||
{"name": "span.timestamp"},
|
||||
{"name": "trace_id"},
|
||||
],
|
||||
"order": [
|
||||
{"key": {"name": "attribute.timestamp"}, "direction": "desc"}
|
||||
],
|
||||
@@ -528,6 +524,10 @@ def test_traces_list(
|
||||
},
|
||||
HTTPStatus.OK,
|
||||
lambda x: [
|
||||
x[3].duration_nano,
|
||||
x[3].name,
|
||||
x[3].response_status_code,
|
||||
x[3].service_name,
|
||||
x[3].span_id,
|
||||
format_timestamp(x[3].timestamp),
|
||||
x[3].trace_id,
|
||||
@@ -553,7 +553,7 @@ def test_traces_list(
|
||||
], # type: Callable[[List[Traces]], List[Any]]
|
||||
),
|
||||
# Case 4: select attribute.timestamp with empty order by
|
||||
# This returns the one span which has attribute.timestamp
|
||||
# This doesn't return any data because of where_clause using aliased timestamp
|
||||
pytest.param(
|
||||
{
|
||||
"type": "builder_query",
|
||||
@@ -567,11 +567,7 @@ def test_traces_list(
|
||||
},
|
||||
},
|
||||
HTTPStatus.OK,
|
||||
lambda x: [
|
||||
x[0].span_id,
|
||||
format_timestamp(x[0].timestamp),
|
||||
x[0].trace_id,
|
||||
], # type: Callable[[List[Traces]], List[Any]]
|
||||
lambda x: [], # type: Callable[[List[Traces]], List[Any]]
|
||||
),
|
||||
# Case 5: select timestamp with timestamp order by
|
||||
pytest.param(
|
||||
@@ -710,112 +706,6 @@ def test_traces_list_with_corrupt_data(
|
||||
assert data[key] == value
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"select_fields,status_code,expected_keys",
|
||||
[
|
||||
pytest.param(
|
||||
[],
|
||||
HTTPStatus.OK,
|
||||
[
|
||||
# all intrinsic column
|
||||
"timestamp",
|
||||
"trace_id",
|
||||
"span_id",
|
||||
"trace_state",
|
||||
"parent_span_id",
|
||||
"flags",
|
||||
"name",
|
||||
"kind",
|
||||
"kind_string",
|
||||
"duration_nano",
|
||||
"status_code",
|
||||
"status_message",
|
||||
"status_code_string",
|
||||
"events",
|
||||
"links",
|
||||
# all calculated columns
|
||||
"response_status_code",
|
||||
"external_http_url",
|
||||
"http_url",
|
||||
"external_http_method",
|
||||
"http_method",
|
||||
"http_host",
|
||||
"db_name",
|
||||
"db_operation",
|
||||
"has_error",
|
||||
"is_remote",
|
||||
# all contextual columns (merged in response layer)
|
||||
"attributes",
|
||||
"resource",
|
||||
],
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
{"name": "service.name"},
|
||||
],
|
||||
HTTPStatus.OK,
|
||||
["timestamp", "trace_id", "span_id", "service.name"],
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_traces_list_with_select_fields(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[List[Traces]], None],
|
||||
select_fields: List[dict],
|
||||
status_code: HTTPStatus,
|
||||
expected_keys: List[str],
|
||||
) -> None:
|
||||
"""
|
||||
Setup:
|
||||
Insert 4 traces with different attributes.
|
||||
|
||||
Tests:
|
||||
1. Empty select fields should return all the fields.
|
||||
2. Non empty select field should return the select field along with timestamp, trace_id and span_id.
|
||||
"""
|
||||
traces = (
|
||||
generate_traces_with_corrupt_metadata()
|
||||
) # using this as the data doesn't matter
|
||||
|
||||
insert_traces(traces)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
payload = {
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "traces",
|
||||
"selectFields": select_fields,
|
||||
"order": [{"key": {"name": "timestamp"}, "direction": "desc"}],
|
||||
"limit": 1,
|
||||
},
|
||||
}
|
||||
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int(
|
||||
(datetime.now(tz=timezone.utc) - timedelta(minutes=5)).timestamp() * 1000
|
||||
),
|
||||
end_ms=int(datetime.now(tz=timezone.utc).timestamp() * 1000),
|
||||
request_type="raw",
|
||||
queries=[payload],
|
||||
)
|
||||
assert response.status_code == status_code
|
||||
|
||||
if response.status_code == HTTPStatus.OK:
|
||||
data = response.json()
|
||||
assert len(data["data"]["data"]["results"][0]["rows"][0]["data"].keys()) == len(
|
||||
expected_keys
|
||||
)
|
||||
assert set(data["data"]["data"]["results"][0]["rows"][0]["data"].keys()) == set(
|
||||
expected_keys
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"order_by,aggregation_alias,expected_status",
|
||||
[
|
||||
@@ -2241,9 +2131,9 @@ def test_traces_list_filter_by_trace_id(
|
||||
narrow_start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
|
||||
narrow_rows = _query(narrow_start_ms, now_ms)
|
||||
|
||||
assert len(narrow_rows) == 1, (
|
||||
f"Expected 1 span for trace_id filter (narrow window), got {len(narrow_rows)}"
|
||||
)
|
||||
assert (
|
||||
len(narrow_rows) == 1
|
||||
), f"Expected 1 span for trace_id filter (narrow window), got {len(narrow_rows)}"
|
||||
assert narrow_rows[0]["data"]["span_id"] == span_id_root
|
||||
assert narrow_rows[0]["data"]["trace_id"] == target_trace_id
|
||||
|
||||
|
||||
Reference in New Issue
Block a user