Compare commits

..

4 Commits

Author SHA1 Message Date
Pradeep Kumar
2d1bc95c87 add default dashboard value for overview page
this relates to https://github.com/SigNoz/signoz/pull/11133
adds default value for overvie page and store in db
2026-05-04 17:08:20 +05:30
Pradeep Kumar
f26aa4e701 removes ai_o11y_overview.json containing default value
this removes default values for dashbaord data for system source

only thing changed is removal of file and not storing anything
at seed time .
will handle this in diff pr.
2026-05-04 14:06:43 +05:30
Pradeep Kumar
94f41927f6 address review comments 2026-05-01 13:30:26 +05:30
Pradeep Kumar
74811b36c8 feat: adds overview page.
Added system dashboard API endpoints under /api/v1/system/{source}/dashboard
GET /api/v1/system/ai-o11y-overview/dashboard
PUT /api/v1/system/ai-o11y-overview/dashboard

reset endpoint to removed any edited dashboard and reset the default values.
POST /api/v1/system/ai-o11y-overview/dashboard/reset

seeding at two points,
- at org creation,
- existing org migration.

change delete to reset
2026-04-29 18:50:02 +05:30
119 changed files with 2020 additions and 3423 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -2103,6 +2103,8 @@ components:
type: boolean
org_id:
type: string
source:
type: string
updatedAt:
format: date-time
type: string
@@ -2134,6 +2136,7 @@ components:
type: object
DashboardtypesStorableDashboardData:
additionalProperties: {}
nullable: true
type: object
DashboardtypesUpdatablePublicDashboard:
properties:
@@ -2474,73 +2477,6 @@ components:
- requiredMetricsCheck
- endTimeBeforeRetention
type: object
InframonitoringtypesNodeCondition:
enum:
- ready
- not_ready
- ""
type: string
InframonitoringtypesNodeRecord:
properties:
condition:
$ref: '#/components/schemas/InframonitoringtypesNodeCondition'
meta:
additionalProperties: {}
nullable: true
type: object
nodeCPU:
format: double
type: number
nodeCPUAllocatable:
format: double
type: number
nodeMemory:
format: double
type: number
nodeMemoryAllocatable:
format: double
type: number
nodeName:
type: string
notReadyNodesCount:
type: integer
readyNodesCount:
type: integer
required:
- nodeName
- condition
- readyNodesCount
- notReadyNodesCount
- nodeCPU
- nodeCPUAllocatable
- nodeMemory
- nodeMemoryAllocatable
- meta
type: object
InframonitoringtypesNodes:
properties:
endTimeBeforeRetention:
type: boolean
records:
items:
$ref: '#/components/schemas/InframonitoringtypesNodeRecord'
nullable: true
type: array
requiredMetricsCheck:
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
total:
type: integer
type:
$ref: '#/components/schemas/InframonitoringtypesResponseType'
warning:
$ref: '#/components/schemas/Querybuildertypesv5QueryWarnData'
required:
- type
- records
- total
- requiredMetricsCheck
- endTimeBeforeRetention
type: object
InframonitoringtypesPodPhase:
enum:
- pending
@@ -2658,32 +2594,6 @@ components:
- end
- limit
type: object
InframonitoringtypesPostableNodes:
properties:
end:
format: int64
type: integer
filter:
$ref: '#/components/schemas/Querybuildertypesv5Filter'
groupBy:
items:
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
nullable: true
type: array
limit:
type: integer
offset:
type: integer
orderBy:
$ref: '#/components/schemas/Querybuildertypesv5OrderBy'
start:
format: int64
type: integer
required:
- start
- end
- limit
type: object
InframonitoringtypesPostablePods:
properties:
end:
@@ -10148,6 +10058,186 @@ paths:
summary: Update a span mapper
tags:
- spanmapper
/api/v1/system/{source}:
get:
deprecated: false
description: This endpoint returns the system dashboard for the callers org
keyed by source (e.g. ai-o11y-overview).
operationId: GetSystemDashboard
parameters:
- in: path
name: source
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/DashboardtypesDashboard'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Get system dashboard
tags:
- systemdashboard
put:
deprecated: false
description: This endpoint replaces the system dashboard for the callers org
with the provided payload.
operationId: UpdateSystemDashboard
parameters:
- in: path
name: source
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/DashboardtypesStorableDashboardData'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/DashboardtypesDashboard'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Update system dashboard
tags:
- systemdashboard
/api/v1/system/{source}/reset:
post:
deprecated: false
description: This resets edited/updated system dashboard to default system dashboard.
operationId: ResetSystemDashboard
parameters:
- in: path
name: source
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/DashboardtypesDashboard'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Reset system dashboard to defaults
tags:
- systemdashboard
/api/v1/testChannel:
post:
deprecated: true
@@ -11193,76 +11283,6 @@ paths:
summary: List Hosts for Infra Monitoring
tags:
- inframonitoring
/api/v2/infra_monitoring/nodes:
post:
deprecated: false
description: 'Returns a paginated list of Kubernetes nodes with key metrics:
CPU usage, CPU allocatable, memory working set, memory allocatable, and per-group
readyNodesCount / notReadyNodesCount derived from each node''s latest k8s.node.condition_ready
value in the window. Each node includes metadata attributes (k8s.node.uid,
k8s.cluster.name). The response type is ''list'' for the default k8s.node.name
grouping (each row is one node with its current condition string: ready /
not_ready / '''') or ''grouped_list'' for custom groupBy keys (each row aggregates
nodes in the group with readyNodesCount and notReadyNodesCount; condition
stays empty). Supports filtering via a filter expression, custom groupBy,
ordering by cpu / cpu_allocatable / memory / memory_allocatable, and pagination
via offset/limit. Also reports missing required metrics and whether the requested
time range falls before the data retention boundary. Numeric metric fields
(nodeCPU, nodeCPUAllocatable, nodeMemory, nodeMemoryAllocatable) return -1
as a sentinel when no data is available for that field.'
operationId: ListNodes
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/InframonitoringtypesPostableNodes'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/InframonitoringtypesNodes'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: List Nodes for Infra Monitoring
tags:
- inframonitoring
/api/v2/infra_monitoring/pods:
post:
deprecated: false

View File

@@ -51,7 +51,7 @@
"@signozhq/design-tokens": "2.1.4",
"@signozhq/icons": "0.1.0",
"@signozhq/resizable": "0.0.2",
"@signozhq/ui": "0.0.12",
"@signozhq/ui": "0.0.10",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.13.22",
"@uiw/codemirror-theme-copilot": "4.23.11",

View File

@@ -13,10 +13,8 @@ import type {
import type {
InframonitoringtypesPostableHostsDTO,
InframonitoringtypesPostableNodesDTO,
InframonitoringtypesPostablePodsDTO,
ListHosts200,
ListNodes200,
ListPods200,
RenderErrorResponseDTO,
} from '../sigNoz.schemas';
@@ -108,90 +106,6 @@ export const useListHosts = <
return useMutation(mutationOptions);
};
/**
* Returns a paginated list of Kubernetes nodes with key metrics: CPU usage, CPU allocatable, memory working set, memory allocatable, and per-group readyNodesCount / notReadyNodesCount derived from each node's latest k8s.node.condition_ready value in the window. Each node includes metadata attributes (k8s.node.uid, k8s.cluster.name). The response type is 'list' for the default k8s.node.name grouping (each row is one node with its current condition string: ready / not_ready / '') or 'grouped_list' for custom groupBy keys (each row aggregates nodes in the group with readyNodesCount and notReadyNodesCount; condition stays empty). Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_allocatable / memory / memory_allocatable, and pagination via offset/limit. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (nodeCPU, nodeCPUAllocatable, nodeMemory, nodeMemoryAllocatable) return -1 as a sentinel when no data is available for that field.
* @summary List Nodes for Infra Monitoring
*/
export const listNodes = (
inframonitoringtypesPostableNodesDTO: BodyType<InframonitoringtypesPostableNodesDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListNodes200>({
url: `/api/v2/infra_monitoring/nodes`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: inframonitoringtypesPostableNodesDTO,
signal,
});
};
export const getListNodesMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof listNodes>>,
TError,
{ data: BodyType<InframonitoringtypesPostableNodesDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof listNodes>>,
TError,
{ data: BodyType<InframonitoringtypesPostableNodesDTO> },
TContext
> => {
const mutationKey = ['listNodes'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof listNodes>>,
{ data: BodyType<InframonitoringtypesPostableNodesDTO> }
> = (props) => {
const { data } = props ?? {};
return listNodes(data);
};
return { mutationFn, ...mutationOptions };
};
export type ListNodesMutationResult = NonNullable<
Awaited<ReturnType<typeof listNodes>>
>;
export type ListNodesMutationBody =
BodyType<InframonitoringtypesPostableNodesDTO>;
export type ListNodesMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List Nodes for Infra Monitoring
*/
export const useListNodes = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof listNodes>>,
TError,
{ data: BodyType<InframonitoringtypesPostableNodesDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof listNodes>>,
TError,
{ data: BodyType<InframonitoringtypesPostableNodesDTO> },
TContext
> => {
const mutationOptions = getListNodesMutationOptions(options);
return useMutation(mutationOptions);
};
/**
* Returns a paginated list of Kubernetes pods with key metrics: CPU usage, CPU request/limit utilization, memory working set, memory request/limit utilization, current pod phase (pending/running/succeeded/failed/unknown), and pod age (ms since start time). Each pod includes metadata attributes (namespace, node, workload owner such as deployment/statefulset/daemonset/job/cronjob, cluster). Supports filtering via a filter expression, custom groupBy to aggregate pods by any attribute, ordering by any of the six metrics (cpu, cpu_request, cpu_limit, memory, memory_request, memory_limit), and pagination via offset/limit. The response type is 'list' for the default k8s.pod.uid grouping (each row is one pod with its current phase) or 'grouped_list' for custom groupBy keys (each row aggregates pods in the group with per-phase counts: pendingPodCount, runningPodCount, succeededPodCount, failedPodCount, unknownPodCount derived from each pod's latest phase in the window). Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (podCPU, podCPURequest, podCPULimit, podMemory, podMemoryRequest, podMemoryLimit, podAge) return -1 as a sentinel when no data is available for that field.
* @summary List Pods for Infra Monitoring

View File

@@ -2781,6 +2781,10 @@ export interface DashboardtypesDashboardDTO {
* @type string
*/
org_id?: string;
/**
* @type string
*/
source?: string;
/**
* @type string
* @format date-time
@@ -2823,9 +2827,12 @@ export interface DashboardtypesPostablePublicDashboardDTO {
timeRangeEnabled?: boolean;
}
export interface DashboardtypesStorableDashboardDataDTO {
/**
* @nullable
*/
export type DashboardtypesStorableDashboardDataDTO = {
[key: string]: unknown;
}
} | null;
export interface DashboardtypesUpdatablePublicDashboardDTO {
/**
@@ -3243,78 +3250,6 @@ export interface InframonitoringtypesHostsDTO {
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
export enum InframonitoringtypesNodeConditionDTO {
ready = 'ready',
not_ready = 'not_ready',
'' = '',
}
/**
* @nullable
*/
export type InframonitoringtypesNodeRecordDTOMeta = {
[key: string]: unknown;
} | null;
export interface InframonitoringtypesNodeRecordDTO {
condition: InframonitoringtypesNodeConditionDTO;
/**
* @type object
* @nullable true
*/
meta: InframonitoringtypesNodeRecordDTOMeta;
/**
* @type number
* @format double
*/
nodeCPU: number;
/**
* @type number
* @format double
*/
nodeCPUAllocatable: number;
/**
* @type number
* @format double
*/
nodeMemory: number;
/**
* @type number
* @format double
*/
nodeMemoryAllocatable: number;
/**
* @type string
*/
nodeName: string;
/**
* @type integer
*/
notReadyNodesCount: number;
/**
* @type integer
*/
readyNodesCount: number;
}
export interface InframonitoringtypesNodesDTO {
/**
* @type boolean
*/
endTimeBeforeRetention: boolean;
/**
* @type array
* @nullable true
*/
records: InframonitoringtypesNodeRecordDTO[] | null;
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
/**
* @type integer
*/
total: number;
type: InframonitoringtypesResponseTypeDTO;
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
export enum InframonitoringtypesPodPhaseDTO {
pending = 'pending',
running = 'running',
@@ -3445,34 +3380,6 @@ export interface InframonitoringtypesPostableHostsDTO {
start: number;
}
export interface InframonitoringtypesPostableNodesDTO {
/**
* @type integer
* @format int64
*/
end: number;
filter?: Querybuildertypesv5FilterDTO;
/**
* @type array
* @nullable true
*/
groupBy?: Querybuildertypesv5GroupByKeyDTO[] | null;
/**
* @type integer
*/
limit: number;
/**
* @type integer
*/
offset?: number;
orderBy?: Querybuildertypesv5OrderByDTO;
/**
* @type integer
* @format int64
*/
start: number;
}
export interface InframonitoringtypesPostablePodsDTO {
/**
* @type integer
@@ -7435,6 +7342,39 @@ export type UpdateSpanMapperPathParameters = {
groupId: string;
mapperId: string;
};
export type GetSystemDashboardPathParameters = {
source: string;
};
export type GetSystemDashboard200 = {
data: DashboardtypesDashboardDTO;
/**
* @type string
*/
status: string;
};
export type UpdateSystemDashboardPathParameters = {
source: string;
};
export type UpdateSystemDashboard200 = {
data: DashboardtypesDashboardDTO;
/**
* @type string
*/
status: string;
};
export type ResetSystemDashboardPathParameters = {
source: string;
};
export type ResetSystemDashboard200 = {
data: DashboardtypesDashboardDTO;
/**
* @type string
*/
status: string;
};
export type ListUsersDeprecated200 = {
/**
* @type array
@@ -7617,14 +7557,6 @@ export type ListHosts200 = {
status: string;
};
export type ListNodes200 = {
data: InframonitoringtypesNodesDTO;
/**
* @type string
*/
status: string;
};
export type ListPods200 = {
data: InframonitoringtypesPodsDTO;
/**

View File

@@ -0,0 +1,318 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'yarn generate:api'
* SigNoz
*/
import { useMutation, useQuery } from 'react-query';
import type {
InvalidateOptions,
MutationFunction,
QueryClient,
QueryFunction,
QueryKey,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import type {
DashboardtypesStorableDashboardDataDTO,
GetSystemDashboard200,
GetSystemDashboardPathParameters,
RenderErrorResponseDTO,
ResetSystemDashboard200,
ResetSystemDashboardPathParameters,
UpdateSystemDashboard200,
UpdateSystemDashboardPathParameters,
} from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
/**
* This endpoint returns the system dashboard for the callers org keyed by source (e.g. ai-o11y-overview).
* @summary Get system dashboard
*/
export const getSystemDashboard = (
{ source }: GetSystemDashboardPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetSystemDashboard200>({
url: `/api/v1/system/${source}`,
method: 'GET',
signal,
});
};
export const getGetSystemDashboardQueryKey = ({
source,
}: GetSystemDashboardPathParameters) => {
return [`/api/v1/system/${source}`] as const;
};
export const getGetSystemDashboardQueryOptions = <
TData = Awaited<ReturnType<typeof getSystemDashboard>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ source }: GetSystemDashboardPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getSystemDashboard>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetSystemDashboardQueryKey({ source });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getSystemDashboard>>
> = ({ signal }) => getSystemDashboard({ source }, signal);
return {
queryKey,
queryFn,
enabled: !!source,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getSystemDashboard>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetSystemDashboardQueryResult = NonNullable<
Awaited<ReturnType<typeof getSystemDashboard>>
>;
export type GetSystemDashboardQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get system dashboard
*/
export function useGetSystemDashboard<
TData = Awaited<ReturnType<typeof getSystemDashboard>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ source }: GetSystemDashboardPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getSystemDashboard>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetSystemDashboardQueryOptions({ source }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary Get system dashboard
*/
export const invalidateGetSystemDashboard = async (
queryClient: QueryClient,
{ source }: GetSystemDashboardPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetSystemDashboardQueryKey({ source }) },
options,
);
return queryClient;
};
/**
* This endpoint replaces the system dashboard for the callers org with the provided payload.
* @summary Update system dashboard
*/
export const updateSystemDashboard = (
{ source }: UpdateSystemDashboardPathParameters,
dashboardtypesStorableDashboardDataDTO: BodyType<DashboardtypesStorableDashboardDataDTO>,
) => {
return GeneratedAPIInstance<UpdateSystemDashboard200>({
url: `/api/v1/system/${source}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: dashboardtypesStorableDashboardDataDTO,
});
};
export const getUpdateSystemDashboardMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateSystemDashboard>>,
TError,
{
pathParams: UpdateSystemDashboardPathParameters;
data: BodyType<DashboardtypesStorableDashboardDataDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateSystemDashboard>>,
TError,
{
pathParams: UpdateSystemDashboardPathParameters;
data: BodyType<DashboardtypesStorableDashboardDataDTO>;
},
TContext
> => {
const mutationKey = ['updateSystemDashboard'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof updateSystemDashboard>>,
{
pathParams: UpdateSystemDashboardPathParameters;
data: BodyType<DashboardtypesStorableDashboardDataDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return updateSystemDashboard(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateSystemDashboardMutationResult = NonNullable<
Awaited<ReturnType<typeof updateSystemDashboard>>
>;
export type UpdateSystemDashboardMutationBody =
BodyType<DashboardtypesStorableDashboardDataDTO>;
export type UpdateSystemDashboardMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Update system dashboard
*/
export const useUpdateSystemDashboard = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateSystemDashboard>>,
TError,
{
pathParams: UpdateSystemDashboardPathParameters;
data: BodyType<DashboardtypesStorableDashboardDataDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateSystemDashboard>>,
TError,
{
pathParams: UpdateSystemDashboardPathParameters;
data: BodyType<DashboardtypesStorableDashboardDataDTO>;
},
TContext
> => {
const mutationOptions = getUpdateSystemDashboardMutationOptions(options);
return useMutation(mutationOptions);
};
/**
* This resets edited/updated system dashboard to default system dashboard.
* @summary Reset system dashboard to defaults
*/
export const resetSystemDashboard = (
{ source }: ResetSystemDashboardPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ResetSystemDashboard200>({
url: `/api/v1/system/${source}/reset`,
method: 'POST',
signal,
});
};
export const getResetSystemDashboardMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof resetSystemDashboard>>,
TError,
{ pathParams: ResetSystemDashboardPathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof resetSystemDashboard>>,
TError,
{ pathParams: ResetSystemDashboardPathParameters },
TContext
> => {
const mutationKey = ['resetSystemDashboard'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof resetSystemDashboard>>,
{ pathParams: ResetSystemDashboardPathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return resetSystemDashboard(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type ResetSystemDashboardMutationResult = NonNullable<
Awaited<ReturnType<typeof resetSystemDashboard>>
>;
export type ResetSystemDashboardMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Reset system dashboard to defaults
*/
export const useResetSystemDashboard = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof resetSystemDashboard>>,
TError,
{ pathParams: ResetSystemDashboardPathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof resetSystemDashboard>>,
TError,
{ pathParams: ResetSystemDashboardPathParameters },
TContext
> => {
const mutationOptions = getResetSystemDashboardMutationOptions(options);
return useMutation(mutationOptions);
};

View File

@@ -1,22 +0,0 @@
.codeBlock {
position: relative;
}
.codeBlockSyntaxHighlighter {
background-color: var(--l2-background) !important;
border-radius: 4px !important;
border: 1px solid var(--l2-border) !important;
color: var(--l2-foreground) !important;
pre {
color: var(--l2-foreground) !important;
font-family: 'Geist Mono' !important;
font-size: 12px !important;
}
code {
color: var(--l1-foreground) !important;
font-family: 'Geist Mono' !important;
font-size: 12px !important;
}
}

View File

@@ -1,46 +0,0 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import CodeBlock from './CodeBlock';
const mockCopyToClipboard = jest.fn();
jest.mock('react-use', () => ({
useCopyToClipboard: (): [unknown, (text: string) => void] => [
undefined,
mockCopyToClipboard,
],
}));
describe('CodeBlock', () => {
beforeEach(() => {
mockCopyToClipboard.mockReset();
});
it('renders code block mode by default', () => {
render(<CodeBlock code={'const x = 1;\n'} language="javascript" />);
const container = screen.getByTestId('code-block-container');
expect(container).toBeInTheDocument();
expect(container).toHaveTextContent('const x = 1;');
});
it('renders inline code when inline is true', () => {
render(<CodeBlock code="inline value" inline />);
const inlineCode = screen.getByText('inline value');
expect(inlineCode.tagName.toLowerCase()).toBe('code');
expect(screen.queryByTestId('code-block-container')).not.toBeInTheDocument();
});
it('copies code and triggers callback', async () => {
const onCopy = jest.fn();
render(<CodeBlock code="SELECT * FROM logs;" onCopy={onCopy} />);
fireEvent.click(screen.getByRole('button', { name: /copy code/i }));
await waitFor(() => {
expect(mockCopyToClipboard).toHaveBeenCalledWith('SELECT * FROM logs;');
});
expect(onCopy).toHaveBeenCalledWith('SELECT * FROM logs;');
});
});

View File

@@ -1,89 +0,0 @@
import { useMemo, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import { Check, Copy } from '@signozhq/icons';
import { Button } from '@signozhq/ui';
import SyntaxHighlighter, {
a11yDark,
} from 'components/MarkdownRenderer/syntaxHighlighter';
import styles from './CodeBlock.module.scss';
export interface CodeBlockProps {
code: string;
language?: string;
className?: string;
inline?: boolean;
showLineNumbers?: boolean;
showCopyButton?: boolean;
onCopy?: (copiedCode: string) => void;
}
function CodeBlock({
code,
language = 'text',
className,
inline = false,
showLineNumbers = false,
showCopyButton = true,
onCopy,
}: CodeBlockProps): JSX.Element {
const [isCopied, setIsCopied] = useState(false);
const [, copyToClipboard] = useCopyToClipboard();
const normalizedCode = useMemo(() => code?.replace(/\n$/, '') ?? '', [code]);
const handleCopy = (): void => {
copyToClipboard(normalizedCode);
setIsCopied(true);
onCopy?.(normalizedCode);
setTimeout(() => {
setIsCopied(false);
}, 1000);
};
if (inline) {
return <code className={className}>{normalizedCode}</code>;
}
return (
<div
className={`${styles.codeBlock} ${className}`}
style={{ position: 'relative' }}
data-testid="code-block-container"
>
{showCopyButton ? (
<Button
variant="ghost"
color="secondary"
size="sm"
onClick={handleCopy}
prefix={isCopied ? <Check size={14} /> : <Copy size={14} />}
aria-label="Copy code"
title={isCopied ? 'Copied' : 'Copy'}
style={{ position: 'absolute', right: 8, top: 8, zIndex: 1 }}
/>
) : null}
<SyntaxHighlighter
style={a11yDark}
language={language}
PreTag="div"
showLineNumbers={showLineNumbers}
wrapLongLines
className={styles.codeBlockSyntaxHighlighter}
>
{normalizedCode}
</SyntaxHighlighter>
</div>
);
}
CodeBlock.defaultProps = {
language: 'text',
className: undefined,
inline: false,
showLineNumbers: false,
showCopyButton: true,
onCopy: undefined,
};
export default CodeBlock;

View File

@@ -46,7 +46,6 @@ function DeleteMemberDialog({
color="destructive"
disabled={isDeleting}
onClick={onConfirm}
loading={isDeleting}
>
<Trash2 size={12} />
{isDeleting ? 'Processing...' : title}
@@ -64,6 +63,7 @@ function DeleteMemberDialog({
}}
title={title}
width="narrow"
className="alert-dialog delete-dialog"
showCloseButton={false}
disableOutsideClick={false}
footer={footer}

View File

@@ -28,6 +28,18 @@
cursor: default;
}
&__input {
height: 32px;
background: var(--l2-background);
border-color: var(--l1-border);
color: var(--l1-foreground);
box-shadow: none;
&::placeholder {
color: var(--l3-foreground);
}
}
&__input-wrapper {
display: flex;
align-items: center;
@@ -36,7 +48,7 @@
padding: var(--padding-1) var(--padding-2);
border-radius: 2px;
background: var(--l2-background);
border: 1px solid var(--border);
border: 1px solid var(--l1-border);
box-sizing: border-box;
&--disabled {
@@ -53,8 +65,8 @@
}
&__email-text {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-normal);
color: var(--foreground);
line-height: var(--line-height-18);
letter-spacing: -0.07px;
@@ -166,6 +178,36 @@
}
}
.delete-dialog {
background: var(--l2-background);
border: 1px solid var(--l1-border);
[data-slot='dialog-title'] {
color: var(--l1-foreground);
}
&__body {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
color: var(--l2-foreground);
line-height: var(--paragraph-base-400-line-height);
letter-spacing: -0.065px;
margin: 0;
strong {
font-weight: var(--font-weight-medium);
color: var(--l1-foreground);
}
}
&__footer {
display: flex;
justify-content: flex-end;
gap: var(--spacing-4);
margin-top: var(--margin-6);
}
}
.reset-link-dialog {
background: var(--l2-background);
border: 1px solid var(--l1-border);
@@ -222,6 +264,13 @@
}
&__copy-btn {
flex-shrink: 0;
height: 32px;
border-radius: 0 2px 2px 0;
border-top: none;
border-right: none;
border-bottom: none;
border-left: 1px solid var(--l1-border);
min-width: 64px;
}
}

View File

@@ -224,7 +224,7 @@ function EditMemberDrawer({
try {
await rawRetry();
setSaveErrors((prev) => prev.filter((e) => e.context !== context));
void refetchUser();
refetchUser();
} catch (err) {
setSaveErrors((prev) =>
prev.map((e) =>
@@ -250,7 +250,7 @@ function EditMemberDrawer({
});
}
setSaveErrors((prev) => prev.filter((e) => e.context !== 'Name update'));
void refetchUser();
refetchUser();
} catch (err) {
setSaveErrors((prev) =>
prev.map((e) =>
@@ -319,7 +319,7 @@ function EditMemberDrawer({
}),
];
});
void refetchUser();
refetchUser();
},
});
} else {
@@ -340,7 +340,7 @@ function EditMemberDrawer({
onComplete();
}
void refetchUser();
refetchUser();
} finally {
setIsSaving(false);
}
@@ -465,6 +465,7 @@ function EditMemberDrawer({
prev.filter((err) => err.context !== 'Name update'),
);
}}
className="edit-member-drawer__input"
placeholder="Enter name"
disabled={isRootUser || isDeleted}
/>
@@ -630,7 +631,7 @@ function EditMemberDrawer({
</div>
<div className="edit-member-drawer__footer-right">
<Button variant="outlined" color="secondary" onClick={handleClose}>
<Button variant="solid" color="secondary" onClick={handleClose}>
<X size={14} />
Cancel
</Button>
@@ -640,7 +641,6 @@ function EditMemberDrawer({
color="primary"
disabled={!isDirty || isSaving || isRootUser}
onClick={handleSave}
loading={isSaving}
>
{isSaving ? 'Saving...' : 'Save Member Details'}
</Button>

View File

@@ -44,8 +44,9 @@ function ResetLinkDialog({
<span className="reset-link-dialog__link-text">{resetLink}</span>
</div>
<Button
variant="link"
variant="outlined"
color="secondary"
size="sm"
onClick={onCopy}
prefix={hasCopied ? <Check size={12} /> : <Copy size={12} />}
className="reset-link-dialog__copy-btn"

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Style } from '@signozhq/design-tokens';
import { ChevronDown, Plus, Trash2, X } from '@signozhq/icons';
import { ChevronDown, CircleAlert, Plus, Trash2, X } from '@signozhq/icons';
import {
Button,
Callout,
@@ -294,8 +294,10 @@ function InviteMembersModal({
type="error"
size="small"
showIcon
title={getValidationErrorMessage()}
/>
icon={<CircleAlert size={12} />}
>
{getValidationErrorMessage()}
</Callout>
</div>
)}
</div>

View File

@@ -87,7 +87,7 @@
input {
color: var(--l1-foreground);
font-size: var(--font-size-xs);
font-size: var(--font-size-sm);
}
.ant-picker-suffix {
@@ -126,6 +126,12 @@
}
&__copy-btn {
flex-shrink: 0;
height: 32px;
border-radius: 0 2px 2px 0;
border-top: none;
border-right: none;
border-bottom: none;
border-left: 1px solid var(--l1-border);
min-width: 40px;
}
@@ -146,7 +152,6 @@
color: var(--foreground);
letter-spacing: 0.48px;
text-transform: uppercase;
margin-bottom: var(--spacing-4);
}
&__footer {

View File

@@ -22,8 +22,9 @@ function KeyCreatedPhase({
<div className="add-key-modal__key-display">
<span className="add-key-modal__key-text">{createdKey.key}</span>
<Button
variant="link"
variant="outlined"
color="secondary"
size="sm"
onClick={onCopy}
className="add-key-modal__copy-btn"
>

View File

@@ -106,7 +106,7 @@ function KeyFormPhase({
<div className="add-key-modal__footer">
<div className="add-key-modal__footer-right">
<Button variant="solid" color="secondary" onClick={onClose}>
<Button variant="solid" color="secondary" size="sm" onClick={onClose}>
Cancel
</Button>
<Button
@@ -115,6 +115,7 @@ function KeyFormPhase({
form={FORM_ID}
variant="solid"
color="primary"
size="sm"
loading={isSubmitting}
disabled={!isValid}
>

View File

@@ -136,7 +136,7 @@ function EditKeyForm({
</form>
<div className="edit-key-modal__footer">
<Button variant="link" color="destructive" onClick={onRevokeClick}>
<Button variant="ghost" color="destructive" onClick={onRevokeClick}>
<Trash2 size={12} />
Revoke Key
</Button>

View File

@@ -119,7 +119,7 @@
input {
color: var(--l1-foreground);
font-size: var(--font-size-xs);
font-size: 13px;
}
.ant-picker-suffix {

View File

@@ -20,7 +20,7 @@ import { useErrorModal } from 'providers/ErrorModalProvider';
import { useTimezone } from 'providers/Timezone';
import APIError from 'types/api/error';
import { RevokeKeyFooter } from '../RevokeKeyModal';
import { RevokeKeyContent } from '../RevokeKeyModal';
import EditKeyForm from './EditKeyForm';
import type { FormValues } from './types';
import { DEFAULT_FORM_VALUES, ExpiryMode } from './types';
@@ -158,25 +158,17 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
}
width={isRevokeConfirmOpen ? 'narrow' : 'base'}
className={
isRevokeConfirmOpen ? 'alert-dialog sa-delete-dialog' : 'edit-key-modal'
isRevokeConfirmOpen ? 'alert-dialog delete-dialog' : 'edit-key-modal'
}
showCloseButton={!isRevokeConfirmOpen}
disableOutsideClick={isErrorModalVisible}
footer={
isRevokeConfirmOpen ? (
<RevokeKeyFooter
isRevoking={isRevoking}
onCancel={(): void => setIsRevokeConfirmOpen(false)}
onConfirm={handleRevoke}
/>
) : undefined
}
>
{isRevokeConfirmOpen ? (
<>
Revoking this key will permanently invalidate it. Any systems using this
key will lose access immediately.
</>
<RevokeKeyContent
isRevoking={isRevoking}
onCancel={(): void => setIsRevokeConfirmOpen(false)}
onConfirm={handleRevoke}
/>
) : (
<EditKeyForm
register={register}

View File

@@ -72,6 +72,7 @@ function OverviewTab({
id="sa-name"
value={localName}
onChange={(e): void => onNameChange(e.target.value)}
className="sa-drawer__input"
placeholder="Enter name"
/>
)}

View File

@@ -17,32 +17,39 @@ import { parseAsString, useQueryState } from 'nuqs';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
export interface RevokeKeyFooterProps {
export interface RevokeKeyContentProps {
isRevoking: boolean;
onCancel: () => void;
onConfirm: () => void;
}
export function RevokeKeyFooter({
export function RevokeKeyContent({
isRevoking,
onCancel,
onConfirm,
}: RevokeKeyFooterProps): JSX.Element {
}: RevokeKeyContentProps): JSX.Element {
return (
<>
<Button variant="solid" color="secondary" onClick={onCancel}>
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color="destructive"
loading={isRevoking}
onClick={onConfirm}
>
<Trash2 size={12} />
Revoke Key
</Button>
<p className="delete-dialog__body">
Revoking this key will permanently invalidate it. Any systems using this key
will lose access immediately.
</p>
<div className="delete-dialog__footer">
<Button variant="solid" color="secondary" size="sm" onClick={onCancel}>
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color="destructive"
size="sm"
loading={isRevoking}
onClick={onConfirm}
>
<Trash2 size={12} />
Revoke Key
</Button>
</div>
</>
);
}
@@ -105,19 +112,15 @@ function RevokeKeyModal(): JSX.Element {
}}
title={`Revoke ${keyName ?? 'key'}?`}
width="narrow"
className="alert-dialog sa-delete-dialog"
className="alert-dialog delete-dialog"
showCloseButton={false}
disableOutsideClick={isErrorModalVisible}
footer={
<RevokeKeyFooter
isRevoking={isRevoking}
onCancel={handleCancel}
onConfirm={handleConfirm}
/>
}
>
Revoking this key will permanently invalidate it. Any systems using this key
will lose access immediately.
<RevokeKeyContent
isRevoking={isRevoking}
onCancel={handleCancel}
onConfirm={handleConfirm}
/>
</DialogWrapper>
);
}

View File

@@ -57,8 +57,6 @@
color: var(--l1-foreground);
}
}
min-width: 220px;
}
&__tab {
@@ -168,6 +166,18 @@
cursor: default;
}
&__input {
height: 32px;
background: var(--l2-background);
border-color: var(--l1-border);
color: var(--l1-foreground);
box-shadow: none;
&::placeholder {
color: var(--l3-foreground);
}
}
&__input-wrapper {
display: flex;
align-items: center;
@@ -176,7 +186,7 @@
padding: 0 var(--padding-2);
border-radius: 2px;
background: var(--l2-background);
border: 1px solid var(--border);
border: 1px solid var(--l1-border);
&--disabled {
cursor: not-allowed;
@@ -185,8 +195,8 @@
}
&__input-text {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-normal);
color: var(--foreground);
line-height: var(--line-height-18);
letter-spacing: -0.07px;

View File

@@ -129,7 +129,7 @@ function ServiceAccountDrawer({
useEffect(() => {
if (account?.id) {
setLocalName(account?.name ?? '');
void setKeysPage(1);
setKeysPage(1);
}
}, [account?.id, account?.name, setKeysPage]);
@@ -176,7 +176,7 @@ function ServiceAccountDrawer({
}
const maxPage = Math.max(1, Math.ceil(keys.length / PAGE_SIZE));
if (keysPage > maxPage) {
void setKeysPage(maxPage);
setKeysPage(maxPage);
}
}, [keysLoading, keys.length, keysPage, setKeysPage]);
@@ -214,8 +214,8 @@ function ServiceAccountDrawer({
data: { name: localName },
});
setSaveErrors((prev) => prev.filter((e) => e.context !== 'Name update'));
void refetchAccount();
void queryClient.invalidateQueries(getListServiceAccountsQueryKey());
refetchAccount();
queryClient.invalidateQueries(getListServiceAccountsQueryKey());
} catch (err) {
setSaveErrors((prev) =>
prev.map((e) =>
@@ -337,8 +337,8 @@ function ServiceAccountDrawer({
onSuccess({ closeDrawer: false });
}
void refetchAccount();
void queryClient.invalidateQueries(getListServiceAccountsQueryKey());
refetchAccount();
queryClient.invalidateQueries(getListServiceAccountsQueryKey());
} finally {
setIsSaving(false);
}
@@ -357,12 +357,12 @@ function ServiceAccountDrawer({
]);
const handleClose = useCallback((): void => {
void setIsDeleteOpen(null);
void setIsAddKeyOpen(null);
void setSelectedAccountId(null);
void setActiveTab(null);
void setKeysPage(null);
void setEditKeyId(null);
setIsDeleteOpen(null);
setIsAddKeyOpen(null);
setSelectedAccountId(null);
setActiveTab(null);
setKeysPage(null);
setEditKeyId(null);
setSaveErrors([]);
}, [
setSelectedAccountId,
@@ -379,13 +379,12 @@ function ServiceAccountDrawer({
<ToggleGroup
type="single"
value={activeTab}
size="sm"
onChange={(val): void => {
if (val) {
void setActiveTab(val as ServiceAccountDrawerTab);
setActiveTab(val as ServiceAccountDrawerTab);
if (val !== ServiceAccountDrawerTab.Keys) {
void setKeysPage(null);
void setEditKeyId(null);
setKeysPage(null);
setEditKeyId(null);
}
}
}}
@@ -416,7 +415,7 @@ function ServiceAccountDrawer({
color="secondary"
disabled={isDeleted}
onClick={(): void => {
void setIsAddKeyOpen(true);
setIsAddKeyOpen(true);
}}
>
<Plus size={12} />
@@ -504,7 +503,7 @@ function ServiceAccountDrawer({
variant="link"
color="destructive"
onClick={(): void => {
void setIsDeleteOpen(true);
setIsDeleteOpen(true);
}}
>
<Trash2 size={12} />
@@ -513,7 +512,7 @@ function ServiceAccountDrawer({
)}
{!isDeleted && (
<div className="sa-drawer__footer-right">
<Button variant="outlined" color="secondary" onClick={handleClose}>
<Button variant="solid" color="secondary" onClick={handleClose}>
<X size={14} />
Cancel
</Button>

View File

@@ -78,7 +78,6 @@
display: flex;
align-items: center;
gap: var(--spacing-10);
padding-left: 18px;
}
.custom-domain-card-meta-row.workspace-name-hidden {
@@ -125,6 +124,30 @@
}
}
.workspace-url-trigger {
display: inline-flex;
align-items: center;
gap: var(--spacing-3);
background: none;
border: none;
padding: 0;
cursor: pointer;
color: var(--l1-foreground);
font-size: var(--font-size-xs);
line-height: var(--line-height-18);
letter-spacing: -0.06px;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
svg {
flex-shrink: 0;
color: var(--l2-foreground);
}
}
.workspace-url-dropdown {
border-radius: 4px;
border: 1px solid var(--l1-border);

View File

@@ -204,7 +204,6 @@ export default function CustomDomainSettings(): JSX.Element {
>
<Dropdown
trigger={['click']}
disabled={isFetchingHosts}
dropdownRender={(): JSX.Element => (
<div className="workspace-url-dropdown">
<span className="workspace-url-dropdown-header">
@@ -240,7 +239,12 @@ export default function CustomDomainSettings(): JSX.Element {
</div>
)}
>
<Button variant="link" color="none">
<Button
className="workspace-url-trigger"
disabled={isFetchingHosts}
variant="link"
color="none"
>
<Link2 size={12} />
<span>{stripProtocol(activeHost?.url ?? '')}</span>
<ChevronDown size={12} />

View File

@@ -104,12 +104,7 @@ export const usePanelContextMenu = ({
}
if (data && data?.record?.queryName) {
onClick(data.coord, {
...data.record,
label: data.label,
seriesColor: data.seriesColor,
timeRange,
});
onClick(data.coord, { ...data.record, label: data.label, timeRange });
}
},
[onClick, queryResponse],

View File

@@ -1,346 +0,0 @@
.cloud-account-setup-modal {
background: var(--l1-background);
color: var(--l1-foreground);
[data-slot='drawer-title'] {
color: var(--l1-foreground);
}
> div {
display: flex;
flex-direction: column;
overflow: hidden;
}
&__content {
flex: 1;
overflow-y: auto;
min-height: 0;
scrollbar-width: thin;
padding-right: 16px;
&::-webkit-scrollbar-thumb {
background: var(--l1-border);
}
&::-webkit-scrollbar-track {
background: var(--l1-background);
}
scrollbar-width: thin;
scrollbar-color: var(--l3-background) var(--l1-background);
}
.cloud-account-setup-prerequisites {
display: flex;
flex-direction: column;
gap: 12px;
&__title {
color: var(--l1-foreground);
font-size: 14px;
font-weight: 500;
line-height: 20px;
letter-spacing: -0.07px;
}
&__list {
display: flex;
flex-direction: column;
gap: 8px;
list-style: none;
padding: 0;
margin: 0;
}
&__list-item {
color: var(--l2-foreground);
font-size: 13px;
line-height: 18px;
letter-spacing: -0.06px;
display: flex;
align-items: center;
gap: 16px;
&-bullet {
color: var(--primary);
font-weight: 500;
}
&-text {
display: flex;
align-items: center;
gap: 4px;
}
}
&__list-item-highlight {
color: var(--l1-foreground);
font-weight: 500;
}
}
.cloud-account-setup-how-it-works-accordion {
display: flex;
flex-direction: column;
margin: 24px 0;
&__title {
display: flex;
align-items: center;
color: var(--l1-foreground);
font-size: 14px;
font-weight: 500;
line-height: 20px;
letter-spacing: -0.07px;
border-radius: 4px;
border: 1px solid var(--l2-border);
background: var(--l2-background);
padding: 4px 16px 4px 0px;
&.open {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
}
&__description {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
opacity: 0;
transform: translateY(-8px);
animation: cloud-account-setup-accordion-reveal 220ms ease-out forwards;
border-radius: 4px;
border-top: none;
border-top-left-radius: 0;
border-top-right-radius: 0;
border: 1px solid var(--l2-border);
background: var(--l2-background);
&-item {
display: flex;
flex-direction: column;
gap: 8px;
color: var(--l1-foreground);
font-size: 13px;
line-height: 18px;
letter-spacing: -0.06px;
}
@media (prefers-reduced-motion: reduce) {
animation: none;
opacity: 1;
transform: none;
}
}
}
.cloud-account-setup-form__code-block-tabs {
padding: 8px;
border-radius: 4px;
border: 1px solid var(--l2-border);
background: var(--l2-background);
&-header {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 12px;
&-title {
color: var(--l1-foreground);
font-size: 14px;
font-weight: 500;
line-height: 20px;
letter-spacing: -0.07px;
}
&-description {
color: var(--l2-foreground);
font-size: 12px;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.06px;
}
}
[role='tablist'] {
gap: 8px !important;
}
[role='tabpanel'] {
padding: 0 !important;
}
[data-slot='tabs-trigger'] {
padding: 4px 24px !important;
border: none !important;
background-color: transparent !important;
font-size: 12px !important;
}
}
@keyframes cloud-account-setup-accordion-reveal {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.account-setup-modal-footer {
&__confirm-button {
background: var(--primary-background);
color: var(--primary-foreground);
font-size: 12px;
font-weight: 500;
}
&__confirm-selection-count {
font-family: 'Geist Mono';
}
&__close-button {
background: var(--l1-background);
border: 1px solid var(--l1-border);
border-radius: 2px;
color: var(--l1-foreground);
font-family: 'Inter';
font-size: 12px;
font-weight: 500;
&:hover {
border-color: var(--l1-border);
color: var(--l1-foreground);
}
}
}
.cloud-account-setup-form {
.disabled {
opacity: 0.4;
}
&,
&__content {
display: flex;
flex-direction: column;
gap: 24px;
}
&__alert {
width: 100%;
[data-slot='callout'] {
width: 100%;
box-sizing: border-box;
}
&-message {
display: flex;
align-items: center;
gap: 8px;
color: var(--l1-foreground);
.retry-time {
font-family: 'Geist Mono';
font-size: 14px;
font-weight: 600;
line-height: 22px;
letter-spacing: -0.07px;
}
}
}
&__form-group {
display: flex;
flex-direction: column;
gap: 12px;
}
&__title {
color: var(--l1-foreground);
font-size: 14px;
font-weight: 500;
line-height: 20px;
letter-spacing: -0.07px;
}
&__description {
color: var(--l2-foreground);
font-size: 12px;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.06px;
}
&__select {
.ant-select-selection-item {
color: var(--l1-foreground);
font-size: 14px;
line-height: 20px;
letter-spacing: -0.07px;
}
}
&__form-item {
margin: 0;
}
&__include-all-regions-switch {
display: flex;
align-items: center;
gap: 10px;
color: var(--l2-foreground);
font-size: 12px;
line-height: 18px;
letter-spacing: -0.06px;
margin-bottom: 12px;
&-label {
background-color: transparent;
border: none;
color: var(--l2-foreground);
font-size: 12px;
line-height: 18px;
letter-spacing: -0.06px;
cursor: pointer;
}
}
&__note {
padding: 12px;
color: var(--callout-primary-description);
font-size: 12px;
line-height: 22px;
letter-spacing: -0.06px;
border-radius: 4px;
border: 1px solid
color-mix(in srgb, var(--primary-background) 10%, transparent);
background: color-mix(in srgb, var(--primary-background) 10%, transparent);
}
&__submit-button {
border-radius: 2px;
background: var(--primary-background);
color: var(--primary-foreground);
font-size: 14px;
font-weight: 500;
line-height: 20px;
&-content {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
&:disabled {
opacity: 0.4;
}
}
}
}

View File

@@ -0,0 +1,28 @@
import awsDarkLogoUrl from '@/assets/Logos/aws-dark.svg';
import AccountActions from './components/AccountActions';
import './HeroSection.style.scss';
function HeroSection(): JSX.Element {
return (
<div className="hero-section">
<div className="hero-section__details">
<div className="hero-section__details-header">
<div className="hero-section__icon">
<img src={awsDarkLogoUrl} alt="AWS" />
</div>
<div className="hero-section__details-title">AWS</div>
</div>
<div className="hero-section__details-description">
AWS is a cloud computing platform that provides a range of services for
building and running applications.
</div>
</div>
<AccountActions />
</div>
);
}
export default HeroSection;

View File

@@ -117,12 +117,6 @@
min-width: 140px !important;
}
&.azure {
.ant-select-selector {
min-width: 282px !important;
}
}
.ant-select-item-option-active {
background: var(--l3-background) !important;
}

View File

@@ -1,4 +1,4 @@
import { Dispatch, SetStateAction, useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom-v5-compat';
import { Color } from '@signozhq/design-tokens';
import { Button } from '@signozhq/ui';
@@ -6,29 +6,19 @@ import { Select, Skeleton } from 'antd';
import { SelectProps } from 'antd/lib';
import logEvent from 'api/common/logEvent';
import { useListAccounts } from 'api/generated/services/cloudintegration';
import cx from 'classnames';
import { getAccountById } from 'container/Integrations/CloudIntegration/utils';
import {
CloudAccount as IntegrationCloudAccount,
IntegrationType,
} from 'container/Integrations/types';
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
import useUrlQuery from 'hooks/useUrlQuery';
import { ChevronDown, Dot, PencilLine, Plug, Plus } from 'lucide-react';
import AzureCloudAccountSetupModal from '../../AzureCloudServices/AddNewAccount/CloudAccountSetupModal';
import AzureAccountSettingsModal from '../../AzureCloudServices/EditAccount/AccountSettingsModal';
import {
mapAccountDtoToAwsCloudAccount,
mapAccountDtoToAzureCloudAccount,
} from '../../mapCloudAccountFromDto';
import AwsCloudAccountSetupModal from '../AddNewAccount/CloudAccountSetupModal';
import AwsAccountSettingsModal from '../EditAccount/AccountSettingsModal';
import { CloudAccount as AwsCloudAccount } from '../types';
import { mapAccountDtoToAwsCloudAccount } from '../../mapAwsCloudAccountFromDto';
import { CloudAccount } from '../../types';
import AccountSettingsModal from './AccountSettingsModal';
import CloudAccountSetupModal from './CloudAccountSetupModal';
import './AccountActions.style.scss';
function AccountActionsRenderer({
type,
accounts,
isLoading,
activeAccount,
@@ -37,10 +27,9 @@ function AccountActionsRenderer({
onIntegrationModalOpen,
onAccountSettingsModalOpen,
}: {
type: IntegrationType;
accounts: IntegrationCloudAccount[] | undefined;
accounts: CloudAccount[] | undefined;
isLoading: boolean;
activeAccount: IntegrationCloudAccount | null;
activeAccount: CloudAccount | null;
selectOptions: SelectProps['options'];
onAccountChange: (value: string) => void;
onIntegrationModalOpen: () => void;
@@ -68,11 +57,9 @@ function AccountActionsRenderer({
<Select
value={activeAccount?.providerAccountId}
options={selectOptions}
rootClassName={cx('cloud-account-selector', {
[type.toLowerCase()]: type,
})}
rootClassName="cloud-account-selector"
popupMatchSelectWidth={false}
placeholder={`Select ${type} Account`}
placeholder="Select AWS Account"
suffixIcon={<ChevronDown size={16} color={Color.BG_VANILLA_400} />}
onChange={onAccountChange}
/>
@@ -115,49 +102,21 @@ function AccountActionsRenderer({
);
}
function AccountActions({ type }: { type: IntegrationType }): JSX.Element {
function AccountActions(): JSX.Element {
const urlQuery = useUrlQuery();
const navigate = useNavigate();
const { data: listAccountsResponse, isLoading } = useListAccounts({
cloudProvider: type,
cloudProvider: INTEGRATION_TYPES.AWS,
});
const accounts = useMemo((): IntegrationCloudAccount[] | undefined => {
const accounts = useMemo((): CloudAccount[] | undefined => {
const raw = listAccountsResponse?.data?.accounts;
if (!raw) {
return undefined;
}
const mappedAccounts: IntegrationCloudAccount[] = [];
if (type === IntegrationType.AWS_SERVICES) {
raw.forEach((account) => {
if (!account) {
return;
}
const mapped = mapAccountDtoToAwsCloudAccount(account);
if (mapped) {
mappedAccounts.push(mapped);
}
});
}
if (type === IntegrationType.AZURE_SERVICES) {
raw.forEach((account) => {
if (!account) {
return;
}
const mapped = mapAccountDtoToAzureCloudAccount(account);
if (mapped) {
mappedAccounts.push(mapped);
}
});
}
return mappedAccounts;
}, [listAccountsResponse, type]);
return raw
.map(mapAccountDtoToAwsCloudAccount)
.filter((account): account is CloudAccount => account !== null);
}, [listAccountsResponse]);
const initialAccount = useMemo(
() =>
@@ -168,8 +127,9 @@ function AccountActions({ type }: { type: IntegrationType }): JSX.Element {
[accounts, urlQuery],
);
const [activeAccount, setActiveAccount] =
useState<IntegrationCloudAccount | null>(initialAccount);
const [activeAccount, setActiveAccount] = useState<CloudAccount | null>(
initialAccount,
);
// Update state when initial value changes
useEffect(() => {
@@ -189,17 +149,16 @@ function AccountActions({ type }: { type: IntegrationType }): JSX.Element {
}, [initialAccount]);
const [isIntegrationModalOpen, setIsIntegrationModalOpen] = useState(false);
const startAccountConnectionAttempt = (): void => {
setIsIntegrationModalOpen(true);
logEvent(`${type} Integration: Account connection attempt started`, {});
logEvent('AWS Integration: Account connection attempt started', {});
};
const [isAccountSettingsModalOpen, setIsAccountSettingsModalOpen] =
useState(false);
const openAccountSettings = (): void => {
setIsAccountSettingsModalOpen(true);
logEvent(`${type} Integration: Account settings viewed`, {
logEvent('AWS Integration: Account settings viewed', {
cloudAccountId: activeAccount?.cloud_account_id,
});
};
@@ -207,16 +166,13 @@ function AccountActions({ type }: { type: IntegrationType }): JSX.Element {
// log telemetry event when an account is viewed.
useEffect(() => {
if (activeAccount) {
logEvent(`${type} Integration: Account viewed`, {
logEvent('AWS Integration: Account viewed', {
cloudAccountId: activeAccount?.cloud_account_id,
status: activeAccount?.status,
enabledRegions:
'regions' in activeAccount.config
? activeAccount.config.regions
: activeAccount.config.resource_groups,
enabledRegions: activeAccount?.config?.regions,
});
}
}, [activeAccount, type]);
}, [activeAccount]);
const selectOptions: SelectProps['options'] = useMemo(
() =>
@@ -232,7 +188,6 @@ function AccountActions({ type }: { type: IntegrationType }): JSX.Element {
return (
<div className="hero-section__actions">
<AccountActionsRenderer
type={type}
accounts={accounts}
isLoading={isLoading}
activeAccount={activeAccount}
@@ -249,39 +204,17 @@ function AccountActions({ type }: { type: IntegrationType }): JSX.Element {
/>
{isIntegrationModalOpen && (
<>
{type === IntegrationType.AWS_SERVICES && (
<AwsCloudAccountSetupModal
onClose={(): void => setIsIntegrationModalOpen(false)}
/>
)}
{type === IntegrationType.AZURE_SERVICES && (
<AzureCloudAccountSetupModal
onClose={(): void => setIsIntegrationModalOpen(false)}
/>
)}
</>
<CloudAccountSetupModal
onClose={(): void => setIsIntegrationModalOpen(false)}
/>
)}
{isAccountSettingsModalOpen && activeAccount && (
<>
{type === IntegrationType.AWS_SERVICES && (
<AwsAccountSettingsModal
onClose={(): void => setIsAccountSettingsModalOpen(false)}
account={activeAccount as AwsCloudAccount}
setActiveAccount={
setActiveAccount as Dispatch<SetStateAction<AwsCloudAccount | null>>
}
/>
)}
{type === IntegrationType.AZURE_SERVICES && (
<AzureAccountSettingsModal
onClose={(): void => setIsAccountSettingsModalOpen(false)}
account={activeAccount}
setActiveAccount={setActiveAccount}
/>
)}
</>
<AccountSettingsModal
onClose={(): void => setIsAccountSettingsModalOpen(false)}
account={activeAccount}
setActiveAccount={setActiveAccount}
/>
)}
</div>
);

View File

@@ -9,10 +9,10 @@ import useUrlQuery from 'hooks/useUrlQuery';
import history from 'lib/history';
import { Save } from 'lucide-react';
import logEvent from '../../../../../api/common/logEvent';
import RemoveIntegrationAccount from '../../RemoveAccount/RemoveIntegrationAccount';
import { RegionSelector } from '../RegionForm/RegionSelector';
import { CloudAccount } from '../types';
import logEvent from '../../../../../../api/common/logEvent';
import { CloudAccount } from '../../types';
import { RegionSelector } from './RegionSelector';
import RemoveIntegrationAccount from './RemoveIntegrationAccount';
import './AccountSettingsModal.style.scss';
@@ -110,7 +110,11 @@ function AccountSettingsModal({
form,
selectedRegions,
includeAllRegions,
account?.providerAccountId,
account?.id,
handleRemoveIntegrationAccountSuccess,
isSaveDisabled,
handleSubmit,
isLoading,
setSelectedRegions,
setIncludeAllRegions,
]);
@@ -129,7 +133,6 @@ function AccountSettingsModal({
<RemoveIntegrationAccount
accountId={account?.id}
onRemoveIntegrationAccountSuccess={handleRemoveIntegrationAccountSuccess}
cloudProvider={INTEGRATION_TYPES.AWS}
/>
<Button

View File

@@ -1,8 +1,8 @@
import { Callout } from '@signozhq/ui';
import { Spin } from 'antd';
import { LoaderCircle } from 'lucide-react';
import { Color } from '@signozhq/design-tokens';
import { Alert, Spin } from 'antd';
import { LoaderCircle, TriangleAlert } from 'lucide-react';
import { ModalStateEnum } from '../HeroSection/types';
import { ModalStateEnum } from '../types';
function AlertMessage({
modalState,
@@ -12,13 +12,14 @@ function AlertMessage({
switch (modalState) {
case ModalStateEnum.WAITING:
return (
<Callout
title={
<Alert
message={
<div className="cloud-account-setup-form__alert-message">
<Spin
indicator={
<LoaderCircle
size={14}
color={Color.BG_AMBER_400}
className="anticon anticon-loading anticon-spin ant-spin-dot"
/>
}
@@ -27,19 +28,21 @@ function AlertMessage({
<span className="retry-time">10</span> secs...
</div>
}
type="info"
showIcon={false}
className="cloud-account-setup-form__alert"
type="warning"
/>
);
case ModalStateEnum.ERROR:
return (
<Callout
title={
<Alert
message={
<div className="cloud-account-setup-form__alert-message">
<TriangleAlert type="solid" size={15} color={Color.BG_SAKURA_400} />
{`We couldn't establish a connection to your AWS account. Please try again`}
</div>
}
type="error"
className="cloud-account-setup-form__alert"
/>
);
default:

View File

@@ -0,0 +1,180 @@
.cloud-account-setup-modal {
background: var(--l1-background);
color: var(--l1-foreground);
[data-slot='drawer-title'] {
color: var(--l1-foreground);
}
> div {
display: flex;
flex-direction: column;
overflow: hidden;
}
&__content {
flex: 1;
overflow-y: auto;
min-height: 0;
scrollbar-width: thin;
&::-webkit-scrollbar {
width: 2px;
}
}
.account-setup-modal-footer {
&__confirm-button {
background: var(--primary-background);
color: var(--primary-foreground);
font-size: 12px;
font-weight: 500;
}
&__confirm-selection-count {
font-family: 'Geist Mono';
}
&__close-button {
background: var(--l1-background);
border: 1px solid var(--l1-border);
border-radius: 2px;
color: var(--l1-foreground);
font-family: 'Inter';
font-size: 12px;
font-weight: 500;
&:hover {
border-color: var(--l1-border);
color: var(--l1-foreground);
}
}
}
.cloud-account-setup-form {
.disabled {
opacity: 0.4;
}
&,
&__content {
display: flex;
flex-direction: column;
gap: 24px;
}
&__alert {
&.ant-alert {
padding: 12px;
border-radius: 6px;
font-size: 14px;
line-height: 22px; /* 157.143% */
letter-spacing: -0.07px;
}
&.ant-alert-error {
color: var(--danger-foreground);
border: 1px solid
color-mix(in srgb, var(--danger-background) 10%, transparent);
background: color-mix(in srgb, var(--danger-background) 10%, transparent);
}
&.ant-alert-warning {
color: var(--warning-foreground);
border: 1px solid
color-mix(in srgb, var(--warning-background) 10%, transparent);
background: color-mix(in srgb, var(--warning-background) 10%, transparent);
}
&-message {
display: flex;
align-items: center;
gap: 8px;
color: var(--l1-foreground);
.retry-time {
font-family: 'Geist Mono';
font-size: 14px;
font-weight: 600;
line-height: 22px;
letter-spacing: -0.07px;
}
}
}
&__form-group {
display: flex;
flex-direction: column;
gap: 12px;
}
&__title {
color: var(--l1-foreground);
font-size: 14px;
font-weight: 500;
line-height: 20px;
letter-spacing: -0.07px;
}
&__description {
color: var(--l2-foreground);
font-size: 12px;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.06px;
}
&__select {
.ant-select-selection-item {
color: var(--l1-foreground);
font-size: 14px;
line-height: 20px;
letter-spacing: -0.07px;
}
}
&__form-item {
margin: 0;
}
&__include-all-regions-switch {
display: flex;
align-items: center;
gap: 10px;
color: var(--l2-foreground);
font-size: 12px;
line-height: 18px;
letter-spacing: -0.06px;
margin-bottom: 12px;
&-label {
background-color: transparent;
border: none;
color: var(--l2-foreground);
font-size: 12px;
line-height: 18px;
letter-spacing: -0.06px;
cursor: pointer;
}
}
&__note {
padding: 12px;
color: var(--callout-primary-description);
font-size: 12px;
line-height: 22px;
letter-spacing: -0.06px;
border-radius: 4px;
border: 1px solid
color-mix(in srgb, var(--primary-background) 10%, transparent);
background: color-mix(in srgb, var(--primary-background) 10%, transparent);
}
&__submit-button {
border-radius: 2px;
background: var(--primary-background);
color: var(--primary-foreground);
font-size: 14px;
font-weight: 500;
line-height: 20px;
&-content {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
&:disabled {
opacity: 0.4;
}
}
}
}

View File

@@ -8,8 +8,8 @@ import {
ActiveViewEnum,
IntegrationModalProps,
ModalStateEnum,
} from '../../../HeroSection/types';
import { RegionForm } from '../RegionForm/RegionForm';
} from '../types';
import { RegionForm } from './RegionForm';
import './CloudAccountSetupModal.style.scss';
@@ -74,6 +74,8 @@ function CloudAccountSetupModal({
isConnectionParamsLoading,
setSelectedRegions,
setIncludeAllRegions,
isLoading,
isGeneratingUrl,
handleConnectionSuccess,
handleConnectionTimeout,
handleConnectionError,

View File

@@ -5,7 +5,7 @@ import { ChevronDown } from 'lucide-react';
import { Region } from 'utils/regions';
import { popupContainer } from 'utils/selectPopupContainer';
import { RegionSelector } from './RegionForm/RegionSelector';
import { RegionSelector } from './RegionSelector';
// Form section components
function RegionDeploymentSection({

View File

@@ -3,18 +3,15 @@ import { Form } from 'antd';
import { useGetAccount } from 'api/generated/services/cloudintegration';
import cx from 'classnames';
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
import {
ModalStateEnum,
RegionFormProps,
} from 'container/Integrations/HeroSection/types';
import { regions } from 'utils/regions';
import AlertMessage from '../../AlertMessage';
import { ModalStateEnum, RegionFormProps } from '../types';
import AlertMessage from './AlertMessage';
import {
ComplianceNote,
MonitoringRegionsSection,
RegionDeploymentSection,
} from '../IntegrateNowFormSections';
} from './IntegrateNowFormSections';
import RenderConnectionFields from './RenderConnectionParams';
export function RegionForm({
@@ -79,6 +76,8 @@ export function RegionForm({
layout="vertical"
onFinish={onSubmit}
>
<AlertMessage modalState={modalState} />
<div
className={cx(`cloud-account-setup-form__content`, {
disabled: isFormDisabled,
@@ -101,10 +100,6 @@ export function RegionForm({
isFormDisabled={isFormDisabled}
/>
</div>
<div className="cloud-account-setup-form__alert">
<AlertMessage modalState={modalState} />
</div>
</Form>
);
}

View File

@@ -1,16 +1,9 @@
.remove-integration-account-modal {
&__cloud-provider {
color: var(--l1-foreground);
font-weight: 500;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.07px;
}
.ant-modal-content {
background-color: var(--l1-background);
border: 1px solid var(--l3-background);
border-radius: 4px;
padding: 12px;
}
.ant-modal-close {

View File

@@ -4,21 +4,16 @@ import { Modal } from 'antd/lib';
import logEvent from 'api/common/logEvent';
import { useDisconnectAccount } from 'api/generated/services/cloudintegration';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import {
INTEGRATION_TELEMETRY_EVENTS,
INTEGRATION_TYPES,
} from 'container/Integrations/constants';
import { INTEGRATION_TELEMETRY_EVENTS } from 'container/Integrations/constants';
import { useNotifications } from 'hooks/useNotifications';
import { Unlink } from 'lucide-react';
import './RemoveIntegrationAccount.scss';
function RemoveIntegrationAccount({
cloudProvider,
accountId,
onRemoveIntegrationAccountSuccess,
}: {
cloudProvider: string;
accountId: string;
onRemoveIntegrationAccountSuccess: () => void;
}): JSX.Element {
@@ -44,13 +39,12 @@ function RemoveIntegrationAccount({
},
});
const handleOk = (): void => {
logEvent(INTEGRATION_TELEMETRY_EVENTS.INTEGRATION_ACCOUNT_REMOVED, {
logEvent(INTEGRATION_TELEMETRY_EVENTS.AWS_INTEGRATION_ACCOUNT_REMOVED, {
accountId,
integration: cloudProvider,
});
disconnectAccount({
pathParams: {
cloudProvider,
cloudProvider: 'aws',
id: accountId,
},
});
@@ -84,28 +78,13 @@ function RemoveIntegrationAccount({
loading: isRemoveIntegrationLoading,
}}
>
{cloudProvider === INTEGRATION_TYPES.AWS ? (
<>
Removing this account will remove all components created for sending
telemetry to SigNoz in your AWS account within the next ~15 minutes
(cloudformation stacks named signoz-integration-telemetry-collection in
enabled regions). <br />
<br />
After that, you can delete the cloudformation stack that was created
manually when connecting this account.
</>
) : (
<>
Removing this account will remove all components created for sending
telemetry to SigNoz in your Azure subscription within the next ~15 minutes
(deployment stack named signoz-integration-telemetry will be deleted
automatically). <br />
<br />
After that, you have to manually delete &apos;signoz-integration&apos;
deployment stack that was created while connecting this account (Takes ~20
minutes to delete).
</>
)}
Removing this account will remove all components created for sending
telemetry to SigNoz in your AWS account within the next ~15 minutes
(cloudformation stacks named signoz-integration-telemetry-collection in
enabled regions). <br />
<br />
After that, you can delete the cloudformation stack that was created
manually when connecting this account.
</Modal>
</div>
);

View File

@@ -4,7 +4,7 @@ import { useListAccounts } from 'api/generated/services/cloudintegration';
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
import useUrlQuery from 'hooks/useUrlQuery';
import { mapAccountDtoToAwsCloudAccount } from '../../mapCloudAccountFromDto';
import { mapAccountDtoToAwsCloudAccount } from '../mapAwsCloudAccountFromDto';
import { CloudAccount } from '../types';
import './S3BucketsSelector.styles.scss';

View File

@@ -12,14 +12,14 @@ import {
useUpdateService,
} from 'api/generated/services/cloudintegration';
import {
CloudintegrationtypesServiceConfigDTO,
CloudintegrationtypesServiceDTO,
ListServicesMetadata200,
} from 'api/generated/services/sigNoz.schemas';
import CloudServiceDataCollected from 'components/CloudIntegrations/CloudServiceDataCollected/CloudServiceDataCollected';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
import ServiceDashboards from 'container/Integrations/CloudIntegration/ServiceDashboards/ServiceDashboards';
import { IntegrationType, IServiceStatus } from 'container/Integrations/types';
import ServiceDashboards from 'container/Integrations/CloudIntegration/AmazonWebServices/ServiceDashboards/ServiceDashboards';
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
import { IServiceStatus } from 'container/Integrations/types';
import useUrlQuery from 'hooks/useUrlQuery';
import { Save, X } from 'lucide-react';
@@ -36,81 +36,7 @@ type ServiceDetailsData = CloudintegrationtypesServiceDTO & {
status?: IServiceStatus;
};
const EMPTY_FORM_VALUES: ServiceConfigFormValues = {
logsEnabled: false,
metricsEnabled: false,
s3BucketsByRegion: {},
};
function getInitialFormValues(
type: IntegrationType,
serviceDetailsData?: ServiceDetailsData,
): ServiceConfigFormValues {
const integrationConfig =
type === IntegrationType.AWS_SERVICES
? serviceDetailsData?.cloudIntegrationService?.config?.aws
: serviceDetailsData?.cloudIntegrationService?.config?.azure;
return {
logsEnabled: integrationConfig?.logs?.enabled || false,
metricsEnabled: integrationConfig?.metrics?.enabled || false,
s3BucketsByRegion:
type === IntegrationType.AWS_SERVICES
? serviceDetailsData?.cloudIntegrationService?.config?.aws?.logs
?.s3Buckets || {}
: {},
};
}
function getServiceConfigPayload({
type,
serviceId,
logsEnabled,
metricsEnabled,
isLogsSupported,
isMetricsSupported,
s3BucketsByRegion,
}: {
type: IntegrationType;
serviceId: string;
logsEnabled: boolean;
metricsEnabled: boolean;
isLogsSupported: boolean;
isMetricsSupported: boolean;
s3BucketsByRegion: Record<string, string[]>;
}): CloudintegrationtypesServiceConfigDTO {
if (type === IntegrationType.AWS_SERVICES) {
return {
aws: {
logs: {
enabled: isLogsSupported ? logsEnabled : false,
s3Buckets:
serviceId === 's3sync' && isLogsSupported ? s3BucketsByRegion : {},
},
metrics: {
enabled: isMetricsSupported ? metricsEnabled : false,
},
},
};
}
return {
azure: {
logs: {
enabled: isLogsSupported ? logsEnabled : false,
},
metrics: {
enabled: isMetricsSupported ? metricsEnabled : false,
},
},
};
}
function ServiceDetails({
type,
}: {
type: IntegrationType;
}): JSX.Element | null {
function ServiceDetails(): JSX.Element | null {
const urlQuery = useUrlQuery();
const cloudAccountId = urlQuery.get('cloudAccountId');
const serviceId = urlQuery.get('service');
@@ -125,7 +51,7 @@ function ServiceDetails({
isLoading: isServiceDetailsLoading,
} = useGetService(
{
cloudProvider: type,
cloudProvider: INTEGRATION_TYPES.AWS,
serviceId: serviceId || '',
},
{
@@ -139,17 +65,10 @@ function ServiceDetails({
},
);
const integrationConfig =
type === IntegrationType.AWS_SERVICES
? serviceDetailsData?.cloudIntegrationService?.config?.aws
: serviceDetailsData?.cloudIntegrationService?.config?.azure;
const awsConfig = serviceDetailsData?.cloudIntegrationService?.config?.aws;
const isServiceEnabledInPersistedConfig =
Boolean(integrationConfig?.logs?.enabled) ||
Boolean(integrationConfig?.metrics?.enabled);
Boolean(awsConfig?.logs?.enabled) || Boolean(awsConfig?.metrics?.enabled);
const serviceDetailsId = serviceDetailsData?.id;
const isLogsSupported = serviceDetailsData?.supportedSignals?.logs || false;
const isMetricsSupported =
serviceDetailsData?.supportedSignals?.metrics || false;
const {
control,
@@ -158,31 +77,43 @@ function ServiceDetails({
watch,
formState: { isDirty },
} = useForm<ServiceConfigFormValues>({
defaultValues: getInitialFormValues(type, serviceDetailsData),
defaultValues: {
logsEnabled: awsConfig?.logs?.enabled || false,
metricsEnabled: awsConfig?.metrics?.enabled || false,
s3BucketsByRegion: awsConfig?.logs?.s3Buckets || {},
},
});
const resetToConfig = useCallback((): void => {
reset(getInitialFormValues(type, serviceDetailsData));
}, [reset, serviceDetailsData, type]);
const resetToAwsConfig = useCallback((): void => {
reset({
logsEnabled: awsConfig?.logs?.enabled || false,
metricsEnabled: awsConfig?.metrics?.enabled || false,
s3BucketsByRegion: awsConfig?.logs?.s3Buckets || {},
});
}, [awsConfig, reset]);
// Ensure form state does not leak across service switches while new details load.
useEffect(() => {
reset(EMPTY_FORM_VALUES);
reset({
logsEnabled: false,
metricsEnabled: false,
s3BucketsByRegion: {},
});
}, [reset, serviceId]);
useEffect(() => {
resetToConfig();
}, [resetToConfig, serviceDetailsId]);
resetToAwsConfig();
}, [resetToAwsConfig, serviceDetailsId]);
// log telemetry event on visiting details of a service.
useEffect(() => {
if (serviceId) {
logEvent(`${type} Integration: Service viewed`, {
logEvent('AWS Integration: Service viewed', {
cloudAccountId,
serviceId,
});
}
}, [cloudAccountId, serviceId, type]);
}, [cloudAccountId, serviceId]);
const { mutate: updateService, isLoading: isUpdatingServiceConfig } =
useUpdateService();
@@ -190,8 +121,8 @@ function ServiceDetails({
const queryClient = useQueryClient();
const handleDiscard = useCallback((): void => {
resetToConfig();
}, [resetToConfig]);
resetToAwsConfig();
}, [resetToAwsConfig]);
const onSubmit = useCallback(
async (values: ServiceConfigFormValues): Promise<void> => {
@@ -210,25 +141,25 @@ function ServiceDetails({
return;
}
const serviceConfigPayload = getServiceConfigPayload({
type,
serviceId,
logsEnabled,
metricsEnabled,
isLogsSupported,
isMetricsSupported,
s3BucketsByRegion: normalizedS3BucketsByRegion,
});
updateService(
{
pathParams: {
cloudProvider: type,
cloudProvider: INTEGRATION_TYPES.AWS,
id: cloudAccountId,
serviceId,
},
data: {
config: serviceConfigPayload,
config: {
aws: {
logs: {
enabled: logsEnabled,
s3Buckets: normalizedS3BucketsByRegion,
},
metrics: {
enabled: metricsEnabled,
},
},
},
},
},
{
@@ -239,7 +170,7 @@ function ServiceDetails({
const servicesListQueryKey = getListServicesMetadataQueryKey(
{
cloudProvider: type,
cloudProvider: INTEGRATION_TYPES.AWS,
},
{
cloud_integration_id: cloudAccountId,
@@ -272,7 +203,7 @@ function ServiceDetails({
invalidateGetService(
queryClient,
{
cloudProvider: type,
cloudProvider: INTEGRATION_TYPES.AWS,
serviceId,
},
{
@@ -283,14 +214,14 @@ function ServiceDetails({
invalidateListServicesMetadata(
queryClient,
{
cloudProvider: type,
cloudProvider: INTEGRATION_TYPES.AWS,
},
{
cloud_integration_id: cloudAccountId,
},
);
logEvent(`${type} Integration: Service settings saved`, {
logEvent('AWS Integration: Service settings saved', {
cloudAccountId,
serviceId,
logsEnabled,
@@ -310,16 +241,7 @@ function ServiceDetails({
console.error('Form submission failed:', error);
}
},
[
serviceId,
cloudAccountId,
updateService,
queryClient,
reset,
type,
isLogsSupported,
isMetricsSupported,
],
[serviceId, cloudAccountId, updateService, queryClient, reset],
);
if (isServiceDetailsLoading) {
@@ -340,6 +262,10 @@ function ServiceDetails({
const logsEnabled = watch('logsEnabled');
const s3BucketsByRegion = watch('s3BucketsByRegion');
const isLogsSupported = serviceDetailsData?.supportedSignals?.logs || false;
const isMetricsSupported =
serviceDetailsData?.supportedSignals?.metrics || false;
const hasUnsavedChanges = isDirty;
const isS3SyncBucketsMissing =

View File

@@ -1,7 +1,7 @@
import cx from 'classnames';
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
import { Service } from './AmazonWebServices/types';
import { Service } from './types';
function ServiceItem({
service,

View File

@@ -4,20 +4,15 @@ import { Skeleton } from 'antd';
import { useListServicesMetadata } from 'api/generated/services/cloudintegration';
import type { CloudintegrationtypesServiceMetadataDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import { IntegrationType } from 'container/Integrations/types';
import useUrlQuery from 'hooks/useUrlQuery';
import emptyStateIconUrl from '@/assets/Icons/emptyState.svg';
interface ServicesListProps {
cloudAccountId: string;
type: IntegrationType;
}
function ServicesList({
cloudAccountId,
type,
}: ServicesListProps): JSX.Element {
function ServicesList({ cloudAccountId }: ServicesListProps): JSX.Element {
const urlQuery = useUrlQuery();
const navigate = useNavigate();
const hasValidCloudAccountId = Boolean(cloudAccountId);
@@ -27,7 +22,7 @@ function ServicesList({
const { data: servicesMetadata, isLoading } = useListServicesMetadata(
{
cloudProvider: type,
cloudProvider: 'aws',
},
serviceQueryParams,
);

View File

@@ -0,0 +1,29 @@
import useUrlQuery from 'hooks/useUrlQuery';
import HeroSection from './HeroSection/HeroSection';
import ServiceDetails from './ServiceDetails/ServiceDetails';
import ServicesList from './ServicesList';
import './ServicesTabs.style.scss';
function ServicesTabs(): JSX.Element {
const urlQuery = useUrlQuery();
const cloudAccountId = urlQuery.get('cloudAccountId') || '';
return (
<div className="services-tabs">
<HeroSection />
<div className="services-section">
<div className="services-section__sidebar">
<ServicesList cloudAccountId={cloudAccountId} />
</div>
<div className="services-section__content">
<ServiceDetails />
</div>
</div>
</div>
);
}
export default ServicesTabs;

View File

@@ -29,7 +29,7 @@ jest.mock('components/MarkdownRenderer/MarkdownRenderer', () => ({
MarkdownRenderer: (): JSX.Element => <div data-testid="markdown-renderer" />,
}));
jest.mock(
'container/Integrations/CloudIntegration/ServiceDashboards/ServiceDashboards',
'container/Integrations/CloudIntegration/AmazonWebServices/ServiceDashboards/ServiceDashboards',
() => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="service-dashboards" />,

View File

@@ -1,5 +1,4 @@
import { render, RenderResult, screen, waitFor } from '@testing-library/react';
import { IntegrationType } from 'container/Integrations/types';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
import ServiceDetails from '../ServiceDetails/ServiceDetails';
@@ -12,11 +11,10 @@ import { accountsResponse } from './mockData';
const renderServiceDetails = (
_initialConfigLogsS3Buckets: Record<string, string[]> = {},
_serviceId = 's3sync',
type: IntegrationType = IntegrationType.AWS_SERVICES,
): RenderResult =>
render(
<MockQueryClientProvider>
<ServiceDetails type={type} />
<ServiceDetails />
</MockQueryClientProvider>,
);

View File

@@ -0,0 +1,25 @@
import { CloudintegrationtypesAccountDTO } from 'api/generated/services/sigNoz.schemas';
import { CloudAccount } from './types';
export function mapAccountDtoToAwsCloudAccount(
account: CloudintegrationtypesAccountDTO,
): CloudAccount | null {
if (!account.providerAccountId) {
return null;
}
return {
id: account.id,
cloud_account_id: account.id,
config: {
regions: account.config?.aws?.regions ?? [],
},
status: {
integration: {
last_heartbeat_ts_ms: account.agentReport?.timestampMillis ?? 0,
},
},
providerAccountId: account.providerAccountId,
};
}

View File

@@ -1,356 +0,0 @@
import { useCallback, useRef, useState } from 'react';
import { Color } from '@signozhq/design-tokens';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import { Button, Callout, DrawerWrapper, Tabs } from '@signozhq/ui';
import { Form, Select, Spin } from 'antd';
import { useGetAccount } from 'api/generated/services/cloudintegration';
import { CloudintegrationtypesAccountDTO } from 'api/generated/services/sigNoz.schemas';
import CodeBlock from 'components/CodeBlock/CodeBlock';
import {
AZURE_REGIONS,
INTEGRATION_TYPES,
} from 'container/Integrations/constants';
import {
IntegrationModalProps,
ModalStateEnum,
} from 'container/Integrations/HeroSection/types';
import { LoaderCircle, SquareArrowOutUpRight } from 'lucide-react';
import { popupContainer } from 'utils/selectPopupContainer';
import { useIntegrationModal } from '../../../../../hooks/integration/azure/useIntegrationModal';
import RenderConnectionFields from '../../AmazonWebServices/RegionForm/RenderConnectionParams';
import '../../AmazonWebServices/AddNewAccount/CloudAccountSetupModal.style.scss';
const AZURE_CLI_DESC =
'Paste the following command if you have Azure CLI setup locally on your machine or use BASH CloudShell on Azure portal with above mentioned permissions.';
const AZURE_POWERSHELL_DESC =
'Paste the following command in PowerShell CloudShell on Azure portal, you can switch to PowerShell on Azure portal.';
function CloudAccountSetupModal({
onClose,
}: IntegrationModalProps): JSX.Element {
const {
form,
modalState,
isLoading,
accountId,
connectionCommands,
handleSubmit,
handleClose,
connectionParams,
isConnectionParamsLoading,
handleConnectionSuccess,
handleConnectionTimeout,
handleConnectionError,
} = useIntegrationModal({ onClose });
const startTimeRef = useRef(Date.now());
const refetchInterval = 10 * 1000;
const errorTimeout = 10 * 60 * 1000;
const [isHowItWorksOpen, setIsHowItWorksOpen] = useState(true);
const [activeTab, setActiveTab] = useState('cli');
useGetAccount(
{
cloudProvider: INTEGRATION_TYPES.AZURE,
id: accountId ?? '',
},
{
query: {
enabled: Boolean(accountId) && modalState === ModalStateEnum.WAITING,
refetchInterval,
select: (response): CloudintegrationtypesAccountDTO => response.data,
onSuccess: (account) => {
const isConnected =
Boolean(account.providerAccountId) && account.removedAt === null;
if (isConnected) {
handleConnectionSuccess({
cloudAccountId: account.providerAccountId ?? account.id,
status: account.agentReport,
});
} else if (Date.now() - startTimeRef.current >= errorTimeout) {
handleConnectionTimeout({ id: accountId });
}
},
onError: () => {
handleConnectionError();
},
},
},
);
const renderAlert = useCallback((): JSX.Element | null => {
if (modalState === ModalStateEnum.WAITING) {
return (
<div className="cloud-account-setup-form__alert">
<Callout
title={
<div className="cloud-account-setup-form__alert-message">
<Spin
indicator={
<LoaderCircle
size={14}
className="anticon anticon-loading anticon-spin ant-spin-dot"
/>
}
/>
Waiting for Azure account connection, retrying in{' '}
<span className="retry-time">10</span> secs...
</div>
}
type="info"
showIcon={false}
/>
</div>
);
}
if (modalState === ModalStateEnum.ERROR) {
return (
<div className="cloud-account-setup-form__alert">
<Callout
title={
<div className="cloud-account-setup-form__alert-message">
We couldn&apos;t establish a connection to your Azure account. Please
try again
</div>
}
type="error"
/>
</div>
);
}
return null;
}, [modalState]);
const footer = (
<div className="cloud-account-setup-modal__footer">
{modalState === ModalStateEnum.FORM && (
<Button
variant="solid"
color="primary"
prefix={<SquareArrowOutUpRight size={17} color={Color.BG_VANILLA_100} />}
onClick={handleSubmit}
loading={isLoading}
>
Generate Azure Setup Commands
</Button>
)}
</div>
);
return (
<DrawerWrapper
open={true}
className="cloud-account-setup-modal"
onOpenChange={(open): void => {
if (!open) {
handleClose();
}
}}
direction="right"
showCloseButton
title="Add Azure Account"
width="wide"
footer={footer}
>
<div className="cloud-account-setup-modal__content">
<div className="cloud-account-setup-prerequisites">
<div className="cloud-account-setup-prerequisites__title">
Prerequisites
</div>
<ul className="cloud-account-setup-prerequisites__list">
<li className="cloud-account-setup-prerequisites__list-item">
<span className="cloud-account-setup-prerequisites__list-item-bullet">
</span>{' '}
<span className="cloud-account-setup-prerequisites__list-item-text">
Ensure that you&apos;re logged in to the Azure workspace which you want
to monitor.
</span>
</li>
<li className="cloud-account-setup-prerequisites__list-item">
<span className="cloud-account-setup-prerequisites__list-item-bullet">
</span>{' '}
<span className="cloud-account-setup-prerequisites__list-item-text">
Ensure that you either have the{' '}
<span className="cloud-account-setup-prerequisites__list-item-highlight">
Owner
</span>{' '}
role OR
</span>
</li>
<li className="cloud-account-setup-prerequisites__list-item">
<span className="cloud-account-setup-prerequisites__list-item-bullet">
</span>{' '}
<span className="cloud-account-setup-prerequisites__list-item-text">
Both the{' '}
<span className="cloud-account-setup-prerequisites__list-item-highlight">
Contributor
</span>{' '}
and{' '}
<span className="cloud-account-setup-prerequisites__list-item-highlight">
user access admin
</span>{' '}
roles
</span>
</li>
</ul>
</div>
<div className="cloud-account-setup-how-it-works-accordion">
<div
className={`cloud-account-setup-how-it-works-accordion__title ${
isHowItWorksOpen ? 'open' : ''
}`}
>
<Button
variant="link"
color="secondary"
onClick={(): void => setIsHowItWorksOpen(!isHowItWorksOpen)}
prefix={isHowItWorksOpen ? <ChevronDown /> : <ChevronRight />}
/>
<span className="cloud-account-setup-how-it-works-accordion__title-text">
How it works?
</span>
</div>
{isHowItWorksOpen && (
<div className="cloud-account-setup-how-it-works-accordion__description">
<div className="cloud-account-setup-how-it-works-accordion__description-item">
SigNoz will create new resource-group to manage the resources required
for this integration. The following steps will create a User-Assigned
Managed Identity with the necessary permissions and follows the
Principle of Least Privilege.
</div>
<div className="cloud-account-setup-how-it-works__description-item">
Once the Integration template is deployed, you can enable the services
you want to monitor right here in Signoz dashboard.
</div>
</div>
)}
</div>
<Form
form={form}
className="cloud-account-setup-form"
layout="vertical"
initialValues={{ resourceGroups: [] }}
>
<div className="cloud-account-setup-form__content">
<div className="cloud-account-setup-form__form-group">
<div className="cloud-account-setup-form__title">
Where should we deploy the SigNoz collector resources?
</div>
<div className="cloud-account-setup-form__description">
Choose the Azure region for deployment.
</div>
<Form.Item
name="region"
rules={[{ required: true, message: 'Please select a region' }]}
className="cloud-account-setup-form__form-item"
>
<Select
placeholder="e.g. East US"
options={AZURE_REGIONS.map((region) => ({
label: `${region.label} (${region.value})`,
value: region.value,
}))}
getPopupContainer={popupContainer}
disabled={modalState === ModalStateEnum.WAITING}
/>
</Form.Item>
</div>
<div className="cloud-account-setup-form__form-group">
<div className="cloud-account-setup-form__title">
Which resource groups do you want to monitor?
</div>
<div className="cloud-account-setup-form__description">
Add one or more Azure resource group names.
</div>
<Form.Item
name="resourceGroups"
rules={[
{
required: true,
type: 'array',
min: 1,
message: 'Please add at least one resource group',
},
]}
className="cloud-account-setup-form__form-item"
>
<Select
mode="tags"
placeholder="e.g. prod-platform-rg"
tokenSeparators={[',']}
disabled={modalState === ModalStateEnum.WAITING}
/>
</Form.Item>
</div>
<RenderConnectionFields
isConnectionParamsLoading={isConnectionParamsLoading}
connectionParams={connectionParams}
isFormDisabled={modalState === ModalStateEnum.WAITING}
/>
{connectionCommands && (
<div className="cloud-account-setup-form__code-block-tabs-container">
<div className="cloud-account-setup-form__code-block-tabs-header">
<div className="cloud-account-setup-form__code-block-tabs-header-title">
Deploy Agent
</div>
<div className="cloud-account-setup-form__code-block-tabs-header-description">
{activeTab === 'cli' ? AZURE_CLI_DESC : AZURE_POWERSHELL_DESC}
</div>
</div>
<Tabs
className="cloud-account-setup-form__code-block-tabs"
items={[
{
key: 'cli',
label: 'CLI',
children: <CodeBlock code={connectionCommands?.cliCommand || ''} />,
},
{
key: 'powershell',
label: 'PowerShell',
children: (
<CodeBlock
code={connectionCommands?.cloudPowerShellCommand || ''}
/>
),
},
]}
value={activeTab}
onChange={(key): void => setActiveTab(key)}
variant="primary"
/>
</div>
)}
{renderAlert()}
{modalState === ModalStateEnum.WAITING && (
<div className="cloud-account-setup-status-message">
After running the command, return here and wait for automatic connection
detection.
</div>
)}
</div>
</Form>
</div>
</DrawerWrapper>
);
}
export default CloudAccountSetupModal;

View File

@@ -1,150 +0,0 @@
import { Dispatch, SetStateAction, useMemo } from 'react';
import { useQueryClient } from 'react-query';
import { Button, DrawerWrapper } from '@signozhq/ui';
import { Form, Select } from 'antd';
import { invalidateListAccounts } from 'api/generated/services/cloudintegration';
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
import { CloudAccount } from 'container/Integrations/types';
import { Save } from 'lucide-react';
import { useAccountSettingsModal } from '../../../../../hooks/integration/azure/useAccountSettingsModal';
import RemoveIntegrationAccount from '../../RemoveAccount/RemoveIntegrationAccount';
import '../../AmazonWebServices/EditAccount/AccountSettingsModal.style.scss';
interface AccountSettingsModalProps {
onClose: () => void;
account: CloudAccount;
setActiveAccount: Dispatch<SetStateAction<CloudAccount | null>>;
}
function AccountSettingsModal({
onClose,
account,
setActiveAccount,
}: AccountSettingsModalProps): JSX.Element {
const {
form,
isLoading,
resourceGroups,
isSaveDisabled,
setResourceGroups,
handleSubmit,
handleClose,
} = useAccountSettingsModal({ onClose, account, setActiveAccount });
const queryClient = useQueryClient();
const azureConfig = useMemo(
() => ('deployment_region' in account.config ? account.config : null),
[account.config],
);
return (
<DrawerWrapper
open={true}
className="account-settings-modal"
title="Account Settings"
direction="right"
showCloseButton
onOpenChange={(open): void => {
if (!open) {
handleClose();
}
}}
width="wide"
footer={
<div className="account-settings-modal__footer">
<RemoveIntegrationAccount
accountId={account?.id}
onRemoveIntegrationAccountSuccess={(): void => {
void invalidateListAccounts(queryClient, {
cloudProvider: INTEGRATION_TYPES.AZURE,
});
setActiveAccount(null);
handleClose();
}}
cloudProvider={INTEGRATION_TYPES.AZURE}
/>
<Button
variant="solid"
color="secondary"
disabled={isSaveDisabled}
onClick={handleSubmit}
loading={isLoading}
prefix={<Save size={14} />}
>
Update Changes
</Button>
</div>
}
>
<Form
form={form}
layout="vertical"
initialValues={{
resourceGroups: azureConfig?.resource_groups || [],
}}
>
<div className="account-settings-modal__body">
<div className="account-settings-modal__body-account-info">
<div className="account-settings-modal__body-account-info-connected-account-details">
<div className="account-settings-modal__body-account-info-connected-account-details-title">
Connected Account details
</div>
<div className="account-settings-modal__body-account-info-connected-account-details-account-id">
Azure Subscription:{' '}
<span className="account-settings-modal__body-account-info-connected-account-details-account-id-account-id">
{account?.providerAccountId}
</span>
</div>
</div>
</div>
{azureConfig?.deployment_region && (
<div className="account-settings-modal__body-region-selector">
<div className="account-settings-modal__body-region-selector-title">
Deployment region
</div>
<div className="account-settings-modal__body-region-selector-description">
{azureConfig.deployment_region}
</div>
</div>
)}
<div className="account-settings-modal__body-region-selector">
<div className="account-settings-modal__body-region-selector-title">
Resource groups
</div>
<div className="account-settings-modal__body-region-selector-description">
Update the resource groups that should be monitored.
</div>
<Form.Item
name="resourceGroups"
rules={[
{
required: true,
type: 'array',
min: 1,
message: 'Please add at least one resource group',
},
]}
>
<Select
mode="tags"
value={resourceGroups}
onChange={(values): void => {
setResourceGroups(values);
form.setFieldValue('resourceGroups', values);
}}
/>
</Form.Item>
</div>
</div>
</Form>
</DrawerWrapper>
);
}
export default AccountSettingsModal;

View File

@@ -1,15 +1,16 @@
import { IntegrationType } from 'container/Integrations/types';
import AWSTabs from './AmazonWebServices/ServicesTabs';
import Header from './Header/Header';
import ServicesTabs from './ServiceTabs/ServicesTabs';
import './CloudIntegration.styles.scss';
const CloudIntegration = ({ type }: { type: IntegrationType }): JSX.Element => {
return (
<div className="cloud-integration-container">
<Header type={type} />
<ServicesTabs type={type} />
<Header title={type} />
{type === IntegrationType.AWS_SERVICES && <AWSTabs />}
</div>
);
};

View File

@@ -7,7 +7,7 @@ import { Blocks, LifeBuoy } from 'lucide-react';
import './Header.styles.scss';
function Header({ type }: { type: IntegrationType }): JSX.Element {
function Header({ title }: { title: IntegrationType }): JSX.Element {
return (
<div className="cloud-header">
<div className="cloud-header__navigation">
@@ -25,30 +25,27 @@ function Header({ type }: { type: IntegrationType }): JSX.Element {
),
},
{
title: <div className="cloud-header__breadcrumb-title">{type}</div>,
title: <div className="cloud-header__breadcrumb-title">{title}</div>,
},
]}
/>
</div>
{type === IntegrationType.AWS_SERVICES && (
<div className="cloud-header__actions">
<Button
variant="solid"
size="sm"
color="secondary"
onClick={(): void => {
window.open(
'https://signoz.io/blog/native-aws-integrations-with-autodiscovery/',
'_blank',
);
}}
prefix={<LifeBuoy size={12} />}
>
Get Help
</Button>
</div>
)}
<div className="cloud-header__actions">
<Button
variant="solid"
size="sm"
color="secondary"
onClick={(): void => {
window.open(
'https://signoz.io/blog/native-aws-integrations-with-autodiscovery/',
'_blank',
);
}}
prefix={<LifeBuoy size={12} />}
>
Get Help
</Button>
</div>
</div>
);
}

View File

@@ -1,30 +0,0 @@
import { IntegrationType } from 'container/Integrations/types';
import useUrlQuery from 'hooks/useUrlQuery';
import HeroSection from '../../HeroSection/HeroSection';
import ServiceDetails from '../AmazonWebServices/ServiceDetails/ServiceDetails';
import ServicesList from '../ServicesList';
import './ServicesTabs.style.scss';
function ServicesTabs({ type }: { type: IntegrationType }): JSX.Element {
const urlQuery = useUrlQuery();
const cloudAccountId = urlQuery.get('cloudAccountId') || '';
return (
<div className="services-tabs">
<HeroSection type={type} />
<div className="services-section">
<div className="services-section__sidebar">
<ServicesList cloudAccountId={cloudAccountId} type={type} />
</div>
<div className="services-section__content">
<ServiceDetails type={type} />
</div>
</div>
</div>
);
}
export default ServicesTabs;

View File

@@ -1,49 +0,0 @@
import { CloudintegrationtypesAccountDTO } from 'api/generated/services/sigNoz.schemas';
import { CloudAccount as IntegrationCloudAccount } from 'container/Integrations/types';
import { CloudAccount as AwsCloudAccount } from './AmazonWebServices/types';
export function mapAccountDtoToAwsCloudAccount(
account: CloudintegrationtypesAccountDTO,
): AwsCloudAccount | null {
if (!account.providerAccountId) {
return null;
}
return {
id: account.id,
cloud_account_id: account.id,
config: {
regions: account.config?.aws?.regions ?? [],
},
status: {
integration: {
last_heartbeat_ts_ms: account.agentReport?.timestampMillis ?? 0,
},
},
providerAccountId: account.providerAccountId,
};
}
export function mapAccountDtoToAzureCloudAccount(
account: CloudintegrationtypesAccountDTO,
): IntegrationCloudAccount | null {
if (!account.providerAccountId) {
return null;
}
return {
id: account.id,
cloud_account_id: account.id,
config: {
deployment_region: account.config?.azure?.deploymentRegion ?? '',
resource_groups: account.config?.azure?.resourceGroups ?? [],
},
status: {
integration: {
last_heartbeat_ts_ms: account.agentReport?.timestampMillis ?? 0,
},
},
providerAccountId: account.providerAccountId,
};
}

View File

@@ -1,32 +1,5 @@
import { ONE_CLICK_INTEGRATIONS } from '../constants';
import { IntegrationType } from '../types';
export const getAccountById = <T extends { cloud_account_id: string }>(
accounts: T[],
accountId: string,
): T | null =>
accounts.find((account) => account.cloud_account_id === accountId) || null;
interface IntegrationMetadata {
title: string;
description: string;
logo: string;
}
export const getIntegrationMetadata = (
type: IntegrationType,
): IntegrationMetadata => {
const integration = ONE_CLICK_INTEGRATIONS.find(
(integration) => integration.id === type,
);
if (!integration) {
return { title: '', description: '', logo: '' };
}
return {
title: integration.title,
description: integration.description,
logo: integration.icon,
};
};

View File

@@ -1,33 +0,0 @@
import { IntegrationType } from 'container/Integrations/types';
import AccountActions from '../CloudIntegration/AmazonWebServices/AccountActions/AccountActions';
import { getIntegrationMetadata } from '../CloudIntegration/utils';
import './HeroSection.style.scss';
function HeroSection({ type }: { type: IntegrationType }): JSX.Element {
const {
title,
description,
logo: integrationLogo,
} = getIntegrationMetadata(type);
return (
<div className="hero-section">
<div className="hero-section__details">
<div className="hero-section__details-header">
<div className="hero-section__icon">
<img src={integrationLogo} alt={type} />
</div>
<div className="hero-section__details-title">{title}</div>
</div>
<div className="hero-section__details-description">{description}</div>
</div>
<AccountActions type={type} />
</div>
);
}
export default HeroSection;

View File

@@ -9,6 +9,53 @@
flex-direction: column;
gap: 16px;
.error-container {
display: flex;
border-radius: 6px;
border: 1px solid var(--l1-border);
background: var(--l1-background);
align-items: center;
justify-content: center;
flex-direction: column;
.error-content {
display: flex;
flex-direction: column;
justify-content: center;
height: 300px;
gap: 15px;
.error-btns {
display: flex;
flex-direction: row;
gap: 16px;
align-items: center;
.retry-btn {
display: flex;
align-items: center;
}
.contact-support {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
.text {
color: var(--callout-primary-description);
font-weight: 500;
}
}
}
.error-state-svg {
height: 40px;
width: 40px;
}
}
}
.loading-integration-details {
display: flex;
flex-direction: column;
@@ -280,36 +327,6 @@
}
}
}
.error-container {
display: flex;
border-radius: 6px;
border: 1px solid var(--l1-border);
background: var(--l1-background);
align-items: center;
justify-content: center;
flex-direction: column;
.error-content {
display: flex;
flex-direction: column;
justify-content: center;
height: 300px;
gap: 15px;
.error-btns {
display: flex;
flex-direction: row;
gap: 12px;
align-items: center;
}
.error-state-svg {
height: 40px;
width: 40px;
}
}
}
}
.remove-integration-modal {

View File

@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useHistory, useParams } from 'react-router-dom';
import { Color } from '@signozhq/design-tokens';
import { Button } from '@signozhq/ui';
import { Flex, Skeleton, Typography } from 'antd';
import ROUTES from 'constants/routes';
@@ -54,19 +55,8 @@ function IntegrationDetailPage(): JSX.Element {
),
);
if (
integrationId === INTEGRATION_TYPES.AWS ||
integrationId === INTEGRATION_TYPES.AZURE
) {
return (
<CloudIntegration
type={
integrationId === INTEGRATION_TYPES.AWS
? IntegrationType.AWS_SERVICES
: IntegrationType.AZURE_SERVICES
}
/>
);
if (integrationId === INTEGRATION_TYPES.AWS) {
return <CloudIntegration type={IntegrationType.AWS_SERVICES} />;
}
return (
@@ -95,20 +85,20 @@ function IntegrationDetailPage(): JSX.Element {
<div className="error-btns">
<Button
variant="solid"
color="secondary"
color="primary"
onClick={(): Promise<any> => refetch()}
prefix={<RotateCw size={14} />}
>
Retry
</Button>
<Button
variant="solid"
color="secondary"
<div
className="contact-support"
onClick={(): void => handleContactSupport(isCloudUserVal)}
suffix={<MoveUpRight size={12} />}
>
Contact Support
</Button>
<Typography.Link className="text">Contact Support </Typography.Link>
<MoveUpRight size={14} color={Color.BG_ROBIN_400} />
</div>
</div>
</div>
</div>

View File

@@ -22,7 +22,6 @@ function OneClickIntegrations(props: OneClickIntegrationsProps): JSX.Element {
if (!query) {
return ONE_CLICK_INTEGRATIONS;
}
return ONE_CLICK_INTEGRATIONS.filter(
(integration) =>
integration.title.toLowerCase().includes(query) ||

View File

@@ -14,8 +14,8 @@ export const INTEGRATION_TELEMETRY_EVENTS = {
'Integrations Detail Page: Clicked remove Integration button for integration',
INTEGRATIONS_DETAIL_CONFIGURE_INSTRUCTION:
'Integrations Detail Page: Navigated to configure an integration',
INTEGRATION_ACCOUNT_REMOVED:
'Integration Detail page: Clicked remove Integration button for integration',
AWS_INTEGRATION_ACCOUNT_REMOVED:
'AWS Integration Detail page: Clicked remove Integration button for integration',
};
export const INTEGRATION_TYPES = {
@@ -53,7 +53,7 @@ export const AZURE_INTEGRATION = {
is_new: true,
};
export const ONE_CLICK_INTEGRATIONS = [AWS_INTEGRATION, AZURE_INTEGRATION];
export const ONE_CLICK_INTEGRATIONS = [AWS_INTEGRATION];
export const AZURE_REGIONS: AzureRegion[] = [
{
@@ -81,7 +81,6 @@ export const AZURE_REGIONS: AzureRegion[] = [
{ label: 'Central India', value: 'centralindia', geography: 'India' },
{ label: 'Central US', value: 'centralus', geography: 'United States' },
{ label: 'Chile Central', value: 'chilecentral', geography: 'Chile' },
{ label: 'Denmark East', value: 'denmarkeast', geography: 'Denmark' },
{ label: 'East Asia', value: 'eastasia', geography: 'Asia Pacific' },
{ label: 'East US', value: 'eastus', geography: 'United States' },
{ label: 'East US 2', value: 'eastus2', geography: 'United States' },

View File

@@ -4,8 +4,8 @@ import {
} from './CloudIntegration/AmazonWebServices/types';
export enum IntegrationType {
AWS_SERVICES = 'aws',
AZURE_SERVICES = 'azure',
AWS_SERVICES = 'aws-services',
AZURE_SERVICES = 'azure-services',
}
interface LogField {
@@ -89,7 +89,6 @@ export interface CloudAccount {
cloud_account_id: string;
config: AzureCloudAccountConfig | AWSCloudAccountConfig;
status: AccountStatus | IServiceStatus;
providerAccountId: string;
}
export interface AzureCloudAccountConfig {

View File

@@ -89,4 +89,25 @@
) !important;
}
}
&__add-btn {
width: 100%;
// Ensure icon is visible
svg,
[class*='icon'] {
color: var(--l2-foreground) !important;
display: inline-block !important;
opacity: 1 !important;
}
&:hover {
color: var(--l1-foreground);
svg,
[class*='icon'] {
color: var(--l1-foreground) !important;
}
}
}
}

View File

@@ -69,10 +69,10 @@ function DomainMappingList({
))}
<Button
variant="outlined"
color="secondary"
variant="dashed"
onClick={(): void => add({ domain: '', adminEmail: '' })}
prefix={<Plus size={14} />}
className="domain-mapping-list__add-btn"
>
Add Domain Mapping
</Button>

View File

@@ -196,7 +196,6 @@ export const getUplotClickData = ({
coord: { x: number; y: number };
record: { queryName: string; filters: FilterData[] };
label: string | React.ReactNode;
seriesColor?: string;
} | null => {
if (!queryData?.queryName || !metric) {
return null;
@@ -209,8 +208,6 @@ export const getUplotClickData = ({
// Generate label from focusedSeries data
let label: string | React.ReactNode = '';
const seriesColor = focusedSeries?.color;
if (focusedSeries && focusedSeries.seriesName) {
label = (
<span style={{ color: focusedSeries.color }}>
@@ -226,7 +223,6 @@ export const getUplotClickData = ({
},
record,
label,
seriesColor,
};
};
@@ -241,7 +237,6 @@ export const getPieChartClickData = (
queryName: string;
filters: FilterData[];
label: string | React.ReactNode;
seriesColor?: string;
} | null => {
const { metric, queryName } = arc.data.record;
if (!queryName || !metric) {
@@ -253,7 +248,6 @@ export const getPieChartClickData = (
queryName,
filters: getFiltersFromMetric(metric), // TODO: add where clause query as well.
label,
seriesColor: arc.data.color,
};
};

View File

@@ -22,7 +22,6 @@ export interface AggregateData {
endTime: number;
};
label?: string | React.ReactNode;
seriesColor?: string;
}
const useAggregateDrilldown = ({

View File

@@ -228,13 +228,7 @@ const useBaseAggregateOptions = ({
return (
<ContextMenu.Item
key={key}
icon={
isLoading ? (
<LoadingOutlined spin />
) : (
<span style={{ color: aggregateData?.seriesColor }}>{icon}</span>
)
}
icon={isLoading ? <LoadingOutlined spin /> : icon}
onClick={(): void => onClick()}
disabled={isLoading}
>

View File

@@ -51,6 +51,35 @@
border-radius: 2px;
}
}
// todo: https://github.com/SigNoz/components/issues/116
.roles-search-wrapper {
flex: 1;
input {
width: 100%;
background: var(--l3-background);
border: 1px solid var(--l1-border);
border-radius: 2px;
padding: 6px 6px 6px 8px;
font-family: Inter;
font-size: 14px;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
color: var(--l1-foreground);
outline: none;
height: 32px;
&::placeholder {
color: var(--l3-foreground);
}
&:focus {
border-color: var(--input);
}
}
}
}
.roles-description-tooltip {

View File

@@ -22,12 +22,14 @@ function RolesSettings(): JSX.Element {
</div>
<div className="roles-settings-content">
<div className="roles-settings-toolbar">
<Input
type="search"
placeholder="Search for roles..."
value={searchQuery}
onChange={(e): void => setSearchQuery(e.target.value)}
/>
<div className="roles-search-wrapper">
<Input
type="search"
placeholder="Search for roles..."
value={searchQuery}
onChange={(e): void => setSearchQuery(e.target.value)}
/>
</div>
{IS_ROLE_DETAILS_AND_CRUD_ENABLED && (
<Button
variant="solid"

View File

@@ -7,13 +7,6 @@ import {
GetIntegrationStatusProps,
} from 'types/api/integrations/types';
export function isOneClickIntegration(integrationId: string): boolean {
return (
integrationId === INTEGRATION_TYPES.AWS ||
integrationId === INTEGRATION_TYPES.AZURE
);
}
export const useGetIntegrationStatus = ({
integrationId,
}: GetIntegrationPayloadProps): UseQueryResult<
@@ -27,5 +20,5 @@ export const useGetIntegrationStatus = ({
enabled:
!!integrationId &&
integrationId !== '' &&
!isOneClickIntegration(integrationId),
integrationId !== INTEGRATION_TYPES.AWS,
});

View File

@@ -20,11 +20,11 @@ import {
CloudintegrationtypesCredentialsDTO,
CloudintegrationtypesPostableAccountDTO,
} from 'api/generated/services/sigNoz.schemas';
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
import {
ActiveViewEnum,
ModalStateEnum,
} from 'container/Integrations/HeroSection/types';
} from 'container/Integrations/CloudIntegration/AmazonWebServices/HeroSection/types';
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
import useAxiosError from 'hooks/useAxiosError';
import { regions } from 'utils/regions';

View File

@@ -1,142 +0,0 @@
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { toast } from '@signozhq/ui';
import { Form } from 'antd';
import { FormInstance } from 'antd/lib';
import { useUpdateAccount } from 'api/generated/services/cloudintegration';
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
import { CloudAccount } from 'container/Integrations/types';
import { isEqual } from 'lodash-es';
import logEvent from '../../../api/common/logEvent';
interface UseAccountSettingsModalProps {
onClose: () => void;
account: CloudAccount;
setActiveAccount: Dispatch<SetStateAction<CloudAccount | null>>;
}
interface UseAccountSettingsModal {
form: FormInstance;
isLoading: boolean;
resourceGroups: string[];
isSaveDisabled: boolean;
setResourceGroups: Dispatch<SetStateAction<string[]>>;
handleSubmit: () => Promise<void>;
handleClose: () => void;
}
export function useAccountSettingsModal({
onClose,
account,
setActiveAccount,
}: UseAccountSettingsModalProps): UseAccountSettingsModal {
const [form] = Form.useForm();
const { mutate: updateAccount, isLoading } = useUpdateAccount();
const accountConfig = useMemo(
() => ('deployment_region' in account.config ? account.config : null),
[account.config],
);
const [resourceGroups, setResourceGroups] = useState<string[]>(
accountConfig?.resource_groups || [],
);
useEffect(() => {
if (!accountConfig) {
return;
}
form.setFieldsValue({
region: accountConfig.deployment_region,
resourceGroups: accountConfig.resource_groups,
});
setResourceGroups(accountConfig.resource_groups);
}, [accountConfig, form]);
const handleSubmit = useCallback(async (): Promise<void> => {
try {
const values = await form.validateFields();
updateAccount(
{
pathParams: {
cloudProvider: INTEGRATION_TYPES.AZURE,
id: account?.id || '',
},
data: {
config: {
azure: {
resourceGroups: values.resourceGroups || [],
},
},
},
},
{
onSuccess: () => {
const nextConfig = {
deployment_region: accountConfig?.deployment_region || '',
resource_groups: values.resourceGroups || [],
};
setActiveAccount({
...account,
config: nextConfig,
});
onClose();
toast.success('Account settings updated successfully', {
position: 'bottom-right',
});
logEvent('Azure Integration: Account settings updated', {
cloudAccountId: account.cloud_account_id,
deploymentRegion: nextConfig.deployment_region,
resourceGroups: nextConfig.resource_groups,
});
},
onError: (error) => {
toast.error('Failed to update account settings', {
description: error?.message,
position: 'bottom-right',
});
},
},
);
} catch (error) {
console.error('Form submission failed:', error);
}
}, [form, updateAccount, account, setActiveAccount, onClose]);
const isSaveDisabled = useMemo(() => {
if (!accountConfig) {
return true;
}
const formResourceGroups = resourceGroups || [];
return isEqual(
[...formResourceGroups].sort(),
[...accountConfig.resource_groups].sort(),
);
}, [accountConfig, resourceGroups, form]);
const handleClose = useCallback(() => {
onClose();
}, [onClose]);
return {
form,
isLoading,
resourceGroups,
isSaveDisabled,
setResourceGroups,
handleSubmit,
handleClose,
};
}

View File

@@ -1,188 +0,0 @@
import { Dispatch, SetStateAction, useCallback, useState } from 'react';
import { useQueryClient } from 'react-query';
import { toast } from '@signozhq/ui';
import { Form, FormInstance } from 'antd';
import {
CreateAccountMutationResult,
GetConnectionCredentialsQueryResult,
invalidateListAccounts,
useCreateAccount,
useGetConnectionCredentials,
} from 'api/generated/services/cloudintegration';
import {
CloudintegrationtypesCredentialsDTO,
CloudintegrationtypesPostableAccountDTO,
} from 'api/generated/services/sigNoz.schemas';
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
import { ModalStateEnum } from 'container/Integrations/HeroSection/types';
import useAxiosError from 'hooks/useAxiosError';
import logEvent from '../../../api/common/logEvent';
interface UseIntegrationModalProps {
onClose: () => void;
}
interface UseAzureIntegrationModal {
form: FormInstance;
modalState: ModalStateEnum;
isLoading: boolean;
accountId?: string;
connectionCommands: {
cliCommand: string;
cloudPowerShellCommand: string;
} | null;
setModalState: Dispatch<SetStateAction<ModalStateEnum>>;
handleSubmit: () => Promise<void>;
handleClose: () => void;
connectionParams?: CloudintegrationtypesCredentialsDTO;
isConnectionParamsLoading: boolean;
handleConnectionSuccess: (payload: {
cloudAccountId: string;
status?: unknown;
}) => void;
handleConnectionTimeout: (payload: { id?: string }) => void;
handleConnectionError: () => void;
}
export function useIntegrationModal({
onClose,
}: UseIntegrationModalProps): UseAzureIntegrationModal {
const queryClient = useQueryClient();
const [form] = Form.useForm();
const [modalState, setModalState] = useState<ModalStateEnum>(
ModalStateEnum.FORM,
);
const [isLoading, setIsLoading] = useState(false);
const [accountId, setAccountId] = useState<string | undefined>(undefined);
const [connectionCommands, setConnectionCommands] = useState<{
cliCommand: string;
cloudPowerShellCommand: string;
} | null>(null);
const handleClose = useCallback((): void => {
setModalState(ModalStateEnum.FORM);
setConnectionCommands(null);
onClose();
}, [onClose]);
const handleConnectionSuccess = useCallback(
(payload: { cloudAccountId: string; status?: unknown }): void => {
logEvent('Azure Integration: Account connected', {
cloudAccountId: payload.cloudAccountId,
status: payload.status,
});
toast.success('Azure account connected successfully', {
position: 'bottom-right',
});
void invalidateListAccounts(queryClient, {
cloudProvider: INTEGRATION_TYPES.AZURE,
});
handleClose();
},
[handleClose, queryClient],
);
const handleConnectionTimeout = useCallback(
(payload: { id?: string }): void => {
setModalState(ModalStateEnum.ERROR);
logEvent('Azure Integration: Account connection attempt timed out', {
id: payload.id,
});
},
[],
);
const handleConnectionError = useCallback((): void => {
setModalState(ModalStateEnum.ERROR);
}, []);
const { mutate: createAccount } = useCreateAccount();
const handleError = useAxiosError();
const { data: connectionParams, isLoading: isConnectionParamsLoading } =
useGetConnectionCredentials<GetConnectionCredentialsQueryResult>(
{
cloudProvider: INTEGRATION_TYPES.AZURE,
},
{
query: {
onError: handleError,
},
},
);
const handleSubmit = useCallback(async (): Promise<void> => {
try {
setIsLoading(true);
const values = await form.validateFields();
const payload: CloudintegrationtypesPostableAccountDTO = {
config: {
azure: {
deploymentRegion: values.region,
resourceGroups: values.resourceGroups || [],
},
},
credentials: {
ingestionUrl: connectionParams?.data?.ingestionUrl || values.ingestionUrl,
ingestionKey: connectionParams?.data?.ingestionKey || values.ingestionKey,
sigNozApiUrl: connectionParams?.data?.sigNozApiUrl || values.sigNozApiUrl,
sigNozApiKey: connectionParams?.data?.sigNozApiKey || values.sigNozApiKey,
},
};
createAccount(
{
pathParams: { cloudProvider: INTEGRATION_TYPES.AZURE },
data: payload,
},
{
onSuccess: (response: CreateAccountMutationResult) => {
const nextAccountId = response.data.id;
const artifact = response.data.connectionArtifact.azure;
logEvent('Azure Integration: Account connection commands generated', {
id: nextAccountId,
});
setConnectionCommands({
cliCommand: artifact?.cliCommand || '',
cloudPowerShellCommand: artifact?.cloudPowerShellCommand || '',
});
setModalState(ModalStateEnum.WAITING);
setAccountId(nextAccountId);
},
onError: () => {
setModalState(ModalStateEnum.ERROR);
toast.error('Failed to create account connection', {
position: 'bottom-right',
});
},
},
);
} catch (error) {
console.error('Form submission failed:', error);
} finally {
setIsLoading(false);
}
}, [form, connectionParams, createAccount]);
return {
form,
modalState,
isLoading,
accountId,
connectionCommands,
setModalState,
handleSubmit,
handleClose,
connectionParams: connectionParams?.data as
| CloudintegrationtypesCredentialsDTO
| undefined,
isConnectionParamsLoading,
handleConnectionSuccess,
handleConnectionTimeout,
handleConnectionError,
};
}

View File

@@ -4,7 +4,7 @@
gap: 8px;
padding: 8px;
cursor: pointer;
color: var(--muted-foreground);
color: var(--foreground);
font-family: Inter;
font-size: var(--font-size-sm);
font-weight: 600;
@@ -20,10 +20,13 @@
overflow: hidden;
text-overflow: ellipsis;
&:hover,
&:hover {
background-color: var(--l1-background);
}
&:focus {
outline: none;
background-color: var(--l2-background-hover);
background-color: var(--l1-background);
}
&.disabled {
@@ -44,8 +47,7 @@
}
&:hover {
background-color: var(--danger-background);
color: var(--l1-foreground);
background-color: var(--bg-cherry-100);
}
}
@@ -72,24 +74,73 @@
}
.context-menu-header {
padding: 8px 12px;
border-bottom: 1px solid var(--l2-border);
padding-bottom: 4px;
border-bottom: 1px solid var(--l1-border);
color: var(--muted-foreground);
}
// Target the popover inner specifically for context menu
.context-menu .ant-popover-inner {
padding: 0;
border-radius: 6px;
max-width: 300px;
background: var(--l2-background) !important;
border: 1px solid var(--l2-border) !important;
padding: 12px 8px !important;
// max-height: 254px !important;
max-width: 300px !important;
}
// Dark mode support
.darkMode {
.context-menu-item {
color: var(--muted-foreground);
&:hover,
&:focus {
background-color: var(--l2-background);
}
&.danger {
color: var(--bg-cherry-400);
.icon {
color: var(--bg-cherry-400);
}
&:hover {
background-color: var(--danger-background);
color: var(--l1-foreground);
}
}
.icon {
color: var(--bg-robin-400);
}
}
.context-menu-header {
border-bottom: 1px solid var(--l1-border);
color: var(--muted-foreground);
}
// Set the menu popover background
.context-menu .ant-popover-inner {
background: var(--l1-background) !important;
border: 1px solid var(--border) !important;
}
}
// Context menu backdrop overlay
.context-menu-backdrop {
position: fixed;
inset: 0;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 9999;
background: transparent;
cursor: default;
// Prevent any pointer events from reaching elements behind
pointer-events: auto;
// Ensure it covers the entire viewport including any scrollable areas
position: fixed !important;
inset: 0;
}

View File

@@ -720,10 +720,6 @@ notifications - 2050
animation: spin 1s linear infinite;
}
.animate-fast-spin {
animation: spin 0.5s linear infinite;
}
// Custom legend tooltip for immediate display
.legend-tooltip {
position: fixed;

View File

@@ -5586,10 +5586,10 @@
tailwind-merge "^2.5.2"
tailwindcss-animate "^1.0.7"
"@signozhq/ui@0.0.12":
version "0.0.12"
resolved "https://registry.yarnpkg.com/@signozhq/ui/-/ui-0.0.12.tgz#b623c1729a0d85532d555fe7e756f3a4207e8e5d"
integrity sha512-69XS/j9R+uTNMdupyjki/WK1j0d5K5j0/pJrINGiteQRRrPg/AOMue7v/W6dkLICRhXcz/mgI6tLeT2FAuzKFw==
"@signozhq/ui@0.0.10":
version "0.0.10"
resolved "https://registry.yarnpkg.com/@signozhq/ui/-/ui-0.0.10.tgz#cdbab838f8cb543cf5b483a86e9d9b65265b81ff"
integrity sha512-XLeET+PgSP7heqKMsb9YZOSRT3TpfMPHNQRnY1I4SK8mXSct7BYWwK0Q3Je0uf4Z3aWOcpRYoRUPHWZQBpweFQ==
dependencies:
"@chenglou/pretext" "^0.0.5"
"@radix-ui/react-checkbox" "^1.2.3"
@@ -5611,7 +5611,7 @@
clsx "^2.1.1"
cmdk "^1.1.1"
dayjs "^1.11.10"
lodash-es "^4.18.1"
lodash-es "^4.17.21"
motion "^11.11.17"
next-themes "^0.4.6"
nuqs "^2.8.9"
@@ -13291,11 +13291,6 @@ lodash-es@4, lodash-es@^4.17.21:
resolved "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz"
integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==
lodash-es@^4.18.1:
version "4.18.1"
resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.18.1.tgz#b962eeb80d9d983a900bf342961fb7418ca10b1d"
integrity sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==
lodash.camelcase@^4.3.0:
version "4.3.0"
resolved "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz"

View File

@@ -48,24 +48,5 @@ func (provider *provider) addInfraMonitoringRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/infra_monitoring/nodes", handler.New(
provider.authZ.ViewAccess(provider.infraMonitoringHandler.ListNodes),
handler.OpenAPIDef{
ID: "ListNodes",
Tags: []string{"inframonitoring"},
Summary: "List Nodes for Infra Monitoring",
Description: "Returns a paginated list of Kubernetes nodes with key metrics: CPU usage, CPU allocatable, memory working set, memory allocatable, and per-group readyNodesCount / notReadyNodesCount derived from each node's latest k8s.node.condition_ready value in the window. Each node includes metadata attributes (k8s.node.uid, k8s.cluster.name). The response type is 'list' for the default k8s.node.name grouping (each row is one node with its current condition string: ready / not_ready / '') or 'grouped_list' for custom groupBy keys (each row aggregates nodes in the group with readyNodesCount and notReadyNodesCount; condition stays empty). Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_allocatable / memory / memory_allocatable, and pagination via offset/limit. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (nodeCPU, nodeCPUAllocatable, nodeMemory, nodeMemoryAllocatable) return -1 as a sentinel when no data is available for that field.",
Request: new(inframonitoringtypes.PostableNodes),
RequestContentType: "application/json",
Response: new(inframonitoringtypes.Nodes),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -25,6 +25,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/modules/session"
"github.com/SigNoz/signoz/pkg/modules/systemdashboard"
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
"github.com/SigNoz/signoz/pkg/modules/user"
@@ -51,6 +52,7 @@ type provider struct {
flaggerHandler flagger.Handler
dashboardModule dashboard.Module
dashboardHandler dashboard.Handler
systemDashboardHandler systemdashboard.Handler
metricsExplorerHandler metricsexplorer.Handler
infraMonitoringHandler inframonitoring.Handler
gatewayHandler gateway.Handler
@@ -82,6 +84,7 @@ func NewFactory(
flaggerHandler flagger.Handler,
dashboardModule dashboard.Module,
dashboardHandler dashboard.Handler,
systemDashboardHandler systemdashboard.Handler,
metricsExplorerHandler metricsexplorer.Handler,
infraMonitoringHandler inframonitoring.Handler,
gatewayHandler gateway.Handler,
@@ -116,6 +119,7 @@ func NewFactory(
flaggerHandler,
dashboardModule,
dashboardHandler,
systemDashboardHandler,
metricsExplorerHandler,
infraMonitoringHandler,
gatewayHandler,
@@ -152,6 +156,7 @@ func newProvider(
flaggerHandler flagger.Handler,
dashboardModule dashboard.Module,
dashboardHandler dashboard.Handler,
systemDashboardHandler systemdashboard.Handler,
metricsExplorerHandler metricsexplorer.Handler,
infraMonitoringHandler inframonitoring.Handler,
gatewayHandler gateway.Handler,
@@ -186,6 +191,7 @@ func newProvider(
flaggerHandler: flaggerHandler,
dashboardModule: dashboardModule,
dashboardHandler: dashboardHandler,
systemDashboardHandler: systemDashboardHandler,
metricsExplorerHandler: metricsExplorerHandler,
infraMonitoringHandler: infraMonitoringHandler,
gatewayHandler: gatewayHandler,
@@ -254,6 +260,10 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
return err
}
if err := provider.addSystemDashboardRoutes(router); err != nil {
return err
}
if err := provider.addMetricsExplorerRoutes(router); err != nil {
return err
}

View File

@@ -0,0 +1,66 @@
package signozapiserver
import (
"net/http"
"github.com/gorilla/mux"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
)
func (provider *provider) addSystemDashboardRoutes(router *mux.Router) error {
if err := router.Handle("/api/v1/system/{source}", handler.New(provider.authZ.ViewAccess(provider.systemDashboardHandler.Get), handler.OpenAPIDef{
ID: "GetSystemDashboard",
Tags: []string{"systemdashboard"},
Summary: "Get system dashboard",
Description: "This endpoint returns the system dashboard for the callers org keyed by source (e.g. ai-o11y-overview).",
Request: nil,
RequestContentType: "",
Response: new(dashboardtypes.GettableDashboard),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/system/{source}", handler.New(provider.authZ.EditAccess(provider.systemDashboardHandler.Update), handler.OpenAPIDef{
ID: "UpdateSystemDashboard",
Tags: []string{"systemdashboard"},
Summary: "Update system dashboard",
Description: "This endpoint replaces the system dashboard for the callers org with the provided payload.",
Request: new(dashboardtypes.UpdatableDashboard),
RequestContentType: "application/json",
Response: new(dashboardtypes.GettableDashboard),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/system/{source}/reset", handler.New(provider.authZ.EditAccess(provider.systemDashboardHandler.Reset), handler.OpenAPIDef{
ID: "ResetSystemDashboard",
Tags: []string{"systemdashboard"},
Summary: "Reset system dashboard to defaults",
Description: "This resets edited/updated system dashboard to default system dashboard.",
Request: nil,
RequestContentType: "",
Response: new(dashboardtypes.GettableDashboard),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -22,7 +22,7 @@ func newConfig() factory.Config {
Agent: AgentConfig{
// we will maintain the latest version of cloud integration agent from here,
// till we automate it externally or figure out a way to validate it.
Version: "v0.0.10",
Version: "v0.0.9",
},
}
}

View File

@@ -38,7 +38,7 @@ func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, an
}
func (module *module) Create(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, postableDashboard dashboardtypes.PostableDashboard) (*dashboardtypes.Dashboard, error) {
dashboard, err := dashboardtypes.NewDashboard(orgID, createdBy, postableDashboard)
dashboard, err := dashboardtypes.NewDashboard(orgID, createdBy, postableDashboard, "")
if err != nil {
return nil, err
}

View File

@@ -21,7 +21,7 @@ func NewStore(sqlstore sqlstore.SQLStore) dashboardtypes.Store {
func (store *store) Create(ctx context.Context, storabledashboard *dashboardtypes.StorableDashboard) error {
_, err := store.
sqlstore.
BunDB().
BunDBCtx(ctx).
NewInsert().
Model(storabledashboard).
Exec(ctx)
@@ -55,6 +55,7 @@ func (store *store) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID)
Model(storableDashboard).
Where("id = ?", id).
Where("org_id = ?", orgID).
Where("source = ?", "").
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, errors.CodeNotFound, "dashboard with id %s doesn't exist", id)
@@ -63,6 +64,23 @@ func (store *store) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID)
return storableDashboard, nil
}
func (store *store) GetBySource(ctx context.Context, orgID valuer.UUID, source string) (*dashboardtypes.StorableDashboard, error) {
storableDashboard := new(dashboardtypes.StorableDashboard)
err := store.
sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(storableDashboard).
Where("org_id = ?", orgID).
Where("source = ?", source).
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, errors.CodeNotFound, "system dashboard with source %s doesn't exist", source)
}
return storableDashboard, nil
}
func (store *store) GetPublic(ctx context.Context, dashboardID string) (*dashboardtypes.StorablePublicDashboard, error) {
storable := new(dashboardtypes.StorablePublicDashboard)
err := store.
@@ -124,6 +142,7 @@ func (store *store) List(ctx context.Context, orgID valuer.UUID) ([]*dashboardty
NewSelect().
Model(&storableDashboards).
Where("org_id = ?", orgID).
Where("source = ?", "").
Scan(ctx)
if err != nil {
return nil, err
@@ -150,14 +169,16 @@ func (store *store) ListPublic(ctx context.Context, orgID valuer.UUID) ([]*dashb
return storable, nil
}
// Update works for user dashboards (Source = "") and system dashboards (Source = "ai-o11y-overview").
func (store *store) Update(ctx context.Context, orgID valuer.UUID, storableDashboard *dashboardtypes.StorableDashboard) error {
_, err := store.
sqlstore.
BunDB().
BunDBCtx(ctx).
NewUpdate().
Model(storableDashboard).
WherePK().
Where("org_id = ?", orgID).
Where("source = ?", storableDashboard.Source).
Exec(ctx)
if err != nil {
return store.sqlstore.WrapNotFoundErrf(err, errors.CodeNotFound, "dashboard with id %s doesn't exist", storableDashboard.ID)
@@ -189,6 +210,7 @@ func (store *store) Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUI
Model(new(dashboardtypes.StorableDashboard)).
Where("id = ?", id).
Where("org_id = ?", orgID).
Where("source = ?", "").
Exec(ctx)
if err != nil {
return store.sqlstore.WrapNotFoundErrf(err, errors.CodeNotFound, "dashboard with id %s doesn't exist", id)

View File

@@ -69,27 +69,3 @@ func (h *handler) ListPods(rw http.ResponseWriter, req *http.Request) {
render.Success(rw, http.StatusOK, result)
}
func (h *handler) ListNodes(rw http.ResponseWriter, req *http.Request) {
claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
var parsedReq inframonitoringtypes.PostableNodes
if err := binding.JSON.BindBody(req.Body, &parsedReq); err != nil {
render.Error(rw, err)
return
}
result, err := h.module.ListNodes(req.Context(), orgID, &parsedReq)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, result)
}

View File

@@ -23,9 +23,3 @@ type podPhaseCounts struct {
Failed int
Unknown int
}
// nodeConditionCounts holds per-group node counts bucketed by latest condition_ready in window.
type nodeConditionCounts struct {
Ready int
NotReady int
}

View File

@@ -242,86 +242,3 @@ func (m *module) ListPods(ctx context.Context, orgID valuer.UUID, req *inframoni
return resp, nil
}
func (m *module) ListNodes(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableNodes) (*inframonitoringtypes.Nodes, error) {
if err := req.Validate(); err != nil {
return nil, err
}
resp := &inframonitoringtypes.Nodes{}
if req.OrderBy == nil {
req.OrderBy = &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: inframonitoringtypes.NodesOrderByCPU,
},
},
Direction: qbtypes.OrderDirectionDesc,
}
}
if len(req.GroupBy) == 0 {
req.GroupBy = []qbtypes.GroupByKey{nodeNameGroupByKey}
resp.Type = inframonitoringtypes.ResponseTypeList
} else {
resp.Type = inframonitoringtypes.ResponseTypeGroupedList
}
missingMetrics, minFirstReportedUnixMilli, err := m.getMetricsExistenceAndEarliestTime(ctx, nodesTableMetricNamesList)
if err != nil {
return nil, err
}
if len(missingMetrics) > 0 {
resp.RequiredMetricsCheck = inframonitoringtypes.RequiredMetricsCheck{MissingMetrics: missingMetrics}
resp.Records = []inframonitoringtypes.NodeRecord{}
resp.Total = 0
return resp, nil
}
if req.End < int64(minFirstReportedUnixMilli) {
resp.EndTimeBeforeRetention = true
resp.Records = []inframonitoringtypes.NodeRecord{}
resp.Total = 0
return resp, nil
}
resp.RequiredMetricsCheck = inframonitoringtypes.RequiredMetricsCheck{MissingMetrics: []string{}}
metadataMap, err := m.getNodesTableMetadata(ctx, req)
if err != nil {
return nil, err
}
resp.Total = len(metadataMap)
pageGroups, err := m.getTopNodeGroups(ctx, orgID, req, metadataMap)
if err != nil {
return nil, err
}
if len(pageGroups) == 0 {
resp.Records = []inframonitoringtypes.NodeRecord{}
return resp, nil
}
filterExpr := ""
if req.Filter != nil {
filterExpr = req.Filter.Expression
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newNodesTableListQuery())
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
if err != nil {
return nil, err
}
conditionCounts, err := m.getPerGroupNodeConditionCounts(ctx, req, pageGroups)
if err != nil {
return nil, err
}
isNodeNameInGroupBy := isKeyInGroupByAttrs(req.GroupBy, nodeNameAttrKey)
resp.Records = buildNodeRecords(isNodeNameInGroupBy, queryResp, pageGroups, req.GroupBy, metadataMap, conditionCounts)
resp.Warning = queryResp.Warning
return resp, nil
}

View File

@@ -1,299 +0,0 @@
package implinframonitoring
import (
"context"
"fmt"
"slices"
"strings"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/huandu/go-sqlbuilder"
)
// buildNodeRecords assembles the page records. Condition counts come from
// conditionCounts in both modes. In list mode (isNodeNameInGroupBy=true) each
// group is one node, so exactly one count is 1; Condition is derived from
// which one. In grouped_list mode Condition stays NodeConditionNone.
func buildNodeRecords(
isNodeNameInGroupBy bool,
resp *qbtypes.QueryRangeResponse,
pageGroups []map[string]string,
groupBy []qbtypes.GroupByKey,
metadataMap map[string]map[string]string,
conditionCounts map[string]nodeConditionCounts,
) []inframonitoringtypes.NodeRecord {
metricsMap := parseFullQueryResponse(resp, groupBy)
records := make([]inframonitoringtypes.NodeRecord, 0, len(pageGroups))
for _, labels := range pageGroups {
compositeKey := compositeKeyFromLabels(labels, groupBy)
nodeName := labels[nodeNameAttrKey]
record := inframonitoringtypes.NodeRecord{ // initialize with default values
NodeName: nodeName,
Condition: inframonitoringtypes.NodeConditionNone,
NodeCPU: -1,
NodeCPUAllocatable: -1,
NodeMemory: -1,
NodeMemoryAllocatable: -1,
Meta: map[string]any{},
}
if metrics, ok := metricsMap[compositeKey]; ok {
if v, exists := metrics["A"]; exists {
record.NodeCPU = v
}
if v, exists := metrics["B"]; exists {
record.NodeCPUAllocatable = v
}
if v, exists := metrics["C"]; exists {
record.NodeMemory = v
}
if v, exists := metrics["D"]; exists {
record.NodeMemoryAllocatable = v
}
}
if conditionCountsForGroup, ok := conditionCounts[compositeKey]; ok {
record.ReadyNodesCount = conditionCountsForGroup.Ready
record.NotReadyNodesCount = conditionCountsForGroup.NotReady
// In list mode each group is one node; the count==1 bucket identifies the condition.
if isNodeNameInGroupBy {
switch {
case conditionCountsForGroup.Ready == 1:
record.Condition = inframonitoringtypes.NodeConditionReady
case conditionCountsForGroup.NotReady == 1:
record.Condition = inframonitoringtypes.NodeConditionNotReady
}
}
}
if attrs, ok := metadataMap[compositeKey]; ok {
for k, v := range attrs {
record.Meta[k] = v
}
}
records = append(records, record)
}
return records
}
func (m *module) getTopNodeGroups(
ctx context.Context,
orgID valuer.UUID,
req *inframonitoringtypes.PostableNodes,
metadataMap map[string]map[string]string,
) ([]map[string]string, error) {
orderByKey := req.OrderBy.Key.Name
queryNamesForOrderBy := orderByToNodesQueryNames[orderByKey]
rankingQueryName := queryNamesForOrderBy[len(queryNamesForOrderBy)-1]
topReq := &qbtypes.QueryRangeRequest{
Start: uint64(req.Start),
End: uint64(req.End),
RequestType: qbtypes.RequestTypeScalar,
CompositeQuery: qbtypes.CompositeQuery{
Queries: make([]qbtypes.QueryEnvelope, 0, len(queryNamesForOrderBy)),
},
}
for _, envelope := range m.newNodesTableListQuery().CompositeQuery.Queries {
if !slices.Contains(queryNamesForOrderBy, envelope.GetQueryName()) {
continue
}
copied := envelope
if copied.Type == qbtypes.QueryTypeBuilder {
existingExpr := ""
if f := copied.GetFilter(); f != nil {
existingExpr = f.Expression
}
reqFilterExpr := ""
if req.Filter != nil {
reqFilterExpr = req.Filter.Expression
}
merged := mergeFilterExpressions(existingExpr, reqFilterExpr)
copied.SetFilter(&qbtypes.Filter{Expression: merged})
copied.SetGroupBy(req.GroupBy)
}
topReq.CompositeQuery.Queries = append(topReq.CompositeQuery.Queries, copied)
}
resp, err := m.querier.QueryRange(ctx, orgID, topReq)
if err != nil {
return nil, err
}
allMetricGroups := parseAndSortGroups(resp, rankingQueryName, req.GroupBy, req.OrderBy.Direction)
return paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit), nil
}
func (m *module) getNodesTableMetadata(ctx context.Context, req *inframonitoringtypes.PostableNodes) (map[string]map[string]string, error) {
var nonGroupByAttrs []string
for _, key := range nodeAttrKeysForMetadata {
if !isKeyInGroupByAttrs(req.GroupBy, key) {
nonGroupByAttrs = append(nonGroupByAttrs, key)
}
}
return m.getMetadata(ctx, nodesTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
}
// getPerGroupNodeConditionCounts computes per-group node counts bucketed by each
// node's latest condition_ready value (0 / 1) in the requested window.
// Pipeline:
//
// timeSeriesFPs: fp ↔ (node_name, groupBy cols) from the time_series table.
// User filter + page-groups filter applied here.
// latestConditionPerNode: INNER JOIN samples × timeSeriesFPs, collapsed to
// the latest condition value per node via argMax(value, unix_milli).
// countNodesPerCondition: per-group uniqExactIf into ready/not_ready buckets.
//
// Groups absent from the result map have implicit zero counts (caller default).
func (m *module) getPerGroupNodeConditionCounts(
ctx context.Context,
req *inframonitoringtypes.PostableNodes,
pageGroups []map[string]string,
) (map[string]nodeConditionCounts, error) {
if len(pageGroups) == 0 || len(req.GroupBy) == 0 {
return map[string]nodeConditionCounts{}, nil
}
// Merged filter expression (user filter + page-groups IN clauses).
reqFilterExpr := ""
if req.Filter != nil {
reqFilterExpr = req.Filter.Expression
}
pageGroupsFilterExpr := buildPageGroupsFilterExpr(pageGroups)
filterExpr := mergeFilterExpressions(reqFilterExpr, pageGroupsFilterExpr)
// Resolve tables. Same convention as pods.
adjustedStart, adjustedEnd, _, localTimeSeriesTable := telemetrymetrics.WhichTSTableToUse(
uint64(req.Start), uint64(req.End), nil,
)
samplesTable := telemetrymetrics.WhichSamplesTableToUse(
uint64(req.Start), uint64(req.End),
metrictypes.UnspecifiedType, metrictypes.TimeAggregationUnspecified, nil,
)
valueCol := telemetrymetrics.ValueColumnForSamplesTable(samplesTable)
// ----- timeSeriesFPs -----
timeSeriesFPs := sqlbuilder.NewSelectBuilder()
timeSeriesFPsSelectCols := []string{
"fingerprint",
fmt.Sprintf("JSONExtractString(labels, %s) AS node_name", timeSeriesFPs.Var(nodeNameAttrKey)),
}
for _, key := range req.GroupBy {
timeSeriesFPsSelectCols = append(timeSeriesFPsSelectCols,
fmt.Sprintf("JSONExtractString(labels, %s) AS %s", timeSeriesFPs.Var(key.Name), quoteIdentifier(key.Name)),
)
}
timeSeriesFPs.Select(timeSeriesFPsSelectCols...)
timeSeriesFPs.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, localTimeSeriesTable))
timeSeriesFPs.Where(
timeSeriesFPs.E("metric_name", nodeConditionMetricName),
timeSeriesFPs.GE("unix_milli", adjustedStart),
timeSeriesFPs.L("unix_milli", adjustedEnd),
)
if filterExpr != "" {
filterClause, err := m.buildFilterClause(ctx, &qbtypes.Filter{Expression: filterExpr}, req.Start, req.End)
if err != nil {
return nil, err
}
if filterClause != nil {
timeSeriesFPs.AddWhereClause(filterClause)
}
}
timeSeriesFPsGroupBy := []string{"fingerprint", "node_name"}
for _, key := range req.GroupBy {
timeSeriesFPsGroupBy = append(timeSeriesFPsGroupBy, quoteIdentifier(key.Name))
}
timeSeriesFPs.GroupBy(timeSeriesFPsGroupBy...)
timeSeriesFPsSQL, timeSeriesFPsArgs := timeSeriesFPs.BuildWithFlavor(sqlbuilder.ClickHouse)
// ----- latestConditionPerNode -----
latestConditionPerNode := sqlbuilder.NewSelectBuilder()
latestConditionPerNodeSelectCols := []string{"tsfp.node_name AS node_name"}
latestConditionPerNodeGroupBy := []string{"node_name"}
for _, key := range req.GroupBy {
col := quoteIdentifier(key.Name)
latestConditionPerNodeSelectCols = append(latestConditionPerNodeSelectCols, fmt.Sprintf("tsfp.%s AS %s", col, col))
latestConditionPerNodeGroupBy = append(latestConditionPerNodeGroupBy, col)
}
latestConditionPerNodeSelectCols = append(latestConditionPerNodeSelectCols,
fmt.Sprintf("argMax(samples.%s, samples.unix_milli) AS condition_value", valueCol),
)
latestConditionPerNode.Select(latestConditionPerNodeSelectCols...)
latestConditionPerNode.From(fmt.Sprintf(
"%s.%s AS samples INNER JOIN time_series_fps AS tsfp ON samples.fingerprint = tsfp.fingerprint",
telemetrymetrics.DBName, samplesTable,
))
latestConditionPerNode.Where(
latestConditionPerNode.E("samples.metric_name", nodeConditionMetricName),
latestConditionPerNode.GE("samples.unix_milli", req.Start),
latestConditionPerNode.L("samples.unix_milli", req.End),
"tsfp.node_name != ''",
)
latestConditionPerNode.GroupBy(latestConditionPerNodeGroupBy...)
latestConditionPerNodeSQL, latestConditionPerNodeArgs := latestConditionPerNode.BuildWithFlavor(sqlbuilder.ClickHouse)
// ----- countNodesPerCondition (outer SELECT) -----
countNodesPerConditionSelectCols := make([]string, 0, len(req.GroupBy)+2)
countNodesPerConditionGroupBy := make([]string, 0, len(req.GroupBy))
for _, key := range req.GroupBy {
col := quoteIdentifier(key.Name)
countNodesPerConditionSelectCols = append(countNodesPerConditionSelectCols, col)
countNodesPerConditionGroupBy = append(countNodesPerConditionGroupBy, col)
}
countNodesPerConditionSelectCols = append(countNodesPerConditionSelectCols,
fmt.Sprintf("uniqExactIf(node_name, condition_value = %d) AS ready_count", inframonitoringtypes.NodeConditionNumReady),
fmt.Sprintf("uniqExactIf(node_name, condition_value = %d) AS not_ready_count", inframonitoringtypes.NodeConditionNumNotReady),
)
countNodesPerConditionSQL := fmt.Sprintf(
"SELECT %s FROM latest_condition_per_node GROUP BY %s",
strings.Join(countNodesPerConditionSelectCols, ", "),
strings.Join(countNodesPerConditionGroupBy, ", "),
)
// Combine CTEs + outer.
cteFragments := []string{
fmt.Sprintf("time_series_fps AS (%s)", timeSeriesFPsSQL),
fmt.Sprintf("latest_condition_per_node AS (%s)", latestConditionPerNodeSQL),
}
finalSQL := querybuilder.CombineCTEs(cteFragments) + countNodesPerConditionSQL
finalArgs := querybuilder.PrependArgs([][]any{timeSeriesFPsArgs, latestConditionPerNodeArgs}, nil)
rows, err := m.telemetryStore.ClickhouseDB().Query(ctx, finalSQL, finalArgs...)
if err != nil {
return nil, err
}
defer rows.Close()
result := make(map[string]nodeConditionCounts)
for rows.Next() {
groupVals := make([]string, len(req.GroupBy))
scanPtrs := make([]any, 0, len(req.GroupBy)+2)
for i := range groupVals {
scanPtrs = append(scanPtrs, &groupVals[i])
}
var ready, notReady uint64
scanPtrs = append(scanPtrs, &ready, &notReady)
if err := rows.Scan(scanPtrs...); err != nil {
return nil, err
}
result[compositeKeyFromList(groupVals)] = nodeConditionCounts{
Ready: int(ready),
NotReady: int(notReady),
}
}
if err := rows.Err(); err != nil {
return nil, err
}
return result, nil
}

View File

@@ -1,134 +0,0 @@
package implinframonitoring
import (
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
const (
nodeNameAttrKey = "k8s.node.name"
nodeConditionMetricName = "k8s.node.condition_ready"
)
var nodeNameGroupByKey = qbtypes.GroupByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: nodeNameAttrKey,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
}
// nodesTableMetricNamesList drives the existence/retention check.
// Includes condition_ready so the response short-circuits cleanly when a
// cluster doesn't ship the metric — even though condition_ready isn't part
// of the QB composite query (it's queried separately via getPerGroupNodeConditionCounts).
var nodesTableMetricNamesList = []string{
"k8s.node.cpu.usage",
"k8s.node.allocatable_cpu",
"k8s.node.memory.working_set",
"k8s.node.allocatable_memory",
"k8s.node.condition_ready",
}
var nodeAttrKeysForMetadata = []string{
"k8s.node.uid",
"k8s.cluster.name",
}
var orderByToNodesQueryNames = map[string][]string{
inframonitoringtypes.NodesOrderByCPU: {"A"},
inframonitoringtypes.NodesOrderByCPUAllocatable: {"B"},
inframonitoringtypes.NodesOrderByMemory: {"C"},
inframonitoringtypes.NodesOrderByMemoryAllocatable: {"D"},
}
// newNodesTableListQuery builds the composite QB v5 request for the nodes list.
// Node condition is derived separately via getPerGroupNodeConditionCounts (works
// for both list and grouped_list modes), so no condition query is included here.
func (m *module) newNodesTableListQuery() *qbtypes.QueryRangeRequest {
queries := []qbtypes.QueryEnvelope{
// Query A: CPU usage
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "A",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "k8s.node.cpu.usage",
TimeAggregation: metrictypes.TimeAggregationAvg,
SpaceAggregation: metrictypes.SpaceAggregationSum,
ReduceTo: qbtypes.ReduceToAvg,
},
},
GroupBy: []qbtypes.GroupByKey{nodeNameGroupByKey},
Disabled: false,
},
},
// Query B: CPU allocatable.
// TimeAggregationLatest is the closest v5 equivalent of v1's AnyLast;
// allocatable values change rarely so divergence in practice is negligible.
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "B",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "k8s.node.allocatable_cpu",
TimeAggregation: metrictypes.TimeAggregationLatest,
SpaceAggregation: metrictypes.SpaceAggregationSum,
ReduceTo: qbtypes.ReduceToAvg,
},
},
GroupBy: []qbtypes.GroupByKey{nodeNameGroupByKey},
Disabled: false,
},
},
// Query C: Memory working set
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "C",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "k8s.node.memory.working_set",
TimeAggregation: metrictypes.TimeAggregationAvg,
SpaceAggregation: metrictypes.SpaceAggregationSum,
ReduceTo: qbtypes.ReduceToAvg,
},
},
GroupBy: []qbtypes.GroupByKey{nodeNameGroupByKey},
Disabled: false,
},
},
// Query D: Memory allocatable. Same Latest caveat as Query B.
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "D",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "k8s.node.allocatable_memory",
TimeAggregation: metrictypes.TimeAggregationLatest,
SpaceAggregation: metrictypes.SpaceAggregationSum,
ReduceTo: qbtypes.ReduceToAvg,
},
},
GroupBy: []qbtypes.GroupByKey{nodeNameGroupByKey},
Disabled: false,
},
},
}
return &qbtypes.QueryRangeRequest{
RequestType: qbtypes.RequestTypeScalar,
CompositeQuery: qbtypes.CompositeQuery{
Queries: queries,
},
}
}

View File

@@ -11,11 +11,9 @@ import (
type Handler interface {
ListHosts(http.ResponseWriter, *http.Request)
ListPods(http.ResponseWriter, *http.Request)
ListNodes(http.ResponseWriter, *http.Request)
}
type Module interface {
ListHosts(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableHosts) (*inframonitoringtypes.Hosts, error)
ListPods(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostablePods) (*inframonitoringtypes.Pods, error)
ListNodes(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableNodes) (*inframonitoringtypes.Nodes, error)
}

View File

@@ -6,18 +6,20 @@ import (
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/quickfilter"
"github.com/SigNoz/signoz/pkg/modules/systemdashboard"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
)
type setter struct {
store types.OrganizationStore
alertmanager alertmanager.Alertmanager
quickfilter quickfilter.Module
store types.OrganizationStore
alertmanager alertmanager.Alertmanager
quickfilter quickfilter.Module
systemDashboard systemdashboard.Module
}
func NewSetter(store types.OrganizationStore, alertmanager alertmanager.Alertmanager, quickfilter quickfilter.Module) organization.Setter {
return &setter{store: store, alertmanager: alertmanager, quickfilter: quickfilter}
func NewSetter(store types.OrganizationStore, alertmanager alertmanager.Alertmanager, quickfilter quickfilter.Module, systemDashboard systemdashboard.Module) organization.Setter {
return &setter{store: store, alertmanager: alertmanager, quickfilter: quickfilter, systemDashboard: systemDashboard}
}
func (module *setter) Create(ctx context.Context, organization *types.Organization, createManagedRoles func(context.Context, valuer.UUID) error) error {
@@ -33,6 +35,10 @@ func (module *setter) Create(ctx context.Context, organization *types.Organizati
return err
}
if err := module.systemDashboard.SetDefaultConfig(ctx, organization.ID); err != nil {
return err
}
if err := createManagedRoles(ctx, organization.ID); err != nil {
return err
}

View File

@@ -0,0 +1,102 @@
package implsystemdashboard
import (
"context"
"encoding/json"
"net/http"
"time"
"github.com/gorilla/mux"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/systemdashboard"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type handler struct {
module systemdashboard.Module
}
func NewHandler(module systemdashboard.Module) systemdashboard.Handler {
return &handler{module: module}
}
func (handler *handler) Get(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
dashboard, err := handler.module.Get(ctx, valuer.MustNewUUID(claims.OrgID), parseSource(r))
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, dashboard)
}
func (handler *handler) Update(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
data := dashboardtypes.UpdatableDashboard{}
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
render.Error(rw, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid request body"))
return
}
if data == nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "data is required"))
return
}
dashboard, err := handler.module.Update(ctx, valuer.MustNewUUID(claims.OrgID), parseSource(r), &dashboardtypes.Dashboard{
Data: data,
UserAuditable: types.UserAuditable{UpdatedBy: claims.Email},
})
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, dashboard)
}
func (handler *handler) Reset(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
dashboard, err := handler.module.Reset(ctx, valuer.MustNewUUID(claims.OrgID), parseSource(r))
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, dashboard)
}
// parseSource reads the {source} path segment.
func parseSource(r *http.Request) dashboardtypes.Source {
return dashboardtypes.Source(mux.Vars(r)["source"])
}

Some files were not shown because too many files have changed in this diff Show More