mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-27 12:20:27 +01:00
Compare commits
7 Commits
custom-rec
...
fix/remove
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7842e28e44 | ||
|
|
10643891e3 | ||
|
|
5263c648f1 | ||
|
|
8da9535c80 | ||
|
|
99866a91e4 | ||
|
|
f94fa7db89 | ||
|
|
aa96ec6fe9 |
@@ -190,7 +190,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.125.1
|
||||
image: signoz/signoz:v0.126.0
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
# - "6060:6060" # pprof port
|
||||
|
||||
@@ -117,7 +117,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.125.1
|
||||
image: signoz/signoz:v0.126.0
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
volumes:
|
||||
|
||||
@@ -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.125.1}
|
||||
image: signoz/signoz:${VERSION:-v0.126.0}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
|
||||
@@ -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.125.1}
|
||||
image: signoz/signoz:${VERSION:-v0.126.0}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
|
||||
@@ -102,7 +102,11 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/ai-assistant/') && !isAIAssistantEnabled) {
|
||||
if (
|
||||
(pathname === ROUTES.AI_ASSISTANT_BASE ||
|
||||
pathname.startsWith('/ai-assistant/')) &&
|
||||
!isAIAssistantEnabled
|
||||
) {
|
||||
return <Redirect to={ROUTES.HOME} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -229,18 +229,18 @@ function App(): JSX.Element {
|
||||
}
|
||||
|
||||
setRoutes((prev) => {
|
||||
const hasAi = prev.some((r) => r.path === ROUTES.AI_ASSISTANT);
|
||||
const hasAi = prev.some((r) => r.key === 'AI_ASSISTANT');
|
||||
if (isAIAssistantEnabled === hasAi) {
|
||||
return prev;
|
||||
}
|
||||
if (isAIAssistantEnabled) {
|
||||
const aiRoute = defaultRoutes.find((r) => r.path === ROUTES.AI_ASSISTANT);
|
||||
const aiRoute = defaultRoutes.find((r) => r.key === 'AI_ASSISTANT');
|
||||
if (!aiRoute) {
|
||||
return prev;
|
||||
}
|
||||
return [...prev.filter((r) => r.path !== ROUTES.AI_ASSISTANT), aiRoute];
|
||||
return [...prev.filter((r) => r.key !== 'AI_ASSISTANT'), aiRoute];
|
||||
}
|
||||
return prev.filter((r) => r.path !== ROUTES.AI_ASSISTANT);
|
||||
return prev.filter((r) => r.key !== 'AI_ASSISTANT');
|
||||
});
|
||||
}, [isLoggedInState, isAIAssistantEnabled]);
|
||||
|
||||
@@ -254,6 +254,7 @@ function App(): JSX.Element {
|
||||
if (
|
||||
pathname === ROUTES.ONBOARDING ||
|
||||
pathname.startsWith('/public/dashboard/') ||
|
||||
pathname === '/ai-assistant' ||
|
||||
pathname.startsWith('/ai-assistant/')
|
||||
) {
|
||||
window.Pylon?.('hideChatBubble');
|
||||
|
||||
@@ -501,7 +501,7 @@ const routes: AppRoutes[] = [
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.AI_ASSISTANT,
|
||||
path: [ROUTES.AI_ASSISTANT_BASE, ROUTES.AI_ASSISTANT],
|
||||
exact: true,
|
||||
component: AIAssistantPage,
|
||||
key: 'AI_ASSISTANT',
|
||||
|
||||
@@ -40,6 +40,7 @@ export function setAIBackendUrl(url: string | null): void {
|
||||
if (aiBackendUrl === url) {
|
||||
return;
|
||||
}
|
||||
|
||||
aiBackendUrl = url;
|
||||
AIAssistantInstance.defaults.baseURL = url ? `${url}${AI_API_PATH}` : '';
|
||||
}
|
||||
|
||||
@@ -37,6 +37,16 @@ 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.
|
||||
*/
|
||||
@@ -63,16 +73,6 @@ 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,6 +139,16 @@ 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
|
||||
@@ -153,6 +163,13 @@ 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
|
||||
@@ -175,13 +192,6 @@ 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',
|
||||
@@ -252,178 +262,21 @@ 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;
|
||||
|
||||
/**
|
||||
* 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',
|
||||
* 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',
|
||||
}
|
||||
/**
|
||||
* "auto" if derived from current page; "mention" if explicitly @-picked.
|
||||
@@ -482,6 +335,193 @@ 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',
|
||||
@@ -616,6 +656,10 @@ export interface RevertRequestDTO {
|
||||
actionMetadataId: string;
|
||||
}
|
||||
|
||||
export enum ScopeDTO {
|
||||
user = 'user',
|
||||
org = 'org',
|
||||
}
|
||||
export type ThreadDetailResponseDTOTitle = string | null;
|
||||
|
||||
export type ThreadDetailResponseDTOState = ExecutionStateDTO | null;
|
||||
@@ -663,18 +707,6 @@ 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;
|
||||
@@ -709,6 +741,18 @@ export interface ThreadSummaryDTO {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ThreadListResponseDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
threads: ThreadSummaryDTO[];
|
||||
nextCursor?: ThreadListResponseDTONextCursor;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
hasMore?: boolean;
|
||||
}
|
||||
|
||||
export interface UndoRequestDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -726,28 +770,29 @@ export interface UpdateThreadRequestDTO {
|
||||
archived?: UpdateThreadRequestDTOArchived;
|
||||
}
|
||||
|
||||
export type ValidationErrorDTOLocItem = string | number;
|
||||
export type UsageResponseDTONextPage = string | null;
|
||||
|
||||
export type ValidationErrorDTOCtx = { [key: string]: unknown };
|
||||
/**
|
||||
* 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 interface ValidationErrorDTO {
|
||||
export interface UsageResponseDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
loc: ValidationErrorDTOLocItem[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
msg: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
type: string;
|
||||
input?: unknown;
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
ctx?: ValidationErrorDTOCtx;
|
||||
data: UsageRowDTO[];
|
||||
nextPage?: UsageResponseDTONextPage;
|
||||
}
|
||||
|
||||
export type ApprovalEventDTODiff = { [key: string]: unknown };
|
||||
@@ -909,6 +954,20 @@ 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;
|
||||
@@ -1315,3 +1374,14 @@ 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;
|
||||
};
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 467 B |
@@ -88,6 +88,7 @@ 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;
|
||||
|
||||
@@ -178,7 +178,7 @@ export default function MessageBubble({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isUser && (
|
||||
{!isUser && !message.isRateLimitError && (
|
||||
<MessageFeedback
|
||||
message={message}
|
||||
onRegenerate={onRegenerate}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/* 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';
|
||||
@@ -21,7 +23,6 @@ import {
|
||||
regenerateMessage,
|
||||
rejectExecution,
|
||||
sendMessage as sendMessageToThread,
|
||||
SSEStreamError,
|
||||
streamEvents,
|
||||
submitFeedback,
|
||||
ThreadSummary,
|
||||
@@ -193,13 +194,75 @@ 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.
|
||||
*
|
||||
* Throws on `error` events — the caller's catch block handles UI feedback.
|
||||
* 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.
|
||||
*/
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
async function runStreamingLoop(
|
||||
@@ -325,6 +388,15 @@ 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,
|
||||
});
|
||||
@@ -412,13 +484,41 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Commits an error message and removes the stream entry.
|
||||
* 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.
|
||||
*/
|
||||
function finalizeStreamingError(
|
||||
conversationId: string,
|
||||
errorContent: string,
|
||||
set: StoreSetter,
|
||||
isRateLimit = false,
|
||||
): void {
|
||||
set((s) => {
|
||||
const conv = s.conversations[conversationId];
|
||||
@@ -428,6 +528,7 @@ function finalizeStreamingError(
|
||||
role: 'assistant',
|
||||
content: errorContent,
|
||||
createdAt: Date.now(),
|
||||
...(isRateLimit ? { isRateLimitError: true } : {}),
|
||||
});
|
||||
conv.updatedAt = Date.now();
|
||||
}
|
||||
@@ -801,7 +902,12 @@ 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
|
||||
// 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`.
|
||||
if (
|
||||
detail.activeExecutionId &&
|
||||
!streamControllers.has(threadId) &&
|
||||
@@ -1052,14 +1158,12 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
|
||||
}
|
||||
});
|
||||
}
|
||||
const executionId = await sendMessageToThread(threadId, text, contexts);
|
||||
const ctrl = newStreamController(convId);
|
||||
await runStreamingLoop(executionId, {
|
||||
conversationId: convId,
|
||||
const tid = threadId;
|
||||
await streamWithAuthRetry(
|
||||
convId,
|
||||
() => sendMessageToThread(tid, text, contexts),
|
||||
set,
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
streamControllers.delete(convId);
|
||||
);
|
||||
|
||||
if (!hasPendingInput(convId, get)) {
|
||||
finalizeStreamingMessage(convId, set, get);
|
||||
@@ -1070,11 +1174,14 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
|
||||
return;
|
||||
}
|
||||
console.error('[AIAssistant] sendMessage failed:', err);
|
||||
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);
|
||||
const rateLimit = rateLimitMessage(err);
|
||||
finalizeStreamingError(
|
||||
convId,
|
||||
rateLimit ??
|
||||
'Something went wrong while fetching the response. Please try again.',
|
||||
set,
|
||||
rateLimit !== null,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1094,14 +1201,11 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
|
||||
});
|
||||
|
||||
try {
|
||||
const executionId = await approveExecution(approvalId);
|
||||
const ctrl = newStreamController(conversationId);
|
||||
await runStreamingLoop(executionId, {
|
||||
await streamWithAuthRetry(
|
||||
conversationId,
|
||||
() => approveExecution(approvalId),
|
||||
set,
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
streamControllers.delete(conversationId);
|
||||
);
|
||||
if (!hasPendingInput(conversationId, get)) {
|
||||
finalizeStreamingMessage(conversationId, set, get);
|
||||
}
|
||||
@@ -1110,10 +1214,13 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
|
||||
return;
|
||||
}
|
||||
console.error('[AIAssistant] approveAction failed:', err);
|
||||
const rateLimit = rateLimitMessage(err);
|
||||
finalizeStreamingError(
|
||||
conversationId,
|
||||
'Something went wrong while processing the approval. Please try again.',
|
||||
rateLimit ??
|
||||
'Something went wrong while processing the approval. Please try again.',
|
||||
set,
|
||||
rateLimit !== null,
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -1176,14 +1283,11 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
|
||||
});
|
||||
|
||||
try {
|
||||
const executionId = await regenerateMessage(messageId);
|
||||
const ctrl = newStreamController(conversationId);
|
||||
await runStreamingLoop(executionId, {
|
||||
await streamWithAuthRetry(
|
||||
conversationId,
|
||||
() => regenerateMessage(messageId),
|
||||
set,
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
streamControllers.delete(conversationId);
|
||||
);
|
||||
if (!hasPendingInput(conversationId, get)) {
|
||||
finalizeStreamingMessage(conversationId, set, get);
|
||||
}
|
||||
@@ -1192,10 +1296,13 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
|
||||
return;
|
||||
}
|
||||
console.error('[AIAssistant] regenerateAssistantMessage failed:', err);
|
||||
const rateLimit = rateLimitMessage(err);
|
||||
finalizeStreamingError(
|
||||
conversationId,
|
||||
'Something went wrong while regenerating the response. Please try again.',
|
||||
rateLimit ??
|
||||
'Something went wrong while regenerating the response. Please try again.',
|
||||
set,
|
||||
rateLimit !== null,
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -1245,14 +1352,11 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
|
||||
});
|
||||
|
||||
try {
|
||||
const executionId = await clarifyExecution(clarificationId, answers);
|
||||
const ctrl = newStreamController(conversationId);
|
||||
await runStreamingLoop(executionId, {
|
||||
await streamWithAuthRetry(
|
||||
conversationId,
|
||||
() => clarifyExecution(clarificationId, answers),
|
||||
set,
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
streamControllers.delete(conversationId);
|
||||
);
|
||||
if (!hasPendingInput(conversationId, get)) {
|
||||
finalizeStreamingMessage(conversationId, set, get);
|
||||
}
|
||||
@@ -1261,10 +1365,13 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
|
||||
return;
|
||||
}
|
||||
console.error('[AIAssistant] submitClarification failed:', err);
|
||||
const rateLimit = rateLimitMessage(err);
|
||||
finalizeStreamingError(
|
||||
conversationId,
|
||||
'Something went wrong while processing your answers. Please try again.',
|
||||
rateLimit ??
|
||||
'Something went wrong while processing your answers. Please try again.',
|
||||
set,
|
||||
rateLimit !== null,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -86,6 +86,11 @@ 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,6 @@ 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,
|
||||
@@ -18,10 +16,6 @@ function LogsActionsContainer({
|
||||
handleToggleFrequencyChart,
|
||||
orderBy,
|
||||
setOrderBy,
|
||||
isFetching,
|
||||
isLoading,
|
||||
isError,
|
||||
isSuccess,
|
||||
}: {
|
||||
listQuery: any;
|
||||
selectedPanelType: PANEL_TYPES;
|
||||
@@ -29,10 +23,6 @@ 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,
|
||||
@@ -106,17 +96,6 @@ 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>
|
||||
|
||||
@@ -155,40 +155,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
.query-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
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>;
|
||||
}
|
||||
@@ -160,7 +160,7 @@ function LogsExplorerViewsContainer({
|
||||
'custom',
|
||||
);
|
||||
|
||||
const { data, isLoading, isFetching, isError, isSuccess, error } =
|
||||
const { data, isLoading, isFetching, isError, error } =
|
||||
useGetExplorerQueryRange(
|
||||
requestData,
|
||||
selectedPanelType,
|
||||
@@ -437,10 +437,6 @@ function LogsExplorerViewsContainer({
|
||||
handleToggleFrequencyChart={handleToggleFrequencyChart}
|
||||
orderBy={orderBy}
|
||||
setOrderBy={setOrderBy}
|
||||
isFetching={isFetching}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
isSuccess={isSuccess}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
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.
|
||||
@@ -20,6 +21,28 @@
|
||||
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;
|
||||
}
|
||||
@@ -29,15 +52,6 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.oldViewBtn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.analyticsBtn {
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.subHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
ArrowLeft,
|
||||
CalendarClock,
|
||||
ChartPie,
|
||||
CornerUpLeft,
|
||||
Server,
|
||||
Timer,
|
||||
} from '@signozhq/icons';
|
||||
@@ -117,7 +118,7 @@ function TraceDetailsHeader({
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.header}>
|
||||
{!isFilterExpanded && (
|
||||
<>
|
||||
<div className={styles.traceIdSection}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
@@ -133,20 +134,39 @@ function TraceDetailsHeader({
|
||||
badgeValue={traceID || ''}
|
||||
maxCharacters={100}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
{isDataLoaded && (
|
||||
<>
|
||||
<div
|
||||
className={cx(
|
||||
styles.filterSection,
|
||||
isFilterExpanded && styles.isExpanded,
|
||||
)}
|
||||
>
|
||||
{!isFilterExpanded && (
|
||||
<>
|
||||
<TooltipProvider>
|
||||
<TooltipProvider>
|
||||
<div className={styles.headerActions}>
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
className={styles.analyticsBtn}
|
||||
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"
|
||||
onClick={(): void => setIsAnalyticsOpen((prev) => !prev)}
|
||||
>
|
||||
<ChartPie size={14} />
|
||||
@@ -154,15 +174,18 @@ function TraceDetailsHeader({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Analytics</TooltipContent>
|
||||
</TooltipRoot>
|
||||
</TooltipProvider>
|
||||
<TraceOptionsMenu
|
||||
showTraceDetails={showTraceDetails}
|
||||
onToggleTraceDetails={handleToggleTraceDetails}
|
||||
onOpenPreviewFields={(): void => setIsPreviewFieldsOpen(true)}
|
||||
/>
|
||||
</>
|
||||
<TraceOptionsMenu
|
||||
showTraceDetails={showTraceDetails}
|
||||
onToggleTraceDetails={handleToggleTraceDetails}
|
||||
onOpenPreviewFields={(): void => setIsPreviewFieldsOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<div className={cx(styles.filter, isFilterExpanded && styles.isExpanded)}>
|
||||
<div
|
||||
key="filter"
|
||||
className={cx(styles.filter, isFilterExpanded && styles.isExpanded)}
|
||||
>
|
||||
<Filters
|
||||
startTime={filterMetadata.startTime}
|
||||
endTime={filterMetadata.endTime}
|
||||
@@ -173,18 +196,7 @@ function TraceDetailsHeader({
|
||||
onCollapse={(): void => setIsFilterExpanded(false)}
|
||||
/>
|
||||
</div>
|
||||
{!isFilterExpanded && (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
className={styles.oldViewBtn}
|
||||
onClick={handleSwitchToOldView}
|
||||
>
|
||||
Legacy View
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 { Ellipsis } from '@signozhq/icons';
|
||||
import { Settings2 } from '@signozhq/icons';
|
||||
|
||||
import { useTraceStore } from '../stores/traceStore';
|
||||
|
||||
@@ -93,7 +93,8 @@ function TraceOptionsMenu({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
prefix={<Ellipsis size={14} />}
|
||||
aria-label="Trace options"
|
||||
prefix={<Settings2 size={14} />}
|
||||
/>
|
||||
</Dropdown>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import TraceDetailsHeader from '../TraceDetailsHeader';
|
||||
|
||||
const mockGoBack = jest.fn();
|
||||
const mockPush = jest.fn();
|
||||
const mockReplace = jest.fn();
|
||||
const mockHasInAppHistory = jest.fn();
|
||||
|
||||
jest.mock('lib/history', () => ({
|
||||
@@ -13,13 +14,47 @@ jest.mock('lib/history', () => ({
|
||||
default: {
|
||||
goBack: (): void => mockGoBack(),
|
||||
push: (path: string): void => mockPush(path),
|
||||
replace: jest.fn(),
|
||||
replace: (path: string): void => mockReplace(path),
|
||||
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,
|
||||
@@ -58,3 +93,70 @@ 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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,12 +3,6 @@
|
||||
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']) {
|
||||
@@ -20,8 +14,43 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -29,6 +58,25 @@
|
||||
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;
|
||||
@@ -85,14 +133,6 @@
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.collapseBtn {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.highlightErrorsToggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -100,37 +140,3 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Copy,
|
||||
Info,
|
||||
Loader,
|
||||
Search,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { ChevronsRight, Copy, 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';
|
||||
@@ -21,7 +13,6 @@ 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';
|
||||
@@ -42,6 +33,7 @@ import {
|
||||
SpanCategory,
|
||||
useSpanCategoryFilter,
|
||||
} from './hooks/useSpanCategoryFilter';
|
||||
import QueryResult from './QueryResult';
|
||||
|
||||
import styles from './Filters.module.scss';
|
||||
|
||||
@@ -152,6 +144,16 @@ 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,
|
||||
@@ -266,164 +268,167 @@ function Filters({
|
||||
</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 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>
|
||||
);
|
||||
|
||||
// --- 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>
|
||||
);
|
||||
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
|
||||
);
|
||||
|
||||
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>
|
||||
// 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>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={(): void => {
|
||||
setCopy(expression);
|
||||
toast.success('Copied to clipboard', {
|
||||
richColors: false,
|
||||
position: 'top-right',
|
||||
});
|
||||
}}
|
||||
onClick={handleClear}
|
||||
>
|
||||
<Copy size={12} />
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.pillPopoverExpression}>{expression}</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</TooltipRoot>
|
||||
) : (
|
||||
pill
|
||||
</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>
|
||||
)}
|
||||
{highlightErrorsToggle}
|
||||
{statusIndicators}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// --- 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 className={styles.highlightControl}>{highlightErrorsToggle}</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
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;
|
||||
@@ -825,4 +825,5 @@ body.ai-assistant-panel-open {
|
||||
// overrides
|
||||
:root {
|
||||
--input-focus-outline-width: 0;
|
||||
--radius-2: 4px;
|
||||
}
|
||||
|
||||
@@ -135,4 +135,5 @@ 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'],
|
||||
};
|
||||
|
||||
@@ -25,7 +25,7 @@ type Alertmanager interface {
|
||||
PutAlerts(context.Context, string, alertmanagertypes.PostableAlerts) error
|
||||
|
||||
// TestReceiver sends a test alert to a receiver.
|
||||
TestReceiver(context.Context, string, *alertmanagertypes.Receiver) error
|
||||
TestReceiver(context.Context, string, alertmanagertypes.Receiver) error
|
||||
|
||||
// TestAlert sends an alert to a list of receivers.
|
||||
TestAlert(ctx context.Context, orgID string, ruleID string, receiversMap map[*alertmanagertypes.PostableAlert][]string) error
|
||||
@@ -40,10 +40,10 @@ type Alertmanager interface {
|
||||
GetChannelByID(context.Context, string, valuer.UUID) (*alertmanagertypes.Channel, error)
|
||||
|
||||
// UpdateChannel updates a channel for the organization.
|
||||
UpdateChannelByReceiverAndID(context.Context, string, *alertmanagertypes.Receiver, valuer.UUID) error
|
||||
UpdateChannelByReceiverAndID(context.Context, string, alertmanagertypes.Receiver, valuer.UUID) error
|
||||
|
||||
// CreateChannel creates a channel for the organization.
|
||||
CreateChannel(context.Context, string, *alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error)
|
||||
CreateChannel(context.Context, string, alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error)
|
||||
|
||||
// DeleteChannelByID deletes a channel for the organization.
|
||||
DeleteChannelByID(context.Context, string, valuer.UUID) error
|
||||
|
||||
@@ -26,8 +26,8 @@ var customNotifierIntegrations = []string{
|
||||
msteamsv2.Integration,
|
||||
}
|
||||
|
||||
func NewReceiverIntegrations(nc *alertmanagertypes.Receiver, tmpl *template.Template, logger *slog.Logger, templater alertmanagertypes.Templater) ([]notify.Integration, error) {
|
||||
upstreamIntegrations, err := receiver.BuildReceiverIntegrations(*nc.Receiver, tmpl, logger)
|
||||
func NewReceiverIntegrations(nc alertmanagertypes.Receiver, tmpl *template.Template, logger *slog.Logger, templater alertmanagertypes.Templater) ([]notify.Integration, error) {
|
||||
upstreamIntegrations, err := receiver.BuildReceiverIntegrations(nc, tmpl, logger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -275,11 +275,7 @@ func (server *Server) SetConfig(ctx context.Context, alertmanagerConfig *alertma
|
||||
server.logger.InfoContext(ctx, "skipping creation of receiver not referenced by any route", slog.String("receiver", rcv.Name))
|
||||
continue
|
||||
}
|
||||
extendedRcv, err := alertmanagerConfig.GetReceiver(rcv.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
integrations, err := alertmanagernotify.NewReceiverIntegrations(extendedRcv, server.tmpl, server.logger, server.templater)
|
||||
integrations, err := alertmanagernotify.NewReceiverIntegrations(rcv, server.tmpl, server.logger, server.templater)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -354,7 +350,7 @@ func (server *Server) SetConfig(ctx context.Context, alertmanagerConfig *alertma
|
||||
return nil
|
||||
}
|
||||
|
||||
func (server *Server) TestReceiver(ctx context.Context, receiver *alertmanagertypes.Receiver) error {
|
||||
func (server *Server) TestReceiver(ctx context.Context, receiver alertmanagertypes.Receiver) error {
|
||||
testAlert := alertmanagertypes.NewTestAlert(receiver, time.Now(), time.Now())
|
||||
return alertmanagertypes.TestReceiver(ctx, receiver, alertmanagernotify.NewReceiverIntegrations, server.alertmanagerConfig, server.tmpl, server.logger, server.templater, testAlert.Labels, testAlert)
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ func TestServerTestReceiverTypeWebhook(t *testing.T) {
|
||||
webhookURL, err := url.Parse("http://" + webhookListener.Addr().String() + "/webhook")
|
||||
require.NoError(t, err)
|
||||
|
||||
err = server.TestReceiver(context.Background(), &alertmanagertypes.Receiver{Receiver: &config.Receiver{
|
||||
err = server.TestReceiver(context.Background(), alertmanagertypes.Receiver{
|
||||
Name: "test-receiver",
|
||||
WebhookConfigs: []*config.WebhookConfig{
|
||||
{
|
||||
@@ -83,7 +83,7 @@ func TestServerTestReceiverTypeWebhook(t *testing.T) {
|
||||
URL: config.SecretTemplateURL(webhookURL.String()),
|
||||
},
|
||||
},
|
||||
}})
|
||||
})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, requestBody.String(), "test-receiver")
|
||||
@@ -101,7 +101,7 @@ func TestServerPutAlerts(t *testing.T) {
|
||||
amConfig, err := alertmanagertypes.NewDefaultConfig(srvCfg.Global, srvCfg.Route, "1")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, amConfig.CreateReceiver(&alertmanagertypes.Receiver{Receiver: &config.Receiver{
|
||||
require.NoError(t, amConfig.CreateReceiver(alertmanagertypes.Receiver{
|
||||
Name: "test-receiver",
|
||||
WebhookConfigs: []*config.WebhookConfig{
|
||||
{
|
||||
@@ -109,7 +109,7 @@ func TestServerPutAlerts(t *testing.T) {
|
||||
URL: config.SecretTemplateURL("http://localhost/test-receiver"),
|
||||
},
|
||||
},
|
||||
}}))
|
||||
}))
|
||||
|
||||
require.NoError(t, server.SetConfig(context.Background(), amConfig))
|
||||
|
||||
@@ -181,7 +181,7 @@ func TestServerTestAlert(t *testing.T) {
|
||||
webhook2URL, err := url.Parse("http://" + webhook2Listener.Addr().String() + "/webhook")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, amConfig.CreateReceiver(&alertmanagertypes.Receiver{Receiver: &config.Receiver{
|
||||
require.NoError(t, amConfig.CreateReceiver(alertmanagertypes.Receiver{
|
||||
Name: "receiver-1",
|
||||
WebhookConfigs: []*config.WebhookConfig{
|
||||
{
|
||||
@@ -189,9 +189,9 @@ func TestServerTestAlert(t *testing.T) {
|
||||
URL: config.SecretTemplateURL(webhook1URL.String()),
|
||||
},
|
||||
},
|
||||
}}))
|
||||
}))
|
||||
|
||||
require.NoError(t, amConfig.CreateReceiver(&alertmanagertypes.Receiver{Receiver: &config.Receiver{
|
||||
require.NoError(t, amConfig.CreateReceiver(alertmanagertypes.Receiver{
|
||||
Name: "receiver-2",
|
||||
WebhookConfigs: []*config.WebhookConfig{
|
||||
{
|
||||
@@ -199,7 +199,7 @@ func TestServerTestAlert(t *testing.T) {
|
||||
URL: config.SecretTemplateURL(webhook2URL.String()),
|
||||
},
|
||||
},
|
||||
}}))
|
||||
}))
|
||||
|
||||
require.NoError(t, server.SetConfig(context.Background(), amConfig))
|
||||
defer func() {
|
||||
@@ -273,7 +273,7 @@ func TestServerTestAlertContinuesOnFailure(t *testing.T) {
|
||||
webhookURL, err := url.Parse("http://" + webhookListener.Addr().String() + "/webhook")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, amConfig.CreateReceiver(&alertmanagertypes.Receiver{Receiver: &config.Receiver{
|
||||
require.NoError(t, amConfig.CreateReceiver(alertmanagertypes.Receiver{
|
||||
Name: "working-receiver",
|
||||
WebhookConfigs: []*config.WebhookConfig{
|
||||
{
|
||||
@@ -281,9 +281,9 @@ func TestServerTestAlertContinuesOnFailure(t *testing.T) {
|
||||
URL: config.SecretTemplateURL(webhookURL.String()),
|
||||
},
|
||||
},
|
||||
}}))
|
||||
}))
|
||||
|
||||
require.NoError(t, amConfig.CreateReceiver(&alertmanagertypes.Receiver{Receiver: &config.Receiver{
|
||||
require.NoError(t, amConfig.CreateReceiver(alertmanagertypes.Receiver{
|
||||
Name: "failing-receiver",
|
||||
WebhookConfigs: []*config.WebhookConfig{
|
||||
{
|
||||
@@ -291,7 +291,7 @@ func TestServerTestAlertContinuesOnFailure(t *testing.T) {
|
||||
URL: config.SecretTemplateURL("http://localhost:1/webhook"),
|
||||
},
|
||||
},
|
||||
}}))
|
||||
}))
|
||||
|
||||
require.NoError(t, server.SetConfig(context.Background(), amConfig))
|
||||
defer func() {
|
||||
|
||||
@@ -110,7 +110,7 @@ func (_c *MockAlertmanager_Collect_Call) RunAndReturn(run func(context1 context.
|
||||
}
|
||||
|
||||
// CreateChannel provides a mock function for the type MockAlertmanager
|
||||
func (_mock *MockAlertmanager) CreateChannel(context1 context.Context, s string, v *alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error) {
|
||||
func (_mock *MockAlertmanager) CreateChannel(context1 context.Context, s string, v alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error) {
|
||||
ret := _mock.Called(context1, s, v)
|
||||
|
||||
if len(ret) == 0 {
|
||||
@@ -119,17 +119,17 @@ func (_mock *MockAlertmanager) CreateChannel(context1 context.Context, s string,
|
||||
|
||||
var r0 *alertmanagertypes.Channel
|
||||
var r1 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, *alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error)); ok {
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error)); ok {
|
||||
return returnFunc(context1, s, v)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, *alertmanagertypes.Receiver) *alertmanagertypes.Channel); ok {
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, alertmanagertypes.Receiver) *alertmanagertypes.Channel); ok {
|
||||
r0 = returnFunc(context1, s, v)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*alertmanagertypes.Channel)
|
||||
}
|
||||
}
|
||||
if returnFunc, ok := ret.Get(1).(func(context.Context, string, *alertmanagertypes.Receiver) error); ok {
|
||||
if returnFunc, ok := ret.Get(1).(func(context.Context, string, alertmanagertypes.Receiver) error); ok {
|
||||
r1 = returnFunc(context1, s, v)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
@@ -145,12 +145,12 @@ type MockAlertmanager_CreateChannel_Call struct {
|
||||
// CreateChannel is a helper method to define mock.On call
|
||||
// - context1 context.Context
|
||||
// - s string
|
||||
// - v *alertmanagertypes.Receiver
|
||||
// - v alertmanagertypes.Receiver
|
||||
func (_e *MockAlertmanager_Expecter) CreateChannel(context1 interface{}, s interface{}, v interface{}) *MockAlertmanager_CreateChannel_Call {
|
||||
return &MockAlertmanager_CreateChannel_Call{Call: _e.mock.On("CreateChannel", context1, s, v)}
|
||||
}
|
||||
|
||||
func (_c *MockAlertmanager_CreateChannel_Call) Run(run func(context1 context.Context, s string, v *alertmanagertypes.Receiver)) *MockAlertmanager_CreateChannel_Call {
|
||||
func (_c *MockAlertmanager_CreateChannel_Call) Run(run func(context1 context.Context, s string, v alertmanagertypes.Receiver)) *MockAlertmanager_CreateChannel_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
@@ -160,9 +160,9 @@ func (_c *MockAlertmanager_CreateChannel_Call) Run(run func(context1 context.Con
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(string)
|
||||
}
|
||||
var arg2 *alertmanagertypes.Receiver
|
||||
var arg2 alertmanagertypes.Receiver
|
||||
if args[2] != nil {
|
||||
arg2 = args[2].(*alertmanagertypes.Receiver)
|
||||
arg2 = args[2].(alertmanagertypes.Receiver)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
@@ -178,7 +178,7 @@ func (_c *MockAlertmanager_CreateChannel_Call) Return(channel *alertmanagertypes
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockAlertmanager_CreateChannel_Call) RunAndReturn(run func(context1 context.Context, s string, v *alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error)) *MockAlertmanager_CreateChannel_Call {
|
||||
func (_c *MockAlertmanager_CreateChannel_Call) RunAndReturn(run func(context1 context.Context, s string, v alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error)) *MockAlertmanager_CreateChannel_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
@@ -1579,7 +1579,7 @@ func (_c *MockAlertmanager_TestAlert_Call) RunAndReturn(run func(ctx context.Con
|
||||
}
|
||||
|
||||
// TestReceiver provides a mock function for the type MockAlertmanager
|
||||
func (_mock *MockAlertmanager) TestReceiver(context1 context.Context, s string, v *alertmanagertypes.Receiver) error {
|
||||
func (_mock *MockAlertmanager) TestReceiver(context1 context.Context, s string, v alertmanagertypes.Receiver) error {
|
||||
ret := _mock.Called(context1, s, v)
|
||||
|
||||
if len(ret) == 0 {
|
||||
@@ -1587,7 +1587,7 @@ func (_mock *MockAlertmanager) TestReceiver(context1 context.Context, s string,
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, *alertmanagertypes.Receiver) error); ok {
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, alertmanagertypes.Receiver) error); ok {
|
||||
r0 = returnFunc(context1, s, v)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
@@ -1603,12 +1603,12 @@ type MockAlertmanager_TestReceiver_Call struct {
|
||||
// TestReceiver is a helper method to define mock.On call
|
||||
// - context1 context.Context
|
||||
// - s string
|
||||
// - v *alertmanagertypes.Receiver
|
||||
// - v alertmanagertypes.Receiver
|
||||
func (_e *MockAlertmanager_Expecter) TestReceiver(context1 interface{}, s interface{}, v interface{}) *MockAlertmanager_TestReceiver_Call {
|
||||
return &MockAlertmanager_TestReceiver_Call{Call: _e.mock.On("TestReceiver", context1, s, v)}
|
||||
}
|
||||
|
||||
func (_c *MockAlertmanager_TestReceiver_Call) Run(run func(context1 context.Context, s string, v *alertmanagertypes.Receiver)) *MockAlertmanager_TestReceiver_Call {
|
||||
func (_c *MockAlertmanager_TestReceiver_Call) Run(run func(context1 context.Context, s string, v alertmanagertypes.Receiver)) *MockAlertmanager_TestReceiver_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
@@ -1618,9 +1618,9 @@ func (_c *MockAlertmanager_TestReceiver_Call) Run(run func(context1 context.Cont
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(string)
|
||||
}
|
||||
var arg2 *alertmanagertypes.Receiver
|
||||
var arg2 alertmanagertypes.Receiver
|
||||
if args[2] != nil {
|
||||
arg2 = args[2].(*alertmanagertypes.Receiver)
|
||||
arg2 = args[2].(alertmanagertypes.Receiver)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
@@ -1636,7 +1636,7 @@ func (_c *MockAlertmanager_TestReceiver_Call) Return(err error) *MockAlertmanage
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockAlertmanager_TestReceiver_Call) RunAndReturn(run func(context1 context.Context, s string, v *alertmanagertypes.Receiver) error) *MockAlertmanager_TestReceiver_Call {
|
||||
func (_c *MockAlertmanager_TestReceiver_Call) RunAndReturn(run func(context1 context.Context, s string, v alertmanagertypes.Receiver) error) *MockAlertmanager_TestReceiver_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
@@ -1705,7 +1705,7 @@ func (_c *MockAlertmanager_UpdateAllRoutePoliciesByRuleId_Call) RunAndReturn(run
|
||||
}
|
||||
|
||||
// UpdateChannelByReceiverAndID provides a mock function for the type MockAlertmanager
|
||||
func (_mock *MockAlertmanager) UpdateChannelByReceiverAndID(context1 context.Context, s string, v *alertmanagertypes.Receiver, uUID valuer.UUID) error {
|
||||
func (_mock *MockAlertmanager) UpdateChannelByReceiverAndID(context1 context.Context, s string, v alertmanagertypes.Receiver, uUID valuer.UUID) error {
|
||||
ret := _mock.Called(context1, s, v, uUID)
|
||||
|
||||
if len(ret) == 0 {
|
||||
@@ -1713,7 +1713,7 @@ func (_mock *MockAlertmanager) UpdateChannelByReceiverAndID(context1 context.Con
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, *alertmanagertypes.Receiver, valuer.UUID) error); ok {
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, alertmanagertypes.Receiver, valuer.UUID) error); ok {
|
||||
r0 = returnFunc(context1, s, v, uUID)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
@@ -1729,13 +1729,13 @@ type MockAlertmanager_UpdateChannelByReceiverAndID_Call struct {
|
||||
// UpdateChannelByReceiverAndID is a helper method to define mock.On call
|
||||
// - context1 context.Context
|
||||
// - s string
|
||||
// - v *alertmanagertypes.Receiver
|
||||
// - v alertmanagertypes.Receiver
|
||||
// - uUID valuer.UUID
|
||||
func (_e *MockAlertmanager_Expecter) UpdateChannelByReceiverAndID(context1 interface{}, s interface{}, v interface{}, uUID interface{}) *MockAlertmanager_UpdateChannelByReceiverAndID_Call {
|
||||
return &MockAlertmanager_UpdateChannelByReceiverAndID_Call{Call: _e.mock.On("UpdateChannelByReceiverAndID", context1, s, v, uUID)}
|
||||
}
|
||||
|
||||
func (_c *MockAlertmanager_UpdateChannelByReceiverAndID_Call) Run(run func(context1 context.Context, s string, v *alertmanagertypes.Receiver, uUID valuer.UUID)) *MockAlertmanager_UpdateChannelByReceiverAndID_Call {
|
||||
func (_c *MockAlertmanager_UpdateChannelByReceiverAndID_Call) Run(run func(context1 context.Context, s string, v alertmanagertypes.Receiver, uUID valuer.UUID)) *MockAlertmanager_UpdateChannelByReceiverAndID_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
@@ -1745,9 +1745,9 @@ func (_c *MockAlertmanager_UpdateChannelByReceiverAndID_Call) Run(run func(conte
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(string)
|
||||
}
|
||||
var arg2 *alertmanagertypes.Receiver
|
||||
var arg2 alertmanagertypes.Receiver
|
||||
if args[2] != nil {
|
||||
arg2 = args[2].(*alertmanagertypes.Receiver)
|
||||
arg2 = args[2].(alertmanagertypes.Receiver)
|
||||
}
|
||||
var arg3 valuer.UUID
|
||||
if args[3] != nil {
|
||||
@@ -1768,7 +1768,7 @@ func (_c *MockAlertmanager_UpdateChannelByReceiverAndID_Call) Return(err error)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockAlertmanager_UpdateChannelByReceiverAndID_Call) RunAndReturn(run func(context1 context.Context, s string, v *alertmanagertypes.Receiver, uUID valuer.UUID) error) *MockAlertmanager_UpdateChannelByReceiverAndID_Call {
|
||||
func (_c *MockAlertmanager_UpdateChannelByReceiverAndID_Call) RunAndReturn(run func(context1 context.Context, s string, v alertmanagertypes.Receiver, uUID valuer.UUID) error) *MockAlertmanager_UpdateChannelByReceiverAndID_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ func (service *Service) PutAlerts(ctx context.Context, orgID string, alerts aler
|
||||
return server.PutAlerts(ctx, alerts)
|
||||
}
|
||||
|
||||
func (service *Service) TestReceiver(ctx context.Context, orgID string, receiver *alertmanagertypes.Receiver) error {
|
||||
func (service *Service) TestReceiver(ctx context.Context, orgID string, receiver alertmanagertypes.Receiver) error {
|
||||
service.serversMtx.RLock()
|
||||
defer service.serversMtx.RUnlock()
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ func (provider *provider) PutAlerts(ctx context.Context, orgID string, alerts al
|
||||
return provider.service.PutAlerts(ctx, orgID, alerts)
|
||||
}
|
||||
|
||||
func (provider *provider) TestReceiver(ctx context.Context, orgID string, receiver *alertmanagertypes.Receiver) error {
|
||||
func (provider *provider) TestReceiver(ctx context.Context, orgID string, receiver alertmanagertypes.Receiver) error {
|
||||
return provider.service.TestReceiver(ctx, orgID, receiver)
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ func (provider *provider) GetChannelByID(ctx context.Context, orgID string, chan
|
||||
return provider.configStore.GetChannelByID(ctx, orgID, channelID)
|
||||
}
|
||||
|
||||
func (provider *provider) UpdateChannelByReceiverAndID(ctx context.Context, orgID string, receiver *alertmanagertypes.Receiver, id valuer.UUID) error {
|
||||
func (provider *provider) UpdateChannelByReceiverAndID(ctx context.Context, orgID string, receiver alertmanagertypes.Receiver, id valuer.UUID) error {
|
||||
channel, err := provider.configStore.GetChannelByID(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -210,7 +210,7 @@ func (provider *provider) DeleteChannelByID(ctx context.Context, orgID string, c
|
||||
}))
|
||||
}
|
||||
|
||||
func (provider *provider) CreateChannel(ctx context.Context, orgID string, receiver *alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error) {
|
||||
func (provider *provider) CreateChannel(ctx context.Context, orgID string, receiver alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error) {
|
||||
config, err := provider.configStore.Get(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -84,244 +84,53 @@ 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(),
|
||||
}
|
||||
intervalWarnings := []string{}
|
||||
q.populateQBEvent(event, req.CompositeQuery.Queries)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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)
|
||||
|
||||
queries := make(map[string]qbtypes.Query)
|
||||
steps := make(map[string]qbtypes.Step)
|
||||
missingMetrics := []string{}
|
||||
missingMetricQueries := []string{}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
for _, query := range req.CompositeQuery.Queries {
|
||||
var queryName string
|
||||
var isTraceOperator bool
|
||||
queryName := query.GetQueryName()
|
||||
|
||||
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] {
|
||||
// skip if it is dependecy of traceOperatorQuery
|
||||
if query.GetType() != qbtypes.QueryTypeTraceOperator && dependencyQueries[queryName] {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -376,40 +185,13 @@ 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]:
|
||||
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)
|
||||
// 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] {
|
||||
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]
|
||||
@@ -428,38 +210,6 @@ 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 {
|
||||
@@ -496,6 +246,166 @@ 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{
|
||||
@@ -1093,3 +1003,129 @@ 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
|
||||
}
|
||||
|
||||
@@ -269,7 +269,7 @@ func (migration *addAlertmanager) msTeamsChannelToMSTeamsV2Channel(c *alertmanag
|
||||
return nil
|
||||
}
|
||||
|
||||
func (migration *addAlertmanager) msTeamsReceiverToMSTeamsV2Receiver(receiver *alertmanagertypes.Receiver) *alertmanagertypes.Receiver {
|
||||
func (migration *addAlertmanager) msTeamsReceiverToMSTeamsV2Receiver(receiver alertmanagertypes.Receiver) alertmanagertypes.Receiver {
|
||||
if receiver.MSTeamsConfigs == nil {
|
||||
return receiver
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ func NewAlertsFromPostableAlerts(ctx context.Context, postableAlerts PostableAle
|
||||
return validAlerts, errs
|
||||
}
|
||||
|
||||
func NewTestAlert(receiver *Receiver, startsAt time.Time, updatedAt time.Time) *Alert {
|
||||
func NewTestAlert(receiver Receiver, startsAt time.Time, updatedAt time.Time) *Alert {
|
||||
return &Alert{
|
||||
Alert: model.Alert{
|
||||
StartsAt: startsAt,
|
||||
|
||||
@@ -56,7 +56,7 @@ type Channel struct {
|
||||
|
||||
// NewChannelFromReceiver creates a new Channel from a Receiver.
|
||||
// It can return nil if the receiver is the default receiver.
|
||||
func NewChannelFromReceiver(receiver *Receiver, orgID string) (*Channel, error) {
|
||||
func NewChannelFromReceiver(receiver config.Receiver, orgID string) (*Channel, error) {
|
||||
if receiver.Name == DefaultReceiverName {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeAlertmanagerChannelInvalid, "cannot use %s name as a channel name", receiver.Name)
|
||||
}
|
||||
@@ -74,61 +74,51 @@ func NewChannelFromReceiver(receiver *Receiver, orgID string) (*Channel, error)
|
||||
OrgID: orgID,
|
||||
}
|
||||
|
||||
// The embedded *config.Receiver marshals inline, so json.Marshal(receiver)
|
||||
// emits every upstream notifier config plus any SigNoz-native ones in a
|
||||
// single pass.
|
||||
data, err := json.Marshal(receiver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
channel.Data = string(data)
|
||||
// Use reflection to examine receiver struct fields
|
||||
receiverType := reflect.TypeOf(receiver)
|
||||
receiverVal := reflect.ValueOf(receiver)
|
||||
|
||||
channel.Type = receiverChannelType(receiver)
|
||||
if channel.Type == "" {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeAlertmanagerChannelInvalid, "channel '%s' must have at least one notification configuration (e.g., email_configs, webhook_configs, slack_configs)", receiver.Name)
|
||||
}
|
||||
|
||||
return &channel, nil
|
||||
}
|
||||
|
||||
// receiverChannelType derives the channel.Type discriminator from the
|
||||
// configured notifier by reflecting over the receiver. It walks both the
|
||||
// SigNoz Receiver's own fields (for native notifiers) and the embedded
|
||||
// config.Receiver's fields (for upstream notifiers); the first non-empty
|
||||
// `*_configs` slice wins.
|
||||
func receiverChannelType(receiver *Receiver) string {
|
||||
if t := nonEmptyConfigsField(reflect.ValueOf(*receiver)); t != "" {
|
||||
return t
|
||||
}
|
||||
if t := nonEmptyConfigsField(reflect.ValueOf(*receiver.Receiver)); t != "" {
|
||||
return t
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func nonEmptyConfigsField(v reflect.Value) string {
|
||||
t := v.Type()
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
fieldVal := v.Field(i)
|
||||
// Iterate through fields looking for *Config fields
|
||||
for i := 0; i < receiverType.NumField(); i++ {
|
||||
field := receiverType.Field(i)
|
||||
fieldVal := receiverVal.Field(i)
|
||||
|
||||
// Skip if not a slice or is empty
|
||||
if fieldVal.Kind() != reflect.Slice || fieldVal.Len() == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get channel type from yaml tag
|
||||
yamlTag := field.Tag.Get("yaml")
|
||||
if yamlTag == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract the base type name (e.g., "email_configs" -> "email").
|
||||
// Extract the base type name (e.g., "email_configs" -> "email")
|
||||
matches := receiverTypeRegex.FindStringSubmatch(yamlTag)
|
||||
if len(matches) != 2 {
|
||||
continue
|
||||
}
|
||||
return matches[1]
|
||||
|
||||
channelType := matches[1]
|
||||
|
||||
// Marshal config data to JSON
|
||||
configData, err := json.Marshal(receiver)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
channel.Type = channelType
|
||||
channel.Data = string(configData)
|
||||
break
|
||||
}
|
||||
return ""
|
||||
|
||||
// If we were unable to find the channel type, return an error
|
||||
if channel.Type == "" {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeAlertmanagerChannelInvalid, "channel '%s' must have at least one notification configuration (e.g., email_configs, webhook_configs, slack_configs)", receiver.Name)
|
||||
}
|
||||
|
||||
return &channel, nil
|
||||
}
|
||||
|
||||
func NewConfigFromChannels(globalConfig GlobalConfig, routeConfig RouteConfig, channels Channels, orgID string) (*Config, error) {
|
||||
@@ -192,7 +182,7 @@ func NewStatsFromChannels(channels Channels) map[string]any {
|
||||
return stats
|
||||
}
|
||||
|
||||
func (c *Channel) Update(receiver *Receiver) error {
|
||||
func (c *Channel) Update(receiver Receiver) error {
|
||||
channel, err := NewChannelFromReceiver(receiver, c.OrgID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -202,7 +192,6 @@ func (c *Channel) Update(receiver *Receiver) error {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeAlertmanagerChannelNameMismatch, "cannot update channel name")
|
||||
}
|
||||
|
||||
c.Type = channel.Type
|
||||
c.Data = channel.Data
|
||||
c.UpdatedAt = time.Now()
|
||||
|
||||
@@ -221,21 +210,15 @@ func (PostableChannel) JSONSchema() (jsonschema.Schema, error) {
|
||||
schema.WithRequired("name")
|
||||
|
||||
var oneOf []jsonschema.SchemaOrBool
|
||||
// Walk both the SigNoz Receiver's own fields (for native notifiers) and the
|
||||
// embedded config.Receiver's fields (for upstream notifiers) — together
|
||||
// they make up PostableChannel's JSON shape.
|
||||
collect := func(t reflect.Type) {
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
jsonTag := strings.Split(t.Field(i).Tag.Get("json"), ",")[0]
|
||||
if !strings.HasSuffix(jsonTag, "_configs") {
|
||||
continue
|
||||
}
|
||||
branch := (&jsonschema.Schema{}).WithRequired(jsonTag)
|
||||
oneOf = append(oneOf, branch.ToSchemaOrBool())
|
||||
receiverType := reflect.TypeOf(Receiver{})
|
||||
for i := 0; i < receiverType.NumField(); i++ {
|
||||
jsonTag := strings.Split(receiverType.Field(i).Tag.Get("json"), ",")[0]
|
||||
if !strings.HasSuffix(jsonTag, "_configs") {
|
||||
continue
|
||||
}
|
||||
branch := (&jsonschema.Schema{}).WithRequired(jsonTag)
|
||||
oneOf = append(oneOf, branch.ToSchemaOrBool())
|
||||
}
|
||||
collect(reflect.TypeOf(Receiver{}))
|
||||
collect(reflect.TypeOf(config.Receiver{}))
|
||||
|
||||
schema.WithOneOf(oneOf...)
|
||||
|
||||
|
||||
@@ -285,8 +285,7 @@ func TestNewChannelFromReceiver(t *testing.T) {
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
receiver := testCase.receiver
|
||||
channel, err := NewChannelFromReceiver(&Receiver{Receiver: &receiver}, "1")
|
||||
channel, err := NewChannelFromReceiver(testCase.receiver, "1")
|
||||
if !testCase.pass {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
@@ -300,33 +299,3 @@ func TestNewChannelFromReceiver(t *testing.T) {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// TestNewChannelFromReceiverGoogleChat covers the SigNoz-native side of the
|
||||
// receiver: the Type discriminator and the per-row Data both come from the
|
||||
// embed's googlechat_configs field — no upstream notifier config is present.
|
||||
func TestNewChannelFromReceiverGoogleChat(t *testing.T) {
|
||||
webhookURL, err := url.Parse("https://chat.googleapis.com/v1/spaces/test/messages")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
receiver := &Receiver{
|
||||
Receiver: &config.Receiver{Name: "googlechat-receiver"},
|
||||
GoogleChatConfigs: []*GoogleChatReceiverConfig{
|
||||
{
|
||||
WebhookURL: &config.SecretURL{URL: webhookURL},
|
||||
Title: "Alert",
|
||||
Text: "Body",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
channel, err := NewChannelFromReceiver(receiver, "1")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "googlechat-receiver", channel.Name)
|
||||
assert.Equal(t, "googlechat", channel.Type)
|
||||
assert.JSONEq(t,
|
||||
`{"name":"googlechat-receiver","googlechat_configs":[{"send_resolved":false,"webhook_url":"https://chat.googleapis.com/v1/spaces/test/messages","title":"Alert","text":"Body"}]}`,
|
||||
channel.Data,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -59,55 +59,12 @@ type Config struct {
|
||||
|
||||
// storeableConfig is the representation of the config in the store
|
||||
storeableConfig *StoreableConfig
|
||||
|
||||
// customConfigs holds the SigNoz-native notifier configs (which the upstream
|
||||
// config.Receiver cannot carry) keyed by receiver name. It runs in parallel
|
||||
// with alertmanagerConfig.Receivers (which holds the upstream base); the two
|
||||
// halves are zipped back together by GetReceiver and serialized together by
|
||||
// newRawFromConfig.
|
||||
customConfigs map[string]customReceiverConfigs
|
||||
}
|
||||
|
||||
// customReceiverConfigs is the per-receiver sidecar for SigNoz-native notifier
|
||||
// configs that the upstream config.Receiver cannot carry. To add another
|
||||
// native notifier, mirror the GoogleChat field below: declare a typed slice
|
||||
// here, add the matching field on Receiver, and extend customConfigsOf and
|
||||
// isEmpty. The serialization (storedConfig) and in-memory plumbing
|
||||
// (CreateReceiver / GetReceiver / newRawFromConfig) need no further changes.
|
||||
type customReceiverConfigs struct {
|
||||
GoogleChat []*GoogleChatReceiverConfig
|
||||
}
|
||||
|
||||
func (c customReceiverConfigs) isEmpty() bool {
|
||||
return len(c.GoogleChat) == 0
|
||||
}
|
||||
|
||||
// customConfigsOf extracts the SigNoz-native configs carried on a Receiver.
|
||||
func customConfigsOf(receiver *Receiver) customReceiverConfigs {
|
||||
return customReceiverConfigs{
|
||||
GoogleChat: receiver.GoogleChatConfigs,
|
||||
}
|
||||
}
|
||||
|
||||
// storedConfig is the serialization unit persisted to StoreableConfig.Config.
|
||||
// Embedding *config.Config emits every upstream field (global, route,
|
||||
// inhibit_rules, templates, ...); the outer Receivers field shadows the
|
||||
// embedded config.Config.Receivers — both marshal to the JSON key "receivers"
|
||||
// and the shallower outer field dominates per encoding/json's visibility
|
||||
// rules — so the receivers are emitted as the extended *Receiver. When the
|
||||
// first SigNoz-native notifier field is added to Receiver, it round-trips
|
||||
// through this unit automatically, with no further changes here.
|
||||
type storedConfig struct {
|
||||
*config.Config
|
||||
Receivers []*Receiver `json:"receivers"`
|
||||
}
|
||||
|
||||
func NewConfig(c *config.Config, orgID string) *Config {
|
||||
customConfigs := make(map[string]customReceiverConfigs)
|
||||
raw := string(newRawFromConfig(c, customConfigs))
|
||||
raw := string(newRawFromConfig(c))
|
||||
return &Config{
|
||||
alertmanagerConfig: c,
|
||||
customConfigs: customConfigs,
|
||||
storeableConfig: &StoreableConfig{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: valuer.GenerateUUID(),
|
||||
@@ -124,14 +81,13 @@ func NewConfig(c *config.Config, orgID string) *Config {
|
||||
}
|
||||
|
||||
func NewConfigFromStoreableConfig(sc *StoreableConfig) (*Config, error) {
|
||||
alertmanagerConfig, customConfigs, err := newConfigFromString(sc.Config)
|
||||
alertmanagerConfig, err := newConfigFromString(sc.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Config{
|
||||
alertmanagerConfig: alertmanagerConfig,
|
||||
customConfigs: customConfigs,
|
||||
storeableConfig: sc,
|
||||
}, nil
|
||||
}
|
||||
@@ -157,49 +113,32 @@ func NewDefaultConfig(globalConfig GlobalConfig, routeConfig RouteConfig, orgID
|
||||
}, orgID), nil
|
||||
}
|
||||
|
||||
func newConfigFromString(s string) (*config.Config, map[string]customReceiverConfigs, error) {
|
||||
stored := storedConfig{Config: new(config.Config)}
|
||||
if err := json.Unmarshal([]byte(s), &stored); err != nil {
|
||||
return nil, nil, err
|
||||
func newConfigFromString(s string) (*config.Config, error) {
|
||||
config := new(config.Config)
|
||||
err := json.Unmarshal([]byte(s), config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
amConfig := stored.Config
|
||||
amConfig.Receivers = make([]config.Receiver, len(stored.Receivers))
|
||||
customConfigs := make(map[string]customReceiverConfigs)
|
||||
for i, receiver := range config.Receivers {
|
||||
bytes, err := json.Marshal(receiver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Re-run each receiver through NewReceiver so upstream defaults are
|
||||
// applied (mirrors the create path) and native fields are pulled off the
|
||||
// embed at the same time.
|
||||
for i, rcv := range stored.Receivers {
|
||||
rcvJSON, err := json.Marshal(rcv)
|
||||
receiver, err := NewReceiver(string(bytes))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
parsed, err := NewReceiver(string(rcvJSON))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
amConfig.Receivers[i] = *parsed.Receiver
|
||||
if custom := customConfigsOf(parsed); !custom.isEmpty() {
|
||||
customConfigs[parsed.Name] = custom
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config.Receivers[i] = receiver
|
||||
}
|
||||
|
||||
return amConfig, customConfigs, nil
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func newRawFromConfig(c *config.Config, customConfigs map[string]customReceiverConfigs) []byte {
|
||||
receivers := make([]*Receiver, len(c.Receivers))
|
||||
for i := range c.Receivers {
|
||||
base := c.Receivers[i]
|
||||
custom := customConfigs[base.Name]
|
||||
receivers[i] = &Receiver{
|
||||
Receiver: &base,
|
||||
GoogleChatConfigs: custom.GoogleChat,
|
||||
}
|
||||
}
|
||||
|
||||
b, err := json.Marshal(storedConfig{Config: c, Receivers: receivers})
|
||||
func newRawFromConfig(c *config.Config) []byte {
|
||||
b, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
// Taking inspiration from the upstream. This is never expected to happen.
|
||||
return []byte(fmt.Sprintf("<error creating config string: %s>", err))
|
||||
@@ -212,16 +151,6 @@ func newConfigHash(s string) [16]byte {
|
||||
return md5.Sum([]byte(s))
|
||||
}
|
||||
|
||||
// flush re-serializes the config into the storeable representation and
|
||||
// refreshes its hash and timestamp. Call it after every mutation of
|
||||
// alertmanagerConfig or customConfigs.
|
||||
func (c *Config) flush() {
|
||||
raw := string(newRawFromConfig(c.alertmanagerConfig, c.customConfigs))
|
||||
c.storeableConfig.Config = raw
|
||||
c.storeableConfig.Hash = fmt.Sprintf("%x", newConfigHash(raw))
|
||||
c.storeableConfig.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
func (c *Config) CopyWithReset() (*Config, error) {
|
||||
newConfig, err := NewDefaultConfig(
|
||||
*c.alertmanagerConfig.Global,
|
||||
@@ -250,7 +179,9 @@ func (c *Config) SetGlobalConfig(globalConfig GlobalConfig) error {
|
||||
globalConfig.SMTPRequireTLS = smtpRequireTLS
|
||||
|
||||
c.alertmanagerConfig.Global = &globalConfig
|
||||
c.flush()
|
||||
c.storeableConfig.Config = string(newRawFromConfig(c.alertmanagerConfig))
|
||||
c.storeableConfig.Hash = fmt.Sprintf("%x", newConfigHash(c.storeableConfig.Config))
|
||||
c.storeableConfig.UpdatedAt = time.Now()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -262,7 +193,9 @@ func (c *Config) SetRouteConfig(routeConfig RouteConfig) error {
|
||||
}
|
||||
c.alertmanagerConfig.Route = route
|
||||
|
||||
c.flush()
|
||||
c.storeableConfig.Config = string(newRawFromConfig(c.alertmanagerConfig))
|
||||
c.storeableConfig.Hash = fmt.Sprintf("%x", newConfigHash(c.storeableConfig.Config))
|
||||
c.storeableConfig.UpdatedAt = time.Now()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -274,7 +207,9 @@ func (c *Config) AddInhibitRules(rules []config.InhibitRule) error {
|
||||
|
||||
c.alertmanagerConfig.InhibitRules = append(c.alertmanagerConfig.InhibitRules, rules...)
|
||||
|
||||
c.flush()
|
||||
c.storeableConfig.Config = string(newRawFromConfig(c.alertmanagerConfig))
|
||||
c.storeableConfig.Hash = fmt.Sprintf("%x", newConfigHash(c.storeableConfig.Config))
|
||||
c.storeableConfig.UpdatedAt = time.Now()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -287,7 +222,7 @@ func (c *Config) StoreableConfig() *StoreableConfig {
|
||||
return c.storeableConfig
|
||||
}
|
||||
|
||||
func (c *Config) CreateReceiver(receiver *Receiver) error {
|
||||
func (c *Config) CreateReceiver(receiver config.Receiver) error {
|
||||
// check that receiver name is not already used
|
||||
for _, existingReceiver := range c.alertmanagerConfig.Receivers {
|
||||
if existingReceiver.Name == receiver.Name {
|
||||
@@ -301,41 +236,33 @@ func (c *Config) CreateReceiver(receiver *Receiver) error {
|
||||
}
|
||||
|
||||
c.alertmanagerConfig.Route.Routes = append(c.alertmanagerConfig.Route.Routes, route)
|
||||
c.alertmanagerConfig.Receivers = append(c.alertmanagerConfig.Receivers, *receiver.Receiver)
|
||||
c.setCustomConfigs(receiver)
|
||||
c.alertmanagerConfig.Receivers = append(c.alertmanagerConfig.Receivers, receiver)
|
||||
|
||||
if err := c.alertmanagerConfig.UnmarshalYAML(func(i interface{}) error { return nil }); err != nil {
|
||||
return err
|
||||
}
|
||||
c.applyNativeDefaults()
|
||||
|
||||
c.flush()
|
||||
c.storeableConfig.Config = string(newRawFromConfig(c.alertmanagerConfig))
|
||||
c.storeableConfig.Hash = fmt.Sprintf("%x", newConfigHash(c.storeableConfig.Config))
|
||||
c.storeableConfig.UpdatedAt = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Config) GetReceiver(name string) (*Receiver, error) {
|
||||
for i := range c.alertmanagerConfig.Receivers {
|
||||
if c.alertmanagerConfig.Receivers[i].Name == name {
|
||||
// Copy out of the slice to avoid handing the caller a pointer
|
||||
// into a slice that may later be reallocated by append.
|
||||
base := c.alertmanagerConfig.Receivers[i]
|
||||
custom := c.customConfigs[name]
|
||||
return &Receiver{
|
||||
Receiver: &base,
|
||||
GoogleChatConfigs: custom.GoogleChat,
|
||||
}, nil
|
||||
func (c *Config) GetReceiver(name string) (Receiver, error) {
|
||||
for _, receiver := range c.alertmanagerConfig.Receivers {
|
||||
if receiver.Name == name {
|
||||
return receiver, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, errors.Newf(errors.TypeNotFound, ErrCodeAlertmanagerChannelNotFound, "channel with name %q not found", name)
|
||||
return Receiver{}, errors.Newf(errors.TypeNotFound, ErrCodeAlertmanagerChannelNotFound, "channel with name %q not found", name)
|
||||
}
|
||||
|
||||
func (c *Config) UpdateReceiver(receiver *Receiver) error {
|
||||
func (c *Config) UpdateReceiver(receiver config.Receiver) error {
|
||||
// find and update receiver
|
||||
for i, existingReceiver := range c.alertmanagerConfig.Receivers {
|
||||
if existingReceiver.Name == receiver.Name {
|
||||
c.alertmanagerConfig.Receivers[i] = *receiver.Receiver
|
||||
c.setCustomConfigs(receiver)
|
||||
c.alertmanagerConfig.Receivers[i] = receiver
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -343,9 +270,10 @@ func (c *Config) UpdateReceiver(receiver *Receiver) error {
|
||||
if err := c.alertmanagerConfig.UnmarshalYAML(func(i interface{}) error { return nil }); err != nil {
|
||||
return err
|
||||
}
|
||||
c.applyNativeDefaults()
|
||||
|
||||
c.flush()
|
||||
c.storeableConfig.Config = string(newRawFromConfig(c.alertmanagerConfig))
|
||||
c.storeableConfig.Hash = fmt.Sprintf("%x", newConfigHash(c.storeableConfig.Config))
|
||||
c.storeableConfig.UpdatedAt = time.Now()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -370,48 +298,13 @@ func (c *Config) DeleteReceiver(name string) error {
|
||||
}
|
||||
}
|
||||
|
||||
delete(c.customConfigs, name)
|
||||
|
||||
c.flush()
|
||||
c.storeableConfig.Config = string(newRawFromConfig(c.alertmanagerConfig))
|
||||
c.storeableConfig.Hash = fmt.Sprintf("%x", newConfigHash(c.storeableConfig.Config))
|
||||
c.storeableConfig.UpdatedAt = time.Now()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setCustomConfigs records (or clears) the SigNoz-native configs for a
|
||||
// receiver in the in-memory sidecar.
|
||||
func (c *Config) setCustomConfigs(receiver *Receiver) {
|
||||
if custom := customConfigsOf(receiver); !custom.isEmpty() {
|
||||
c.customConfigs[receiver.Name] = custom
|
||||
} else {
|
||||
delete(c.customConfigs, receiver.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// applyNativeDefaults threads global-scoped defaults (currently:
|
||||
// Global.HTTPConfig) into each SigNoz-native notifier config that has not
|
||||
// supplied its own. This mirrors the corresponding loop in upstream's
|
||||
// config.Config.UnmarshalYAML, which does
|
||||
//
|
||||
// wh.HTTPConfig = cmp.Or(wh.HTTPConfig, c.Global.HTTPConfig)
|
||||
//
|
||||
// for each upstream notifier config. Call it from mutation paths after the
|
||||
// upstream UnmarshalYAML pass has run. Extend it when adding another native
|
||||
// notifier type that needs anything threaded from Global.
|
||||
func (c *Config) applyNativeDefaults() {
|
||||
if c.alertmanagerConfig.Global == nil {
|
||||
return
|
||||
}
|
||||
httpDefault := c.alertmanagerConfig.Global.HTTPConfig
|
||||
|
||||
for _, custom := range c.customConfigs {
|
||||
for _, gc := range custom.GoogleChat {
|
||||
if gc.HTTPConfig == nil {
|
||||
gc.HTTPConfig = httpDefault
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) CreateRuleIDMatcher(ruleID string, receiverNames []string) error {
|
||||
if c.alertmanagerConfig.Route == nil {
|
||||
return errors.New(errors.TypeInvalidInput, ErrCodeAlertmanagerConfigInvalid, "route is nil")
|
||||
@@ -425,7 +318,9 @@ func (c *Config) CreateRuleIDMatcher(ruleID string, receiverNames []string) erro
|
||||
}
|
||||
}
|
||||
|
||||
c.flush()
|
||||
c.storeableConfig.Config = string(newRawFromConfig(c.alertmanagerConfig))
|
||||
c.storeableConfig.Hash = fmt.Sprintf("%x", newConfigHash(c.storeableConfig.Config))
|
||||
c.storeableConfig.UpdatedAt = time.Now()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -444,7 +339,9 @@ func (c *Config) DeleteRuleIDInhibitor(ruleID string) error {
|
||||
}
|
||||
}
|
||||
c.alertmanagerConfig.InhibitRules = filteredRules
|
||||
c.flush()
|
||||
c.storeableConfig.Config = string(newRawFromConfig(c.alertmanagerConfig))
|
||||
c.storeableConfig.Hash = fmt.Sprintf("%x", newConfigHash(c.storeableConfig.Config))
|
||||
c.storeableConfig.UpdatedAt = time.Now()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -465,7 +362,9 @@ func (c *Config) DeleteRuleIDMatcher(ruleID string) error {
|
||||
}
|
||||
}
|
||||
|
||||
c.flush()
|
||||
c.storeableConfig.Config = string(newRawFromConfig(c.alertmanagerConfig))
|
||||
c.storeableConfig.Hash = fmt.Sprintf("%x", newConfigHash(c.storeableConfig.Config))
|
||||
c.storeableConfig.UpdatedAt = time.Now()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ func TestCreateRuleIDMatcher(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, receiver := range tc.receivers {
|
||||
err := cfg.CreateReceiver(&Receiver{Receiver: &receiver})
|
||||
err := cfg.CreateReceiver(receiver)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
@@ -203,7 +203,7 @@ func TestDeleteRuleIDMatcher(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, receiver := range tc.receivers {
|
||||
err := cfg.CreateReceiver(&Receiver{Receiver: &receiver})
|
||||
err := cfg.CreateReceiver(receiver)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
@@ -329,68 +329,3 @@ func TestSetGlobalConfigPreservesSMTPRequireTLS(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigPreservesGoogleChatConfigs is the round-trip proof for the
|
||||
// native-notifier seam: create a Config with a GoogleChat receiver, serialize
|
||||
// to the storeable blob, reload via NewConfigFromStoreableConfig, and verify
|
||||
// the GoogleChatConfigs survive the trip — through both the blob and the
|
||||
// in-memory sidecar — and that GetReceiver zips them back into a *Receiver.
|
||||
func TestConfigPreservesGoogleChatConfigs(t *testing.T) {
|
||||
webhookURL, err := url.Parse("https://chat.googleapis.com/v1/spaces/test/messages")
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg, err := NewDefaultConfig(
|
||||
GlobalConfig{SMTPSmarthost: config.HostPort{Host: "localhost", Port: "25"}, SMTPFrom: "test@example.com"},
|
||||
RouteConfig{GroupInterval: time.Minute, GroupWait: time.Minute, RepeatInterval: time.Minute},
|
||||
"1",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
receiver := &Receiver{
|
||||
Receiver: &config.Receiver{Name: "googlechat-receiver"},
|
||||
GoogleChatConfigs: []*GoogleChatReceiverConfig{
|
||||
{
|
||||
WebhookURL: &config.SecretURL{URL: webhookURL},
|
||||
Title: "Alert",
|
||||
Text: "Body",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, cfg.CreateReceiver(receiver))
|
||||
|
||||
// In-memory: GetReceiver zips the base and the native sidecar back together.
|
||||
got, err := cfg.GetReceiver("googlechat-receiver")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got.GoogleChatConfigs, 1)
|
||||
assert.Equal(t, "Alert", got.GoogleChatConfigs[0].Title)
|
||||
assert.Equal(t, "Body", got.GoogleChatConfigs[0].Text)
|
||||
|
||||
// Global threading: HTTPConfig was nil on input, so applyNativeDefaults
|
||||
// should have filled it in from Config.Global.HTTPConfig. This mirrors
|
||||
// upstream's webhook/email/slack defaulting in config.Config.UnmarshalYAML.
|
||||
require.NotNil(t, got.GoogleChatConfigs[0].HTTPConfig, "HTTPConfig should be threaded from Global")
|
||||
assert.Same(t, cfg.alertmanagerConfig.Global.HTTPConfig, got.GoogleChatConfigs[0].HTTPConfig)
|
||||
|
||||
// Persisted blob: reload it and confirm the same.
|
||||
reloaded, err := NewConfigFromStoreableConfig(cfg.StoreableConfig())
|
||||
require.NoError(t, err)
|
||||
|
||||
reloadedReceiver, err := reloaded.GetReceiver("googlechat-receiver")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, reloadedReceiver.GoogleChatConfigs, 1)
|
||||
assert.Equal(t, "Alert", reloadedReceiver.GoogleChatConfigs[0].Title)
|
||||
assert.Equal(t, "Body", reloadedReceiver.GoogleChatConfigs[0].Text)
|
||||
assert.Equal(t, "https://chat.googleapis.com/v1/spaces/test/messages", reloadedReceiver.GoogleChatConfigs[0].WebhookURL.String())
|
||||
// HTTPConfig persisted into the blob and re-hydrated on load.
|
||||
require.NotNil(t, reloadedReceiver.GoogleChatConfigs[0].HTTPConfig)
|
||||
|
||||
// Update path keeps the sidecar in sync.
|
||||
receiver.GoogleChatConfigs[0].Title = "Updated"
|
||||
require.NoError(t, cfg.UpdateReceiver(receiver))
|
||||
|
||||
updated, err := cfg.GetReceiver("googlechat-receiver")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, updated.GoogleChatConfigs, 1)
|
||||
assert.Equal(t, "Updated", updated.GoogleChatConfigs[0].Title)
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
)
|
||||
|
||||
// GoogleChatReceiverConfig is a SigNoz-native notifier config that upstream
|
||||
// alertmanager does not know about. It is carried on Receiver alongside the
|
||||
// embedded *config.Receiver and round-trips through JSON via that embed's
|
||||
// struct tags — Channel.Data and the stored config blob preserve it
|
||||
// automatically without any separate registry or marshalling.
|
||||
//
|
||||
// The shape mirrors upstream's notifier configs (e.g. SlackConfig): the
|
||||
// inline-embedded NotifierConfig contributes send_resolved + the
|
||||
// SendResolved() method that the notify pipeline uses to gate resolved
|
||||
// notifications, and HTTPConfig is filled in from Config.Global.HTTPConfig
|
||||
// when omitted (see Config.applyNativeDefaults). Only the config shape is
|
||||
// defined here; a future notifier package would consume these fields, POST
|
||||
// to the webhook, and implement notify.Notifier.
|
||||
type GoogleChatReceiverConfig struct {
|
||||
config.NotifierConfig `yaml:",inline" json:",inline"`
|
||||
|
||||
HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"`
|
||||
|
||||
WebhookURL *config.SecretURL `yaml:"webhook_url,omitempty" json:"webhook_url,omitempty"`
|
||||
Title string `yaml:"title,omitempty" json:"title,omitempty"`
|
||||
Text string `yaml:"text,omitempty" json:"text,omitempty"`
|
||||
}
|
||||
|
||||
// DefaultGoogleChatReceiverConfig holds the defaults applied by
|
||||
// GoogleChatReceiverConfig.UnmarshalYAML before user-specified fields are
|
||||
// overlaid. Mirrors upstream's DefaultSlackConfig / DefaultPagerdutyConfig.
|
||||
var DefaultGoogleChatReceiverConfig = GoogleChatReceiverConfig{
|
||||
NotifierConfig: config.NotifierConfig{
|
||||
VSendResolved: false,
|
||||
},
|
||||
Title: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
|
||||
Text: `{{ range .Alerts -}}
|
||||
*Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} ({{ .Labels.severity }}){{ end }}{{ if .Annotations.summary }}
|
||||
*Summary:* {{ .Annotations.summary }}{{ end }}{{ if .Annotations.description }}
|
||||
*Description:* {{ .Annotations.description }}{{ end }}
|
||||
{{ end }}`,
|
||||
}
|
||||
|
||||
// UnmarshalYAML implements the per-config defaulting pattern used by every
|
||||
// upstream notifier config: install the defaults first, then overlay the
|
||||
// user-specified fields. Triggered by the yaml round-trip in NewReceiver.
|
||||
func (c *GoogleChatReceiverConfig) UnmarshalYAML(unmarshal func(any) error) error {
|
||||
*c = DefaultGoogleChatReceiverConfig
|
||||
type plain GoogleChatReceiverConfig
|
||||
return unmarshal((*plain)(c))
|
||||
}
|
||||
@@ -22,7 +22,7 @@ func TestAddRuleIDToRoute(t *testing.T) {
|
||||
{
|
||||
name: "Simple",
|
||||
route: func() *config.Route {
|
||||
route, err := NewRouteFromReceiver(&Receiver{Receiver: &config.Receiver{Name: "test"}})
|
||||
route, err := NewRouteFromReceiver(Receiver{Name: "test"})
|
||||
require.NoError(t, err)
|
||||
|
||||
return route
|
||||
@@ -33,7 +33,7 @@ func TestAddRuleIDToRoute(t *testing.T) {
|
||||
{
|
||||
name: "AlreadyExists",
|
||||
route: func() *config.Route {
|
||||
route, err := NewRouteFromReceiver(&Receiver{Receiver: &config.Receiver{Name: "test"}})
|
||||
route, err := NewRouteFromReceiver(Receiver{Name: "test"})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = addRuleIDToRoute(route, "1")
|
||||
@@ -84,7 +84,7 @@ func TestRemoveRuleIDFromRoute(t *testing.T) {
|
||||
{
|
||||
name: "Simple",
|
||||
route: func() *config.Route {
|
||||
route, err := NewRouteFromReceiver(&Receiver{Receiver: &config.Receiver{Name: "test"}})
|
||||
route, err := NewRouteFromReceiver(Receiver{Name: "test"})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = addRuleIDToRoute(route, "1")
|
||||
@@ -98,7 +98,7 @@ func TestRemoveRuleIDFromRoute(t *testing.T) {
|
||||
{
|
||||
name: "DoesNotExist",
|
||||
route: func() *config.Route {
|
||||
route, err := NewRouteFromReceiver(&Receiver{Receiver: &config.Receiver{Name: "test"}})
|
||||
route, err := NewRouteFromReceiver(Receiver{Name: "test"})
|
||||
require.NoError(t, err)
|
||||
|
||||
return route
|
||||
@@ -109,7 +109,7 @@ func TestRemoveRuleIDFromRoute(t *testing.T) {
|
||||
{
|
||||
name: "DeleteMatcher",
|
||||
route: func() *config.Route {
|
||||
route, err := NewRouteFromReceiver(&Receiver{Receiver: &config.Receiver{Name: "test"}})
|
||||
route, err := NewRouteFromReceiver(Receiver{Name: "test"})
|
||||
require.NoError(t, err)
|
||||
|
||||
return route
|
||||
|
||||
@@ -17,4 +17,4 @@ type Templater interface {
|
||||
|
||||
// ReceiverIntegrationsFunc constructs the notify.Integration list for a
|
||||
// configured receiver.
|
||||
type ReceiverIntegrationsFunc = func(nc *Receiver, tmpl *template.Template, logger *slog.Logger, templater Templater) ([]notify.Integration, error)
|
||||
type ReceiverIntegrationsFunc = func(nc Receiver, tmpl *template.Template, logger *slog.Logger, templater Templater) ([]notify.Integration, error)
|
||||
|
||||
@@ -17,97 +17,40 @@ import (
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
)
|
||||
|
||||
// Receiver is the SigNoz receiver type. It embeds the upstream alertmanager
|
||||
// config.Receiver so every upstream notifier field (slack_configs,
|
||||
// webhook_configs, email_configs, ...) is promoted inline and round-trips
|
||||
// through encoding/json transparently.
|
||||
//
|
||||
// The struct is also the home for SigNoz-native notifier configs that upstream
|
||||
// alertmanager does not know about (currently: GoogleChatConfigs). Because
|
||||
// config.Receiver has no custom (Un)MarshalJSON, declaring a sibling field on
|
||||
// this struct with matching json/yaml tags lets json.Marshal and json.Unmarshal
|
||||
// carry that field alongside the upstream ones in a single pass — no
|
||||
// allow-list, no post-marshal patching.
|
||||
//
|
||||
// To add another native notifier, mirror GoogleChatConfigs: add a typed slice
|
||||
// field here, add the same field on customReceiverConfigs in config.go, and
|
||||
// extend customConfigsOf / isEmpty. The serialization, in-memory storage, and
|
||||
// channel-type detection all flow from there without further changes.
|
||||
type Receiver struct {
|
||||
*config.Receiver
|
||||
GoogleChatConfigs []*GoogleChatReceiverConfig `json:"googlechat_configs,omitempty" yaml:"googlechat_configs,omitempty"`
|
||||
}
|
||||
type (
|
||||
// Receiver is the type for the receiver configuration.
|
||||
Receiver = config.Receiver
|
||||
)
|
||||
|
||||
// NewReceiver builds a Receiver from its JSON representation, applying the
|
||||
// per-config defaults from each notifier's UnmarshalYAML (mirrors upstream:
|
||||
// every notifier config sets `*c = DefaultXxxConfig` first, then overlays the
|
||||
// user-specified fields). Global-scoped defaulting (HTTPConfig from
|
||||
// Config.Global.HTTPConfig) happens later, in Config.applyNativeDefaults.
|
||||
//
|
||||
// The only default missed is `send_resolved` (a bool) which, if absent from
|
||||
// the input, stays false.
|
||||
func NewReceiver(input string) (*Receiver, error) {
|
||||
receiver := &Receiver{Receiver: &config.Receiver{}}
|
||||
if err := json.Unmarshal([]byte(input), receiver); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Default the embedded upstream base. Each upstream *Config's UnmarshalYAML
|
||||
// runs during the yaml roundtrip and applies its DefaultXxxConfig.
|
||||
withDefaults, err := defaultedBaseReceiver(receiver.Receiver)
|
||||
// Creates a new receiver from a string. The input is initialized with the default values from the upstream alertmanager.
|
||||
// The only default value which is missed is `send_resolved` (as it is a bool) which if not set in the input will always be set to `false`.
|
||||
func NewReceiver(input string) (Receiver, error) {
|
||||
receiver := Receiver{}
|
||||
err := json.Unmarshal([]byte(input), &receiver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
receiver.Receiver = withDefaults
|
||||
|
||||
// Default each SigNoz-native notifier config the same way. Extend this
|
||||
// block when adding another native notifier type.
|
||||
for i, gc := range receiver.GoogleChatConfigs {
|
||||
defaulted, err := defaultedNotifierConfig(gc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
receiver.GoogleChatConfigs[i] = defaulted
|
||||
return Receiver{}, err
|
||||
}
|
||||
|
||||
return receiver, nil
|
||||
}
|
||||
|
||||
func defaultedBaseReceiver(base *config.Receiver) (*config.Receiver, error) {
|
||||
bytes, err := yaml.Marshal(base)
|
||||
// We marshal and unmarshal the receiver to ensure that the receiver is
|
||||
// initialized with defaults from the upstream alertmanager.
|
||||
bytes, err := yaml.Marshal(receiver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return Receiver{}, err
|
||||
}
|
||||
|
||||
withDefaults := &config.Receiver{}
|
||||
if err := yaml.Unmarshal(bytes, withDefaults); err != nil {
|
||||
return nil, err
|
||||
receiverWithDefaults := Receiver{}
|
||||
if err := yaml.Unmarshal(bytes, &receiverWithDefaults); err != nil {
|
||||
return Receiver{}, err
|
||||
}
|
||||
|
||||
if err := withDefaults.UnmarshalYAML(func(i interface{}) error { return nil }); err != nil {
|
||||
return nil, err
|
||||
if err := receiverWithDefaults.UnmarshalYAML(func(i interface{}) error { return nil }); err != nil {
|
||||
return Receiver{}, err
|
||||
}
|
||||
|
||||
return withDefaults, nil
|
||||
return receiverWithDefaults, nil
|
||||
}
|
||||
|
||||
// defaultedNotifierConfig applies a single notifier config's per-config defaults
|
||||
// by round-tripping it through yaml. UnmarshalYAML on the config type runs
|
||||
// during the unmarshal step and installs DefaultXxxConfig before re-overlaying
|
||||
// the user-specified fields (the standard upstream defaulting pattern).
|
||||
func defaultedNotifierConfig[T any](cfg *T) (*T, error) {
|
||||
bytes, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := new(T)
|
||||
if err := yaml.Unmarshal(bytes, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func TestReceiver(ctx context.Context, receiver *Receiver, receiverIntegrationsFunc ReceiverIntegrationsFunc, config *Config, tmpl *template.Template, logger *slog.Logger, templater Templater, lSet model.LabelSet, alert ...*Alert) error {
|
||||
func TestReceiver(ctx context.Context, receiver Receiver, receiverIntegrationsFunc ReceiverIntegrationsFunc, config *Config, tmpl *template.Template, logger *slog.Logger, templater Templater, lSet model.LabelSet, alert ...*Alert) error {
|
||||
ctx = notify.WithGroupKey(ctx, fmt.Sprintf("%s-%s-%d", receiver.Name, lSet.Fingerprint(), time.Now().Unix()))
|
||||
ctx = notify.WithGroupLabels(ctx, lSet)
|
||||
ctx = notify.WithReceiverName(ctx, receiver.Name)
|
||||
@@ -124,12 +67,12 @@ func TestReceiver(ctx context.Context, receiver *Receiver, receiverIntegrationsF
|
||||
return err
|
||||
}
|
||||
|
||||
defaultedReceiver, err := testConfig.GetReceiver(receiver.Name)
|
||||
receiver, err = testConfig.GetReceiver(receiver.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
integrations, err := receiverIntegrationsFunc(defaultedReceiver, tmpl, logger, templater)
|
||||
integrations, err := receiverIntegrationsFunc(receiver, tmpl, logger, templater)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -21,18 +21,6 @@ func TestNewReceiver(t *testing.T) {
|
||||
expected: `{"name":"telegram","telegram_configs":[{"send_resolved":false,"token":"1234567890","chat":12345,"message":"{{ template \"telegram.default.message\" . }}","parse_mode":"HTML"}]}`,
|
||||
pass: true,
|
||||
},
|
||||
{
|
||||
// GoogleChatConfig exercises the SigNoz-native side of the
|
||||
// Receiver embed: googlechat_configs is unmarshalled into the
|
||||
// sibling field and re-marshalled alongside the upstream fields
|
||||
// in a single pass. send_resolved is contributed by the embedded
|
||||
// NotifierConfig and is always emitted (no omitempty), matching
|
||||
// upstream's behaviour for every other notifier config.
|
||||
name: "GoogleChatConfig",
|
||||
input: `{"name":"googlechat","googlechat_configs":[{"webhook_url":"https://chat.googleapis.com/v1/spaces/test/messages","title":"Alert","text":"Body"}]}`,
|
||||
expected: `{"name":"googlechat","googlechat_configs":[{"send_resolved":false,"webhook_url":"https://chat.googleapis.com/v1/spaces/test/messages","title":"Alert","text":"Body"}]}`,
|
||||
pass: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
@@ -51,35 +39,3 @@ func TestNewReceiver(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewReceiverGoogleChatAppliesDefaults verifies the per-config defaulting
|
||||
// mechanism for SigNoz-native configs: when the user omits Title / Text /
|
||||
// send_resolved, GoogleChatReceiverConfig.UnmarshalYAML installs the values
|
||||
// from DefaultGoogleChatReceiverConfig before any user-specified fields are
|
||||
// overlaid. This mirrors how every upstream notifier config defaults itself
|
||||
// (e.g. DefaultSlackConfig).
|
||||
func TestNewReceiverGoogleChatAppliesDefaults(t *testing.T) {
|
||||
receiver, err := NewReceiver(`{"name":"googlechat","googlechat_configs":[{"webhook_url":"https://chat.googleapis.com/v1/spaces/test/messages"}]}`)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, receiver.GoogleChatConfigs, 1)
|
||||
|
||||
got := receiver.GoogleChatConfigs[0]
|
||||
assert.Equal(t, DefaultGoogleChatReceiverConfig.Title, got.Title, "Title should fall back to the default template")
|
||||
assert.Equal(t, DefaultGoogleChatReceiverConfig.Text, got.Text, "Text should fall back to the default template")
|
||||
assert.Equal(t, DefaultGoogleChatReceiverConfig.VSendResolved, got.SendResolved(), "send_resolved should fall back to the default")
|
||||
}
|
||||
|
||||
// TestNewReceiverGoogleChatPreservesUserOverrides verifies that user-specified
|
||||
// values survive the defaulting pass — the default is installed first, then
|
||||
// the user's fields are overlaid. send_resolved=true from the input must win
|
||||
// over the default's false.
|
||||
func TestNewReceiverGoogleChatPreservesUserOverrides(t *testing.T) {
|
||||
receiver, err := NewReceiver(`{"name":"googlechat","googlechat_configs":[{"webhook_url":"https://chat.googleapis.com/v1/spaces/test/messages","title":"X","text":"Y","send_resolved":true}]}`)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, receiver.GoogleChatConfigs, 1)
|
||||
|
||||
got := receiver.GoogleChatConfigs[0]
|
||||
assert.Equal(t, "X", got.Title)
|
||||
assert.Equal(t, "Y", got.Text)
|
||||
assert.True(t, got.SendResolved())
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ func NewRouteFromRouteConfig(route *config.Route, cfg RouteConfig) (*config.Rout
|
||||
return route, nil
|
||||
}
|
||||
|
||||
func NewRouteFromReceiver(receiver *Receiver) (*config.Route, error) {
|
||||
func NewRouteFromReceiver(receiver Receiver) (*config.Route, error) {
|
||||
route := &config.Route{Receiver: receiver.Name, Continue: true, Matchers: config.Matchers{noRuleIDMatcher}}
|
||||
if err := route.UnmarshalYAML(func(i interface{}) error { return nil }); err != nil {
|
||||
return nil, err
|
||||
|
||||
Reference in New Issue
Block a user