Compare commits

..

1 Commits

Author SHA1 Message Date
manika-signoz
94ecc85e55 chore: migrate antd divider to signozhq/ui divider 2026-05-27 13:15:55 +05:30
59 changed files with 956 additions and 1354 deletions

View File

@@ -190,7 +190,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.126.0
image: signoz/signoz:v0.125.1
ports:
- "8080:8080" # signoz port
# - "6060:6060" # pprof port

View File

@@ -117,7 +117,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.126.0
image: signoz/signoz:v0.125.1
ports:
- "8080:8080" # signoz port
volumes:

View File

@@ -181,7 +181,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.126.0}
image: signoz/signoz:${VERSION:-v0.125.1}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

@@ -109,7 +109,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.126.0}
image: signoz/signoz:${VERSION:-v0.125.1}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

@@ -50,7 +50,7 @@
"@signozhq/design-tokens": "2.1.4",
"@signozhq/icons": "0.4.0",
"@signozhq/resizable": "0.0.2",
"@signozhq/ui": "0.0.22",
"@signozhq/ui": "0.0.23",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.13.22",
"@uiw/codemirror-theme-copilot": "4.23.11",

View File

@@ -19,6 +19,7 @@ const BANNED_COMPONENTS = {
Switch: 'Use @signozhq/ui/switch Switch instead of antd Switch.',
Badge: 'Use @signozhq/ui/badge instead of antd Badge.',
Progress: 'Use @signozhq/ui/progress instead of antd Progress.',
Divider: 'Use @signozhq/ui/divider Divider instead of antd Divider.',
};
export default {

View File

@@ -77,8 +77,8 @@ importers:
specifier: 0.0.2
version: 0.0.2(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@signozhq/ui':
specifier: 0.0.22
version: 0.0.22(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0)
specifier: 0.0.23
version: 0.0.23(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0)
'@tanstack/react-table':
specifier: 8.21.3
version: 8.21.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -3279,8 +3279,8 @@ packages:
peerDependencies:
react: ^18.2.0
'@signozhq/ui@0.0.22':
resolution: {integrity: sha512-CJDyA4H+uXG/U2/d7/nRMNY6WIW0YWc843mfzUQALjm+xOhbO4T+qt67THjV4s1wTMs1cZLkmScbMddf+hXLIQ==}
'@signozhq/ui@0.0.23':
resolution: {integrity: sha512-JqIYlVHksPf07rLGWm1mgV+qpaTFfXIrXUdW0YsDN57wnW5Mu2TaMcertegJVJz/XK/sWcUVVCGXwmx1F//wqQ==}
peerDependencies:
'@signozhq/icons': 0.3.0
react: ^18.2.0
@@ -12041,7 +12041,7 @@ snapshots:
- react-dom
- tailwindcss
'@signozhq/ui@0.0.22(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0)':
'@signozhq/ui@0.0.23(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0)':
dependencies:
'@chenglou/pretext': 0.0.5
'@radix-ui/react-checkbox': 1.3.3(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)

View File

@@ -102,11 +102,7 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
return <>{children}</>;
}
if (
(pathname === ROUTES.AI_ASSISTANT_BASE ||
pathname.startsWith('/ai-assistant/')) &&
!isAIAssistantEnabled
) {
if (pathname.startsWith('/ai-assistant/') && !isAIAssistantEnabled) {
return <Redirect to={ROUTES.HOME} />;
}

View File

@@ -229,18 +229,18 @@ function App(): JSX.Element {
}
setRoutes((prev) => {
const hasAi = prev.some((r) => r.key === 'AI_ASSISTANT');
const hasAi = prev.some((r) => r.path === ROUTES.AI_ASSISTANT);
if (isAIAssistantEnabled === hasAi) {
return prev;
}
if (isAIAssistantEnabled) {
const aiRoute = defaultRoutes.find((r) => r.key === 'AI_ASSISTANT');
const aiRoute = defaultRoutes.find((r) => r.path === ROUTES.AI_ASSISTANT);
if (!aiRoute) {
return prev;
}
return [...prev.filter((r) => r.key !== 'AI_ASSISTANT'), aiRoute];
return [...prev.filter((r) => r.path !== ROUTES.AI_ASSISTANT), aiRoute];
}
return prev.filter((r) => r.key !== 'AI_ASSISTANT');
return prev.filter((r) => r.path !== ROUTES.AI_ASSISTANT);
});
}, [isLoggedInState, isAIAssistantEnabled]);
@@ -254,7 +254,6 @@ function App(): JSX.Element {
if (
pathname === ROUTES.ONBOARDING ||
pathname.startsWith('/public/dashboard/') ||
pathname === '/ai-assistant' ||
pathname.startsWith('/ai-assistant/')
) {
window.Pylon?.('hideChatBubble');

View File

@@ -501,7 +501,7 @@ const routes: AppRoutes[] = [
isPrivate: true,
},
{
path: [ROUTES.AI_ASSISTANT_BASE, ROUTES.AI_ASSISTANT],
path: ROUTES.AI_ASSISTANT,
exact: true,
component: AIAssistantPage,
key: 'AI_ASSISTANT',

View File

@@ -40,7 +40,6 @@ export function setAIBackendUrl(url: string | null): void {
if (aiBackendUrl === url) {
return;
}
aiBackendUrl = url;
AIAssistantInstance.defaults.baseURL = url ? `${url}${AI_API_PATH}` : '';
}

View File

@@ -37,16 +37,6 @@ export enum ApplyFilterSignalDTO {
traces = 'traces',
metrics = 'metrics',
}
export enum ApprovalStateDTO {
pending = 'pending',
approved = 'approved',
rejected = 'rejected',
superseded = 'superseded',
}
export enum ApprovalActionTypeDTO {
modify = 'modify',
delete = 'delete',
}
/**
* Resolved approval (approved/rejected/superseded) anchored on the assistant message that proposed it. Pending approvals never appear here - they live at the top-level pendingApproval slot.
*/
@@ -73,6 +63,16 @@ export interface ApprovalActionSummaryDTO {
resolvedAt: string;
}
export enum ApprovalActionTypeDTO {
modify = 'modify',
delete = 'delete',
}
export enum ApprovalStateDTO {
pending = 'pending',
approved = 'approved',
rejected = 'rejected',
superseded = 'superseded',
}
export type ApprovalSummaryDTODiff = { [key: string]: unknown };
export interface ApprovalSummaryDTO {
@@ -139,16 +139,6 @@ export interface CancelRequestDTO {
threadId: string;
}
export enum ExecutionStateDTO {
queued = 'queued',
running = 'running',
awaiting_approval = 'awaiting_approval',
awaiting_clarification = 'awaiting_clarification',
resumed = 'resumed',
completed = 'completed',
failed = 'failed',
canceled = 'canceled',
}
export interface CancelResponseDTO {
/**
* @type string
@@ -163,13 +153,6 @@ export type ClarificationFieldDTOOptions = string[] | null;
export type ClarificationFieldDTODefault = string | string[] | null;
export enum ClarificationFieldTypeDTO {
text = 'text',
number = 'number',
select = 'select',
multi_select = 'multi_select',
boolean = 'boolean',
}
export interface ClarificationFieldDTO {
/**
* @type string
@@ -192,6 +175,13 @@ export interface ClarificationFieldDTO {
default?: ClarificationFieldDTODefault;
}
export enum ClarificationFieldTypeDTO {
text = 'text',
number = 'number',
select = 'select',
multi_select = 'multi_select',
boolean = 'boolean',
}
export enum ClarificationStateDTO {
pending = 'pending',
submitted = 'submitted',
@@ -262,21 +252,178 @@ export interface ClarifyResponseDTO {
executionId: string;
}
export type CreateMessageRequestDTOContexts = MessageContextDTO[] | null;
export type CreateMessageRequestDTOForkFromMessageId = string | null;
export interface CreateMessageRequestDTO {
/**
* @type string
* @maxLength 20000
* @minLength 1
*/
content: string;
contexts?: CreateMessageRequestDTOContexts;
forkFromMessageId?: CreateMessageRequestDTOForkFromMessageId;
}
export interface CreateMessageResponseDTO {
/**
* @type string
* @format uuid
*/
executionId: string;
}
export type CreateThreadRequestDTOTitle = string | null;
export interface CreateThreadRequestDTO {
title?: CreateThreadRequestDTOTitle;
}
export interface CreateThreadResponseDTO {
/**
* @type string
* @format uuid
*/
threadId: string;
}
export type ErrorBodyDTOErrors = ErrorResponseAdditionalDTO[] | null;
export type ErrorBodyDTOUrl = string | null;
/**
* Identifier exposed on the wire for each counter row.
Mirrors the ``RateLimitCounterType`` model enum minus the cost
counter. The daily-cost limit is enforced internally (Redis
counter + 429 from the pre-flight gate) but never surfaced on the
customer-facing API: shipping the raw provider cost to tenant users
pins our public pricing model to what we pay Anthropic and forecloses
markup, per-seat bundling, or tiered pricing. Cost stays internal on
``assistant_executions`` + Redis for billing.
*/
export enum CounterTypeNameDTO {
hourly_message = 'hourly_message',
daily_message = 'daily_message',
daily_token = 'daily_token',
* Inner error object — matches Go ErrorsJSON.
*/
export interface ErrorBodyDTO {
/**
* @type string
* @pattern ^[a-z_]+$
*/
code: string;
/**
* @type string
*/
message: string;
errors?: ErrorBodyDTOErrors;
url?: ErrorBodyDTOUrl;
}
/**
* Top-level error envelope — matches Go RenderErrorResponse.
*/
export interface ErrorResponseDTO {
/**
* @type string
*/
status?: string;
error: ErrorBodyDTO;
}
/**
* Single sub-error entry — matches Go ErrorsResponseerroradditional.
*/
export interface ErrorResponseAdditionalDTO {
/**
* @type string
*/
message: string;
}
export enum ExecutionStateDTO {
queued = 'queued',
running = 'running',
awaiting_approval = 'awaiting_approval',
awaiting_clarification = 'awaiting_clarification',
resumed = 'resumed',
completed = 'completed',
failed = 'failed',
canceled = 'canceled',
}
export enum FeedbackRatingDTO {
positive = 'positive',
negative = 'negative',
}
export type FeedbackRequestDTOComment = string | null;
export interface FeedbackRequestDTO {
rating: FeedbackRatingDTO;
comment?: FeedbackRequestDTOComment;
}
export interface FeedbackResponseDTO {
[key: string]: unknown;
}
export interface HTTPValidationErrorDTO {
/**
* @type array
*/
detail?: ValidationErrorDTO[];
}
export const HealthResponseDTOValue = {
/**
* @type string
*/
status: 'ok',
} as const;
export type HealthResponseDTO = typeof HealthResponseDTOValue;
export type MessageActionDTOActionMetadataId = string | null;
export type MessageActionDTOResourceType = string | null;
export type MessageActionDTOResourceId = string | null;
export type MessageActionDTOState = string | null;
export type MessageActionDTOInputAnyOf = { [key: string]: unknown };
export type MessageActionDTOInput = MessageActionDTOInputAnyOf | null;
export type MessageActionDTOTooltip = string | null;
export type MessageActionDTOSignal = ApplyFilterSignalDTO | null;
export type MessageActionDTOQueryAnyOf = { [key: string]: unknown };
export type MessageActionDTOQuery = MessageActionDTOQueryAnyOf | null;
export type MessageActionDTOUrl = string | null;
/**
* Assistant action. Kind-specific requirements: rollback actions require actionMetadataId/resourceType/resourceId; follow_up requires input.intent; open_resource requires resourceType/resourceId; apply_filter requires signal and query; open_docs requires a SigNoz docs url.
*/
export interface MessageActionDTO {
kind: MessageActionKindDTO;
/**
* @type string
*/
label: string;
actionMetadataId?: MessageActionDTOActionMetadataId;
resourceType?: MessageActionDTOResourceType;
resourceId?: MessageActionDTOResourceId;
state?: MessageActionDTOState;
input?: MessageActionDTOInput;
tooltip?: MessageActionDTOTooltip;
signal?: MessageActionDTOSignal;
query?: MessageActionDTOQuery;
url?: MessageActionDTOUrl;
}
export enum MessageActionKindDTO {
undo = 'undo',
revert = 'revert',
restore = 'restore',
follow_up = 'follow_up',
open_resource = 'open_resource',
open_docs = 'open_docs',
apply_filter = 'apply_filter',
}
export enum MessageContentTypeDTO {
markdown = 'markdown',
}
/**
* "auto" if derived from current page; "mention" if explicitly @-picked.
@@ -335,193 +482,6 @@ export interface MessageContextDTO {
metadata?: MessageContextDTOMetadata;
}
export type CreateMessageRequestDTOContexts = MessageContextDTO[] | null;
export type CreateMessageRequestDTOForkFromMessageId = string | null;
export interface CreateMessageRequestDTO {
/**
* @type string
* @maxLength 20000
* @minLength 1
*/
content: string;
contexts?: CreateMessageRequestDTOContexts;
forkFromMessageId?: CreateMessageRequestDTOForkFromMessageId;
}
export interface CreateMessageResponseDTO {
/**
* @type string
* @format uuid
*/
executionId: string;
}
export type CreateThreadRequestDTOTitle = string | null;
export interface CreateThreadRequestDTO {
title?: CreateThreadRequestDTOTitle;
}
export interface CreateThreadResponseDTO {
/**
* @type string
* @format uuid
*/
threadId: string;
}
/**
* Single sub-error entry — matches Go ErrorsResponseerroradditional.
*/
export interface ErrorResponseAdditionalDTO {
/**
* @type string
*/
message: string;
}
export type ErrorBodyDTOErrors = ErrorResponseAdditionalDTO[] | null;
export type ErrorBodyDTOUrl = string | null;
/**
* Inner error object — matches Go ErrorsJSON.
*/
export interface ErrorBodyDTO {
/**
* @type string
* @pattern ^[a-z_]+$
*/
code: string;
/**
* @type string
*/
message: string;
errors?: ErrorBodyDTOErrors;
url?: ErrorBodyDTOUrl;
}
/**
* Top-level error envelope — matches Go RenderErrorResponse.
*/
export interface ErrorResponseDTO {
/**
* @type string
*/
status?: string;
error: ErrorBodyDTO;
}
export enum FeedbackRatingDTO {
positive = 'positive',
negative = 'negative',
}
export type FeedbackRequestDTOComment = string | null;
export interface FeedbackRequestDTO {
rating: FeedbackRatingDTO;
comment?: FeedbackRequestDTOComment;
}
export interface FeedbackResponseDTO {
[key: string]: unknown;
}
export type ValidationErrorDTOLocItem = string | number;
export type ValidationErrorDTOCtx = { [key: string]: unknown };
export interface ValidationErrorDTO {
/**
* @type array
*/
loc: ValidationErrorDTOLocItem[];
/**
* @type string
*/
msg: string;
/**
* @type string
*/
type: string;
input?: unknown;
/**
* @type object
*/
ctx?: ValidationErrorDTOCtx;
}
export interface HTTPValidationErrorDTO {
/**
* @type array
*/
detail?: ValidationErrorDTO[];
}
export const HealthResponseDTOValue = {
/**
* @type string
*/
status: 'ok',
} as const;
export type HealthResponseDTO = typeof HealthResponseDTOValue;
export type MessageActionDTOActionMetadataId = string | null;
export type MessageActionDTOResourceType = string | null;
export type MessageActionDTOResourceId = string | null;
export type MessageActionDTOState = string | null;
export type MessageActionDTOInputAnyOf = { [key: string]: unknown };
export type MessageActionDTOInput = MessageActionDTOInputAnyOf | null;
export type MessageActionDTOTooltip = string | null;
export type MessageActionDTOSignal = ApplyFilterSignalDTO | null;
export type MessageActionDTOQueryAnyOf = { [key: string]: unknown };
export type MessageActionDTOQuery = MessageActionDTOQueryAnyOf | null;
export type MessageActionDTOUrl = string | null;
export enum MessageActionKindDTO {
undo = 'undo',
revert = 'revert',
restore = 'restore',
follow_up = 'follow_up',
open_resource = 'open_resource',
open_docs = 'open_docs',
apply_filter = 'apply_filter',
}
/**
* Assistant action. Kind-specific requirements: rollback actions require actionMetadataId/resourceType/resourceId; follow_up requires input.intent; open_resource requires resourceType/resourceId; apply_filter requires signal and query; open_docs requires a SigNoz docs url.
*/
export interface MessageActionDTO {
kind: MessageActionKindDTO;
/**
* @type string
*/
label: string;
actionMetadataId?: MessageActionDTOActionMetadataId;
resourceType?: MessageActionDTOResourceType;
resourceId?: MessageActionDTOResourceId;
state?: MessageActionDTOState;
input?: MessageActionDTOInput;
tooltip?: MessageActionDTOTooltip;
signal?: MessageActionDTOSignal;
query?: MessageActionDTOQuery;
url?: MessageActionDTOUrl;
}
export enum MessageContentTypeDTO {
markdown = 'markdown',
}
export enum MessageRoleDTO {
user = 'user',
assistant = 'assistant',
@@ -656,10 +616,6 @@ export interface RevertRequestDTO {
actionMetadataId: string;
}
export enum ScopeDTO {
user = 'user',
org = 'org',
}
export type ThreadDetailResponseDTOTitle = string | null;
export type ThreadDetailResponseDTOState = ExecutionStateDTO | null;
@@ -707,6 +663,18 @@ export interface ThreadDetailResponseDTO {
export type ThreadListResponseDTONextCursor = string | null;
export interface ThreadListResponseDTO {
/**
* @type array
*/
threads: ThreadSummaryDTO[];
nextCursor?: ThreadListResponseDTONextCursor;
/**
* @type boolean
*/
hasMore?: boolean;
}
export type ThreadSummaryDTOTitle = string | null;
export type ThreadSummaryDTOState = ExecutionStateDTO | null;
@@ -741,18 +709,6 @@ export interface ThreadSummaryDTO {
updatedAt: string;
}
export interface ThreadListResponseDTO {
/**
* @type array
*/
threads: ThreadSummaryDTO[];
nextCursor?: ThreadListResponseDTONextCursor;
/**
* @type boolean
*/
hasMore?: boolean;
}
export interface UndoRequestDTO {
/**
* @type string
@@ -770,29 +726,28 @@ export interface UpdateThreadRequestDTO {
archived?: UpdateThreadRequestDTOArchived;
}
export type UsageResponseDTONextPage = string | null;
export type ValidationErrorDTOLocItem = string | number;
/**
* One row in the ``GET /usage`` response.
*/
export interface UsageRowDTO {
type: CounterTypeNameDTO;
scope: ScopeDTO;
used: number;
limit: number;
/**
* @type string
* @format date-time
*/
resetsAt: string;
}
export type ValidationErrorDTOCtx = { [key: string]: unknown };
export interface UsageResponseDTO {
export interface ValidationErrorDTO {
/**
* @type array
*/
data: UsageRowDTO[];
nextPage?: UsageResponseDTONextPage;
loc: ValidationErrorDTOLocItem[];
/**
* @type string
*/
msg: string;
/**
* @type string
*/
type: string;
input?: unknown;
/**
* @type object
*/
ctx?: ValidationErrorDTOCtx;
}
export type ApprovalEventDTODiff = { [key: string]: unknown };
@@ -954,20 +909,6 @@ export interface ErrorEventDTO {
retryAction?: RetryActionDTO;
}
/**
* Per-connection SSE keep-alive emitted every `sse_heartbeat_interval_seconds`.
Carries no `executionId` and no `eventId` — heartbeats are wire-level
keep-alives, not part of the replayable event log.
*/
export const HeartbeatEventDTOValue = {
/**
* @type string
*/
type: 'heartbeat',
} as const;
export type HeartbeatEventDTO = typeof HeartbeatEventDTOValue;
export type MessageActionEventDTOActionMetadataId = string | null;
export type MessageActionEventDTOResourceType = string | null;
@@ -1374,14 +1315,3 @@ export type SubmitFeedbackApiV1AssistantMessagesMessageIdFeedbackPostHeaders = {
*/
'X-SigNoz-URL'?: string | null;
};
export type GetUsageApiV1AssistantUsageGetHeaders = {
/**
* @description SigNoz auth token (Bearer or raw JWT)
*/
authorization?: string | null;
/**
* @description SigNoz instance base URL for multi-tenant deployments. Falls back to SIGNOZ_API_URL env var when omitted.
*/
'X-SigNoz-URL'?: string | null;
};

View File

@@ -0,0 +1 @@
<svg width="14" height="14" fill="none" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#prefix__clip0_4062_7291)" stroke-width="1.167" stroke-linecap="round" stroke-linejoin="round"><path d="M7 12.833A5.833 5.833 0 107 1.167a5.833 5.833 0 000 11.666z" fill="#E5484D" stroke="#E5484D"/><path d="M8.75 5.25l-3.5 3.5M5.25 5.25l3.5 3.5" stroke="#121317"/></g><defs><clipPath id="prefix__clip0_4062_7291"><path fill="#fff" d="M0 0h14v14H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 467 B

View File

@@ -1,4 +1,5 @@
import { Breadcrumb, Divider } from 'antd';
import { Breadcrumb } from 'antd';
import { Divider } from '@signozhq/ui/divider';
import styles from './AlertBreadcrumb.module.scss';
import BreadcrumbItem, { BreadcrumbItemConfig } from './BreadcrumbItem';

View File

@@ -1,6 +1,7 @@
import { useState } from 'react';
import { Color, Spacing } from '@signozhq/design-tokens';
import { Divider, Drawer } from 'antd';
import { Drawer } from 'antd';
import { Divider } from '@signozhq/ui/divider';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import { PANEL_TYPES } from 'constants/queryBuilder';

View File

@@ -4,7 +4,8 @@ import { useSelector } from 'react-redux'; // old code, TODO: fix this correctly
import { useCopyToClipboard, useLocation } from 'react-use';
import { Color, Spacing } from '@signozhq/design-tokens';
import { Button } from '@signozhq/ui/button';
import { Divider, Drawer, Radio, Tooltip } from 'antd';
import { Drawer, Radio, Tooltip } from 'antd';
import { Divider } from '@signozhq/ui/divider';
import { Typography } from '@signozhq/ui/typography';
import type { RadioChangeEvent } from 'antd/lib';
import cx from 'classnames';

View File

@@ -4,13 +4,10 @@ import {
ButtonProps,
Col,
ColProps,
Divider,
DividerProps,
Row,
RowProps,
Space,
SpaceProps,
TabsProps,
} from 'antd';
import {
Typography,
@@ -34,21 +31,11 @@ const StyledRow = styled(Row)<TStyledRow>`
${styledClass}
`;
type TStyledDivider = DividerProps & IStyledClass;
const StyledDivider = styled(Divider)<TStyledDivider>`
${styledClass}
`;
type TStyledSpace = SpaceProps & IStyledClass;
const StyledSpace = styled(Space)<TStyledSpace>`
${styledClass}
`;
type TStyledTabs = TabsProps & IStyledClass;
const StyledTabs = styled(Divider)<TStyledTabs>`
${styledClass}
`;
type TStyledButton = ButtonProps & IStyledClass;
const StyledButton = styled(Button)<TStyledButton>`
${styledClass}
@@ -79,9 +66,7 @@ export {
StyledButton,
StyledCol,
StyledDiv,
StyledDivider,
StyledRow,
StyledSpace,
StyledTabs,
StyledTypography,
};

View File

@@ -88,7 +88,6 @@ const ROUTES = {
PUBLIC_DASHBOARD: '/public/dashboard/:dashboardId',
SERVICE_ACCOUNTS_SETTINGS: '/settings/service-accounts',
AI_ASSISTANT: '/ai-assistant/:conversationId',
AI_ASSISTANT_BASE: '/ai-assistant',
AI_ASSISTANT_ICON_PREVIEW: '/ai-assistant-icon-preview',
MCP_SERVER: '/settings/mcp-server',
} as const;

View File

@@ -178,7 +178,7 @@ export default function MessageBubble({
</div>
</div>
{!isUser && !message.isRateLimitError && (
{!isUser && (
<MessageFeedback
message={message}
onRegenerate={onRegenerate}

View File

@@ -1,12 +1,10 @@
/* eslint-disable sonarjs/cognitive-complexity */
import axios from 'axios';
import { v4 as uuidv4 } from 'uuid';
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
import type {
ErrorResponseDTO,
MessageActionDTO,
MessageSummaryDTOBlocksAnyOfItem,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
@@ -23,6 +21,7 @@ import {
regenerateMessage,
rejectExecution,
sendMessage as sendMessageToThread,
SSEStreamError,
streamEvents,
submitFeedback,
ThreadSummary,
@@ -194,75 +193,13 @@ function resetStreamingState(
};
}
/**
* Marker thrown by `runStreamingLoop` when an SSE event reports
* `invalid_token`. Callers that own an originating action (sendMessage /
* approve / clarify / regenerate) catch this and re-issue that action via
* `streamWithAuthRetry`; the retry's first REST call will 401, at which point
* the shared axios `interceptorRejected` rotates the access token and replays.
*/
class AuthExpiredError extends Error {
constructor() {
super('Access token expired during execution');
this.name = 'AuthExpiredError';
}
}
/**
* Runs the originating action (e.g. sendMessage POST) and streams the
* resulting execution. On `AuthExpiredError`, re-issues `start` once — the
* retry's REST call hits 401, the shared axios interceptor rotates the
* access token and replays, and the new SSE picks up the rotated token from
* localStorage. Backend signals `retryAction: 'manual'` for `invalid_token`,
* so the dead execution can't be resumed — only a fresh one helps.
*/
async function streamWithAuthRetry(
conversationId: string,
start: () => Promise<string>,
set: StoreSetter,
): Promise<void> {
for (let attempt = 0; attempt <= 1; attempt += 1) {
if (attempt > 0) {
// Drop any partial content/events from the previous attempt so the
// retried execution's stream isn't concatenated with the dead one.
set((s) => {
resetStreamingState(s, conversationId);
});
}
// eslint-disable-next-line no-await-in-loop
const executionId = await start();
const ctrl = newStreamController(conversationId);
try {
// eslint-disable-next-line no-await-in-loop
await runStreamingLoop(executionId, {
conversationId,
set,
signal: ctrl.signal,
});
streamControllers.delete(conversationId);
return;
} catch (err) {
streamControllers.delete(conversationId);
if (err instanceof AuthExpiredError && attempt < 1) {
continue;
}
throw err;
}
}
}
/**
* Runs one SSE execution stream, updating the per-conversation stream state.
*
* Breaks early and sets pendingApproval / pendingClarification when the
* agent needs user input before it can continue.
*
* On an `invalid_token` error event (e.g. MCP auth expired mid-execution),
* throws `AuthExpiredError` so the caller can re-issue the originating
* action via `streamWithAuthRetry`. We don't refresh here ourselves — the
* retry's REST call will 401 and the shared axios `interceptorRejected`
* handles rotation + replay. Throws on any other `error` event — the
* caller's catch block handles UI feedback.
* Throws on `error` events — the caller's catch block handles UI feedback.
*/
// eslint-disable-next-line sonarjs/cognitive-complexity
async function runStreamingLoop(
@@ -388,15 +325,6 @@ async function runStreamingLoop(
});
break;
} else if (event.type === 'error') {
// MCP/SigNoz auth expired mid-execution — signal the caller to
// re-issue the originating action. The retry's REST call will hit
// 401 and the shared axios `interceptorRejected` will rotate the
// access token + replay, so we don't refresh here ourselves.
// (Backend sets `retryAction: 'manual'`, so the failed execution
// can't itself be resumed — only a fresh one helps.)
if (event.error.code === 'invalid_token') {
throw new AuthExpiredError();
}
throw Object.assign(new Error(event.error.message), {
retryAction: event.retryAction,
});
@@ -484,41 +412,13 @@ function hasPendingInput(conversationId: string, get: StoreGetter): boolean {
return Boolean(stream?.pendingApproval || stream?.pendingClarification);
}
function parseErrorBody(value: unknown): string | null {
if (typeof value === 'string') {
try {
return parseErrorBody(JSON.parse(value));
} catch {
return null;
}
}
const message = (value as ErrorResponseDTO | undefined)?.error?.message;
return typeof message === 'string' && message.length > 0 ? message : null;
}
/**
* Returns the backend's `error.message` when `err` is a 429 axios response
* (typically from the threads API surface — createThread, sendMessage, approve,
* clarify, regenerate). Returns null for any other error so callers fall
* through to their generic copy.
*/
function rateLimitMessage(err: unknown): string | null {
if (axios.isAxiosError(err) && err.response?.status === 429) {
return parseErrorBody(err.response.data);
}
return null;
}
/**
* Commits an error message and removes the stream entry. When `isRateLimit`
* is true, the committed message is flagged so the feedback/regenerate bar
* is hidden — clicking regenerate would just 429 again.
* Commits an error message and removes the stream entry.
*/
function finalizeStreamingError(
conversationId: string,
errorContent: string,
set: StoreSetter,
isRateLimit = false,
): void {
set((s) => {
const conv = s.conversations[conversationId];
@@ -528,7 +428,6 @@ function finalizeStreamingError(
role: 'assistant',
content: errorContent,
createdAt: Date.now(),
...(isRateLimit ? { isRateLimitError: true } : {}),
});
conv.updatedAt = Date.now();
}
@@ -902,12 +801,7 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
});
// Reconnect to SSE if backend execution is still running
// and we don't already have an active SSE reader for this
// thread. No auth-retry wrapper here: on `invalid_token`
// there's no "originating action" to redo — reopening the
// same dead executionId would just re-emit the failure.
// Let the error bubble; the user can send a new message,
// which will go through `streamWithAuthRetry`.
// and we don't already have an active SSE reader for this thread
if (
detail.activeExecutionId &&
!streamControllers.has(threadId) &&
@@ -1158,12 +1052,14 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
}
});
}
const tid = threadId;
await streamWithAuthRetry(
convId,
() => sendMessageToThread(tid, text, contexts),
const executionId = await sendMessageToThread(threadId, text, contexts);
const ctrl = newStreamController(convId);
await runStreamingLoop(executionId, {
conversationId: convId,
set,
);
signal: ctrl.signal,
});
streamControllers.delete(convId);
if (!hasPendingInput(convId, get)) {
finalizeStreamingMessage(convId, set, get);
@@ -1174,14 +1070,11 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
return;
}
console.error('[AIAssistant] sendMessage failed:', err);
const rateLimit = rateLimitMessage(err);
finalizeStreamingError(
convId,
rateLimit ??
'Something went wrong while fetching the response. Please try again.',
set,
rateLimit !== null,
);
const message =
err instanceof SSEStreamError && err.status === 429
? 'You sent that a bit too quickly. Please wait a moment and try again.'
: 'Something went wrong while fetching the response. Please try again.';
finalizeStreamingError(convId, message, set);
}
},
@@ -1201,11 +1094,14 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
});
try {
await streamWithAuthRetry(
const executionId = await approveExecution(approvalId);
const ctrl = newStreamController(conversationId);
await runStreamingLoop(executionId, {
conversationId,
() => approveExecution(approvalId),
set,
);
signal: ctrl.signal,
});
streamControllers.delete(conversationId);
if (!hasPendingInput(conversationId, get)) {
finalizeStreamingMessage(conversationId, set, get);
}
@@ -1214,13 +1110,10 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
return;
}
console.error('[AIAssistant] approveAction failed:', err);
const rateLimit = rateLimitMessage(err);
finalizeStreamingError(
conversationId,
rateLimit ??
'Something went wrong while processing the approval. Please try again.',
'Something went wrong while processing the approval. Please try again.',
set,
rateLimit !== null,
);
}
},
@@ -1283,11 +1176,14 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
});
try {
await streamWithAuthRetry(
const executionId = await regenerateMessage(messageId);
const ctrl = newStreamController(conversationId);
await runStreamingLoop(executionId, {
conversationId,
() => regenerateMessage(messageId),
set,
);
signal: ctrl.signal,
});
streamControllers.delete(conversationId);
if (!hasPendingInput(conversationId, get)) {
finalizeStreamingMessage(conversationId, set, get);
}
@@ -1296,13 +1192,10 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
return;
}
console.error('[AIAssistant] regenerateAssistantMessage failed:', err);
const rateLimit = rateLimitMessage(err);
finalizeStreamingError(
conversationId,
rateLimit ??
'Something went wrong while regenerating the response. Please try again.',
'Something went wrong while regenerating the response. Please try again.',
set,
rateLimit !== null,
);
}
},
@@ -1352,11 +1245,14 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
});
try {
await streamWithAuthRetry(
const executionId = await clarifyExecution(clarificationId, answers);
const ctrl = newStreamController(conversationId);
await runStreamingLoop(executionId, {
conversationId,
() => clarifyExecution(clarificationId, answers),
set,
);
signal: ctrl.signal,
});
streamControllers.delete(conversationId);
if (!hasPendingInput(conversationId, get)) {
finalizeStreamingMessage(conversationId, set, get);
}
@@ -1365,13 +1261,10 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
return;
}
console.error('[AIAssistant] submitClarification failed:', err);
const rateLimit = rateLimitMessage(err);
finalizeStreamingError(
conversationId,
rateLimit ??
'Something went wrong while processing your answers. Please try again.',
'Something went wrong while processing your answers. Please try again.',
set,
rateLimit !== null,
);
}
},

View File

@@ -86,11 +86,6 @@ export interface Message {
actions?: MessageActionDTO[];
/** Persisted feedback rating — set after user votes and the API confirms. */
feedbackRating?: FeedbackRating | null;
/**
* Set on client-side rate-limit error messages so the feedback/regenerate
* bar (copy/vote/regenerate) is hidden — retrying would just 429 again.
*/
isRateLimitError?: boolean;
createdAt: number;
}

View File

@@ -2,7 +2,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { Spacing } from '@signozhq/design-tokens';
import { Button, Divider, Drawer, Radio } from 'antd';
import { Button, Drawer, Radio } from 'antd';
import { Divider } from '@signozhq/ui/divider';
import { Typography } from '@signozhq/ui/typography';
import type { RadioChangeEvent } from 'antd/lib';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';

View File

@@ -2,7 +2,8 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from 'react-query';
import { useLocation } from 'react-router-dom';
import { Button, Divider, Space } from 'antd';
import { Button, Space } from 'antd';
import { Divider } from '@signozhq/ui/divider';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import getNextPrevId from 'api/errors/getNextPrevId';

View File

@@ -22,13 +22,13 @@ import { Color } from '@signozhq/design-tokens';
import {
Button,
ColorPicker,
Divider,
Input,
Modal,
RefSelectProps,
Select,
Tooltip,
} from 'antd';
import { Divider } from '@signozhq/ui/divider';
import { Typography } from '@signozhq/ui/typography';
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageKey from 'api/browser/localstorage/set';

View File

@@ -8,7 +8,8 @@ import React, {
import { useQuery } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { Color, Spacing } from '@signozhq/design-tokens';
import { Button, Divider, Drawer, Radio, Tooltip } from 'antd';
import { Button, Drawer, Radio, Tooltip } from 'antd';
import { Divider } from '@signozhq/ui/divider';
import { Typography } from '@signozhq/ui/typography';
import type { RadioChangeEvent } from 'antd/lib';
import logEvent from 'api/common/logEvent';

View File

@@ -1,6 +1,7 @@
import React, { useCallback, useState } from 'react';
import { Plus } from '@signozhq/icons';
import { Button, Divider, Flex } from 'antd';
import { Button, Flex } from 'antd';
import { Divider } from '@signozhq/ui/divider';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import ROUTES from 'constants/routes';

View File

@@ -1,7 +1,8 @@
import { memo, useMemo } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useDispatch, useSelector } from 'react-redux';
import { Button, Divider, Flex } from 'antd';
import { Button, Flex } from 'antd';
import { Divider } from '@signozhq/ui/divider';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import Controls from 'container/Controls';
import Download from 'container/Download/Download';

View File

@@ -1,4 +1,4 @@
import { Divider } from 'antd';
import { Divider } from '@signozhq/ui/divider';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';

View File

@@ -3,7 +3,8 @@ import MEditor, { EditorProps, Monaco } from '@monaco-editor/react';
import { Color } from '@signozhq/design-tokens';
import { Button } from '@signozhq/ui/button';
import { Switch } from '@signozhq/ui/switch';
import { Collapse, Divider, Input, Tag } from 'antd';
import { Collapse, Input, Tag } from 'antd';
import { Divider } from '@signozhq/ui/divider';
import { Typography } from '@signozhq/ui/typography';
import { AddToQueryHOCProps } from 'components/Logs/AddToQueryHOC';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';

View File

@@ -9,6 +9,8 @@ import { useOptionsMenu } from 'container/OptionsMenu';
import { ArrowUp10, Minus } from '@signozhq/icons';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
import QueryStatus from './QueryStatus';
function LogsActionsContainer({
listQuery,
selectedPanelType,
@@ -16,6 +18,10 @@ function LogsActionsContainer({
handleToggleFrequencyChart,
orderBy,
setOrderBy,
isFetching,
isLoading,
isError,
isSuccess,
}: {
listQuery: any;
selectedPanelType: PANEL_TYPES;
@@ -23,6 +29,10 @@ function LogsActionsContainer({
handleToggleFrequencyChart: () => void;
orderBy: string;
setOrderBy: (value: string) => void;
isFetching: boolean;
isLoading: boolean;
isError: boolean;
isSuccess: boolean;
}): JSX.Element {
const { options, config } = useOptionsMenu({
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
@@ -96,6 +106,17 @@ function LogsActionsContainer({
</div>
</>
)}
{(selectedPanelType === PANEL_TYPES.TIME_SERIES ||
selectedPanelType === PANEL_TYPES.TABLE) && (
<div className="query-stats">
<QueryStatus
loading={isLoading || isFetching}
error={isError}
success={isSuccess}
/>
</div>
)}
</div>
</div>
</div>

View File

@@ -155,6 +155,40 @@
}
}
.query-stats {
display: flex;
align-items: center;
gap: 12px;
align-self: flex-end;
.rows {
color: var(--l2-foreground);
font-family: 'Geist Mono';
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 150% */
letter-spacing: 0.36px;
}
.divider {
width: 1px;
height: 14px;
background: var(--l3-background);
}
.time {
color: var(--l2-foreground);
font-family: 'Geist Mono';
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 150% */
letter-spacing: 0.36px;
}
}
.ant-btn {
border: none;
}

View File

@@ -0,0 +1,4 @@
.query-status {
display: flex;
align-items: center;
}

View File

@@ -0,0 +1,49 @@
import React, { useMemo } from 'react';
import { Color } from '@signozhq/design-tokens';
import { LoaderCircle, CircleCheck } from '@signozhq/icons';
import { Spin } from 'antd';
import solidXCircleUrl from '@/assets/Icons/solid-x-circle.svg';
import './QueryStatus.styles.scss';
interface IQueryStatusProps {
loading: boolean;
error: boolean;
success: boolean;
}
export default function QueryStatus(
props: IQueryStatusProps,
): React.ReactElement {
const { loading, error, success } = props;
const content = useMemo((): React.ReactElement => {
if (loading) {
return (
<Spin
spinning
size="small"
indicator={<LoaderCircle className="animate-spin" size="md" />}
/>
);
}
if (error) {
return (
<img
src={solidXCircleUrl}
alt="header"
className="error"
style={{ height: '14px', width: '14px' }}
/>
);
}
if (success) {
return (
<CircleCheck className="success" size={14} fill={Color.BG_ROBIN_500} />
);
}
return <div />;
}, [error, loading, success]);
return <div className="query-status">{content}</div>;
}

View File

@@ -160,7 +160,7 @@ function LogsExplorerViewsContainer({
'custom',
);
const { data, isLoading, isFetching, isError, error } =
const { data, isLoading, isFetching, isError, isSuccess, error } =
useGetExplorerQueryRange(
requestData,
selectedPanelType,
@@ -437,6 +437,10 @@ function LogsExplorerViewsContainer({
handleToggleFrequencyChart={handleToggleFrequencyChart}
orderBy={orderBy}
setOrderBy={setOrderBy}
isFetching={isFetching}
isLoading={isLoading}
isError={isError}
isSuccess={isSuccess}
/>
)}

View File

@@ -2,7 +2,8 @@ import { useCallback, useEffect, useMemo } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { Color } from '@signozhq/design-tokens';
import { Button, Divider, Drawer } from 'antd';
import { Button, Drawer } from 'antd';
import { Divider } from '@signozhq/ui/divider';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import { useGetMetricMetadata } from 'api/generated/services/metrics';

View File

@@ -7,7 +7,8 @@ import {
DropResult,
} from 'react-beautiful-dnd';
import { Color } from '@signozhq/design-tokens';
import { Button, Divider, Dropdown, Input, MenuProps, Tooltip } from 'antd';
import { Button, Dropdown, Input, MenuProps, Tooltip } from 'antd';
import { Divider } from '@signozhq/ui/divider';
import { Typography } from '@signozhq/ui/typography';
import { FieldDataType } from 'api/v5/v5';
import { SOMETHING_WENT_WRONG } from 'constants/api';

View File

@@ -1,6 +1,7 @@
import { useState } from 'react';
import { EyeOpen } from '@signozhq/icons';
import { Divider, Modal } from 'antd';
import { Modal } from 'antd';
import { Divider } from '@signozhq/ui/divider';
import logEvent from 'api/common/logEvent';
import PipelineProcessingPreview from 'container/PipelinePage/PipelineListsView/Preview/PipelineProcessingPreview';
import { PipelineData } from 'types/api/pipeline/def';

View File

@@ -1,5 +1,6 @@
import { useMemo } from 'react';
import { Button, Divider, Flex, Form, Input, Modal, Select } from 'antd';
import { Button, Flex, Form, Input, Modal, Select } from 'antd';
import { Divider } from '@signozhq/ui/divider';
import { Typography } from '@signozhq/ui/typography';
import ROUTES from 'constants/routes';
import { ModalTitle } from 'container/PipelinePage/PipelineListsView/styles';

View File

@@ -1,6 +1,7 @@
import { useCallback, useMemo, useState } from 'react';
import { Color, Spacing } from '@signozhq/design-tokens';
import { Button, Divider, Drawer, RadioChangeEvent } from 'antd';
import { Button, Drawer, RadioChangeEvent } from 'antd';
import { Divider } from '@signozhq/ui/divider';
import { Typography } from '@signozhq/ui/typography';
import LogsIcon from 'assets/AlertHistory/LogsIcon';
import SignozRadioGroup from 'components/SignozRadioGroup/SignozRadioGroup';

View File

@@ -2,7 +2,8 @@ import { MouseEventHandler, useState } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useDispatch, useSelector } from 'react-redux';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import { Card, Divider } from 'antd';
import { Card } from 'antd';
import { Divider } from '@signozhq/ui/divider';
import { Typography } from '@signozhq/ui/typography';
import getFilters from 'api/trace/getFilters';
import { AxiosError } from 'axios';

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { Divider } from 'antd';
import { Divider } from '@signozhq/ui/divider';
import logEvent from 'api/common/logEvent';
import classNames from 'classnames';
import AlertBreadcrumb from 'components/AlertBreadcrumb';

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react';
import { Color } from '@signozhq/design-tokens';
import { Divider, Dropdown, MenuProps, Tooltip } from 'antd';
import { Dropdown, MenuProps, Tooltip } from 'antd';
import { Divider } from '@signozhq/ui/divider';
import { Switch } from '@signozhq/ui/switch';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { Copy, Ellipsis, PenLine, Trash2 } from '@signozhq/icons';

View File

@@ -1,6 +1,7 @@
import { useMemo } from 'react';
import { Color, Spacing } from '@signozhq/design-tokens';
import { Divider, Drawer } from 'antd';
import { Drawer } from 'antd';
import { Divider } from '@signozhq/ui/divider';
import { Typography } from '@signozhq/ui/typography';
import { RowData } from 'components/CeleryOverview/CeleryOverviewTable/CeleryOverviewTable';
import { useIsDarkMode } from 'hooks/useDarkMode';

View File

@@ -2,7 +2,8 @@ import { useCallback, useMemo } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useDispatch, useSelector } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { Button, Col, Divider, Popover, Row, Select, Space } from 'antd';
import { Button, Col, Popover, Row, Select, Space } from 'antd';
import { Divider } from '@signozhq/ui/divider';
import { QueryParams } from 'constants/query';
import LogControls from 'container/LogControls';
import LogDetailedView from 'container/LogDetailedView';

View File

@@ -8,7 +8,6 @@
align-items: center;
padding: 8px 16px;
gap: 8px;
min-height: 52px;
// KeyValueLabel renders with a global `.key-value-label` root; keep it from
// shrinking on the trace details header.
@@ -21,28 +20,6 @@
flex-shrink: 0;
}
.traceIdSection {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.filterSection {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
margin-left: auto;
}
.headerActions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.filter {
min-width: 0;
}
@@ -52,6 +29,15 @@
flex: 1;
}
.oldViewBtn {
flex-shrink: 0;
}
.analyticsBtn {
flex-shrink: 0;
margin-left: auto;
}
.subHeader {
display: flex;
align-items: center;

View File

@@ -21,7 +21,6 @@ import {
ArrowLeft,
CalendarClock,
ChartPie,
CornerUpLeft,
Server,
Timer,
} from '@signozhq/icons';
@@ -118,7 +117,7 @@ function TraceDetailsHeader({
<div className={styles.wrapper}>
<div className={styles.header}>
{!isFilterExpanded && (
<div className={styles.traceIdSection}>
<>
<Button
variant="solid"
color="secondary"
@@ -134,39 +133,20 @@ function TraceDetailsHeader({
badgeValue={traceID || ''}
maxCharacters={100}
/>
</div>
</>
)}
{isDataLoaded && (
<div
className={cx(
styles.filterSection,
isFilterExpanded && styles.isExpanded,
)}
>
<>
{!isFilterExpanded && (
<TooltipProvider>
<div className={styles.headerActions}>
<>
<TooltipProvider>
<TooltipRoot>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
color="secondary"
aria-label="Switch to legacy trace view"
onClick={handleSwitchToOldView}
>
<CornerUpLeft size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>Switch to legacy trace view</TooltipContent>
</TooltipRoot>
<TooltipRoot>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
color="secondary"
aria-label="Analytics"
className={styles.analyticsBtn}
onClick={(): void => setIsAnalyticsOpen((prev) => !prev)}
>
<ChartPie size={14} />
@@ -174,18 +154,15 @@ function TraceDetailsHeader({
</TooltipTrigger>
<TooltipContent>Analytics</TooltipContent>
</TooltipRoot>
<TraceOptionsMenu
showTraceDetails={showTraceDetails}
onToggleTraceDetails={handleToggleTraceDetails}
onOpenPreviewFields={(): void => setIsPreviewFieldsOpen(true)}
/>
</div>
</TooltipProvider>
</TooltipProvider>
<TraceOptionsMenu
showTraceDetails={showTraceDetails}
onToggleTraceDetails={handleToggleTraceDetails}
onOpenPreviewFields={(): void => setIsPreviewFieldsOpen(true)}
/>
</>
)}
<div
key="filter"
className={cx(styles.filter, isFilterExpanded && styles.isExpanded)}
>
<div className={cx(styles.filter, isFilterExpanded && styles.isExpanded)}>
<Filters
startTime={filterMetadata.startTime}
endTime={filterMetadata.endTime}
@@ -196,7 +173,18 @@ function TraceDetailsHeader({
onCollapse={(): void => setIsFilterExpanded(false)}
/>
</div>
</div>
{!isFilterExpanded && (
<Button
variant="solid"
color="secondary"
size="sm"
className={styles.oldViewBtn}
onClick={handleSwitchToOldView}
>
Legacy View
</Button>
)}
</>
)}
</div>

View File

@@ -2,7 +2,7 @@ import { useMemo } from 'react';
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple as Dropdown } from '@signozhq/ui/dropdown-menu';
import { Settings2 } from '@signozhq/icons';
import { Ellipsis } from '@signozhq/icons';
import { useTraceStore } from '../stores/traceStore';
@@ -93,8 +93,7 @@ function TraceOptionsMenu({
variant="ghost"
size="icon"
color="secondary"
aria-label="Trace options"
prefix={<Settings2 size={14} />}
prefix={<Ellipsis size={14} />}
/>
</Dropdown>
);

View File

@@ -6,7 +6,6 @@ import TraceDetailsHeader from '../TraceDetailsHeader';
const mockGoBack = jest.fn();
const mockPush = jest.fn();
const mockReplace = jest.fn();
const mockHasInAppHistory = jest.fn();
jest.mock('lib/history', () => ({
@@ -14,47 +13,13 @@ jest.mock('lib/history', () => ({
default: {
goBack: (): void => mockGoBack(),
push: (path: string): void => mockPush(path),
replace: (path: string): void => mockReplace(path),
replace: jest.fn(),
location: { pathname: '/', search: '' },
listen: (): (() => void) => (): void => undefined,
},
hasInAppHistory: (): boolean => mockHasInAppHistory(),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: (): { id: string } => ({ id: 'trace-123' }),
}));
const mockSetLocalStorageKey = jest.fn();
jest.mock('api/browser/localstorage/set', () => ({
__esModule: true,
default: (key: string, value: string): void =>
mockSetLocalStorageKey(key, value),
}));
jest.mock(
'../../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters',
() => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="filters-stub" />,
}),
);
jest.mock('../../SpanDetailsPanel/AnalyticsPanel/AnalyticsPanel', () => ({
__esModule: true,
default: ({ isOpen }: { isOpen: boolean }): JSX.Element => (
<div data-testid="analytics-panel" data-open={isOpen ? 'true' : 'false'} />
),
}));
jest.mock('components/FieldsSelector', () => ({
__esModule: true,
default: ({ isOpen }: { isOpen: boolean }): JSX.Element => (
<div data-testid="fields-selector" data-open={isOpen ? 'true' : 'false'} />
),
}));
const baseProps = {
filterMetadata: {
startTime: 0,
@@ -93,70 +58,3 @@ describe('TraceDetailsHeader back button', () => {
expect(mockGoBack).not.toHaveBeenCalled();
});
});
describe('TraceDetailsHeader action cluster', () => {
beforeEach(() => {
mockReplace.mockClear();
mockSetLocalStorageKey.mockClear();
});
it('does not render the action buttons while data is still loading', () => {
render(<TraceDetailsHeader {...baseProps} isDataLoaded={false} />);
expect(
screen.queryByRole('button', { name: /switch to legacy trace view/i }),
).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /^analytics$/i }),
).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /trace options/i }),
).not.toBeInTheDocument();
});
it('renders Legacy View, Analytics, and Settings action buttons once data is loaded', () => {
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
expect(
screen.getByRole('button', { name: /switch to legacy trace view/i }),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /^analytics$/i }),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /trace options/i }),
).toBeInTheDocument();
});
it('routes to the legacy trace view and persists the preference on click', () => {
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
fireEvent.click(
screen.getByRole('button', { name: /switch to legacy trace view/i }),
);
expect(mockSetLocalStorageKey).toHaveBeenCalledWith(
'TRACE_DETAILS_PREFER_OLD_VIEW',
'true',
);
expect(mockReplace).toHaveBeenCalledTimes(1);
expect(mockReplace).toHaveBeenCalledWith(
expect.stringContaining('/trace-old/trace-123'),
);
});
it('toggles the AnalyticsPanel open state when the Analytics button is clicked', () => {
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
const panel = screen.getByTestId('analytics-panel');
expect(panel).toHaveAttribute('data-open', 'false');
const analyticsBtn = screen.getByRole('button', { name: /^analytics$/i });
fireEvent.click(analyticsBtn);
expect(panel).toHaveAttribute('data-open', 'true');
fireEvent.click(analyticsBtn);
expect(panel).toHaveAttribute('data-open', 'false');
});
});

View File

@@ -3,6 +3,12 @@
align-items: center;
gap: 12px;
// QuerySearch child sets `query-builder-search-v2` globally; size it to the
// search container by reaching into the descendant.
:global(.query-builder-search-v2) {
width: 100%;
}
// ToggleGroup children use generated class names; nest the global selectors
// under the local row so they only apply inside this filter row.
:global([class*='toggle-group']) {
@@ -14,43 +20,8 @@
}
}
// Expanded-mode root: grows to fill .filter wrapper, and lets the search
// input flex within. In collapsed mode none of these grow — the whole
// Filters region is content-sized (just the pill + result + toggle).
.isExpanded {
flex: 1;
.searchInput {
flex: 1;
}
.searchAndNav {
flex: 1;
}
}
.categoryControls {
display: flex;
align-items: center;
flex-shrink: 0;
}
.searchInput {
display: flex;
align-items: center;
min-width: 0;
}
.searchPill {
display: flex;
align-items: center;
flex-shrink: 0;
}
.searchAndNav {
display: flex;
align-items: center;
min-width: 0;
}
.searchContainer {
@@ -58,25 +29,6 @@
min-width: 0;
}
.resultActions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.expandedActions {
display: flex;
align-items: center;
gap: 2px;
}
.highlightControl {
display: flex;
align-items: center;
flex-shrink: 0;
}
.pill {
display: flex;
align-items: center;
@@ -133,6 +85,14 @@
border-radius: 4px;
}
.collapseBtn {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
box-shadow: none;
}
.highlightErrorsToggle {
display: flex;
align-items: center;
@@ -140,3 +100,37 @@
flex-shrink: 0;
white-space: nowrap;
}
.preNextToggle {
display: flex;
flex-shrink: 0;
gap: 12px;
}
.preNextCount {
display: flex;
align-items: center;
margin: auto;
color: var(--l2-foreground);
font-family: 'Geist Mono';
font-size: 12px;
font-weight: 400;
line-height: 18px;
}
.filterStatus {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
color: var(--l2-foreground);
font-family: 'Geist Mono';
font-size: 12px;
font-weight: 400;
line-height: 18px;
}
.hasError {
color: var(--destructive);
cursor: help;
}

View File

@@ -1,7 +1,15 @@
import { useCallback, useRef, useState } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import { useCopyToClipboard } from 'react-use';
import { ChevronsRight, Copy, Search, X } from '@signozhq/icons';
import {
ChevronDown,
ChevronUp,
Copy,
Info,
Loader,
Search,
X,
} from '@signozhq/icons';
import { Switch } from '@signozhq/ui/switch';
import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
import { toast } from '@signozhq/ui/sonner';
@@ -13,6 +21,7 @@ import {
TooltipTrigger,
} from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { AxiosError } from 'axios';
import cx from 'classnames';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import { convertExpressionToFilters } from 'components/QueryBuilderV2/utils';
@@ -33,7 +42,6 @@ import {
SpanCategory,
useSpanCategoryFilter,
} from './hooks/useSpanCategoryFilter';
import QueryResult from './QueryResult';
import styles from './Filters.module.scss';
@@ -144,16 +152,6 @@ function Filters({
runQuery(expressionRef.current);
}, [runQuery]);
const handleClear = useCallback((): void => {
setExpression('');
expressionRef.current = '';
setFilters({ items: [], op: 'AND' });
setFilteredSpanIds([]);
onFilteredSpansChange?.([], false);
setCurrentSearchedIndex(0);
setNoData(false);
}, [onFilteredSpansChange]);
// Expression-based filter hooks
const filterProps = {
expression,
@@ -268,167 +266,164 @@ function Filters({
</div>
);
const hasExpression = expression.trim().length > 0;
const hasResults = filteredSpanIds.length > 0;
const handlePrev = useCallback((): void => {
handlePrevNext(currentSearchedIndex - 1);
setCurrentSearchedIndex((prev) => prev - 1);
}, [currentSearchedIndex, handlePrevNext]);
const handleNext = useCallback((): void => {
handlePrevNext(currentSearchedIndex + 1);
setCurrentSearchedIndex((prev) => prev + 1);
}, [currentSearchedIndex, handlePrevNext]);
const pill = (
/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */
<div className={styles.pill} onClick={onExpand}>
<Search size={12} />
<span className={styles.pillText}>{expression || 'Search...'}</span>
{expression && <span className={styles.pillIndicator} />}
</div>
const statusIndicators = (
<>
{isFetching && <Loader className="animate-spin" />}
{error && (
<TooltipRoot>
<TooltipTrigger asChild>
<span className={cx(styles.filterStatus, styles.hasError)}>
<Info />
API error
</span>
</TooltipTrigger>
<TooltipContent>
{(error as AxiosError)?.message || 'Something went wrong'}
</TooltipContent>
</TooltipRoot>
)}
{!error && noData && (
<Typography.Text className={styles.filterStatus}>
No results found
</Typography.Text>
)}
</>
);
const pillWithPopover = expression ? (
<TooltipRoot>
<TooltipTrigger asChild>{pill}</TooltipTrigger>
<TooltipContent side="bottom" align="start">
<div className={styles.pillPopover}>
<div className={styles.pillPopoverHeader}>
<Typography.Text>Search query</Typography.Text>
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={(): void => {
setCopy(expression);
toast.success('Copied to clipboard', {
richColors: false,
position: 'top-right',
});
}}
>
<Copy size={12} />
</Button>
</div>
<div className={styles.pillPopoverExpression}>{expression}</div>
</div>
</TooltipContent>
</TooltipRoot>
) : (
pill
);
// --- COLLAPSED VIEW ---
if (!isExpanded) {
const pill = (
/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */
<div className={styles.pill} onClick={onExpand}>
<Search size={12} />
<span className={styles.pillText}>{expression || 'Search...'}</span>
{expression && <span className={styles.pillIndicator} />}
</div>
);
// Mode-conditional render: only one of (pill | QuerySearch) is mounted
// at a time. Collapsing unmounts the editor — half-written queries are
// dropped, so collapse can't accidentally commit a malformed expression
// and fire an erroring /query_range request.
return (
<TooltipProvider>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
className={cx(styles.root, isExpanded && styles.isExpanded)}
ref={containerRef}
onBlur={(e): void => {
const relatedTarget = e.relatedTarget as Node | null;
const blurredIntoSelf = !!containerRef.current?.contains(relatedTarget);
if (!blurredIntoSelf) {
handleBlur();
}
}}
>
{isExpanded && (
<div className={styles.categoryControls}>
<ToggleGroup
type="single"
value={selectedCategory}
onChange={(value): void => {
if (value) {
handleCategoryChange(value as SpanCategory);
}
}}
size="sm"
>
{categories.map((category) => (
<ToggleGroupItem key={category} value={category}>
{category}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
)}
<div className={styles.searchInput}>
{isExpanded ? (
<div className={styles.searchAndNav}>
<div className={styles.searchContainer}>
<QuerySearch
queryData={{
...BASE_FILTER_QUERY,
filters,
filter: { expression },
}}
onChange={handleExpressionChange}
onRun={handleRunQuery}
dataSource={DataSource.TRACES}
placeholder="Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')"
/>
</div>
</div>
) : (
<div className={styles.searchPill}>{pillWithPopover}</div>
)}
</div>
<div className={styles.resultActions}>
<QueryResult
hasExpression={hasExpression}
hasResults={hasResults}
isFetching={isFetching}
error={error}
noData={noData}
currentIndex={currentSearchedIndex}
total={filteredSpanIds.length}
onPrev={handlePrev}
onNext={handleNext}
showNavigation={isExpanded}
/>
{isExpanded && (
<div className={styles.expandedActions}>
{hasExpression && (
<TooltipRoot>
<TooltipTrigger asChild>
return (
<TooltipProvider>
<div className={styles.root}>
{expression ? (
<TooltipRoot>
<TooltipTrigger asChild>{pill}</TooltipTrigger>
<TooltipContent side="bottom" align="start">
<div className={styles.pillPopover}>
<div className={styles.pillPopoverHeader}>
<Typography.Text>Search query</Typography.Text>
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={handleClear}
onClick={(): void => {
setCopy(expression);
toast.success('Copied to clipboard', {
richColors: false,
position: 'top-right',
});
}}
>
<X size={14} />
<Copy size={12} />
</Button>
</TooltipTrigger>
<TooltipContent>Clear filter</TooltipContent>
</TooltipRoot>
)}
<TooltipRoot>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={onCollapse}
>
<ChevronsRight size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>Collapse filters</TooltipContent>
</TooltipRoot>
</div>
</div>
<div className={styles.pillPopoverExpression}>{expression}</div>
</div>
</TooltipContent>
</TooltipRoot>
) : (
pill
)}
{highlightErrorsToggle}
{statusIndicators}
</div>
</TooltipProvider>
);
}
<div className={styles.highlightControl}>{highlightErrorsToggle}</div>
// --- EXPANDED VIEW ---
return (
<TooltipProvider>
<div className={cx(styles.root, styles.isExpanded)}>
<ToggleGroup
type="single"
value={selectedCategory}
onChange={(value): void => {
if (value) {
handleCategoryChange(value as SpanCategory);
}
}}
size="sm"
>
{categories.map((category) => (
<ToggleGroupItem key={category} value={category}>
{category}
</ToggleGroupItem>
))}
</ToggleGroup>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
className={styles.searchContainer}
ref={containerRef}
onBlur={(e): void => {
if (!containerRef.current?.contains(e.relatedTarget as Node)) {
handleBlur();
}
}}
>
<QuerySearch
queryData={{
...BASE_FILTER_QUERY,
filters,
filter: { expression },
}}
onChange={handleExpressionChange}
onRun={handleRunQuery}
dataSource={DataSource.TRACES}
placeholder="Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')"
/>
</div>
{filteredSpanIds.length > 0 && (
<div className={styles.preNextToggle}>
<Typography.Text className={styles.preNextCount}>
{currentSearchedIndex + 1} / {filteredSpanIds.length}
</Typography.Text>
<Button
variant="ghost"
size="icon"
color="secondary"
disabled={currentSearchedIndex === 0}
onClick={(): void => {
handlePrevNext(currentSearchedIndex - 1);
setCurrentSearchedIndex((prev) => prev - 1);
}}
>
<ChevronUp size={14} />
</Button>
<Button
variant="ghost"
size="icon"
color="secondary"
disabled={currentSearchedIndex === filteredSpanIds.length - 1}
onClick={(): void => {
handlePrevNext(currentSearchedIndex + 1);
setCurrentSearchedIndex((prev) => prev + 1);
}}
>
<ChevronDown size={14} />
</Button>
</div>
)}
<Button
variant="ghost"
size="icon"
color="secondary"
className={styles.collapseBtn}
onClick={onCollapse}
>
<X size={14} />
</Button>
{highlightErrorsToggle}
{statusIndicators}
</div>
</TooltipProvider>
);

View File

@@ -1,32 +0,0 @@
.resultNavCount {
padding: 0 6px;
white-space: nowrap;
color: var(--l1-foreground);
font-family: 'Geist Mono';
font-size: 12px;
}
.resultNavDivider {
width: 1px;
height: 14px;
background: var(--l3-border);
margin: 0 4px;
flex-shrink: 0;
}
.filterStatus {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
color: var(--l2-foreground);
font-family: 'Geist Mono';
font-size: 12px;
font-weight: 400;
line-height: 18px;
}
.hasError {
color: var(--destructive);
cursor: help;
}

View File

@@ -1,111 +0,0 @@
import { ChevronDown, ChevronUp, Info, Loader } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import {
TooltipContent,
TooltipRoot,
TooltipTrigger,
} from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { AxiosError } from 'axios';
import cx from 'classnames';
import styles from './QueryResult.module.scss';
type QueryResultProps = {
hasExpression: boolean;
hasResults: boolean;
isFetching: boolean;
error: unknown;
noData: boolean;
currentIndex: number;
total: number;
onPrev: () => void;
onNext: () => void;
showNavigation?: boolean;
};
function QueryResult({
hasExpression,
hasResults,
isFetching,
error,
noData,
currentIndex,
total,
onPrev,
onNext,
showNavigation = true,
}: QueryResultProps): JSX.Element | null {
if (!hasExpression) {
return null;
}
let content: JSX.Element | null = null;
if (hasResults && showNavigation) {
// Prefer count over loader on refresh so stale results stay visible.
content = (
<>
<Typography.Text className={styles.resultNavCount}>
{currentIndex + 1} / {total}
</Typography.Text>
<Button
variant="ghost"
size="icon"
color="secondary"
disabled={currentIndex === 0}
onClick={onPrev}
>
<ChevronUp size={14} />
</Button>
<Button
variant="ghost"
size="icon"
color="secondary"
disabled={currentIndex === total - 1}
onClick={onNext}
>
<ChevronDown size={14} />
</Button>
</>
);
} else if (isFetching) {
content = <Loader className="animate-spin" />;
} else if (error) {
content = (
<TooltipRoot>
<TooltipTrigger asChild>
<span className={cx(styles.filterStatus, styles.hasError)}>
<Info />
API error
</span>
</TooltipTrigger>
<TooltipContent>
{(error as AxiosError)?.message || 'Something went wrong'}
</TooltipContent>
</TooltipRoot>
);
} else if (noData) {
content = (
<Typography.Text className={styles.filterStatus}>
No results found
</Typography.Text>
);
}
if (!content) {
return null;
}
return (
<>
{content}
{showNavigation && <span className={styles.resultNavDivider} />}
</>
);
}
QueryResult.defaultProps = {
showNavigation: true,
};
export default QueryResult;

View File

@@ -6,7 +6,8 @@ import {
useMemo,
useState,
} from 'react';
import { Button, Collapse, Divider } from 'antd';
import { Button, Collapse } from 'antd';
import { Divider } from '@signozhq/ui/divider';
import { DurationSection } from './DurationSection';
import {

View File

@@ -1,5 +1,6 @@
import { memo, useState } from 'react';
import { Button, Divider, Tooltip } from 'antd';
import { Button, Tooltip } from 'antd';
import { Divider } from '@signozhq/ui/divider';
import cx from 'classnames';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import useFunnelConfiguration from 'hooks/TracesFunnels/useFunnelConfiguration';

View File

@@ -1,13 +1,6 @@
import { useMemo, useState } from 'react';
import {
Button,
Divider,
Dropdown,
Form,
MenuProps,
Space,
Tooltip,
} from 'antd';
import { Button, Dropdown, Form, MenuProps, Space, Tooltip } from 'antd';
import { Divider } from '@signozhq/ui/divider';
import { Switch } from '@signozhq/ui/switch';
import cx from 'classnames';
import { FilterSelect } from 'components/CeleryOverview/CeleryOverviewConfigOptions/CeleryOverviewConfigOptions';

View File

@@ -1,4 +1,4 @@
import { Divider } from 'antd';
import { Divider } from '@signozhq/ui/divider';
import SignozRadioGroup from 'components/SignozRadioGroup/SignozRadioGroup';
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
import { useAppContext } from 'providers/App/App';

View File

@@ -825,5 +825,4 @@ body.ai-assistant-panel-open {
// overrides
:root {
--input-focus-outline-width: 0;
--radius-2: 4px;
}

View File

@@ -135,5 +135,4 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
AI_ASSISTANT: ['ADMIN', 'EDITOR', 'VIEWER'],
AI_ASSISTANT_ICON_PREVIEW: ['ADMIN', 'EDITOR', 'VIEWER'],
MCP_SERVER: ['ADMIN', 'EDITOR', 'VIEWER'],
AI_ASSISTANT_BASE: ['ADMIN', 'EDITOR', 'VIEWER'],
};

View File

@@ -84,53 +84,244 @@ func New(
}
}
// extractShiftFromBuilderQuery extracts the shift value from timeShift function if present.
func extractShiftFromBuilderQuery[T any](spec qbtypes.QueryBuilderQuery[T]) int64 {
for _, fn := range spec.Functions {
if fn.Name == qbtypes.FunctionNameTimeShift && len(fn.Args) > 0 {
switch v := fn.Args[0].Value.(type) {
case float64:
return int64(v)
case int64:
return v
case int:
return int64(v)
case string:
if shiftFloat, err := strconv.ParseFloat(v, 64); err == nil {
return int64(shiftFloat)
}
}
}
}
return 0
}
// adjustTimeRangeForShift adjusts the time range based on the shift value from timeShift function.
func adjustTimeRangeForShift[T any](spec qbtypes.QueryBuilderQuery[T], tr qbtypes.TimeRange, kind qbtypes.RequestType) qbtypes.TimeRange {
// Only apply time shift for time series and scalar queries
// Raw/list queries don't support timeshift
if kind != qbtypes.RequestTypeTimeSeries && kind != qbtypes.RequestTypeScalar {
return tr
}
// Use the ShiftBy field if it's already populated, otherwise extract it
shiftBy := spec.ShiftBy
if shiftBy == 0 {
shiftBy = extractShiftFromBuilderQuery(spec)
}
if shiftBy == 0 {
return tr
}
// ShiftBy is in seconds, convert to milliseconds and shift backward in time
shiftMS := shiftBy * 1000
return qbtypes.TimeRange{
From: tr.From - uint64(shiftMS),
To: tr.To - uint64(shiftMS),
}
}
func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest) (*qbtypes.QueryRangeResponse, error) {
tmplVars := req.Variables
if tmplVars == nil {
tmplVars = make(map[string]qbtypes.VariableItem)
}
event := &qbtypes.QBEvent{
Version: "v5",
NumberOfQueries: len(req.CompositeQuery.Queries),
PanelType: req.RequestType.StringValue(),
}
q.populateQBEvent(event, req.CompositeQuery.Queries)
intervalWarnings := []string{}
// TraceOperatorQuery leverages other queries defined in the rangeRequest
// Eg: C := A => B
// Need to create dependency map { "A": true, "B": true }
dependencyQueries, err := q.constructTraceOperatorDependencyMap(req.CompositeQuery.Queries)
if err != nil {
return nil, err
dependencyQueries := make(map[string]bool)
traceOperatorQueries := make(map[string]qbtypes.QueryBuilderTraceOperator)
for _, query := range req.CompositeQuery.Queries {
if query.Type == qbtypes.QueryTypeTraceOperator {
if spec, ok := query.Spec.(qbtypes.QueryBuilderTraceOperator); ok {
// Parse expression to find dependencies
if err := spec.ParseExpression(); err != nil {
return nil, err
}
deps := spec.CollectReferencedQueries(spec.ParsedExpression)
for _, dep := range deps {
dependencyQueries[dep] = true
}
traceOperatorQueries[spec.Name] = spec
}
}
}
// Step interval is the aggregation parameter for timeseries requests.
// We need to set if it is unspecified or adjust it if value is not within recommended range
intervalWarnings := q.adjustStepInterval(req.CompositeQuery.Queries, req.Start, req.End)
// First pass: collect all metric names that need temporality
metricNames := make([]string, 0)
for idx, query := range req.CompositeQuery.Queries {
event.QueryType = query.Type.StringValue()
switch query.Type {
case qbtypes.QueryTypeBuilder:
if spec, ok := query.Spec.(qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]); ok {
for _, agg := range spec.Aggregations {
if agg.MetricName != "" {
metricNames = append(metricNames, agg.MetricName)
}
}
}
// if step interval is not set, we set it ourselves with recommended value
// if step interval is set to value which could result in points more than
// allowed, we override it.
switch spec := query.Spec.(type) {
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
event.TracesUsed = true
event.FilterApplied = spec.Filter != nil && spec.Filter.Expression != ""
event.GroupByApplied = len(spec.GroupBy) > 0
if spec.StepInterval.Seconds() == 0 {
spec.StepInterval = qbtypes.Step{
Duration: time.Second * time.Duration(querybuilder.RecommendedStepInterval(req.Start, req.End)),
}
}
if spec.StepInterval.Seconds() < float64(querybuilder.MinAllowedStepInterval(req.Start, req.End)) {
newStep := qbtypes.Step{
Duration: time.Second * time.Duration(querybuilder.MinAllowedStepInterval(req.Start, req.End)),
}
intervalWarnings = append(intervalWarnings, fmt.Sprintf(intervalWarn, spec.Name, spec.StepInterval.Seconds(), newStep.Seconds()))
spec.StepInterval = newStep
}
req.CompositeQuery.Queries[idx].Spec = spec
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
event.LogsUsed = true
event.FilterApplied = spec.Filter != nil && spec.Filter.Expression != ""
event.GroupByApplied = len(spec.GroupBy) > 0
if spec.StepInterval.Seconds() == 0 {
spec.StepInterval = qbtypes.Step{
Duration: time.Second * time.Duration(querybuilder.RecommendedStepInterval(req.Start, req.End)),
}
}
if spec.StepInterval.Seconds() < float64(querybuilder.MinAllowedStepInterval(req.Start, req.End)) {
newStep := qbtypes.Step{
Duration: time.Second * time.Duration(querybuilder.MinAllowedStepInterval(req.Start, req.End)),
}
intervalWarnings = append(intervalWarnings, fmt.Sprintf(intervalWarn, spec.Name, spec.StepInterval.Seconds(), newStep.Seconds()))
spec.StepInterval = newStep
}
req.CompositeQuery.Queries[idx].Spec = spec
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
event.MetricsUsed = true
event.FilterApplied = spec.Filter != nil && spec.Filter.Expression != ""
event.GroupByApplied = len(spec.GroupBy) > 0
if spec.Source == telemetrytypes.SourceMeter {
if spec.StepInterval.Seconds() == 0 {
spec.StepInterval = qbtypes.Step{Duration: time.Second * time.Duration(querybuilder.RecommendedStepIntervalForMeter(req.Start, req.End))}
}
if spec.StepInterval.Seconds() < float64(querybuilder.MinAllowedStepIntervalForMeter(req.Start, req.End)) {
newStep := qbtypes.Step{
Duration: time.Second * time.Duration(querybuilder.MinAllowedStepIntervalForMeter(req.Start, req.End)),
}
spec.StepInterval = newStep
}
} else {
if spec.StepInterval.Seconds() == 0 {
spec.StepInterval = qbtypes.Step{
Duration: time.Second * time.Duration(querybuilder.RecommendedStepIntervalForMetric(req.Start, req.End)),
}
}
if spec.StepInterval.Seconds() < float64(querybuilder.MinAllowedStepIntervalForMetric(req.Start, req.End)) {
newStep := qbtypes.Step{
Duration: time.Second * time.Duration(querybuilder.MinAllowedStepIntervalForMetric(req.Start, req.End)),
}
intervalWarnings = append(intervalWarnings, fmt.Sprintf(intervalWarn, spec.Name, spec.StepInterval.Seconds(), newStep.Seconds()))
spec.StepInterval = newStep
}
}
req.CompositeQuery.Queries[idx].Spec = spec
}
case qbtypes.QueryTypePromQL:
event.MetricsUsed = true
switch spec := query.Spec.(type) {
case qbtypes.PromQuery:
if spec.Step.Seconds() == 0 {
spec.Step = qbtypes.Step{
Duration: time.Second * time.Duration(querybuilder.RecommendedStepIntervalForMetric(req.Start, req.End)),
}
}
req.CompositeQuery.Queries[idx].Spec = spec
}
case qbtypes.QueryTypeClickHouseSQL:
switch spec := query.Spec.(type) {
case qbtypes.ClickHouseQuery:
if strings.TrimSpace(spec.Query) != "" {
event.MetricsUsed = strings.Contains(spec.Query, "signoz_metrics")
event.LogsUsed = strings.Contains(spec.Query, "signoz_logs")
event.TracesUsed = strings.Contains(spec.Query, "signoz_traces")
}
}
case qbtypes.QueryTypeTraceOperator:
if spec, ok := query.Spec.(qbtypes.QueryBuilderTraceOperator); ok {
if spec.StepInterval.Seconds() == 0 {
spec.StepInterval = qbtypes.Step{
Duration: time.Second * time.Duration(querybuilder.RecommendedStepInterval(req.Start, req.End)),
}
}
if spec.StepInterval.Seconds() < float64(querybuilder.MinAllowedStepInterval(req.Start, req.End)) {
newStep := qbtypes.Step{
Duration: time.Second * time.Duration(querybuilder.MinAllowedStepInterval(req.Start, req.End)),
}
intervalWarnings = append(intervalWarnings, fmt.Sprintf(intervalWarn, spec.Name, spec.StepInterval.Seconds(), newStep.Seconds()))
spec.StepInterval = newStep
}
req.CompositeQuery.Queries[idx].Spec = spec
}
}
}
queries := make(map[string]qbtypes.Query)
steps := make(map[string]qbtypes.Step)
// Resolve metric metadata once per request: patches each metric-aggregation
// query's spec in place, returns the queries whose every aggregation was
// missing (used for preseeded empty results), and any dormant-metric
// warning string. NotFound errors for never-seen metrics are propagated.
missingMetricQueries, dormantMetricsWarningMsg, err := q.resolveMetricMetadata(ctx, req.CompositeQuery.Queries, req.Start, req.End)
if err != nil {
return nil, err
}
missingMetricQuerySet := make(map[string]bool, len(missingMetricQueries))
for _, name := range missingMetricQueries {
missingMetricQuerySet[name] = true
}
missingMetrics := []string{}
missingMetricQueries := []string{}
for _, query := range req.CompositeQuery.Queries {
queryName := query.GetQueryName()
var queryName string
var isTraceOperator bool
// skip if it is dependecy of traceOperatorQuery
if query.GetType() != qbtypes.QueryTypeTraceOperator && dependencyQueries[queryName] {
switch query.Type {
case qbtypes.QueryTypeTraceOperator:
if spec, ok := query.Spec.(qbtypes.QueryBuilderTraceOperator); ok {
queryName = spec.Name
isTraceOperator = true
}
case qbtypes.QueryTypePromQL:
if spec, ok := query.Spec.(qbtypes.PromQuery); ok {
queryName = spec.Name
}
case qbtypes.QueryTypeClickHouseSQL:
if spec, ok := query.Spec.(qbtypes.ClickHouseQuery); ok {
queryName = spec.Name
}
case qbtypes.QueryTypeBuilder:
switch spec := query.Spec.(type) {
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
queryName = spec.Name
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
queryName = spec.Name
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
queryName = spec.Name
}
}
if !isTraceOperator && dependencyQueries[queryName] {
continue
}
@@ -185,13 +376,40 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
queries[spec.Name] = bq
steps[spec.Name] = spec.StepInterval
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
// Spec was already patched by resolveMetricMetadata. Queries
// whose every aggregation was missing live in
// missingMetricQuerySet and produce empty preseeded results
// rather than running here.
if missingMetricQuerySet[spec.Name] {
var metricTemporality map[string]metrictypes.Temporality
var metricTypes map[string]metrictypes.Type
if len(metricNames) > 0 {
var err error
metricTemporality, metricTypes, err = q.metadataStore.FetchTemporalityAndTypeMulti(ctx, req.Start, req.End, metricNames...)
if err != nil {
q.logger.WarnContext(ctx, "failed to fetch metric temporality", errors.Attr(err), slog.Any("metrics", metricNames))
return nil, errors.NewInternalf(errors.CodeInternal, "failed to fetch metrics temporality")
}
q.logger.DebugContext(ctx, "fetched metric temporalities and types", slog.Any("metric_temporality", metricTemporality), slog.Any("metric_types", metricTypes))
}
presentAggregations := []qbtypes.MetricAggregation{}
for i := range spec.Aggregations {
if spec.Aggregations[i].MetricName != "" && spec.Aggregations[i].Temporality == metrictypes.Unknown {
if temp, ok := metricTemporality[spec.Aggregations[i].MetricName]; ok && temp != metrictypes.Unknown {
spec.Aggregations[i].Temporality = temp
}
}
if spec.Aggregations[i].MetricName != "" && spec.Aggregations[i].Type == metrictypes.UnspecifiedType {
if foundMetricType, ok := metricTypes[spec.Aggregations[i].MetricName]; ok && foundMetricType != metrictypes.UnspecifiedType {
spec.Aggregations[i].Type = foundMetricType
}
}
if spec.Aggregations[i].Type == metrictypes.UnspecifiedType {
missingMetrics = append(missingMetrics, spec.Aggregations[i].MetricName)
continue
}
presentAggregations = append(presentAggregations, spec.Aggregations[i])
}
if len(presentAggregations) == 0 {
missingMetricQueries = append(missingMetricQueries, spec.Name)
continue
}
spec.Aggregations = presentAggregations
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType)
var bq *builderQuery[qbtypes.MetricAggregation]
@@ -210,6 +428,38 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
}
}
}
nonExistentMetrics := []string{}
var dormantMetricsWarningMsg string
if len(missingMetrics) > 0 {
lastSeenInfo, _ := q.metadataStore.FetchLastSeenInfoMulti(ctx, missingMetrics...)
for _, missingMetricName := range missingMetrics {
if ts, ok := lastSeenInfo[missingMetricName]; ok && ts > 0 {
continue
}
nonExistentMetrics = append(nonExistentMetrics, missingMetricName)
}
if len(nonExistentMetrics) == 1 {
return nil, errors.NewNotFoundf(errors.CodeNotFound, "could not find the metric %s", nonExistentMetrics[0])
} else if len(nonExistentMetrics) > 1 {
return nil, errors.NewNotFoundf(errors.CodeNotFound, "the following metrics were not found: %s", strings.Join(nonExistentMetrics, ", "))
}
lastSeenStr := func(name string) string {
if ts, ok := lastSeenInfo[name]; ok && ts > 0 {
ago := humanize.RelTime(time.UnixMilli(ts), time.Now(), "ago", "from now")
return fmt.Sprintf("%s (last seen %s)", name, ago)
}
return name // this case won't come cuz lastSeenStr is never called for metrics in nonExistentMetrics
}
if len(missingMetrics) == 1 {
dormantMetricsWarningMsg = fmt.Sprintf("no data found for the metric %s in the query time range", lastSeenStr(missingMetrics[0]))
} else {
parts := make([]string, len(missingMetrics))
for i, m := range missingMetrics {
parts[i] = lastSeenStr(m)
}
dormantMetricsWarningMsg = fmt.Sprintf("no data found for the following metrics in the query time range: %s", strings.Join(parts, ", "))
}
}
preseededResults := make(map[string]any)
for _, name := range missingMetricQueries { // at this point missing metrics will not have any non existent metrics, only normal ones
switch req.RequestType {
@@ -246,166 +496,6 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
return qbResp, qbErr
}
func (q *querier) populateQBEvent(event *qbtypes.QBEvent, queries []qbtypes.QueryEnvelope) {
for _, query := range queries {
// BUG: QueryType doesn't make sense as range_request can have multiple query types.
event.QueryType = query.Type.StringValue()
switch query.Type {
case qbtypes.QueryTypeBuilder:
filter := query.GetFilter()
event.FilterApplied = event.FilterApplied || (filter != nil && filter.Expression != "")
event.GroupByApplied = event.GroupByApplied || len(query.GetGroupBy()) > 0
switch query.Spec.(type) {
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
event.TracesUsed = true
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
event.LogsUsed = true
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
event.MetricsUsed = true
}
case qbtypes.QueryTypePromQL:
event.MetricsUsed = true
case qbtypes.QueryTypeTraceOperator:
event.TracesUsed = true
case qbtypes.QueryTypeClickHouseSQL:
sql := query.GetQuery()
if strings.TrimSpace(sql) != "" {
event.MetricsUsed = strings.Contains(sql, "signoz_metrics")
event.LogsUsed = strings.Contains(sql, "signoz_logs")
event.TracesUsed = strings.Contains(sql, "signoz_traces")
}
}
}
}
// resolveMetricMetadata fetches metadata for every metric referenced by builder
// metric-aggregation queries, patches each query's aggregations in place with
// the resolved values, and classifies any metric that could not be resolved.
//
// Side effects on queries:
// - Aggregations with Unknown Temporality / UnspecifiedType are filled in from
// the metadata store.
// - Aggregations whose Type is still UnspecifiedType after the patch are
// dropped from the spec.
// - Queries whose entire aggregation list was dropped are NOT patched and are
// surfaced via the returned missingMetricQueries; the caller should skip
// them.
//
// Returns:
// - missingMetricQueries: names of queries whose every aggregation was
// missing. Used downstream to preseed empty result placeholders so the
// response still has an entry per requested query name.
// - dormantWarning: a human-readable warning describing metrics that exist in
// the store but produced no data within the query window. Empty when no
// such metrics are present.
// - err: NotFound when one or more referenced metrics have never been seen,
// or Internal when a metadata fetch fails.
func (q *querier) resolveMetricMetadata(ctx context.Context, queries []qbtypes.QueryEnvelope, start, end uint64) (missingMetricQueries []string, dormantWarning string, err error) {
metricNames := make([]string, 0)
for idx := range queries {
if queries[idx].Type != qbtypes.QueryTypeBuilder {
continue
}
spec, ok := queries[idx].Spec.(qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation])
if !ok {
continue
}
for _, agg := range spec.Aggregations {
if agg.MetricName != "" {
metricNames = append(metricNames, agg.MetricName)
}
}
}
if len(metricNames) == 0 {
return nil, "", nil
}
metricTemporality, metricTypes, err := q.metadataStore.FetchTemporalityAndTypeMulti(ctx, start, end, metricNames...)
if err != nil {
q.logger.WarnContext(ctx, "failed to fetch metric temporality", errors.Attr(err), slog.Any("metrics", metricNames))
return nil, "", errors.NewInternalf(errors.CodeInternal, "failed to fetch metrics temporality")
}
q.logger.DebugContext(ctx, "fetched metric temporalities and types", slog.Any("metric_temporality", metricTemporality), slog.Any("metric_types", metricTypes))
missingMetrics := []string{}
for idx := range queries {
if queries[idx].Type != qbtypes.QueryTypeBuilder {
continue
}
spec, ok := queries[idx].Spec.(qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation])
if !ok {
continue
}
presentAggregations := make([]qbtypes.MetricAggregation, 0, len(spec.Aggregations))
for i := range spec.Aggregations {
if spec.Aggregations[i].MetricName != "" && spec.Aggregations[i].Temporality == metrictypes.Unknown {
if temp, ok := metricTemporality[spec.Aggregations[i].MetricName]; ok && temp != metrictypes.Unknown {
spec.Aggregations[i].Temporality = temp
}
}
if spec.Aggregations[i].MetricName != "" && spec.Aggregations[i].Type == metrictypes.UnspecifiedType {
if foundMetricType, ok := metricTypes[spec.Aggregations[i].MetricName]; ok && foundMetricType != metrictypes.UnspecifiedType {
spec.Aggregations[i].Type = foundMetricType
}
}
if spec.Aggregations[i].Type == metrictypes.UnspecifiedType {
missingMetrics = append(missingMetrics, spec.Aggregations[i].MetricName)
continue
}
presentAggregations = append(presentAggregations, spec.Aggregations[i])
}
if len(presentAggregations) == 0 {
missingMetricQueries = append(missingMetricQueries, spec.Name)
continue
}
spec.Aggregations = presentAggregations
queries[idx].Spec = spec
}
if len(missingMetrics) == 0 {
return missingMetricQueries, "", nil
}
// Classify each missing metric: never-seen → NotFound error; seen-but-no-
// data-in-window → dormant warning.
lastSeenInfo, _ := q.metadataStore.FetchLastSeenInfoMulti(ctx, missingMetrics...)
nonExistentMetrics := []string{}
for _, name := range missingMetrics {
if ts, ok := lastSeenInfo[name]; ok && ts > 0 {
continue
}
nonExistentMetrics = append(nonExistentMetrics, name)
}
if len(nonExistentMetrics) == 1 {
return nil, "", errors.NewNotFoundf(errors.CodeNotFound, "could not find the metric %s", nonExistentMetrics[0])
}
if len(nonExistentMetrics) > 1 {
return nil, "", errors.NewNotFoundf(errors.CodeNotFound, "the following metrics were not found: %s", strings.Join(nonExistentMetrics, ", "))
}
// All missing metrics are dormant — assemble the warning string.
lastSeenStr := func(name string) string {
if ts, ok := lastSeenInfo[name]; ok && ts > 0 {
ago := humanize.RelTime(time.UnixMilli(ts), time.Now(), "ago", "from now")
return fmt.Sprintf("%s (last seen %s)", name, ago)
}
return name
}
if len(missingMetrics) == 1 {
dormantWarning = fmt.Sprintf("no data found for the metric %s in the query time range", lastSeenStr(missingMetrics[0]))
} else {
parts := make([]string, len(missingMetrics))
for i, m := range missingMetrics {
parts[i] = lastSeenStr(m)
}
dormantWarning = fmt.Sprintf("no data found for the following metrics in the query time range: %s", strings.Join(parts, ", "))
}
return missingMetricQueries, dormantWarning, nil
}
func (q *querier) QueryRawStream(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest, client *qbtypes.RawStream) {
event := &qbtypes.QBEvent{
@@ -1003,129 +1093,3 @@ func (q *querier) mergeTimeSeriesResults(cachedValue *qbtypes.TimeSeriesData, fr
return result
}
func secondsStep(s uint64) qbtypes.Step {
return qbtypes.Step{Duration: time.Second * time.Duration(s)}
}
// clampStep sets the step to recommended when zero and clamps to min when below it.
// When clamped and warn is true, a warning is appended for the user.
func clampStep(qe *qbtypes.QueryEnvelope, recommended, min uint64, warnings *[]string) {
step := qe.GetStepInterval()
if step.Seconds() == 0 {
step = secondsStep(recommended)
qe.SetStepInterval(step)
}
if step.Seconds() < float64(min) {
newStep := secondsStep(min)
*warnings = append(*warnings, fmt.Sprintf(intervalWarn, qe.GetQueryName(), step.Seconds(), newStep.Seconds()))
qe.SetStepInterval(newStep)
}
}
// extractShiftFromBuilderQuery extracts the shift value from timeShift function if present.
func extractShiftFromBuilderQuery[T any](spec qbtypes.QueryBuilderQuery[T]) int64 {
for _, fn := range spec.Functions {
if fn.Name == qbtypes.FunctionNameTimeShift && len(fn.Args) > 0 {
switch v := fn.Args[0].Value.(type) {
case float64:
return int64(v)
case int64:
return v
case int:
return int64(v)
case string:
if shiftFloat, err := strconv.ParseFloat(v, 64); err == nil {
return int64(shiftFloat)
}
}
}
}
return 0
}
// adjustTimeRangeForShift adjusts the time range based on the shift value from timeShift function.
func adjustTimeRangeForShift[T any](spec qbtypes.QueryBuilderQuery[T], tr qbtypes.TimeRange, kind qbtypes.RequestType) qbtypes.TimeRange {
// Only apply time shift for time series and scalar queries
// Raw/list queries don't support timeshift
if kind != qbtypes.RequestTypeTimeSeries && kind != qbtypes.RequestTypeScalar {
return tr
}
// Use the ShiftBy field if it's already populated, otherwise extract it
shiftBy := spec.ShiftBy
if shiftBy == 0 {
shiftBy = extractShiftFromBuilderQuery(spec)
}
if shiftBy == 0 {
return tr
}
// ShiftBy is in seconds, convert to milliseconds and shift backward in time
shiftMS := shiftBy * 1000
return qbtypes.TimeRange{
From: tr.From - uint64(shiftMS),
To: tr.To - uint64(shiftMS),
}
}
func (q *querier) constructTraceOperatorDependencyMap(queries []qbtypes.QueryEnvelope) (map[string]bool, error) {
dependencyQueries := make(map[string]bool)
for _, query := range queries {
if query.Type == qbtypes.QueryTypeTraceOperator {
if spec, ok := query.Spec.(qbtypes.QueryBuilderTraceOperator); ok {
// Parse expression to find dependencies
if err := spec.ParseExpression(); err != nil {
return nil, err
}
deps := spec.CollectReferencedQueries(spec.ParsedExpression)
for _, dep := range deps {
dependencyQueries[dep] = true
}
}
}
}
return dependencyQueries, nil
}
// adjustStepInterval normalizes each query's step interval in place and returns
// any clamp warnings emitted along the way.
func (q *querier) adjustStepInterval(queries []qbtypes.QueryEnvelope, start, end uint64) []string {
// Compute the per-signal bounds once per call — they only depend on start/end.
traceLogRecommended := querybuilder.RecommendedStepInterval(start, end)
traceLogMin := querybuilder.MinAllowedStepInterval(start, end)
meterRecommended := querybuilder.RecommendedStepIntervalForMeter(start, end)
meterMin := querybuilder.MinAllowedStepIntervalForMeter(start, end)
metricRecommended := querybuilder.RecommendedStepIntervalForMetric(start, end)
metricMin := querybuilder.MinAllowedStepIntervalForMetric(start, end)
warnings := make([]string, 0)
for idx := range queries {
qe := &queries[idx]
switch qe.Type {
case qbtypes.QueryTypeBuilder:
switch qe.Spec.(type) {
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation], qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
clampStep(qe, traceLogRecommended, traceLogMin, &warnings)
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
if qe.GetSource() == telemetrytypes.SourceMeter {
clampStep(qe, meterRecommended, meterMin, &warnings)
} else {
clampStep(qe, metricRecommended, metricMin, &warnings)
}
}
case qbtypes.QueryTypePromQL:
// PromQL only fills an unset step — no min clamp.
if qe.GetStepInterval().Seconds() == 0 {
qe.SetStepInterval(secondsStep(metricRecommended))
}
case qbtypes.QueryTypeTraceOperator:
clampStep(qe, traceLogRecommended, traceLogMin, &warnings)
}
}
return warnings
}