mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-23 08:30:35 +01:00
Compare commits
5 Commits
tvats-dry-
...
feat/event
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abe24fb3c5 | ||
|
|
3369ed7172 | ||
|
|
a98b84c1cd | ||
|
|
4dda1e0ab5 | ||
|
|
749943abe4 |
69
.github/CODEOWNERS
vendored
69
.github/CODEOWNERS
vendored
@@ -199,3 +199,72 @@ go.mod @therealpandey
|
||||
## OpenAPI Schema - Generated
|
||||
/frontend/src/api/generated/services/ @therealpandey @vikrantgupta25 @srikanthccv
|
||||
/docs/api/openapi.yml @therealpandey @vikrantgupta25 @srikanthccv
|
||||
|
||||
## Logs
|
||||
/frontend/src/pages/Logs/ @SigNoz/events-frontend
|
||||
/frontend/src/pages/LogsExplorer/ @SigNoz/events-frontend
|
||||
/frontend/src/pages/LogsModulePage/ @SigNoz/events-frontend
|
||||
/frontend/src/pages/LogsSettings/ @SigNoz/events-frontend
|
||||
/frontend/src/pages/LiveLogs/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsExplorerChart/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsExplorerContext/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsExplorerList/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsExplorerTable/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsExplorerViews/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsFilters/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsSearchFilter/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsTable/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsAggregate/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsContextList/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsIndexToFields/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsLoading/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsPanelTable/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogControls/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogDetailedView/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogExplorerQuerySection/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogLiveTail/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LiveLogs/ @SigNoz/events-frontend
|
||||
/frontend/src/container/EmptyLogsSearch/ @SigNoz/events-frontend
|
||||
/frontend/src/container/NoLogs/ @SigNoz/events-frontend
|
||||
/frontend/src/components/Logs/ @SigNoz/events-frontend
|
||||
/frontend/src/components/LogDetail/ @SigNoz/events-frontend
|
||||
/frontend/src/components/LogsFormatOptionsMenu/ @SigNoz/events-frontend
|
||||
/frontend/src/hooks/logs/ @SigNoz/events-frontend
|
||||
|
||||
## Logs Pipelines
|
||||
/frontend/src/pages/Pipelines/ @SigNoz/events-frontend
|
||||
/frontend/src/container/PipelinePage/ @SigNoz/events-frontend
|
||||
|
||||
## Traces / Trace Explorer
|
||||
/frontend/src/pages/Trace/ @SigNoz/events-frontend
|
||||
/frontend/src/pages/TracesExplorer/ @SigNoz/events-frontend
|
||||
/frontend/src/pages/TracesModulePage/ @SigNoz/events-frontend
|
||||
/frontend/src/container/Trace/ @SigNoz/events-frontend
|
||||
/frontend/src/container/TracesExplorer/ @SigNoz/events-frontend
|
||||
/frontend/src/container/TracesTableComponent/ @SigNoz/events-frontend
|
||||
|
||||
## Trace Funnels
|
||||
/frontend/src/pages/TracesFunnels/ @SigNoz/events-frontend
|
||||
/frontend/src/pages/TracesFunnelDetails/ @SigNoz/events-frontend
|
||||
/frontend/src/hooks/TracesFunnels/ @SigNoz/events-frontend
|
||||
|
||||
## Trace Details
|
||||
/frontend/src/pages/TraceDetailsV3/ @SigNoz/events-frontend
|
||||
/frontend/src/pages/TraceDetailOldRedirect/ @SigNoz/events-frontend
|
||||
/frontend/src/hooks/trace/ @SigNoz/events-frontend
|
||||
|
||||
## Exceptions
|
||||
/frontend/src/pages/AllErrors/ @SigNoz/events-frontend
|
||||
/frontend/src/pages/ErrorDetails/ @SigNoz/events-frontend
|
||||
/frontend/src/container/AllError/ @SigNoz/events-frontend
|
||||
/frontend/src/container/ErrorDetails/ @SigNoz/events-frontend
|
||||
|
||||
## External APIs
|
||||
/frontend/src/pages/ApiMonitoring/ @SigNoz/events-frontend
|
||||
/frontend/src/container/ApiMonitoring/ @SigNoz/events-frontend
|
||||
|
||||
## Messaging Queues
|
||||
/frontend/src/pages/MessagingQueues/ @SigNoz/events-frontend
|
||||
/frontend/src/components/MessagingQueues/ @SigNoz/events-frontend
|
||||
/frontend/src/components/MessagingQueueHealthCheck/ @SigNoz/events-frontend
|
||||
/frontend/src/hooks/messagingQueue/ @SigNoz/events-frontend
|
||||
|
||||
@@ -659,6 +659,29 @@ components:
|
||||
refreshToken:
|
||||
type: string
|
||||
type: object
|
||||
AuthtypesPostableUser:
|
||||
properties:
|
||||
displayName:
|
||||
type: string
|
||||
email:
|
||||
type: string
|
||||
frontendBaseUrl:
|
||||
type: string
|
||||
userRoles:
|
||||
items:
|
||||
$ref: '#/components/schemas/AuthtypesPostableUserRole'
|
||||
type: array
|
||||
required:
|
||||
- email
|
||||
- userRoles
|
||||
type: object
|
||||
AuthtypesPostableUserRole:
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
type: object
|
||||
AuthtypesRelation:
|
||||
enum:
|
||||
- create
|
||||
@@ -5597,22 +5620,6 @@ components:
|
||||
nullable: true
|
||||
type: array
|
||||
type: object
|
||||
Querybuildertypesv5EstimateEntry:
|
||||
properties:
|
||||
database:
|
||||
type: string
|
||||
marks:
|
||||
format: int64
|
||||
type: integer
|
||||
parts:
|
||||
format: int64
|
||||
type: integer
|
||||
rows:
|
||||
format: int64
|
||||
type: integer
|
||||
table:
|
||||
type: string
|
||||
type: object
|
||||
Querybuildertypesv5ExecStats:
|
||||
description: Execution statistics for the query, including rows scanned, bytes
|
||||
scanned, and duration.
|
||||
@@ -5680,25 +5687,6 @@ components:
|
||||
- anomaly
|
||||
- fillzero
|
||||
type: string
|
||||
Querybuildertypesv5Granules:
|
||||
properties:
|
||||
initial:
|
||||
format: int64
|
||||
type: integer
|
||||
reads:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5MergeTreeRead'
|
||||
type: array
|
||||
selected:
|
||||
format: int64
|
||||
type: integer
|
||||
skipScore:
|
||||
format: double
|
||||
type: number
|
||||
skipped:
|
||||
format: int64
|
||||
type: integer
|
||||
type: object
|
||||
Querybuildertypesv5GroupByKey:
|
||||
properties:
|
||||
description:
|
||||
@@ -5721,31 +5709,6 @@ components:
|
||||
expression:
|
||||
type: string
|
||||
type: object
|
||||
Querybuildertypesv5IndexStep:
|
||||
properties:
|
||||
condition:
|
||||
type: string
|
||||
initialGranules:
|
||||
format: int64
|
||||
type: integer
|
||||
initialParts:
|
||||
format: int64
|
||||
type: integer
|
||||
keys:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
name:
|
||||
type: string
|
||||
selectedGranules:
|
||||
format: int64
|
||||
type: integer
|
||||
selectedParts:
|
||||
format: int64
|
||||
type: integer
|
||||
type:
|
||||
type: string
|
||||
type: object
|
||||
Querybuildertypesv5Label:
|
||||
properties:
|
||||
key:
|
||||
@@ -5769,16 +5732,6 @@ components:
|
||||
expression:
|
||||
type: string
|
||||
type: object
|
||||
Querybuildertypesv5MergeTreeRead:
|
||||
properties:
|
||||
steps:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5IndexStep'
|
||||
nullable: true
|
||||
type: array
|
||||
table:
|
||||
type: string
|
||||
type: object
|
||||
Querybuildertypesv5MetricAggregation:
|
||||
properties:
|
||||
comparisonSpaceAggregationParam:
|
||||
@@ -5823,20 +5776,6 @@ components:
|
||||
- asc
|
||||
- desc
|
||||
type: string
|
||||
Querybuildertypesv5PreviewStatement:
|
||||
properties:
|
||||
db.statement.args:
|
||||
items: {}
|
||||
type: array
|
||||
db.statement.query:
|
||||
type: string
|
||||
estimate:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5EstimateEntry'
|
||||
type: array
|
||||
granules:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Granules'
|
||||
type: object
|
||||
Querybuildertypesv5PromQuery:
|
||||
properties:
|
||||
disabled:
|
||||
@@ -6159,39 +6098,6 @@ components:
|
||||
type:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryType'
|
||||
type: object
|
||||
Querybuildertypesv5QueryPreview:
|
||||
properties:
|
||||
error: {}
|
||||
magnitudeScore:
|
||||
nullable: true
|
||||
type: number
|
||||
selectivityScore:
|
||||
nullable: true
|
||||
type: number
|
||||
statements:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5PreviewStatement'
|
||||
type: array
|
||||
valid:
|
||||
type: boolean
|
||||
warnings:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
type: object
|
||||
Querybuildertypesv5QueryRangePreviewResponse:
|
||||
description: Response from the v5 query range preview (dry-run) endpoint. For
|
||||
each query in the composite query, returns the underlying ClickHouse statement(s)
|
||||
it renders to without executing them (one per PromQL metric selector; exactly
|
||||
one for builder/ClickHouse/trace-operator queries), with the optional EXPLAIN
|
||||
ESTIMATE and granule analysis attached per statement when requested.
|
||||
properties:
|
||||
compositeQuery:
|
||||
additionalProperties:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryPreview'
|
||||
nullable: true
|
||||
type: object
|
||||
type: object
|
||||
Querybuildertypesv5QueryRangeRequest:
|
||||
description: Request body for the v5 query range endpoint. Supports builder
|
||||
queries (traces, logs, metrics), formulas, joins, trace operators, PromQL,
|
||||
@@ -10300,7 +10206,7 @@ paths:
|
||||
- global
|
||||
/api/v1/invite:
|
||||
post:
|
||||
deprecated: false
|
||||
deprecated: true
|
||||
description: This endpoint creates an invite for a user
|
||||
operationId: CreateInvite
|
||||
requestBody:
|
||||
@@ -10363,7 +10269,7 @@ paths:
|
||||
- users
|
||||
/api/v1/invite/bulk:
|
||||
post:
|
||||
deprecated: false
|
||||
deprecated: true
|
||||
description: This endpoint creates a bulk invite for a user
|
||||
operationId: CreateBulkInvite
|
||||
requestBody:
|
||||
@@ -13204,7 +13110,7 @@ paths:
|
||||
- tracedetail
|
||||
/api/v1/user:
|
||||
get:
|
||||
deprecated: false
|
||||
deprecated: true
|
||||
description: This endpoint lists all users
|
||||
operationId: ListUsersDeprecated
|
||||
responses:
|
||||
@@ -13297,7 +13203,7 @@ paths:
|
||||
tags:
|
||||
- users
|
||||
get:
|
||||
deprecated: false
|
||||
deprecated: true
|
||||
description: This endpoint returns the user by id
|
||||
operationId: GetUserDeprecated
|
||||
parameters:
|
||||
@@ -13354,7 +13260,7 @@ paths:
|
||||
tags:
|
||||
- users
|
||||
put:
|
||||
deprecated: false
|
||||
deprecated: true
|
||||
description: This endpoint updates the user by id
|
||||
operationId: UpdateUserDeprecated
|
||||
parameters:
|
||||
@@ -13423,7 +13329,7 @@ paths:
|
||||
- users
|
||||
/api/v1/user/me:
|
||||
get:
|
||||
deprecated: false
|
||||
deprecated: true
|
||||
description: This endpoint returns the user I belong to
|
||||
operationId: GetMyUserDeprecated
|
||||
responses:
|
||||
@@ -20839,6 +20745,68 @@ paths:
|
||||
summary: List users v2
|
||||
tags:
|
||||
- users
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint creates a user for the organization
|
||||
operationId: CreateUser
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AuthtypesPostableUser'
|
||||
responses:
|
||||
"201":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/TypesIdentifiable'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: Created
|
||||
"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
|
||||
"409":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Conflict
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Create user
|
||||
tags:
|
||||
- users
|
||||
/api/v2/users/{id}:
|
||||
get:
|
||||
deprecated: false
|
||||
@@ -22258,78 +22226,6 @@ paths:
|
||||
summary: Query range
|
||||
tags:
|
||||
- querier
|
||||
/api/v5/query_range/preview:
|
||||
post:
|
||||
deprecated: false
|
||||
description: 'Validate a composite query without executing it. Accepts the same
|
||||
payload as the query range endpoint. By default (verbose=true) returns, for
|
||||
each query, the rendered underlying ClickHouse statement(s) with each statement''s
|
||||
EXPLAIN ESTIMATE (per-table parts/rows/marks) and granule index analysis (candidate/surviving
|
||||
granules, skip score, and the per-index pruning funnel), plus two top-level
|
||||
scores: selectivityScore (0-100 granule-skip selectivity; higher is better)
|
||||
and magnitudeScore (0-100 absolute scan cost; higher is cheaper). Pass ?verbose=false
|
||||
for the lightweight per-query verdict (valid/error/warnings) with no rendered
|
||||
SQL and no ClickHouse round trips. Intended for agentic/dry-run consumption:
|
||||
per-query errors are reported in the response rather than failing the whole
|
||||
request.'
|
||||
operationId: QueryRangePreviewV5
|
||||
parameters:
|
||||
- in: query
|
||||
name: verbose
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryRangeRequest'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryRangePreviewResponse'
|
||||
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: Query range preview
|
||||
tags:
|
||||
- querier
|
||||
/api/v5/substitute_vars:
|
||||
post:
|
||||
deprecated: false
|
||||
|
||||
@@ -101,10 +101,6 @@ func (h *handler) QueryRange(rw http.ResponseWriter, req *http.Request) {
|
||||
h.community.QueryRange(rw, req)
|
||||
}
|
||||
|
||||
func (h *handler) QueryRangePreview(rw http.ResponseWriter, req *http.Request) {
|
||||
h.community.QueryRangePreview(rw, req)
|
||||
}
|
||||
|
||||
func (h *handler) QueryRawStream(rw http.ResponseWriter, req *http.Request) {
|
||||
h.community.QueryRawStream(rw, req)
|
||||
}
|
||||
|
||||
@@ -98,6 +98,15 @@ func (ah *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
|
||||
Route: "",
|
||||
})
|
||||
|
||||
aiObservability := ah.Signoz.Flagger.BooleanOrEmpty(ctx, flagger.FeatureEnableAIObservability, evalCtx)
|
||||
featureSet = append(featureSet, &licensetypes.Feature{
|
||||
Name: valuer.NewString(flagger.FeatureEnableAIObservability.String()),
|
||||
Active: aiObservability,
|
||||
Usage: 0,
|
||||
UsageLimit: -1,
|
||||
Route: "",
|
||||
})
|
||||
|
||||
if constants.IsDotMetricsEnabled {
|
||||
for idx, feature := range featureSet {
|
||||
if feature.Name == licensetypes.DotMetricsEnabled {
|
||||
|
||||
@@ -12,8 +12,6 @@ import type {
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
QueryRangePreviewV5200,
|
||||
QueryRangePreviewV5Params,
|
||||
QueryRangeV5200,
|
||||
Querybuildertypesv5QueryRangeRequestDTO,
|
||||
RenderErrorResponseDTO,
|
||||
@@ -106,107 +104,6 @@ export const useQueryRangeV5 = <
|
||||
> => {
|
||||
return useMutation(getQueryRangeV5MutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Validate a composite query without executing it. Accepts the same payload as the query range endpoint. By default (verbose=true) returns, for each query, the rendered underlying ClickHouse statement(s) with each statement's EXPLAIN ESTIMATE (per-table parts/rows/marks) and granule index analysis (candidate/surviving granules, skip score, and the per-index pruning funnel), plus two top-level scores: selectivityScore (0-100 granule-skip selectivity; higher is better) and magnitudeScore (0-100 absolute scan cost; higher is cheaper). Pass ?verbose=false for the lightweight per-query verdict (valid/error/warnings) with no rendered SQL and no ClickHouse round trips. Intended for agentic/dry-run consumption: per-query errors are reported in the response rather than failing the whole request.
|
||||
* @summary Query range preview
|
||||
*/
|
||||
export const queryRangePreviewV5 = (
|
||||
querybuildertypesv5QueryRangeRequestDTO?: BodyType<Querybuildertypesv5QueryRangeRequestDTO>,
|
||||
params?: QueryRangePreviewV5Params,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<QueryRangePreviewV5200>({
|
||||
url: `/api/v5/query_range/preview`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: querybuildertypesv5QueryRangeRequestDTO,
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getQueryRangePreviewV5MutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof queryRangePreviewV5>>,
|
||||
TError,
|
||||
{
|
||||
data?: BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
|
||||
params?: QueryRangePreviewV5Params;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof queryRangePreviewV5>>,
|
||||
TError,
|
||||
{
|
||||
data?: BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
|
||||
params?: QueryRangePreviewV5Params;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['queryRangePreviewV5'];
|
||||
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 queryRangePreviewV5>>,
|
||||
{
|
||||
data?: BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
|
||||
params?: QueryRangePreviewV5Params;
|
||||
}
|
||||
> = (props) => {
|
||||
const { data, params } = props ?? {};
|
||||
|
||||
return queryRangePreviewV5(data, params);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type QueryRangePreviewV5MutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof queryRangePreviewV5>>
|
||||
>;
|
||||
export type QueryRangePreviewV5MutationBody =
|
||||
| BodyType<Querybuildertypesv5QueryRangeRequestDTO>
|
||||
| undefined;
|
||||
export type QueryRangePreviewV5MutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Query range preview
|
||||
*/
|
||||
export const useQueryRangePreviewV5 = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof queryRangePreviewV5>>,
|
||||
TError,
|
||||
{
|
||||
data?: BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
|
||||
params?: QueryRangePreviewV5Params;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof queryRangePreviewV5>>,
|
||||
TError,
|
||||
{
|
||||
data?: BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
|
||||
params?: QueryRangePreviewV5Params;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getQueryRangePreviewV5MutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Replace variables in a query
|
||||
* @summary Replace variables
|
||||
|
||||
@@ -2258,6 +2258,32 @@ export interface AuthtypesPostableRotateTokenDTO {
|
||||
refreshToken?: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesPostableUserRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesPostableUserDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
displayName?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
email: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
frontendBaseUrl?: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
userRoles: AuthtypesPostableUserRoleDTO[];
|
||||
}
|
||||
|
||||
export interface AuthtypesRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -7130,32 +7156,6 @@ export interface Querybuildertypesv5ColumnDescriptorDTO {
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5EstimateEntryDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
database?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
marks?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
parts?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
rows?: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
table?: string;
|
||||
}
|
||||
|
||||
export type Querybuildertypesv5ExecStatsDTOStepIntervals = {
|
||||
[key: string]: number;
|
||||
};
|
||||
@@ -7196,99 +7196,6 @@ export interface Querybuildertypesv5FormatOptionsDTO {
|
||||
formatTableResultForUI?: boolean;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5IndexStepDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
condition?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
initialGranules?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
initialParts?: number;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
keys?: string[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
selectedGranules?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
selectedParts?: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5MergeTreeReadDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
steps?: Querybuildertypesv5IndexStepDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
table?: string;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5GranulesDTO {
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
initial?: number;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
reads?: Querybuildertypesv5MergeTreeReadDTO[];
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
selected?: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
skipScore?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
skipped?: number;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5PreviewStatementDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
'db.statement.args'?: unknown[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
'db.statement.query'?: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
estimate?: Querybuildertypesv5EstimateEntryDTO[];
|
||||
granules?: Querybuildertypesv5GranulesDTO;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5TimeSeriesDataDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
@@ -7370,49 +7277,6 @@ export type Querybuildertypesv5QueryDataDTO =
|
||||
results?: unknown[] | null;
|
||||
});
|
||||
|
||||
export interface Querybuildertypesv5QueryPreviewDTO {
|
||||
error?: unknown;
|
||||
/**
|
||||
* @type number,null
|
||||
*/
|
||||
magnitudeScore?: number | null;
|
||||
/**
|
||||
* @type number,null
|
||||
*/
|
||||
selectivityScore?: number | null;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
statements?: Querybuildertypesv5PreviewStatementDTO[];
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
valid?: boolean;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
export type Querybuildertypesv5QueryRangePreviewResponseDTOCompositeQueryAnyOf =
|
||||
{ [key: string]: Querybuildertypesv5QueryPreviewDTO };
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type Querybuildertypesv5QueryRangePreviewResponseDTOCompositeQuery =
|
||||
Querybuildertypesv5QueryRangePreviewResponseDTOCompositeQueryAnyOf | null;
|
||||
|
||||
/**
|
||||
* Response from the v5 query range preview (dry-run) endpoint. For each query in the composite query, returns the underlying ClickHouse statement(s) it renders to without executing them (one per PromQL metric selector; exactly one for builder/ClickHouse/trace-operator queries), with the optional EXPLAIN ESTIMATE and granule analysis attached per statement when requested.
|
||||
*/
|
||||
export interface Querybuildertypesv5QueryRangePreviewResponseDTO {
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
compositeQuery?: Querybuildertypesv5QueryRangePreviewResponseDTOCompositeQuery;
|
||||
}
|
||||
|
||||
export enum Querybuildertypesv5VariableTypeDTO {
|
||||
query = 'query',
|
||||
dynamic = 'dynamic',
|
||||
@@ -10969,6 +10833,14 @@ export type ListUsers200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateUser201 = {
|
||||
data: TypesIdentifiableDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetUserPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
@@ -11112,22 +10984,6 @@ export type QueryRangeV5200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type QueryRangePreviewV5Params = {
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
verbose?: string;
|
||||
};
|
||||
|
||||
export type QueryRangePreviewV5200 = {
|
||||
data: Querybuildertypesv5QueryRangePreviewResponseDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ReplaceVariables200 = {
|
||||
data: Querybuildertypesv5QueryRangeRequestDTO;
|
||||
/**
|
||||
|
||||
@@ -18,9 +18,11 @@ import type {
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
AuthtypesPostableUserDTO,
|
||||
CreateInvite201,
|
||||
CreateResetPasswordToken201,
|
||||
CreateResetPasswordTokenPathParameters,
|
||||
CreateUser201,
|
||||
DeleteUserPathParameters,
|
||||
GetMyUser200,
|
||||
GetMyUserDeprecated200,
|
||||
@@ -169,6 +171,7 @@ export const invalidateGetResetPasswordTokenDeprecated = async (
|
||||
|
||||
/**
|
||||
* This endpoint creates an invite for a user
|
||||
* @deprecated
|
||||
* @summary Create invite
|
||||
*/
|
||||
export const createInvite = (
|
||||
@@ -230,6 +233,7 @@ export type CreateInviteMutationBody =
|
||||
export type CreateInviteMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Create invite
|
||||
*/
|
||||
export const useCreateInvite = <
|
||||
@@ -252,6 +256,7 @@ export const useCreateInvite = <
|
||||
};
|
||||
/**
|
||||
* This endpoint creates a bulk invite for a user
|
||||
* @deprecated
|
||||
* @summary Create bulk invite
|
||||
*/
|
||||
export const createBulkInvite = (
|
||||
@@ -313,6 +318,7 @@ export type CreateBulkInviteMutationBody =
|
||||
export type CreateBulkInviteMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Create bulk invite
|
||||
*/
|
||||
export const useCreateBulkInvite = <
|
||||
@@ -418,6 +424,7 @@ export const useResetPassword = <
|
||||
};
|
||||
/**
|
||||
* This endpoint lists all users
|
||||
* @deprecated
|
||||
* @summary List users
|
||||
*/
|
||||
export const listUsersDeprecated = (signal?: AbortSignal) => {
|
||||
@@ -463,6 +470,7 @@ export type ListUsersDeprecatedQueryResult = NonNullable<
|
||||
export type ListUsersDeprecatedQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary List users
|
||||
*/
|
||||
|
||||
@@ -486,6 +494,7 @@ export function useListUsersDeprecated<
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary List users
|
||||
*/
|
||||
export const invalidateListUsersDeprecated = async (
|
||||
@@ -581,6 +590,7 @@ export const useDeleteUser = <
|
||||
};
|
||||
/**
|
||||
* This endpoint returns the user by id
|
||||
* @deprecated
|
||||
* @summary Get user
|
||||
*/
|
||||
export const getUserDeprecated = (
|
||||
@@ -640,6 +650,7 @@ export type GetUserDeprecatedQueryResult = NonNullable<
|
||||
export type GetUserDeprecatedQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Get user
|
||||
*/
|
||||
|
||||
@@ -666,6 +677,7 @@ export function useGetUserDeprecated<
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Get user
|
||||
*/
|
||||
export const invalidateGetUserDeprecated = async (
|
||||
@@ -683,6 +695,7 @@ export const invalidateGetUserDeprecated = async (
|
||||
|
||||
/**
|
||||
* This endpoint updates the user by id
|
||||
* @deprecated
|
||||
* @summary Update user
|
||||
*/
|
||||
export const updateUserDeprecated = (
|
||||
@@ -755,6 +768,7 @@ export type UpdateUserDeprecatedMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Update user
|
||||
*/
|
||||
export const useUpdateUserDeprecated = <
|
||||
@@ -783,6 +797,7 @@ export const useUpdateUserDeprecated = <
|
||||
};
|
||||
/**
|
||||
* This endpoint returns the user I belong to
|
||||
* @deprecated
|
||||
* @summary Get my user
|
||||
*/
|
||||
export const getMyUserDeprecated = (signal?: AbortSignal) => {
|
||||
@@ -828,6 +843,7 @@ export type GetMyUserDeprecatedQueryResult = NonNullable<
|
||||
export type GetMyUserDeprecatedQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Get my user
|
||||
*/
|
||||
|
||||
@@ -851,6 +867,7 @@ export function useGetMyUserDeprecated<
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Get my user
|
||||
*/
|
||||
export const invalidateGetMyUserDeprecated = async (
|
||||
@@ -1209,6 +1226,89 @@ export const invalidateListUsers = async (
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint creates a user for the organization
|
||||
* @summary Create user
|
||||
*/
|
||||
export const createUser = (
|
||||
authtypesPostableUserDTO?: BodyType<AuthtypesPostableUserDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateUser201>({
|
||||
url: `/api/v2/users`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: authtypesPostableUserDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateUserMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createUser>>,
|
||||
TError,
|
||||
{ data?: BodyType<AuthtypesPostableUserDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createUser>>,
|
||||
TError,
|
||||
{ data?: BodyType<AuthtypesPostableUserDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createUser'];
|
||||
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 createUser>>,
|
||||
{ data?: BodyType<AuthtypesPostableUserDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return createUser(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateUserMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createUser>>
|
||||
>;
|
||||
export type CreateUserMutationBody =
|
||||
| BodyType<AuthtypesPostableUserDTO>
|
||||
| undefined;
|
||||
export type CreateUserMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Create user
|
||||
*/
|
||||
export const useCreateUser = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createUser>>,
|
||||
TError,
|
||||
{ data?: BodyType<AuthtypesPostableUserDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createUser>>,
|
||||
TError,
|
||||
{ data?: BodyType<AuthtypesPostableUserDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getCreateUserMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint returns the user by id
|
||||
* @summary Get user by user id
|
||||
|
||||
@@ -12,4 +12,5 @@ export enum FeatureKeys {
|
||||
USE_JSON_BODY = 'use_json_body',
|
||||
USE_FINE_GRAINED_AUTHZ = 'use_fine_grained_authz',
|
||||
USE_DASHBOARD_V2 = 'use_dashboard_v2',
|
||||
EMABLE_AI_OBSERVABILITY = 'enable_ai_observability',
|
||||
}
|
||||
|
||||
@@ -43,4 +43,5 @@ export enum LOCALSTORAGE {
|
||||
DASHBOARD_PREFERENCES = 'DASHBOARD_PREFERENCES',
|
||||
ACTIVE_SIGNOZ_INSTANCE_URL = 'ACTIVE_SIGNOZ_INSTANCE_URL',
|
||||
DASHBOARDS_LIST_VISIBLE_COLUMNS = 'DASHBOARDS_LIST_VISIBLE_COLUMNS',
|
||||
DASHBOARDS_LIST_VIEWS = 'DASHBOARDS_LIST_VIEWS',
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import APIError from 'types/api/error';
|
||||
import DashboardActions from './DashboardActions/DashboardActions';
|
||||
import DashboardInfo from './DashboardInfo/DashboardInfo';
|
||||
import { useEditableTitle } from './DashboardInfo/useEditableTitle';
|
||||
import { usePublicDashboardMeta } from '../DashboardSettings/PublicDashboard/usePublicDashboardMeta';
|
||||
import VariablesBar from '../VariablesBar/VariablesBar';
|
||||
|
||||
import styles from './DashboardPageToolbar.module.scss';
|
||||
|
||||
@@ -53,10 +53,6 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
(s) => s.setIsPanelTypeSelectionModalOpen,
|
||||
);
|
||||
|
||||
// Single global fetch of the public-sharing meta (the drawer reuses this cache);
|
||||
// drives the public-access badge.
|
||||
const { isPublic: isPublicDashboard } = usePublicDashboardMeta(id);
|
||||
|
||||
const isAuthor =
|
||||
!!user?.email && !!dashboard.createdBy && dashboard.createdBy === user.email;
|
||||
|
||||
@@ -122,7 +118,7 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
image={image}
|
||||
tags={tags}
|
||||
description={description}
|
||||
isPublicDashboard={isPublicDashboard}
|
||||
isPublicDashboard={false}
|
||||
isDashboardLocked={isDashboardLocked}
|
||||
isEditing={isEditing}
|
||||
draft={draft}
|
||||
@@ -142,6 +138,8 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
onOpenRename={startEdit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VariablesBar dashboard={dashboard} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sortBy } from 'lodash-es';
|
||||
import type { VariableDefaultValueDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
/**
|
||||
@@ -76,3 +77,26 @@ export function emptyVariableFormModel(): VariableFormModel {
|
||||
dynamicSignal: 'traces',
|
||||
};
|
||||
}
|
||||
|
||||
/** Maps the dynamic-variable signal to the field-values API signal. */
|
||||
export function signalForApi(
|
||||
signal: TelemetrySignal,
|
||||
): TelemetrySignal | undefined {
|
||||
return signal;
|
||||
}
|
||||
|
||||
type SortableValues = (string | number | boolean)[];
|
||||
|
||||
/** Sorts option/preview values by the variable's chosen order (no-op when disabled). */
|
||||
export function sortValuesByOrder(
|
||||
values: SortableValues,
|
||||
sort: VariableSort,
|
||||
): SortableValues {
|
||||
if (sort === 'ASC') {
|
||||
return sortBy(values);
|
||||
}
|
||||
if (sort === 'DESC') {
|
||||
return sortBy(values).reverse();
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useMemo } from 'react';
|
||||
import { SolidInfoCircle } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- lightweight description tooltip, matches V1
|
||||
import { Tooltip } from 'antd';
|
||||
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
|
||||
|
||||
import { sortValuesByOrder } from '../DashboardSettings/Variables/variableModel';
|
||||
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
|
||||
import type { VariableSelection, VariableSelectionMap } from './selectionTypes';
|
||||
import DynamicSelector from './selectors/DynamicSelector';
|
||||
import QuerySelector from './selectors/QuerySelector';
|
||||
import TextSelector from './selectors/TextSelector';
|
||||
import ValueSelector from './selectors/ValueSelector';
|
||||
import styles from './VariablesBar.module.scss';
|
||||
|
||||
interface VariableSelectorProps {
|
||||
variable: VariableFormModel;
|
||||
/** All variables (Dynamic uses them to scope options by sibling selections). */
|
||||
variables: VariableFormModel[];
|
||||
/** Names this variable depends on (for Query gating). */
|
||||
parents: string[];
|
||||
/** All current selections (Query passes them as the request payload). */
|
||||
selections: VariableSelectionMap;
|
||||
selection: VariableSelection;
|
||||
onChange: (selection: VariableSelection) => void;
|
||||
}
|
||||
|
||||
/** One labelled variable control; dispatches on the variable type. */
|
||||
function VariableSelector({
|
||||
variable,
|
||||
variables,
|
||||
parents,
|
||||
selections,
|
||||
selection,
|
||||
onChange,
|
||||
}: VariableSelectorProps): JSX.Element {
|
||||
const customOptions = useMemo(
|
||||
() =>
|
||||
variable.type === 'CUSTOM'
|
||||
? sortValuesByOrder(
|
||||
commaValuesParser(variable.customValue),
|
||||
variable.sort,
|
||||
).map(String)
|
||||
: [],
|
||||
[variable],
|
||||
);
|
||||
|
||||
const renderControl = (): JSX.Element => {
|
||||
switch (variable.type) {
|
||||
case 'TEXT':
|
||||
return (
|
||||
<TextSelector
|
||||
selection={selection}
|
||||
defaultValue={variable.textValue}
|
||||
onChange={onChange}
|
||||
testId={`variable-input-${variable.name}`}
|
||||
/>
|
||||
);
|
||||
case 'QUERY':
|
||||
return (
|
||||
<QuerySelector
|
||||
variable={variable}
|
||||
parents={parents}
|
||||
selections={selections}
|
||||
selection={selection}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
case 'DYNAMIC':
|
||||
return (
|
||||
<DynamicSelector
|
||||
variable={variable}
|
||||
variables={variables}
|
||||
selections={selections}
|
||||
selection={selection}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
case 'CUSTOM':
|
||||
default:
|
||||
return (
|
||||
<ValueSelector
|
||||
options={customOptions}
|
||||
multiSelect={variable.multiSelect}
|
||||
showAllOption={variable.showAllOption}
|
||||
selection={selection}
|
||||
onChange={onChange}
|
||||
testId={`variable-select-${variable.name}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.variableItem}
|
||||
data-testid={`variable-${variable.name}`}
|
||||
>
|
||||
<Typography.Text className={styles.variableName}>
|
||||
${variable.name}
|
||||
{variable.description ? (
|
||||
<Tooltip title={variable.description}>
|
||||
<SolidInfoCircle className={styles.infoIcon} size="md" />
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Typography.Text>
|
||||
|
||||
<div className={styles.variableValue}>{renderControl()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariableSelector;
|
||||
@@ -0,0 +1,71 @@
|
||||
/* Mirrors the V1 dashboard variable bar: each variable is a connected pill —
|
||||
a robin `$name` segment joined to a value segment. */
|
||||
/* Sits inside the already-padded sticky toolbar section, so it only needs a top
|
||||
gap from the tags — horizontal/bottom padding comes from the toolbar. */
|
||||
.bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.variableItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.variableName {
|
||||
display: flex;
|
||||
min-width: 56px;
|
||||
height: 32px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 6px 6px 8px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 2px 0 0 2px;
|
||||
background: var(--l3-background);
|
||||
color: var(--bg-robin-300);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.infoIcon {
|
||||
margin-left: 4px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.variableValue {
|
||||
display: flex;
|
||||
min-width: 120px;
|
||||
height: 32px;
|
||||
align-items: center;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-left: none;
|
||||
border-radius: 0 2px 2px 0;
|
||||
background: var(--l2-background);
|
||||
color: var(--l2-foreground);
|
||||
font-size: 12px;
|
||||
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
outline: 1px solid var(--bg-robin-400);
|
||||
}
|
||||
}
|
||||
|
||||
/* Inner control fills the value segment; the segment provides the frame, so the
|
||||
control itself is borderless/transparent. */
|
||||
.control {
|
||||
width: 100%;
|
||||
min-width: 120px;
|
||||
|
||||
:global(.ant-select-selector),
|
||||
:global(.ant-input),
|
||||
&:global(.ant-input) {
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { useVariableSelection } from './useVariableSelection';
|
||||
import VariableSelector from './VariableSelector';
|
||||
import styles from './VariablesBar.module.scss';
|
||||
|
||||
interface VariablesBarProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime variable selector bar shown above the panels. Renders one control per
|
||||
* dashboard variable; selections live in the store + URL (never the spec).
|
||||
*/
|
||||
function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
|
||||
const { variables, dependencyData, selection, setSelection } =
|
||||
useVariableSelection(dashboard);
|
||||
|
||||
if (variables.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.bar} data-testid="dashboard-variables-bar">
|
||||
{variables.map((variable) => (
|
||||
<VariableSelector
|
||||
key={variable.name}
|
||||
variable={variable}
|
||||
variables={variables}
|
||||
parents={dependencyData.parentGraph[variable.name] ?? []}
|
||||
selections={selection}
|
||||
selection={
|
||||
selection[variable.name] ?? {
|
||||
value: variable.multiSelect ? [] : '',
|
||||
allSelected: false,
|
||||
}
|
||||
}
|
||||
onChange={(next): void => setSelection(variable.name, next)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariablesBar;
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
|
||||
import type { VariableSelectionMap } from './selectionTypes';
|
||||
|
||||
function formatQueryValue(val: string): string {
|
||||
const num = Number(val);
|
||||
if (!Number.isNaN(num) && Number.isFinite(num)) {
|
||||
return val;
|
||||
}
|
||||
return `'${val.replace(/'/g, "\\'")}'`;
|
||||
}
|
||||
|
||||
function buildQueryPart(attribute: string, values: string[]): string {
|
||||
const formatted = values.map(formatQueryValue);
|
||||
if (formatted.length === 1) {
|
||||
return `${attribute} = ${formatted[0]}`;
|
||||
}
|
||||
return `${attribute} IN [${formatted.join(', ')}]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a filter expression from the OTHER dynamic variables' current
|
||||
* selections (e.g. `k8s.namespace.name IN ['prod'] AND service = 'api'`), so a
|
||||
* dynamic variable's option list is scoped by its sibling selections. Variables
|
||||
* in the ALL state, with no selection, or non-dynamic are skipped. Ported from
|
||||
* the V1 dynamic-variable runtime.
|
||||
*/
|
||||
export function buildExistingDynamicVariableQuery(
|
||||
variables: VariableFormModel[],
|
||||
selections: VariableSelectionMap,
|
||||
currentName: string,
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
variables.forEach((variable) => {
|
||||
if (
|
||||
variable.name === currentName ||
|
||||
variable.type !== 'DYNAMIC' ||
|
||||
!variable.dynamicAttribute
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const selection = selections[variable.name];
|
||||
if (!selection || selection.allSelected) {
|
||||
return;
|
||||
}
|
||||
const raw = Array.isArray(selection.value)
|
||||
? selection.value
|
||||
: [selection.value];
|
||||
const valid = raw
|
||||
.filter((v) => v !== null && v !== undefined && v !== '')
|
||||
.map((v) => String(v));
|
||||
if (valid.length > 0) {
|
||||
parts.push(buildQueryPart(variable.dynamicAttribute, valid));
|
||||
}
|
||||
});
|
||||
return parts.join(' AND ');
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/** A user-selected variable value at runtime (not persisted to the spec). */
|
||||
export type SelectedVariableValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| (string | number | boolean)[]
|
||||
| null;
|
||||
|
||||
export interface VariableSelection {
|
||||
value: SelectedVariableValue;
|
||||
/** True when every option is selected ("ALL"); for dynamic vars value may be null. */
|
||||
allSelected: boolean;
|
||||
}
|
||||
|
||||
/** Selected values for a dashboard's variables, keyed by variable name. */
|
||||
export type VariableSelectionMap = Record<string, VariableSelection>;
|
||||
@@ -0,0 +1,31 @@
|
||||
import type {
|
||||
SelectedVariableValue,
|
||||
VariableSelection,
|
||||
VariableSelectionMap,
|
||||
} from './selectionTypes';
|
||||
|
||||
/** A selection counts as resolved (usable as a parent value) when it's non-empty. */
|
||||
export function isResolved(selection?: VariableSelection): boolean {
|
||||
if (!selection) {
|
||||
return false;
|
||||
}
|
||||
if (selection.allSelected) {
|
||||
return true;
|
||||
}
|
||||
const { value } = selection;
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0;
|
||||
}
|
||||
return value !== '' && value !== null && value !== undefined;
|
||||
}
|
||||
|
||||
/** Flatten the selection map into the `{ name: value }` payload a query expects. */
|
||||
export function selectionToPayload(
|
||||
selection: VariableSelectionMap,
|
||||
): Record<string, SelectedVariableValue> {
|
||||
const payload: Record<string, SelectedVariableValue> = {};
|
||||
Object.entries(selection).forEach(([name, sel]) => {
|
||||
payload[name] = sel.value;
|
||||
});
|
||||
return payload;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useMemo } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
|
||||
import type { AppState } from 'store/reducers';
|
||||
import type { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import {
|
||||
signalForApi,
|
||||
sortValuesByOrder,
|
||||
} from '../../DashboardSettings/Variables/variableModel';
|
||||
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableModel';
|
||||
import { buildExistingDynamicVariableQuery } from '../dynamicFilter';
|
||||
import type {
|
||||
VariableSelection,
|
||||
VariableSelectionMap,
|
||||
} from '../selectionTypes';
|
||||
import { useAutoSelect } from '../useAutoSelect';
|
||||
import ValueSelector from './ValueSelector';
|
||||
|
||||
interface DynamicSelectorProps {
|
||||
variable: VariableFormModel;
|
||||
/** All variables + current selections, to scope options by sibling dynamics. */
|
||||
variables: VariableFormModel[];
|
||||
selections: VariableSelectionMap;
|
||||
selection: VariableSelection;
|
||||
onChange: (selection: VariableSelection) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic-variable options sourced from live telemetry field values for the
|
||||
* chosen signal + attribute, scoped by the other dynamic variables' selections
|
||||
* (so e.g. `pod` narrows to the chosen `namespace`).
|
||||
*/
|
||||
function DynamicSelector({
|
||||
variable,
|
||||
variables,
|
||||
selections,
|
||||
selection,
|
||||
onChange,
|
||||
}: DynamicSelectorProps): JSX.Element {
|
||||
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
const existingQuery = useMemo(
|
||||
() => buildExistingDynamicVariableQuery(variables, selections, variable.name),
|
||||
[variables, selections, variable.name],
|
||||
);
|
||||
|
||||
const { data, isFetching } = useGetFieldValues({
|
||||
signal: signalForApi(variable.dynamicSignal),
|
||||
name: variable.dynamicAttribute,
|
||||
startUnixMilli: minTime,
|
||||
endUnixMilli: maxTime,
|
||||
existingQuery: existingQuery || undefined,
|
||||
enabled: !!variable.dynamicAttribute,
|
||||
});
|
||||
|
||||
const options = useMemo(() => {
|
||||
const payload = data?.data;
|
||||
const values =
|
||||
payload?.normalizedValues ?? payload?.values?.StringValues ?? [];
|
||||
return sortValuesByOrder(values, variable.sort).map(String);
|
||||
}, [data, variable.sort]);
|
||||
|
||||
useAutoSelect(variable, options, selection, onChange);
|
||||
|
||||
return (
|
||||
<ValueSelector
|
||||
options={options}
|
||||
multiSelect={variable.multiSelect}
|
||||
showAllOption={variable.showAllOption}
|
||||
loading={isFetching}
|
||||
selection={selection}
|
||||
onChange={onChange}
|
||||
testId={`variable-select-${variable.name}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default DynamicSelector;
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
|
||||
import type { AppState } from 'store/reducers';
|
||||
import type { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { sortValuesByOrder } from '../../DashboardSettings/Variables/variableModel';
|
||||
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableModel';
|
||||
import type {
|
||||
VariableSelection,
|
||||
VariableSelectionMap,
|
||||
} from '../selectionTypes';
|
||||
import { isResolved, selectionToPayload } from '../selectionUtils';
|
||||
import { useAutoSelect } from '../useAutoSelect';
|
||||
import ValueSelector from './ValueSelector';
|
||||
|
||||
interface QuerySelectorProps {
|
||||
variable: VariableFormModel;
|
||||
/** Names this variable's query references; it waits until they're resolved. */
|
||||
parents: string[];
|
||||
/** All current selections, fed to the query as `{ name: value }`. */
|
||||
selections: VariableSelectionMap;
|
||||
selection: VariableSelection;
|
||||
onChange: (selection: VariableSelection) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query-driven options. Dependency orchestration is declarative: the query is
|
||||
* `enabled` only once every parent is resolved, and the parent values are in the
|
||||
* query key — so it refetches automatically when a parent changes (and a cyclic
|
||||
* dependency is simply never enabled).
|
||||
*/
|
||||
function QuerySelector({
|
||||
variable,
|
||||
parents,
|
||||
selections,
|
||||
selection,
|
||||
onChange,
|
||||
}: QuerySelectorProps): JSX.Element {
|
||||
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
const payload = useMemo(() => selectionToPayload(selections), [selections]);
|
||||
const enabled = parents.every((parent) => isResolved(selections[parent]));
|
||||
|
||||
const { data, isFetching } = useQuery(
|
||||
[
|
||||
'dashboard-variable',
|
||||
variable.name,
|
||||
variable.queryValue,
|
||||
payload,
|
||||
minTime,
|
||||
maxTime,
|
||||
],
|
||||
() =>
|
||||
dashboardVariablesQuery({
|
||||
query: variable.queryValue,
|
||||
variables: payload,
|
||||
}),
|
||||
{ enabled, refetchOnWindowFocus: false },
|
||||
);
|
||||
|
||||
const options = useMemo(() => {
|
||||
if (!data || data.statusCode !== 200 || !data.payload) {
|
||||
return [] as string[];
|
||||
}
|
||||
return sortValuesByOrder(
|
||||
data.payload.variableValues ?? [],
|
||||
variable.sort,
|
||||
).map(String);
|
||||
}, [data, variable.sort]);
|
||||
|
||||
useAutoSelect(variable, options, selection, onChange);
|
||||
|
||||
return (
|
||||
<ValueSelector
|
||||
options={options}
|
||||
multiSelect={variable.multiSelect}
|
||||
showAllOption={variable.showAllOption}
|
||||
loading={isFetching}
|
||||
selection={selection}
|
||||
onChange={onChange}
|
||||
testId={`variable-select-${variable.name}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default QuerySelector;
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import type { InputRef } from 'antd';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- match V1 textbox behaviour (commit on blur/Enter, borderless)
|
||||
import { Input } from 'antd';
|
||||
|
||||
import type { VariableSelection } from '../selectionTypes';
|
||||
import styles from '../VariablesBar.module.scss';
|
||||
|
||||
interface TextSelectorProps {
|
||||
selection: VariableSelection;
|
||||
/** Configured default; an emptied input falls back to it (V1 behaviour). */
|
||||
defaultValue?: string;
|
||||
onChange: (selection: VariableSelection) => void;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Free-text variable input. Mirrors V1: edits are local and only committed on
|
||||
* blur / Enter (not per keystroke), and clearing the field restores the default.
|
||||
*/
|
||||
function TextSelector({
|
||||
selection,
|
||||
defaultValue,
|
||||
onChange,
|
||||
testId,
|
||||
}: TextSelectorProps): JSX.Element {
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
const [value, setValue] = useState<string>(
|
||||
typeof selection.value === 'string' ? selection.value : (defaultValue ?? ''),
|
||||
);
|
||||
|
||||
const commit = useCallback(
|
||||
(next: string): void => onChange({ value: next, allSelected: false }),
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(
|
||||
(event: React.FocusEvent<HTMLInputElement>): void => {
|
||||
const trimmed = event.target.value.trim();
|
||||
if (!trimmed && defaultValue) {
|
||||
setValue(defaultValue);
|
||||
commit(defaultValue);
|
||||
} else {
|
||||
commit(trimmed);
|
||||
}
|
||||
},
|
||||
[commit, defaultValue],
|
||||
);
|
||||
|
||||
return (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
className={styles.control}
|
||||
bordered={false}
|
||||
placeholder="Enter value"
|
||||
value={value}
|
||||
title={value}
|
||||
onChange={(e): void => setValue(e.target.value)}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter') {
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
}}
|
||||
data-testid={testId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default TextSelector;
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useMemo } from 'react';
|
||||
import { CustomMultiSelect, CustomSelect } from 'components/NewSelect';
|
||||
import type { OptionData } from 'components/NewSelect/types';
|
||||
import { ALL_SELECT_VALUE } from 'container/DashboardContainer/utils';
|
||||
|
||||
import type { VariableSelection } from '../selectionTypes';
|
||||
import styles from '../VariablesBar.module.scss';
|
||||
|
||||
interface ValueSelectorProps {
|
||||
options: string[];
|
||||
multiSelect: boolean;
|
||||
showAllOption: boolean;
|
||||
loading?: boolean;
|
||||
selection: VariableSelection;
|
||||
onChange: (selection: VariableSelection) => void;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single/multi value picker for Custom/Query/Dynamic variables. Reuses the
|
||||
* shared NewSelect components, which provide search, the "ALL" option and
|
||||
* apply-on-close batching (so multi-select edits don't cascade per toggle).
|
||||
*/
|
||||
function ValueSelector({
|
||||
options,
|
||||
multiSelect,
|
||||
showAllOption,
|
||||
loading,
|
||||
selection,
|
||||
onChange,
|
||||
testId,
|
||||
}: ValueSelectorProps): JSX.Element {
|
||||
const optionData = useMemo<OptionData[]>(
|
||||
() => options.map((option) => ({ label: option, value: option })),
|
||||
[options],
|
||||
);
|
||||
|
||||
if (multiSelect) {
|
||||
const value = selection.allSelected
|
||||
? ALL_SELECT_VALUE
|
||||
: (Array.isArray(selection.value) ? selection.value : []).map(String);
|
||||
return (
|
||||
<CustomMultiSelect
|
||||
className={styles.control}
|
||||
data-testid={testId}
|
||||
options={optionData}
|
||||
value={value}
|
||||
loading={loading}
|
||||
showSearch
|
||||
placeholder="Select value"
|
||||
enableAllSelection={showAllOption}
|
||||
onChange={(next): void => {
|
||||
const values = Array.isArray(next)
|
||||
? next.map(String)
|
||||
: next
|
||||
? [String(next)]
|
||||
: [];
|
||||
if (values.length === 0) {
|
||||
onChange({ value: [], allSelected: false });
|
||||
return;
|
||||
}
|
||||
// CustomMultiSelect emits the full value set when ALL is picked.
|
||||
const isAll =
|
||||
showAllOption &&
|
||||
options.length > 0 &&
|
||||
options.every((option) => values.includes(option));
|
||||
onChange({ value: values, allSelected: isAll });
|
||||
}}
|
||||
onClear={(): void => onChange({ value: [], allSelected: false })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CustomSelect
|
||||
className={styles.select}
|
||||
data-testid={testId}
|
||||
options={optionData}
|
||||
value={
|
||||
selection.value == null || Array.isArray(selection.value)
|
||||
? undefined
|
||||
: String(selection.value)
|
||||
}
|
||||
loading={loading}
|
||||
showSearch
|
||||
placeholder="Select value"
|
||||
onChange={(next): void =>
|
||||
onChange({ value: next == null ? '' : String(next), allSelected: false })
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ValueSelector;
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
|
||||
import type { VariableSelection } from './selectionTypes';
|
||||
|
||||
/**
|
||||
* When fetched options arrive and the current selection isn't one of them,
|
||||
* auto-pick the variable's default (if present in the options) or the first
|
||||
* option — so dependent children always have a usable parent value.
|
||||
*/
|
||||
export function useAutoSelect(
|
||||
variable: VariableFormModel,
|
||||
options: string[],
|
||||
selection: VariableSelection,
|
||||
onChange: (selection: VariableSelection) => void,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (options.length === 0 || selection.allSelected) {
|
||||
return;
|
||||
}
|
||||
const current = selection.value;
|
||||
const isValid = Array.isArray(current)
|
||||
? current.length > 0 && current.every((c) => options.includes(String(c)))
|
||||
: current !== '' &&
|
||||
current !== null &&
|
||||
current !== undefined &&
|
||||
options.includes(String(current));
|
||||
if (isValid) {
|
||||
return;
|
||||
}
|
||||
const fallback = (variable.defaultValue as { value?: string } | undefined)
|
||||
?.value;
|
||||
const initial =
|
||||
fallback && options.includes(fallback) ? fallback : options[0];
|
||||
onChange({
|
||||
value: variable.multiSelect ? [initial] : initial,
|
||||
allSelected: false,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [options]);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { parseAsJson, useQueryState } from 'nuqs';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { dtoToFormModel } from '../DashboardSettings/Variables/variableAdapters';
|
||||
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
|
||||
import { selectVariableValues } from '../store/slices/variableSelectionSlice';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import type {
|
||||
SelectedVariableValue,
|
||||
VariableSelection,
|
||||
VariableSelectionMap,
|
||||
} from './selectionTypes';
|
||||
import {
|
||||
computeVariableDependencies,
|
||||
type VariableDependencyData,
|
||||
} from './variableDependencies';
|
||||
|
||||
/** URL sentinel for an "ALL values selected" state (matches V1). */
|
||||
export const ALL_SELECTED = '__ALL__';
|
||||
|
||||
/** `?variables=` holds `{ [name]: value }` (ALL encoded as the sentinel). */
|
||||
const variablesUrlParser = parseAsJson<Record<string, SelectedVariableValue>>(
|
||||
(v) =>
|
||||
typeof v === 'object' && v !== null
|
||||
? (v as Record<string, SelectedVariableValue>)
|
||||
: null,
|
||||
);
|
||||
|
||||
function defaultSelection(model: VariableFormModel): VariableSelection {
|
||||
const def = (
|
||||
model.defaultValue as { value?: SelectedVariableValue } | undefined
|
||||
)?.value;
|
||||
if (def !== undefined && def !== null && def !== '') {
|
||||
return { value: def, allSelected: false };
|
||||
}
|
||||
return { value: model.multiSelect ? [] : '', allSelected: false };
|
||||
}
|
||||
|
||||
function fromUrlValue(raw: SelectedVariableValue): VariableSelection {
|
||||
return raw === ALL_SELECTED
|
||||
? { value: null, allSelected: true }
|
||||
: { value: raw, allSelected: false };
|
||||
}
|
||||
|
||||
interface UseVariableSelection {
|
||||
variables: VariableFormModel[];
|
||||
dependencyData: VariableDependencyData;
|
||||
selection: VariableSelectionMap;
|
||||
setSelection: (name: string, selection: VariableSelection) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime variable selection: derives the variable list from the spec, seeds
|
||||
* each value from URL → localStorage(store) → default, and persists changes to
|
||||
* both the store and the URL. Never writes to the dashboard spec.
|
||||
*/
|
||||
export function useVariableSelection(
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO,
|
||||
): UseVariableSelection {
|
||||
const dashboardId = dashboard.id ?? '';
|
||||
|
||||
const variables = useMemo(
|
||||
() => (dashboard.spec?.variables ?? []).map(dtoToFormModel),
|
||||
[dashboard.spec?.variables],
|
||||
);
|
||||
const dependencyData = useMemo(
|
||||
() => computeVariableDependencies(variables),
|
||||
[variables],
|
||||
);
|
||||
|
||||
const selection = useDashboardStore(selectVariableValues(dashboardId));
|
||||
const setVariableValue = useDashboardStore((s) => s.setVariableValue);
|
||||
const setVariableValues = useDashboardStore((s) => s.setVariableValues);
|
||||
|
||||
const [urlValues, setUrlValues] = useQueryState(
|
||||
'variables',
|
||||
variablesUrlParser.withOptions({ history: 'replace' }),
|
||||
);
|
||||
|
||||
// Seed selections for this dashboard: URL wins, then persisted store, then default.
|
||||
useEffect(() => {
|
||||
if (!dashboardId || variables.length === 0) {
|
||||
return;
|
||||
}
|
||||
// `selection` here is the persisted (localStorage) map on mount — the
|
||||
// effect deliberately doesn't depend on it, so seeding runs once per set.
|
||||
const stored = selection;
|
||||
const seeded: VariableSelectionMap = {};
|
||||
variables.forEach((variable) => {
|
||||
const urlValue = urlValues?.[variable.name];
|
||||
if (urlValue !== undefined) {
|
||||
seeded[variable.name] = fromUrlValue(urlValue);
|
||||
} else if (stored[variable.name]) {
|
||||
seeded[variable.name] = stored[variable.name];
|
||||
} else {
|
||||
seeded[variable.name] = defaultSelection(variable);
|
||||
}
|
||||
});
|
||||
setVariableValues(dashboardId, seeded);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dashboardId, variables]);
|
||||
|
||||
const setSelection = useCallback(
|
||||
(name: string, next: VariableSelection): void => {
|
||||
setVariableValue(dashboardId, name, next);
|
||||
void setUrlValues((prev) => ({
|
||||
...(prev ?? {}),
|
||||
[name]: next.allSelected ? ALL_SELECTED : next.value,
|
||||
}));
|
||||
},
|
||||
[dashboardId, setVariableValue, setUrlValues],
|
||||
);
|
||||
|
||||
return { variables, dependencyData, selection, setSelection };
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { textContainsVariableReference } from 'lib/dashboardVariables/variableReference';
|
||||
|
||||
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
|
||||
|
||||
/**
|
||||
* Inter-variable dependency graph for runtime selection. A QUERY variable
|
||||
* "depends on" another variable when its query text references that variable
|
||||
* (`{{.name}}`, `{{name}}`, `$name`, `[[name]]`). When a variable's value
|
||||
* changes, its dependent QUERY variables must refetch. Ported from the V1
|
||||
* dashboard-variables runtime; operates on the V2 flat variable model.
|
||||
*/
|
||||
|
||||
export type VariableGraph = Record<string, string[]>;
|
||||
|
||||
export interface VariableDependencyData {
|
||||
/** Topological order of variables (parents before children). */
|
||||
order: string[];
|
||||
/** Direct children (dependents) of each variable. */
|
||||
graph: VariableGraph;
|
||||
/** Direct parents of each variable. */
|
||||
parentGraph: VariableGraph;
|
||||
/** All transitive descendants of each variable (precomputed). */
|
||||
transitiveDescendants: VariableGraph;
|
||||
hasCycle: boolean;
|
||||
cycleNodes?: string[];
|
||||
}
|
||||
|
||||
/** Names of QUERY variables whose query references `variableName`. */
|
||||
function getDependents(
|
||||
variableName: string,
|
||||
variables: VariableFormModel[],
|
||||
): string[] {
|
||||
return variables
|
||||
.filter(
|
||||
(v) =>
|
||||
v.type === 'QUERY' &&
|
||||
!!v.name &&
|
||||
textContainsVariableReference(v.queryValue || '', variableName),
|
||||
)
|
||||
.map((v) => v.name);
|
||||
}
|
||||
|
||||
/** variable name → its direct dependents (children). */
|
||||
export function buildDependencies(
|
||||
variables: VariableFormModel[],
|
||||
): VariableGraph {
|
||||
const graph: VariableGraph = {};
|
||||
variables.forEach((v) => {
|
||||
if (v.name) {
|
||||
graph[v.name] = getDependents(v.name, variables);
|
||||
}
|
||||
});
|
||||
return graph;
|
||||
}
|
||||
|
||||
/** Invert a child graph into a parent graph. */
|
||||
export function buildParentGraph(graph: VariableGraph): VariableGraph {
|
||||
const parents: VariableGraph = {};
|
||||
Object.keys(graph).forEach((node) => {
|
||||
parents[node] = parents[node] ?? [];
|
||||
});
|
||||
Object.entries(graph).forEach(([node, children]) => {
|
||||
children.forEach((child) => {
|
||||
parents[child] = parents[child] ?? [];
|
||||
parents[child].push(node);
|
||||
});
|
||||
});
|
||||
return parents;
|
||||
}
|
||||
|
||||
function collectCyclePath(
|
||||
graph: VariableGraph,
|
||||
start: string,
|
||||
end: string,
|
||||
): string[] {
|
||||
const path: string[] = [];
|
||||
let current = start;
|
||||
const findParent = (node: string): string | undefined =>
|
||||
Object.keys(graph).find((key) => graph[key]?.includes(node));
|
||||
while (current !== end) {
|
||||
const parent = findParent(current);
|
||||
if (!parent) {
|
||||
break;
|
||||
}
|
||||
path.push(parent);
|
||||
current = parent;
|
||||
}
|
||||
return [start, ...path];
|
||||
}
|
||||
|
||||
function detectCycle(
|
||||
graph: VariableGraph,
|
||||
node: string,
|
||||
visited: Set<string>,
|
||||
recStack: Set<string>,
|
||||
): string[] | null {
|
||||
if (!visited.has(node)) {
|
||||
visited.add(node);
|
||||
recStack.add(node);
|
||||
let cycleNodes: string[] | null = null;
|
||||
(graph[node] || []).some((neighbor) => {
|
||||
if (!visited.has(neighbor)) {
|
||||
const found = detectCycle(graph, neighbor, visited, recStack);
|
||||
if (found) {
|
||||
cycleNodes = found;
|
||||
return true;
|
||||
}
|
||||
} else if (recStack.has(neighbor)) {
|
||||
cycleNodes = collectCyclePath(graph, node, neighbor);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (cycleNodes) {
|
||||
return cycleNodes;
|
||||
}
|
||||
}
|
||||
recStack.delete(node);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Build the full dependency data (topo order, parents, transitive descendants, cycle info). */
|
||||
export function buildDependencyData(
|
||||
dependencies: VariableGraph,
|
||||
): VariableDependencyData {
|
||||
const inDegree: Record<string, number> = {};
|
||||
const adjList: VariableGraph = {};
|
||||
|
||||
Object.keys(dependencies).forEach((node) => {
|
||||
inDegree[node] = inDegree[node] ?? 0;
|
||||
adjList[node] = adjList[node] ?? [];
|
||||
(dependencies[node] || []).forEach((child) => {
|
||||
inDegree[child] = inDegree[child] ?? 0;
|
||||
inDegree[child] += 1;
|
||||
adjList[node].push(child);
|
||||
});
|
||||
});
|
||||
|
||||
const visited = new Set<string>();
|
||||
const recStack = new Set<string>();
|
||||
let cycleNodes: string[] | undefined;
|
||||
Object.keys(dependencies).some((node) => {
|
||||
if (!visited.has(node)) {
|
||||
const found = detectCycle(dependencies, node, visited, recStack);
|
||||
if (found) {
|
||||
cycleNodes = found;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// Topological sort (Kahn's algorithm).
|
||||
const queue = Object.keys(inDegree).filter((n) => inDegree[n] === 0);
|
||||
const order: string[] = [];
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
if (current === undefined) {
|
||||
break;
|
||||
}
|
||||
order.push(current);
|
||||
(adjList[current] || []).forEach((neighbor) => {
|
||||
inDegree[neighbor] -= 1;
|
||||
if (inDegree[neighbor] === 0) {
|
||||
queue.push(neighbor);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const hasCycle = order.length !== Object.keys(dependencies).length;
|
||||
|
||||
// Transitive descendants: walk topo order in reverse.
|
||||
const transitiveDescendants: VariableGraph = {};
|
||||
for (let i = order.length - 1; i >= 0; i--) {
|
||||
const node = order[i];
|
||||
const desc = new Set<string>();
|
||||
(adjList[node] || []).forEach((child) => {
|
||||
desc.add(child);
|
||||
(transitiveDescendants[child] || []).forEach((d) => desc.add(d));
|
||||
});
|
||||
transitiveDescendants[node] = Array.from(desc);
|
||||
}
|
||||
|
||||
return {
|
||||
order,
|
||||
graph: adjList,
|
||||
parentGraph: buildParentGraph(adjList),
|
||||
transitiveDescendants,
|
||||
hasCycle,
|
||||
cycleNodes,
|
||||
};
|
||||
}
|
||||
|
||||
/** Compute the full dependency data straight from the variable list. */
|
||||
export function computeVariableDependencies(
|
||||
variables: VariableFormModel[],
|
||||
): VariableDependencyData {
|
||||
return buildDependencyData(buildDependencies(variables));
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { StateCreator } from 'zustand';
|
||||
|
||||
import type {
|
||||
VariableSelection,
|
||||
VariableSelectionMap,
|
||||
} from '../../VariablesBar/selectionTypes';
|
||||
import type { DashboardStore } from '../useDashboardStore';
|
||||
|
||||
/**
|
||||
* Runtime variable selection — the values the user picks in the variable bar.
|
||||
* Keyed by dashboardId → variable name. Frontend-only and persisted to
|
||||
* localStorage (mirrored to the URL by the bar for shareable links); it is
|
||||
* deliberately NOT part of the dashboard spec, so selecting a value never
|
||||
* patches the dashboard.
|
||||
*/
|
||||
export interface VariableSelectionSlice {
|
||||
variableValues: Record<string, VariableSelectionMap>;
|
||||
setVariableValue: (
|
||||
dashboardId: string,
|
||||
name: string,
|
||||
selection: VariableSelection,
|
||||
) => void;
|
||||
/** Bulk set (used to seed from URL/localStorage/defaults on load). */
|
||||
setVariableValues: (dashboardId: string, values: VariableSelectionMap) => void;
|
||||
}
|
||||
|
||||
export const createVariableSelectionSlice: StateCreator<
|
||||
DashboardStore,
|
||||
[['zustand/persist', unknown]],
|
||||
[],
|
||||
VariableSelectionSlice
|
||||
> = (set, get) => ({
|
||||
variableValues: {},
|
||||
setVariableValue: (dashboardId, name, selection): void => {
|
||||
const { variableValues } = get();
|
||||
set({
|
||||
variableValues: {
|
||||
...variableValues,
|
||||
[dashboardId]: { ...variableValues[dashboardId], [name]: selection },
|
||||
},
|
||||
});
|
||||
},
|
||||
setVariableValues: (dashboardId, values): void => {
|
||||
const { variableValues } = get();
|
||||
set({
|
||||
variableValues: { ...variableValues, [dashboardId]: values },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
/** Selector: the selection map for a dashboard (empty if none). */
|
||||
export const selectVariableValues =
|
||||
(dashboardId: string) =>
|
||||
(state: DashboardStore): VariableSelectionMap =>
|
||||
state.variableValues[dashboardId] ?? {};
|
||||
@@ -9,25 +9,36 @@ import {
|
||||
createCollapseSlice,
|
||||
type CollapseSlice,
|
||||
} from './slices/collapseSlice';
|
||||
import {
|
||||
createVariableSelectionSlice,
|
||||
type VariableSelectionSlice,
|
||||
} from './slices/variableSelectionSlice';
|
||||
|
||||
export type DashboardStore = EditContextSlice & CollapseSlice;
|
||||
export type DashboardStore = EditContextSlice &
|
||||
CollapseSlice &
|
||||
VariableSelectionSlice;
|
||||
|
||||
/**
|
||||
* V2 dashboard session store. Holds cross-cutting client state only — never the
|
||||
* dashboard spec (that stays in react-query via useGetDashboardV2). Two slices:
|
||||
* dashboard spec (that stays in react-query via useGetDashboardV2). Slices:
|
||||
* - edit-context: dashboardId / isEditable / refetch (set once, not persisted).
|
||||
* - collapse: per-section open state (frontend-only, persisted to localStorage).
|
||||
* - variable-selection: runtime variable values (frontend-only, persisted).
|
||||
*/
|
||||
export const useDashboardStore = create<DashboardStore>()(
|
||||
persist(
|
||||
(...a) => ({
|
||||
...createEditContextSlice(...a),
|
||||
...createCollapseSlice(...a),
|
||||
...createVariableSelectionSlice(...a),
|
||||
}),
|
||||
{
|
||||
name: '@signoz/dashboard-v2',
|
||||
// Persist only the collapse map — context (incl. the refetch fn) is transient.
|
||||
partialize: (state) => ({ collapsed: state.collapsed }),
|
||||
// Persist UI-only state (context incl. the refetch fn is transient).
|
||||
partialize: (state) => ({
|
||||
collapsed: state.collapsed,
|
||||
variableValues: state.variableValues,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 8px;
|
||||
padding: 0 16px;
|
||||
gap: 8px;
|
||||
height: 48px;
|
||||
flex: none;
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.headerLeft {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { AnnouncementBanner } from '@signozhq/ui/announcement-banner';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { LayoutGrid } from '@signozhq/icons';
|
||||
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
import DashboardsList from './components/DashboardsList';
|
||||
import DashboardsList from './components/DashboardsList/DashboardsList';
|
||||
|
||||
import styles from './DashboardsListPageV2.module.scss';
|
||||
import { BreadcrumbLink } from '@signozhq/ui/breadcrumb';
|
||||
|
||||
function DashboardsListPageV2(): JSX.Element {
|
||||
const [showBanner, setShowBanner] = useState(true);
|
||||
@@ -24,8 +24,7 @@ function DashboardsListPageV2(): JSX.Element {
|
||||
)}
|
||||
<div className={styles.header}>
|
||||
<div className={styles.headerLeft}>
|
||||
<LayoutGrid size={14} className={styles.icon} />
|
||||
<Typography.Text className={styles.text}>Dashboards</Typography.Text>
|
||||
<BreadcrumbLink icon={<LayoutGrid size={14} />}>Dashboard</BreadcrumbLink>
|
||||
</div>
|
||||
<HeaderRightSection
|
||||
enableAnnouncements={false}
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import { useMutation } from 'react-query';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { Popover } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import {
|
||||
Copy,
|
||||
Expand,
|
||||
EllipsisVertical,
|
||||
Link2,
|
||||
SquareArrowOutUpRight,
|
||||
} from '@signozhq/icons';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { cloneDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
@@ -31,6 +40,23 @@ function ActionsPopover({
|
||||
onView,
|
||||
}: Props): JSX.Element {
|
||||
const [, setCopy] = useCopyToClipboard();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
// Clone keeps the source's name/panels/tags as a new unlocked dashboard owned
|
||||
// by the caller; open the copy so it can be tweaked right away.
|
||||
const { mutate: runClone, isLoading: isCloning } = useMutation({
|
||||
mutationFn: () => cloneDashboardV2({ id: dashboardId }),
|
||||
onSuccess: (response) => {
|
||||
toast.success(`Duplicated "${dashboardName}"`);
|
||||
safeNavigate(
|
||||
generatePath(ROUTES.DASHBOARD, { dashboardId: response.data.id }),
|
||||
);
|
||||
},
|
||||
onError: (error: APIError) => {
|
||||
showErrorModal(error);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Popover
|
||||
@@ -71,6 +97,20 @@ function ActionsPopover({
|
||||
>
|
||||
Copy Link
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
className={styles.menuItem}
|
||||
prefix={<Copy size={14} />}
|
||||
loading={isCloning}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
runClone();
|
||||
}}
|
||||
testId="dashboard-action-duplicate"
|
||||
>
|
||||
Duplicate
|
||||
</Button>
|
||||
<DeleteActionItem
|
||||
dashboardId={dashboardId}
|
||||
dashboardName={dashboardName}
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.preview {
|
||||
display: flex;
|
||||
padding: 12px 14.634px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 7.317px;
|
||||
border-radius: 4px;
|
||||
border: 0.915px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.previewHeader {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.previewIcon {
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
.previewTitle {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12.805px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 18.293px;
|
||||
letter-spacing: -0.064px;
|
||||
}
|
||||
|
||||
.previewDetails {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.previewRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.formattedTime {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.formattedTimeText {
|
||||
font-family: Inter;
|
||||
font-size: 12.805px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 16.463px;
|
||||
letter-spacing: -0.064px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.userTag {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--l2-foreground);
|
||||
font-size: 8px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: normal;
|
||||
letter-spacing: -0.05px;
|
||||
border-radius: 12.805px;
|
||||
background-color: var(--l1-background);
|
||||
}
|
||||
|
||||
.userLabel {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12.805px;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 16.463px;
|
||||
letter-spacing: -0.064px;
|
||||
}
|
||||
|
||||
.action {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0px 0px 0px 14.634px;
|
||||
}
|
||||
|
||||
.actionLeft {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.connectionLine {
|
||||
border-top: 1px dashed var(--l1-border);
|
||||
min-width: 20px;
|
||||
flex-grow: 1;
|
||||
margin: 0px 8px;
|
||||
}
|
||||
|
||||
.actionRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.saveChanges {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
padding: 8px 16px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-border);
|
||||
}
|
||||
|
||||
:global(.configureMetadataModalRoot) {
|
||||
:global(.ant-modal-content) {
|
||||
width: 500px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--card);
|
||||
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
:global(.ant-modal-header) {
|
||||
background: var(--card);
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
:global(.ant-modal-body) {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
:global(.ant-modal-footer) {
|
||||
margin-top: 0px;
|
||||
padding: 4px 16px 16px 16px;
|
||||
}
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Modal } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { CalendarClock, Check, Clock4 } from '@signozhq/icons';
|
||||
import { get } from 'lodash-es';
|
||||
import { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import { lastUpdatedLabel, type DashboardListItem } from '../../utils';
|
||||
import {
|
||||
DynamicColumns,
|
||||
useDashboardsListVisibleColumnsStore,
|
||||
type DashboardDynamicColumns,
|
||||
} from './useDynamicColumns';
|
||||
|
||||
import styles from './ConfigureMetadataModal.module.scss';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
previewDashboard: DashboardListItem | undefined;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function ConfigureMetadataModal({
|
||||
open,
|
||||
previewDashboard,
|
||||
onClose,
|
||||
}: Props): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
const storedColumns = useDashboardsListVisibleColumnsStore(
|
||||
(s) => s.visibleColumns,
|
||||
);
|
||||
const setStoredColumns = useDashboardsListVisibleColumnsStore(
|
||||
(s) => s.setVisibleColumns,
|
||||
);
|
||||
const [draftColumns, setDraftColumns] =
|
||||
useState<DashboardDynamicColumns>(storedColumns);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setDraftColumns(storedColumns);
|
||||
}
|
||||
}, [open, storedColumns]);
|
||||
|
||||
const handleSave = (): void => {
|
||||
setStoredColumns(draftColumns);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const previewImage = previewDashboard?.image || Base64Icons[0];
|
||||
const previewName = previewDashboard?.spec?.display?.name;
|
||||
const previewCreatedBy = previewDashboard?.createdBy;
|
||||
const previewUpdatedBy = previewDashboard?.updatedBy;
|
||||
const previewUpdatedAt = previewDashboard?.updatedAt;
|
||||
|
||||
const formattedCreatedAt = previewDashboard
|
||||
? formatTimezoneAdjustedTimestamp(
|
||||
get(previewDashboard, 'createdAt', '') as string,
|
||||
DATE_TIME_FORMATS.DASH_DATETIME_UTC,
|
||||
)
|
||||
: '';
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
title="Configure Metadata"
|
||||
footer={
|
||||
<Button
|
||||
type="text"
|
||||
icon={<Check size={14} />}
|
||||
className={styles.saveChanges}
|
||||
onClick={handleSave}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
}
|
||||
rootClassName="configureMetadataModalRoot"
|
||||
>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.preview}>
|
||||
<section className={styles.previewHeader}>
|
||||
<img
|
||||
src={previewImage}
|
||||
alt="dashboard-image"
|
||||
className={styles.previewIcon}
|
||||
/>
|
||||
<Typography.Text className={styles.previewTitle}>
|
||||
{previewName}
|
||||
</Typography.Text>
|
||||
</section>
|
||||
<section className={styles.previewDetails}>
|
||||
<section className={styles.previewRow}>
|
||||
{draftColumns.createdAt && (
|
||||
<span className={styles.formattedTime}>
|
||||
<CalendarClock size={14} />
|
||||
<Typography.Text className={styles.formattedTimeText}>
|
||||
{formattedCreatedAt}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
)}
|
||||
{draftColumns.createdBy && (
|
||||
<div className={styles.user}>
|
||||
<Typography.Text className={styles.userTag}>
|
||||
{previewCreatedBy?.substring(0, 1).toUpperCase()}
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.userLabel}>
|
||||
{previewCreatedBy}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<section className={styles.previewRow}>
|
||||
{draftColumns.updatedAt && (
|
||||
<span className={styles.formattedTime}>
|
||||
<CalendarClock size={14} />
|
||||
<Typography.Text className={styles.formattedTimeText}>
|
||||
{lastUpdatedLabel(previewUpdatedAt)}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
)}
|
||||
{draftColumns.updatedBy && (
|
||||
<div className={styles.user}>
|
||||
<Typography.Text className={styles.userTag}>
|
||||
{previewUpdatedBy?.substring(0, 1).toUpperCase()}
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.userLabel}>
|
||||
{previewUpdatedBy}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className={styles.action}>
|
||||
<div className={styles.actionLeft}>
|
||||
<CalendarClock size={14} />
|
||||
<Typography.Text>Created at</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.connectionLine} />
|
||||
<div className={styles.actionRight}>
|
||||
<Switch
|
||||
value
|
||||
disabled
|
||||
onChange={(check): void =>
|
||||
setDraftColumns((prev) => ({
|
||||
...prev,
|
||||
[DynamicColumns.CREATED_AT]: check,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.action}>
|
||||
<div className={styles.actionLeft}>
|
||||
<CalendarClock size={14} />
|
||||
<Typography.Text>Created by</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.connectionLine} />
|
||||
<div className={styles.actionRight}>
|
||||
<Switch
|
||||
value
|
||||
disabled
|
||||
onChange={(check): void =>
|
||||
setDraftColumns((prev) => ({
|
||||
...prev,
|
||||
[DynamicColumns.CREATED_BY]: check,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.action}>
|
||||
<div className={styles.actionLeft}>
|
||||
<Clock4 size={14} />
|
||||
<Typography.Text>Updated at</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.connectionLine} />
|
||||
<div className={styles.actionRight}>
|
||||
<Switch
|
||||
value={draftColumns.updatedAt}
|
||||
onChange={(check): void =>
|
||||
setDraftColumns((prev) => ({
|
||||
...prev,
|
||||
[DynamicColumns.UPDATED_AT]: check,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.action}>
|
||||
<div className={styles.actionLeft}>
|
||||
<Clock4 size={14} />
|
||||
<Typography.Text>Updated by</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.connectionLine} />
|
||||
<div className={styles.actionRight}>
|
||||
<Switch
|
||||
value={draftColumns.updatedBy}
|
||||
onChange={(check): void =>
|
||||
setDraftColumns((prev) => ({
|
||||
...prev,
|
||||
[DynamicColumns.UPDATED_BY]: check,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfigureMetadataModal;
|
||||
@@ -1,34 +0,0 @@
|
||||
.menuItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.templatesItem {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.primaryButton {
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
.textButton {
|
||||
display: flex;
|
||||
width: 153px;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
padding: 6px 12px;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
border-radius: 2px;
|
||||
background: var(--primary-background);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
:global(.createDashboardMenuOverlay) {
|
||||
width: 200px;
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- TODO: migrate Dropdown to @signozhq/ui/dropdown-menu
|
||||
import { Button, Dropdown, MenuProps } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
ExternalLink,
|
||||
Github,
|
||||
LayoutGrid,
|
||||
Plus,
|
||||
Radius,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
import styles from './CreateDashboardDropdown.module.scss';
|
||||
|
||||
interface Props {
|
||||
canCreate: boolean;
|
||||
onCreate: () => void;
|
||||
onImportJSON: () => void;
|
||||
variant?: 'primary' | 'text';
|
||||
}
|
||||
|
||||
const TEMPLATES_HREF =
|
||||
'https://signoz.io/docs/dashboards/dashboard-templates/overview/';
|
||||
|
||||
function CreateDashboardDropdown({
|
||||
canCreate,
|
||||
onCreate,
|
||||
onImportJSON,
|
||||
variant = 'primary',
|
||||
}: Props): JSX.Element {
|
||||
const items: MenuProps['items'] = useMemo(() => {
|
||||
const menuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: 'import-json',
|
||||
label: (
|
||||
<div
|
||||
className={styles.menuItem}
|
||||
data-testid="import-json-menu-cta"
|
||||
onClick={onImportJSON}
|
||||
>
|
||||
<Radius size={14} /> Import JSON
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'view-templates',
|
||||
label: (
|
||||
<a
|
||||
href={TEMPLATES_HREF}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-testid="view-templates-menu-cta"
|
||||
>
|
||||
<div className={styles.templatesItem}>
|
||||
<div className={styles.menuItem}>
|
||||
<Github size={14} /> View templates
|
||||
</div>
|
||||
<ExternalLink size={14} />
|
||||
</div>
|
||||
</a>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (canCreate) {
|
||||
menuItems.unshift({
|
||||
key: 'create-dashboard',
|
||||
label: (
|
||||
<div
|
||||
className={styles.menuItem}
|
||||
data-testid="create-dashboard-menu-cta"
|
||||
onClick={onCreate}
|
||||
>
|
||||
<LayoutGrid size={14} /> Create dashboard
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return menuItems;
|
||||
}, [canCreate, onCreate, onImportJSON]);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
overlayClassName="createDashboardMenuOverlay"
|
||||
menu={{ items }}
|
||||
placement="bottomRight"
|
||||
trigger={['click']}
|
||||
>
|
||||
{variant === 'primary' ? (
|
||||
<Button
|
||||
type="primary"
|
||||
className={cx('periscope-btn primary', styles.primaryButton)}
|
||||
icon={<Plus size={14} />}
|
||||
data-testid="new-dashboard-cta"
|
||||
onClick={(): void => {
|
||||
logEvent('Dashboard List: New dashboard clicked', {});
|
||||
}}
|
||||
>
|
||||
New dashboard
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="text"
|
||||
className={styles.textButton}
|
||||
icon={<Plus size={14} />}
|
||||
onClick={(): void => {
|
||||
logEvent('Dashboard List: New dashboard clicked', {});
|
||||
}}
|
||||
>
|
||||
New Dashboard
|
||||
</Button>
|
||||
)}
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateDashboardDropdown;
|
||||
@@ -1,9 +1,14 @@
|
||||
.row {
|
||||
padding: 12px 16px 16px 16px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-top: none;
|
||||
background: var(--l2-background);
|
||||
background: var(--l1-background);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.titleWithAction {
|
||||
@@ -57,6 +62,40 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.favBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex: none;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: transparent;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.12s,
|
||||
color 0.12s;
|
||||
}
|
||||
|
||||
.row:hover .favBtn {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.favBtn:hover {
|
||||
background: var(--l1-background);
|
||||
color: var(--bg-amber-500);
|
||||
}
|
||||
|
||||
.favBtnOn {
|
||||
color: var(--bg-amber-500);
|
||||
|
||||
svg {
|
||||
fill: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { CalendarClock } from '@signozhq/icons';
|
||||
import { CalendarClock, Star } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
|
||||
@@ -11,6 +12,7 @@ import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { isModifierKeyPressed } from 'utils/app';
|
||||
|
||||
import { useDashboardViewsStore } from '../../store/useDashboardViewsStore';
|
||||
import type { DashboardListItem } from '../../utils';
|
||||
import { lastUpdatedLabel, tagsToStrings } from '../../utils';
|
||||
import ActionsPopover from '../ActionsPopover/ActionsPopover';
|
||||
@@ -35,6 +37,12 @@ function DashboardRow({
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
const isFavorite = useDashboardViewsStore((s) =>
|
||||
s.favorites.includes(dashboard.id),
|
||||
);
|
||||
const toggleFavorite = useDashboardViewsStore((s) => s.toggleFavorite);
|
||||
const markViewed = useDashboardViewsStore((s) => s.markViewed);
|
||||
|
||||
const id = dashboard.id;
|
||||
const name = dashboard.spec?.display?.name ?? '';
|
||||
const image = dashboard.image || Base64Icons[0];
|
||||
@@ -53,6 +61,7 @@ function DashboardRow({
|
||||
|
||||
const onClickHandler = (event: React.MouseEvent<HTMLElement>): void => {
|
||||
event.stopPropagation();
|
||||
markViewed(id);
|
||||
safeNavigate(link, { newTab: isModifierKeyPressed(event) });
|
||||
logEvent('Dashboard List: Clicked on dashboard', {
|
||||
dashboardId: id,
|
||||
@@ -60,6 +69,11 @@ function DashboardRow({
|
||||
});
|
||||
};
|
||||
|
||||
const onToggleFavorite = (event: React.MouseEvent<HTMLElement>): void => {
|
||||
event.stopPropagation();
|
||||
toggleFavorite(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.row} onClick={onClickHandler}>
|
||||
<div className={styles.titleWithAction}>
|
||||
@@ -98,6 +112,17 @@ function DashboardRow({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={cx(styles.favBtn, { [styles.favBtnOn]: isFavorite })}
|
||||
aria-label={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
|
||||
title={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
|
||||
data-testid={`dashboard-favorite-${index}`}
|
||||
onClick={onToggleFavorite}
|
||||
>
|
||||
<Star size={14} />
|
||||
</button>
|
||||
|
||||
{canAct && (
|
||||
<ActionsPopover
|
||||
link={link}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import NewDashboardButton from './NewDashboardButton';
|
||||
|
||||
import styles from './DashboardsList.module.scss';
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
count: number;
|
||||
canCreate: boolean;
|
||||
onCreate: () => void;
|
||||
}
|
||||
|
||||
function CommandHeader({
|
||||
label,
|
||||
count,
|
||||
canCreate,
|
||||
onCreate,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<div className={styles.commandHeader}>
|
||||
<div className={styles.headingBlock}>
|
||||
<Typography.Title className={styles.title}>{label}</Typography.Title>
|
||||
<span className={styles.countPill}>{count}</span>
|
||||
</div>
|
||||
<div className={styles.grow} />
|
||||
{canCreate && <NewDashboardButton onClick={onCreate} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CommandHeader;
|
||||
@@ -1,14 +1,43 @@
|
||||
.container {
|
||||
margin-top: 30px;
|
||||
margin-bottom: 30px;
|
||||
.layout {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
// Deepest layer — the results canvas, so the lighter header zone and the
|
||||
// row cards read with clear contrast (matches the design's list surface).
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
.mainScroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.headerZone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 20px 24px;
|
||||
background: var(--l2-background);
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.emptyWrap {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.viewContent {
|
||||
width: calc(100% - 30px);
|
||||
max-width: 836px;
|
||||
width: 100%;
|
||||
|
||||
:global(.ant-table-wrapper) :global(.ant-table-cell) {
|
||||
padding: 0 !important;
|
||||
@@ -16,14 +45,6 @@
|
||||
background: var(--l1-background) !important;
|
||||
}
|
||||
|
||||
:global(.ant-table-wrapper)
|
||||
:global(.ant-table-tbody)
|
||||
:global(.ant-table-row)
|
||||
:global(.ant-table-cell)
|
||||
> div {
|
||||
// Row content is the only child of the td; it carries the borders.
|
||||
}
|
||||
|
||||
:global(.ant-table-wrapper)
|
||||
:global(.ant-table-tbody)
|
||||
:global(.ant-table-row:last-child)
|
||||
@@ -55,19 +76,43 @@
|
||||
}
|
||||
}
|
||||
|
||||
.titleContainer {
|
||||
.commandHeader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.headingBlock {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.grow {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.countPill {
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--font-size-lg);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 28px;
|
||||
letter-spacing: -0.09px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
@@ -80,17 +125,16 @@
|
||||
}
|
||||
|
||||
.integrationsContainer {
|
||||
margin: 16px 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.integrationsContent {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 16px 0;
|
||||
// The shared request banner ships a 12px margin; drop it so the banner's
|
||||
// left edge lines up with the heading and filters above/below it.
|
||||
:global(.request-entity-container) {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,55 +1,45 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { AxiosError } from 'axios';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
createDashboardV2,
|
||||
useListDashboardsV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import { useListDashboardsV2 } from 'api/generated/services/dashboard';
|
||||
import {
|
||||
DashboardtypesListOrderDTO,
|
||||
DashboardtypesListSortDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { RequestDashboardBtn } from 'container/ListOfDashboard/RequestDashboardBtn';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import { combineQueries } from '../../filterQuery';
|
||||
import { useActiveView } from '../../hooks/useActiveView';
|
||||
import { useDashboardFilters } from '../../hooks/useDashboardFilters';
|
||||
import {
|
||||
usePage,
|
||||
useSearch,
|
||||
useSortColumn,
|
||||
useSortOrder,
|
||||
} from '../../hooks/useDashboardsListQueryParams';
|
||||
import { useDashboardViewsStore } from '../../store/useDashboardViewsStore';
|
||||
import { useDashboardsListVisibleColumnsStore } from '../../store/useVisibleColumnsStore';
|
||||
import type { UpdatedWindow } from '../../types';
|
||||
import type { DashboardListItem } from '../../utils';
|
||||
import ConfigureMetadataModal from '../ConfigureMetadataModal/ConfigureMetadataModal';
|
||||
import { useDashboardsListVisibleColumnsStore } from '../ConfigureMetadataModal/useDynamicColumns';
|
||||
import CreateDashboardDropdown from '../CreateDashboardDropdown/CreateDashboardDropdown';
|
||||
import ImportJSONModal from '../ImportJSONModal/ImportJSONModal';
|
||||
import ListHeader from '../ListHeader/ListHeader';
|
||||
import EmptyState from '../states/EmptyState/EmptyState';
|
||||
import ErrorState from '../states/ErrorState/ErrorState';
|
||||
import LoadingState from '../states/LoadingState/LoadingState';
|
||||
import NoResultsState from '../states/NoResultsState/NoResultsState';
|
||||
import SearchBar from '../SearchBar/SearchBar';
|
||||
import DashboardsListContent from './DashboardsListContent';
|
||||
import { applyClientView } from '../../views';
|
||||
import type { CreatorOption } from '../FilterZone/FilterChips';
|
||||
import FilterZone from '../FilterZone/FilterZone';
|
||||
import NewDashboardModal from '../NewDashboardModal/NewDashboardModal';
|
||||
import StatusBar from '../StatusBar/StatusBar';
|
||||
import ViewsRail from '../ViewsRail/ViewsRail';
|
||||
import CommandHeader from './CommandHeader';
|
||||
import DashboardsResults from './DashboardsResults';
|
||||
import WorkspaceEmptyState from './WorkspaceEmptyState';
|
||||
|
||||
import styles from './DashboardsList.module.scss';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
// Favorites / recently-viewed are filtered client-side (no server id filter), so
|
||||
// we pull a single large page and constrain it in-memory.
|
||||
const CLIENT_VIEW_LIMIT = 200;
|
||||
|
||||
function DashboardsList(): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { t } = useTranslation('dashboard');
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { isCloudUser } = useGetTenantLicense();
|
||||
|
||||
const { user } = useAppContext();
|
||||
@@ -58,38 +48,100 @@ function DashboardsList(): JSX.Element {
|
||||
user.role,
|
||||
);
|
||||
|
||||
const [searchString, setSearchString] = useSearch();
|
||||
const {
|
||||
filters,
|
||||
query,
|
||||
isEmpty: filtersEmpty,
|
||||
setSearch,
|
||||
setCreatedBy,
|
||||
setUpdated,
|
||||
applyFilters,
|
||||
clearAll,
|
||||
} = useDashboardFilters();
|
||||
const [sortColumn, setSortColumn] = useSortColumn();
|
||||
const [sortOrder, setSortOrder] = useSortOrder();
|
||||
const [page, setPage] = usePage();
|
||||
|
||||
const [searchInput, setSearchInput] = useState(searchString);
|
||||
const {
|
||||
activeViewId,
|
||||
builtinViews,
|
||||
customViews,
|
||||
isCustomActive,
|
||||
isModified,
|
||||
viewQuery,
|
||||
clientView,
|
||||
selectView,
|
||||
saveView,
|
||||
saveActiveView,
|
||||
resetView,
|
||||
removeView,
|
||||
} = useActiveView({ filters, applyFilters, userEmail: user.email });
|
||||
|
||||
// Keep the local input in sync with external searchString changes
|
||||
// (browser back/forward, deep link). User typing only mutates
|
||||
// searchInput, so this won't fight with in-flight edits.
|
||||
useEffect(() => {
|
||||
setSearchInput(searchString);
|
||||
}, [searchString]);
|
||||
const railCollapsed = useDashboardViewsStore((s) => s.railCollapsed);
|
||||
const setRailCollapsed = useDashboardViewsStore((s) => s.setRailCollapsed);
|
||||
const favorites = useDashboardViewsStore((s) => s.favorites);
|
||||
const recent = useDashboardViewsStore((s) => s.recent);
|
||||
|
||||
const handleSubmitSearch = useCallback((): void => {
|
||||
const next = searchInput.trim();
|
||||
if (next === searchString) {
|
||||
return;
|
||||
}
|
||||
void setSearchString(next);
|
||||
// Any filter change resets to the first page so the user isn't stranded on a
|
||||
// now-out-of-range offset.
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string): void => {
|
||||
setSearch(value);
|
||||
void setPage(1);
|
||||
},
|
||||
[setSearch, setPage],
|
||||
);
|
||||
const handleCreatedByChange = useCallback(
|
||||
(emails: string[]): void => {
|
||||
setCreatedBy(emails);
|
||||
void setPage(1);
|
||||
},
|
||||
[setCreatedBy, setPage],
|
||||
);
|
||||
const handleUpdatedChange = useCallback(
|
||||
(window: UpdatedWindow): void => {
|
||||
setUpdated(window);
|
||||
void setPage(1);
|
||||
},
|
||||
[setUpdated, setPage],
|
||||
);
|
||||
const handleClearAll = useCallback((): void => {
|
||||
clearAll();
|
||||
void setPage(1);
|
||||
}, [searchInput, searchString, setSearchString, setPage]);
|
||||
}, [clearAll, setPage]);
|
||||
|
||||
// View actions that change the result set reset pagination too.
|
||||
const handleSelectView = useCallback(
|
||||
(id: string): void => {
|
||||
selectView(id);
|
||||
void setPage(1);
|
||||
},
|
||||
[selectView, setPage],
|
||||
);
|
||||
const handleResetView = useCallback((): void => {
|
||||
resetView();
|
||||
void setPage(1);
|
||||
}, [resetView, setPage]);
|
||||
const handleRemoveView = useCallback(
|
||||
(id: string): void => {
|
||||
removeView(id);
|
||||
void setPage(1);
|
||||
},
|
||||
[removeView, setPage],
|
||||
);
|
||||
const toggleRail = useCallback((): void => {
|
||||
setRailCollapsed(!railCollapsed);
|
||||
}, [setRailCollapsed, railCollapsed]);
|
||||
|
||||
const listParams = useMemo(
|
||||
() => ({
|
||||
query: searchString.trim() || undefined,
|
||||
query: combineQueries(viewQuery, query) || undefined,
|
||||
sort: sortColumn,
|
||||
order: sortOrder,
|
||||
limit: PAGE_SIZE,
|
||||
offset: (page - 1) * PAGE_SIZE,
|
||||
limit: clientView ? CLIENT_VIEW_LIMIT : PAGE_SIZE,
|
||||
offset: clientView ? 0 : (page - 1) * PAGE_SIZE,
|
||||
}),
|
||||
[searchString, sortColumn, sortOrder, page],
|
||||
[viewQuery, query, sortColumn, sortOrder, page, clientView],
|
||||
);
|
||||
|
||||
const {
|
||||
@@ -107,52 +159,49 @@ function DashboardsList(): JSX.Element {
|
||||
const errorHttpStatus = apiError?.getHttpStatusCode();
|
||||
const errorMessage = apiError?.getErrorMessage();
|
||||
|
||||
const dashboards = useMemo<DashboardListItem[]>(
|
||||
const rawDashboards = useMemo<DashboardListItem[]>(
|
||||
() => response?.data?.dashboards ?? [],
|
||||
[response],
|
||||
);
|
||||
const total = response?.data?.total ?? 0;
|
||||
|
||||
const [isImportOpen, setIsImportOpen] = useState(false);
|
||||
const [isConfigureOpen, setIsConfigureOpen] = useState(false);
|
||||
// Favorites / recently-viewed constrain the fetched rows by a client-side id
|
||||
// set; all other views are already constrained server-side.
|
||||
const dashboards = useMemo<DashboardListItem[]>(
|
||||
() =>
|
||||
clientView
|
||||
? applyClientView(rawDashboards, activeViewId, favorites, recent)
|
||||
: rawDashboards,
|
||||
[clientView, rawDashboards, activeViewId, favorites, recent],
|
||||
);
|
||||
const total = clientView ? dashboards.length : (response?.data?.total ?? 0);
|
||||
|
||||
// Creator filter options: distinct authors on the loaded page plus the
|
||||
// current user (so "me" is always selectable). Page-scoped until a members
|
||||
// source backs this.
|
||||
const creatorOptions = useMemo<CreatorOption[]>(() => {
|
||||
const emails = new Set<string>();
|
||||
if (user.email) {
|
||||
emails.add(user.email);
|
||||
}
|
||||
rawDashboards.forEach((d) => {
|
||||
if (d.createdBy) {
|
||||
emails.add(d.createdBy);
|
||||
}
|
||||
});
|
||||
return [...emails].sort().map((email) => ({
|
||||
email,
|
||||
label: email === user.email ? `${email} (me)` : email,
|
||||
}));
|
||||
}, [rawDashboards, user.email]);
|
||||
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const visibleColumns = useDashboardsListVisibleColumnsStore(
|
||||
(s) => s.visibleColumns,
|
||||
);
|
||||
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const handleCreateNew = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
logEvent('Dashboard List: Create dashboard clicked', {});
|
||||
setCreating(true);
|
||||
const created = await createDashboardV2({
|
||||
schemaVersion: 'v6',
|
||||
// Backend requires `name` (immutable, server-side identifier);
|
||||
// asking it to generate one keeps the UI's "new dashboard" flow.
|
||||
generateName: true,
|
||||
tags: null,
|
||||
spec: {
|
||||
display: { name: t('new_dashboard_title', { ns: 'dashboard' }) },
|
||||
layouts: [],
|
||||
panels: {},
|
||||
variables: [],
|
||||
// TODO(@AshwinBhatkal): duration and refresh interval need to be integrated
|
||||
},
|
||||
});
|
||||
safeNavigate(
|
||||
generatePath(ROUTES.DASHBOARD, { dashboardId: created.data.id }),
|
||||
);
|
||||
} catch (e) {
|
||||
showErrorModal(e as APIError);
|
||||
toast.error((e as AxiosError).toString() || 'Failed to create dashboard');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}, [safeNavigate, showErrorModal, t]);
|
||||
|
||||
const handleImportToggle = useCallback((): void => {
|
||||
logEvent('Dashboard List V2: Import JSON clicked', {});
|
||||
setIsImportOpen((s) => !s);
|
||||
const openCreate = useCallback((): void => {
|
||||
logEvent('Dashboard List: New dashboard clicked', {});
|
||||
setIsCreateOpen(true);
|
||||
}, []);
|
||||
|
||||
const onSortChange = useCallback(
|
||||
@@ -180,102 +229,109 @@ function DashboardsList(): JSX.Element {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isLoading]);
|
||||
|
||||
const activeLabel =
|
||||
customViews.find((v) => v.id === activeViewId)?.name ??
|
||||
builtinViews.find((v) => v.id === activeViewId)?.label ??
|
||||
'Dashboards';
|
||||
|
||||
// The workspace-empty CTA ("create your first dashboard") belongs only to the
|
||||
// unfiltered All view; every other view's zero result is a no-results state.
|
||||
const showWorkspaceEmpty =
|
||||
!error &&
|
||||
dashboards.length === 0 &&
|
||||
activeViewId === 'all' &&
|
||||
filtersEmpty &&
|
||||
page === 1;
|
||||
|
||||
const isWorkspaceEmpty = showWorkspaceEmpty && !isLoading;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.viewContent}>
|
||||
<div className={styles.titleContainer}>
|
||||
<Typography.Title className={styles.title}>Dashboards</Typography.Title>
|
||||
<Typography.Text className={styles.subtitle}>
|
||||
Create and manage dashboards for your workspace.
|
||||
</Typography.Text>
|
||||
{isCloudUser && (
|
||||
<div className={styles.integrationsContainer}>
|
||||
<div className={styles.integrationsContent}>
|
||||
<RequestDashboardBtn />
|
||||
<div className={styles.layout}>
|
||||
<ViewsRail
|
||||
activeViewId={activeViewId}
|
||||
builtinViews={builtinViews}
|
||||
customViews={customViews}
|
||||
isCustomActive={isCustomActive}
|
||||
isModified={isModified}
|
||||
collapsed={railCollapsed}
|
||||
onSelect={handleSelectView}
|
||||
onSave={saveView}
|
||||
onSaveChanges={saveActiveView}
|
||||
onReset={handleResetView}
|
||||
onClearFilters={handleClearAll}
|
||||
onDelete={handleRemoveView}
|
||||
/>
|
||||
<div className={styles.main}>
|
||||
<div className={styles.mainScroll}>
|
||||
{isWorkspaceEmpty ? (
|
||||
<WorkspaceEmptyState
|
||||
canCreate={canCreateNewDashboard}
|
||||
onCreate={openCreate}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.headerZone}>
|
||||
<CommandHeader
|
||||
label={activeLabel}
|
||||
count={total}
|
||||
canCreate={canCreateNewDashboard}
|
||||
onCreate={openCreate}
|
||||
/>
|
||||
<FilterZone
|
||||
search={filters.search}
|
||||
createdBy={filters.createdBy}
|
||||
updated={filters.updated}
|
||||
creatorOptions={creatorOptions}
|
||||
isEmpty={filtersEmpty}
|
||||
onSearchChange={handleSearchChange}
|
||||
onCreatedByChange={handleCreatedByChange}
|
||||
onUpdatedChange={handleUpdatedChange}
|
||||
onClearAll={handleClearAll}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<LoadingState />
|
||||
) : !error && dashboards.length === 0 && !searchString && page === 1 ? (
|
||||
<EmptyState
|
||||
createDropdown={
|
||||
canCreateNewDashboard ? (
|
||||
<CreateDashboardDropdown
|
||||
canCreate={!!canCreateNewDashboard}
|
||||
onCreate={handleCreateNew}
|
||||
onImportJSON={handleImportToggle}
|
||||
variant="text"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.toolbar}>
|
||||
<SearchBar
|
||||
value={searchInput}
|
||||
onChange={setSearchInput}
|
||||
onSubmit={handleSubmitSearch}
|
||||
/>
|
||||
{canCreateNewDashboard && (
|
||||
<CreateDashboardDropdown
|
||||
canCreate={!!canCreateNewDashboard}
|
||||
onCreate={handleCreateNew}
|
||||
onImportJSON={handleImportToggle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<ErrorState
|
||||
isCloudUser={!!isCloudUser}
|
||||
onRetry={(): void => {
|
||||
refetch();
|
||||
}}
|
||||
httpStatus={errorHttpStatus}
|
||||
errorMessage={errorMessage}
|
||||
/>
|
||||
) : dashboards.length === 0 ? (
|
||||
<NoResultsState searchString={searchInput} />
|
||||
) : (
|
||||
<>
|
||||
<ListHeader
|
||||
<div className={styles.viewContent}>
|
||||
<DashboardsResults
|
||||
isLoading={isLoading}
|
||||
hasError={!!error}
|
||||
isCloudUser={!!isCloudUser}
|
||||
onRetry={(): void => {
|
||||
refetch();
|
||||
}}
|
||||
errorHttpStatus={errorHttpStatus}
|
||||
errorMessage={errorMessage}
|
||||
dashboards={dashboards}
|
||||
activeViewId={activeViewId}
|
||||
searchValue={filters.search}
|
||||
hasFilters={!filtersEmpty}
|
||||
sortColumn={sortColumn}
|
||||
onSortChange={onSortChange}
|
||||
sortOrder={sortOrder}
|
||||
onOrderChange={onOrderChange}
|
||||
onConfigureMetadata={(): void => setIsConfigureOpen(true)}
|
||||
/>
|
||||
<DashboardsListContent
|
||||
dashboards={dashboards}
|
||||
page={page}
|
||||
pageSize={PAGE_SIZE}
|
||||
pageSize={clientView ? CLIENT_VIEW_LIMIT : PAGE_SIZE}
|
||||
total={total}
|
||||
onPageChange={setPage}
|
||||
canAct={!!action}
|
||||
showUpdatedAt={visibleColumns.updatedAt}
|
||||
showUpdatedBy={visibleColumns.updatedBy}
|
||||
loading={creating || isFetching}
|
||||
loading={isFetching}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<ImportJSONModal
|
||||
open={isImportOpen}
|
||||
onClose={(): void => setIsImportOpen(false)}
|
||||
/>
|
||||
|
||||
<ConfigureMetadataModal
|
||||
open={isConfigureOpen}
|
||||
previewDashboard={dashboards[0]}
|
||||
onClose={(): void => setIsConfigureOpen(false)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<StatusBar
|
||||
collapsed={railCollapsed}
|
||||
onToggleCollapse={toggleRail}
|
||||
count={dashboards.length}
|
||||
total={total}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<NewDashboardModal
|
||||
open={isCreateOpen}
|
||||
onClose={(): void => setIsCreateOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
DashboardtypesListOrderDTO,
|
||||
DashboardtypesListSortDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { DashboardListItem } from '../../utils';
|
||||
import { noResultsCopy } from '../../views';
|
||||
import ListHeader from '../ListHeader/ListHeader';
|
||||
import ErrorState from '../states/ErrorState/ErrorState';
|
||||
import LoadingState from '../states/LoadingState/LoadingState';
|
||||
import NoResultsState from '../states/NoResultsState/NoResultsState';
|
||||
import DashboardsListContent from './DashboardsListContent';
|
||||
|
||||
interface Props {
|
||||
isLoading: boolean;
|
||||
hasError: boolean;
|
||||
isCloudUser: boolean;
|
||||
onRetry: () => void;
|
||||
errorHttpStatus?: number;
|
||||
errorMessage?: string;
|
||||
dashboards: DashboardListItem[];
|
||||
activeViewId: string;
|
||||
searchValue: string;
|
||||
hasFilters: boolean;
|
||||
sortColumn: DashboardtypesListSortDTO;
|
||||
onSortChange: (column: DashboardtypesListSortDTO) => void;
|
||||
sortOrder: DashboardtypesListOrderDTO;
|
||||
onOrderChange: (order: DashboardtypesListOrderDTO) => void;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
onPageChange: (page: number) => void;
|
||||
canAct: boolean;
|
||||
showUpdatedAt: boolean;
|
||||
showUpdatedBy: boolean;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
function DashboardsResults({
|
||||
isLoading,
|
||||
hasError,
|
||||
isCloudUser,
|
||||
onRetry,
|
||||
errorHttpStatus,
|
||||
errorMessage,
|
||||
dashboards,
|
||||
activeViewId,
|
||||
searchValue,
|
||||
hasFilters,
|
||||
sortColumn,
|
||||
onSortChange,
|
||||
sortOrder,
|
||||
onOrderChange,
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
onPageChange,
|
||||
canAct,
|
||||
showUpdatedAt,
|
||||
showUpdatedBy,
|
||||
loading,
|
||||
}: Props): JSX.Element {
|
||||
if (isLoading) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
if (hasError) {
|
||||
return (
|
||||
<ErrorState
|
||||
isCloudUser={isCloudUser}
|
||||
onRetry={onRetry}
|
||||
httpStatus={errorHttpStatus}
|
||||
errorMessage={errorMessage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (dashboards.length === 0) {
|
||||
const copy = noResultsCopy(activeViewId, searchValue, hasFilters);
|
||||
return <NoResultsState title={copy.title} description={copy.description} />;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<ListHeader
|
||||
sortColumn={sortColumn}
|
||||
onSortChange={onSortChange}
|
||||
sortOrder={sortOrder}
|
||||
onOrderChange={onOrderChange}
|
||||
/>
|
||||
<DashboardsListContent
|
||||
dashboards={dashboards}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
onPageChange={onPageChange}
|
||||
canAct={canAct}
|
||||
showUpdatedAt={showUpdatedAt}
|
||||
showUpdatedBy={showUpdatedBy}
|
||||
loading={loading}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardsResults;
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
|
||||
interface Props {
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function NewDashboardButton({ onClick }: Props): JSX.Element {
|
||||
return (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={onClick}
|
||||
testId="new-dashboard-cta"
|
||||
>
|
||||
New dashboard
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default NewDashboardButton;
|
||||
@@ -0,0 +1,23 @@
|
||||
import EmptyState from '../states/EmptyState/EmptyState';
|
||||
import NewDashboardButton from './NewDashboardButton';
|
||||
|
||||
import styles from './DashboardsList.module.scss';
|
||||
|
||||
interface Props {
|
||||
canCreate: boolean;
|
||||
onCreate: () => void;
|
||||
}
|
||||
|
||||
function WorkspaceEmptyState({ canCreate, onCreate }: Props): JSX.Element {
|
||||
return (
|
||||
<div className={styles.emptyWrap}>
|
||||
<EmptyState
|
||||
createDropdown={
|
||||
canCreate ? <NewDashboardButton onClick={onCreate} /> : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WorkspaceEmptyState;
|
||||
@@ -1,3 +0,0 @@
|
||||
import DashboardsList from './DashboardsList';
|
||||
|
||||
export default DashboardsList;
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
import { CalendarClock, ChevronDown, User } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
|
||||
import type { UpdatedWindow } from '../../types';
|
||||
|
||||
import styles from './FilterZone.module.scss';
|
||||
|
||||
export interface CreatorOption {
|
||||
email: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const UPDATED_LABELS: Record<UpdatedWindow, string> = {
|
||||
any: 'Any time',
|
||||
today: 'Today',
|
||||
'7d': 'Last 7 days',
|
||||
'30d': 'Last 30 days',
|
||||
};
|
||||
|
||||
const UPDATED_WINDOWS: UpdatedWindow[] = ['any', 'today', '7d', '30d'];
|
||||
|
||||
interface Props {
|
||||
createdBy: string[];
|
||||
updated: UpdatedWindow;
|
||||
creatorOptions: CreatorOption[];
|
||||
onCreatedByChange: (emails: string[]) => void;
|
||||
onUpdatedChange: (window: UpdatedWindow) => void;
|
||||
}
|
||||
|
||||
function FilterChips({
|
||||
createdBy,
|
||||
updated,
|
||||
creatorOptions,
|
||||
onCreatedByChange,
|
||||
onUpdatedChange,
|
||||
}: Props): JSX.Element {
|
||||
const createdByLabel = useMemo((): string => {
|
||||
if (createdBy.length === 0) {
|
||||
return 'Anyone';
|
||||
}
|
||||
if (createdBy.length === 1) {
|
||||
const match = creatorOptions.find((o) => o.email === createdBy[0]);
|
||||
return match?.label ?? createdBy[0];
|
||||
}
|
||||
return `${createdBy.length} people`;
|
||||
}, [createdBy, creatorOptions]);
|
||||
|
||||
const createdByItems = useMemo<MenuItem[]>(() => {
|
||||
const items: MenuItem[] = creatorOptions.map((option) => ({
|
||||
type: 'checkbox',
|
||||
key: option.email,
|
||||
label: option.label,
|
||||
checked: createdBy.includes(option.email),
|
||||
onCheckedChange: (checked: boolean): void =>
|
||||
onCreatedByChange(
|
||||
checked
|
||||
? [...createdBy, option.email]
|
||||
: createdBy.filter((e) => e !== option.email),
|
||||
),
|
||||
}));
|
||||
if (createdBy.length > 0) {
|
||||
items.push({ type: 'divider', key: 'sep' });
|
||||
items.push({
|
||||
key: 'clear',
|
||||
label: 'Clear selection',
|
||||
onClick: (): void => onCreatedByChange([]),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}, [creatorOptions, createdBy, onCreatedByChange]);
|
||||
|
||||
const updatedItems = useMemo<MenuItem[]>(
|
||||
() => [
|
||||
{
|
||||
type: 'radio-group',
|
||||
value: updated,
|
||||
onChange: (value: string): void => onUpdatedChange(value as UpdatedWindow),
|
||||
children: UPDATED_WINDOWS.map((window) => ({
|
||||
type: 'radio',
|
||||
key: window,
|
||||
value: window,
|
||||
label: UPDATED_LABELS[window],
|
||||
})),
|
||||
},
|
||||
],
|
||||
[updated, onUpdatedChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.chips}>
|
||||
<DropdownMenuSimple menu={{ items: createdByItems }} align="start">
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<User size={12} />}
|
||||
suffix={<ChevronDown size={12} />}
|
||||
className={cx(styles.chip, {
|
||||
[styles.chipActive]: createdBy.length > 0,
|
||||
})}
|
||||
testId="dashboards-filter-created-by"
|
||||
>
|
||||
Created by: {createdByLabel}
|
||||
</Button>
|
||||
</DropdownMenuSimple>
|
||||
|
||||
<DropdownMenuSimple menu={{ items: updatedItems }} align="start">
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<CalendarClock size={12} />}
|
||||
suffix={<ChevronDown size={12} />}
|
||||
className={cx(styles.chip, {
|
||||
[styles.chipActive]: updated !== 'any',
|
||||
})}
|
||||
testId="dashboards-filter-updated"
|
||||
>
|
||||
Updated: {UPDATED_LABELS[updated]}
|
||||
</Button>
|
||||
</DropdownMenuSimple>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FilterChips;
|
||||
@@ -0,0 +1,50 @@
|
||||
.filterZone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.searchRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filtersRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filtersLabel {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--l2-foreground);
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chip {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.chipActive {
|
||||
border-color: var(--primary-background) !important;
|
||||
color: var(--l1-foreground) !important;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { type ReactNode, useCallback, useEffect, useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { X } from '@signozhq/icons';
|
||||
|
||||
import type { UpdatedWindow } from '../../types';
|
||||
import SearchBar from '../SearchBar/SearchBar';
|
||||
import FilterChips, { type CreatorOption } from './FilterChips';
|
||||
|
||||
import styles from './FilterZone.module.scss';
|
||||
|
||||
interface Props {
|
||||
search: string;
|
||||
createdBy: string[];
|
||||
updated: UpdatedWindow;
|
||||
creatorOptions: CreatorOption[];
|
||||
isEmpty: boolean;
|
||||
onSearchChange: (value: string) => void;
|
||||
onCreatedByChange: (emails: string[]) => void;
|
||||
onUpdatedChange: (window: UpdatedWindow) => void;
|
||||
onClearAll: () => void;
|
||||
// Rendered at the end of the search row (e.g. the New Dashboard action).
|
||||
rightSlot?: ReactNode;
|
||||
}
|
||||
|
||||
// The filter command zone: name search + structured chips (created-by, updated)
|
||||
// + clear-all. Search is committed on submit/blur (matching the prior bar);
|
||||
// chips apply immediately.
|
||||
function FilterZone({
|
||||
search,
|
||||
createdBy,
|
||||
updated,
|
||||
creatorOptions,
|
||||
isEmpty,
|
||||
onSearchChange,
|
||||
onCreatedByChange,
|
||||
onUpdatedChange,
|
||||
onClearAll,
|
||||
rightSlot,
|
||||
}: Props): JSX.Element {
|
||||
const [searchInput, setSearchInput] = useState(search);
|
||||
|
||||
// Keep the local input in sync with external search changes (applying a view,
|
||||
// clear-all, back/forward). User typing only mutates the local copy.
|
||||
useEffect(() => {
|
||||
setSearchInput(search);
|
||||
}, [search]);
|
||||
|
||||
const handleSubmit = useCallback((): void => {
|
||||
const next = searchInput.trim();
|
||||
if (next !== search) {
|
||||
onSearchChange(next);
|
||||
}
|
||||
}, [searchInput, search, onSearchChange]);
|
||||
|
||||
return (
|
||||
<div className={styles.filterZone}>
|
||||
<div className={styles.searchRow}>
|
||||
<div className={styles.searchInput}>
|
||||
<SearchBar
|
||||
value={searchInput}
|
||||
placeholder="Search dashboards by name"
|
||||
onChange={setSearchInput}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
{rightSlot}
|
||||
</div>
|
||||
<div className={styles.filtersRow}>
|
||||
<span className={styles.filtersLabel}>Filters</span>
|
||||
<FilterChips
|
||||
createdBy={createdBy}
|
||||
updated={updated}
|
||||
creatorOptions={creatorOptions}
|
||||
onCreatedByChange={onCreatedByChange}
|
||||
onUpdatedChange={onUpdatedChange}
|
||||
/>
|
||||
{!isEmpty && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<X size={12} />}
|
||||
onClick={onClearAll}
|
||||
testId="dashboards-filter-clear"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FilterZone;
|
||||
@@ -1,73 +0,0 @@
|
||||
.contentContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.contentHeader {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.jsonError {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.errorText {
|
||||
color: var(--warning-background);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
:global(.importJsonModalWrapper) {
|
||||
:global(.ant-modal-content) {
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: linear-gradient(
|
||||
139deg,
|
||||
color-mix(in srgb, var(--card) 80%, transparent) 0%,
|
||||
color-mix(in srgb, var(--card) 90%, transparent) 98.68%
|
||||
);
|
||||
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(20px);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:global(.margin) {
|
||||
background: linear-gradient(
|
||||
139deg,
|
||||
color-mix(in srgb, var(--card) 80%, transparent) 0%,
|
||||
color-mix(in srgb, var(--card) 90%, transparent) 98.68%
|
||||
);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
:global(.view-lines) {
|
||||
background: linear-gradient(
|
||||
139deg,
|
||||
color-mix(in srgb, var(--card) 80%, transparent) 0%,
|
||||
color-mix(in srgb, var(--card) 90%, transparent) 98.68%
|
||||
);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
:global(.ant-modal-footer) {
|
||||
margin-top: 0;
|
||||
padding: 16px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
}
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { red } from '@ant-design/colors';
|
||||
import MEditor, { Monaco } from '@monaco-editor/react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Flex, Modal, Upload, UploadProps } from 'antd';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import {
|
||||
CircleAlert,
|
||||
ExternalLink,
|
||||
Github,
|
||||
MonitorDot,
|
||||
MoveRight,
|
||||
Sparkles,
|
||||
} from '@signozhq/icons';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { createDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import sampleDashboard from './sampleDashboard.json';
|
||||
|
||||
import styles from './ImportJSONModal.module.scss';
|
||||
import { normalizeToPostable } from './ImportJSONModalUtils';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function ImportJSONModal({ open, onClose }: Props): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { t } = useTranslation(['dashboard', 'common']);
|
||||
const [isUploadError, setIsUploadError] = useState(false);
|
||||
const [isCreateError, setIsCreateError] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [editorValue, setEditorValue] = useState('');
|
||||
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const handleUpload: UploadProps['onChange'] = (info) => {
|
||||
const lastFile = info.fileList[info.fileList.length - 1];
|
||||
if (!lastFile?.originFileObj) {
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event): void => {
|
||||
try {
|
||||
const target = event.target?.result;
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
const parsed = JSON.parse(target.toString());
|
||||
setEditorValue(JSON.stringify(parsed, null, 2));
|
||||
setIsUploadError(false);
|
||||
} catch {
|
||||
setIsUploadError(true);
|
||||
}
|
||||
};
|
||||
reader.readAsText(lastFile.originFileObj);
|
||||
};
|
||||
|
||||
const handleImport = async (): Promise<void> => {
|
||||
try {
|
||||
setIsCreating(true);
|
||||
logEvent('Dashboard List V2: Import and next clicked', {});
|
||||
const parsed = JSON.parse(editorValue) as Record<string, unknown>;
|
||||
const payload = normalizeToPostable(parsed);
|
||||
const response = await createDashboardV2(payload);
|
||||
safeNavigate(
|
||||
generatePath(ROUTES.DASHBOARD, { dashboardId: response.data.id }),
|
||||
);
|
||||
logEvent('Dashboard List V2: New dashboard imported successfully', {
|
||||
dashboardId: response.data?.id,
|
||||
});
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
setIsCreateError(true);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('error_loading_json'),
|
||||
);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = (): void => {
|
||||
setIsUploadError(false);
|
||||
setIsCreateError(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const setEditorTheme = (monaco: Monaco): void => {
|
||||
monaco.editor.defineTheme('my-theme', {
|
||||
base: 'vs-dark',
|
||||
inherit: true,
|
||||
rules: [
|
||||
{ token: 'string.key.json', foreground: Color.BG_VANILLA_400 },
|
||||
{ token: 'string.value.json', foreground: Color.BG_ROBIN_400 },
|
||||
],
|
||||
colors: { 'editor.background': Color.BG_INK_300 },
|
||||
});
|
||||
};
|
||||
|
||||
const renderError = (msg: string): JSX.Element => (
|
||||
<div className={styles.jsonError}>
|
||||
<CircleAlert size="md" color={red[7]} />
|
||||
<Typography className={styles.errorText}>{msg}</Typography>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
wrapClassName="importJsonModalWrapper"
|
||||
open={open}
|
||||
centered
|
||||
closable
|
||||
keyboard
|
||||
maskClosable
|
||||
onCancel={handleClose}
|
||||
destroyOnClose
|
||||
width="60vw"
|
||||
footer={
|
||||
<div className={styles.footer}>
|
||||
{isCreateError && renderError(t('error_loading_json'))}
|
||||
{isUploadError && renderError(t('error_upload_json'))}
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Flex gap="small">
|
||||
<Upload
|
||||
accept=".json"
|
||||
showUploadList={false}
|
||||
multiple={false}
|
||||
onChange={handleUpload}
|
||||
beforeUpload={(): boolean => false}
|
||||
action="none"
|
||||
>
|
||||
<Button
|
||||
type="default"
|
||||
className="periscope-btn"
|
||||
icon={<MonitorDot size={14} />}
|
||||
onClick={(): void => {
|
||||
logEvent('Dashboard List V2: Upload JSON file clicked', {});
|
||||
}}
|
||||
>
|
||||
{t('upload_json_file')}
|
||||
</Button>
|
||||
</Upload>
|
||||
<Button
|
||||
type="default"
|
||||
className="periscope-btn"
|
||||
icon={<Sparkles size={14} />}
|
||||
onClick={(): void => {
|
||||
setEditorValue(JSON.stringify(sampleDashboard, null, 2));
|
||||
setIsUploadError(false);
|
||||
logEvent('Dashboard List V2: Load sample clicked', {});
|
||||
}}
|
||||
>
|
||||
Load sample
|
||||
</Button>
|
||||
<a
|
||||
href="https://signoz.io/docs/dashboards/dashboard-templates/overview/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button
|
||||
type="default"
|
||||
className="periscope-btn"
|
||||
icon={<Github size={14} />}
|
||||
>
|
||||
{t('view_template')}
|
||||
<ExternalLink size={14} />
|
||||
</Button>
|
||||
</a>
|
||||
</Flex>
|
||||
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
loading={isCreating}
|
||||
className="periscope-btn primary"
|
||||
type="primary"
|
||||
>
|
||||
{t('import_and_next')} <MoveRight size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className={styles.contentContainer}>
|
||||
<div className={styles.contentHeader}>
|
||||
<Typography.Text>{t('import_json')}</Typography.Text>
|
||||
</div>
|
||||
<MEditor
|
||||
language="json"
|
||||
height="40vh"
|
||||
onChange={(newValue): void => setEditorValue(newValue || '')}
|
||||
value={editorValue}
|
||||
options={{
|
||||
scrollbar: { alwaysConsumeMouseWheel: false },
|
||||
minimap: { enabled: false },
|
||||
fontSize: 14,
|
||||
fontFamily: 'Space Mono',
|
||||
}}
|
||||
theme={isDarkMode ? 'my-theme' : 'light'}
|
||||
onMount={(_, monaco): void => {
|
||||
document.fonts.ready.then(() => {
|
||||
monaco.editor.remeasureFonts();
|
||||
});
|
||||
}}
|
||||
beforeMount={setEditorTheme}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImportJSONModal;
|
||||
@@ -1,154 +0,0 @@
|
||||
{
|
||||
"display": {
|
||||
"name": "NV dashboard with sections",
|
||||
"description": ""
|
||||
},
|
||||
"datasources": {
|
||||
"SigNozDatasource": {
|
||||
"default": true,
|
||||
"plugin": {
|
||||
"kind": "signoz/Datasource",
|
||||
"spec": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"panels": {
|
||||
"b424e23b": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": ""
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "signoz/NumberPanel",
|
||||
"spec": {
|
||||
"formatting": {
|
||||
"unit": "s",
|
||||
"decimalPrecision": "2"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "TimeSeriesQuery",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "signoz/BuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [
|
||||
{
|
||||
"metricName": "container.cpu.time",
|
||||
"reduceTo": "sum",
|
||||
"spaceAggregation": "sum",
|
||||
"timeAggregation": "rate"
|
||||
}
|
||||
],
|
||||
"filter": {
|
||||
"expression": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"251df4d5": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": ""
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "signoz/TimeSeriesPanel",
|
||||
"spec": {
|
||||
"visualization": {
|
||||
"fillSpans": false
|
||||
},
|
||||
"formatting": {
|
||||
"unit": "recommendations",
|
||||
"decimalPrecision": "2"
|
||||
},
|
||||
"chartAppearance": {
|
||||
"lineInterpolation": "spline",
|
||||
"showPoints": false,
|
||||
"lineStyle": "solid",
|
||||
"fillMode": "none",
|
||||
"spanGaps": {"fillOnlyBelow": true}
|
||||
},
|
||||
"legend": {
|
||||
"position": "bottom"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "TimeSeriesQuery",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "signoz/BuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [
|
||||
{
|
||||
"metricName": "app_recommendations_counter",
|
||||
"reduceTo": "sum",
|
||||
"spaceAggregation": "sum",
|
||||
"timeAggregation": "rate"
|
||||
}
|
||||
],
|
||||
"filter": {
|
||||
"expression": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"layouts": [
|
||||
{
|
||||
"kind": "Grid",
|
||||
"spec": {
|
||||
"display": {
|
||||
"title": "Bravo"
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/b424e23b"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "Grid",
|
||||
"spec": {
|
||||
"display": {
|
||||
"title": "Alpha"
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/251df4d5"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -6,9 +6,8 @@
|
||||
height: 44px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 6px 6px 0px 0px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
box-shadow: 0px 4px 12px 0px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid var(--l2-border);
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
.label {
|
||||
@@ -23,10 +22,36 @@
|
||||
|
||||
.rightActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.sortPrefix {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
// Inline metadata-visibility toggles (replaces the configure modal).
|
||||
.metaPanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 220px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.metaRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.metaLabel {
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
// Shared trigger button for the sort + configure-group icons in the right
|
||||
// actions cluster. Provides a square hover/active background so users know
|
||||
// which icon they're targeting.
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { Button, Popover, Tooltip } from 'antd';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- Popover/Tooltip not yet migrated for this menu
|
||||
import { Popover, Tooltip } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import {
|
||||
ArrowDownWideNarrow,
|
||||
Check,
|
||||
Ellipsis,
|
||||
HdmiPort,
|
||||
} from '@signozhq/icons';
|
||||
import { ArrowDown, ArrowUp, Check, HdmiPort } from '@signozhq/icons';
|
||||
|
||||
import {
|
||||
DashboardtypesListOrderDTO,
|
||||
DashboardtypesListSortDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
type DashboardDynamicColumns,
|
||||
useDashboardsListVisibleColumnsStore,
|
||||
} from '../../store/useVisibleColumnsStore';
|
||||
|
||||
import styles from './ListHeader.module.scss';
|
||||
|
||||
interface Props {
|
||||
@@ -19,131 +22,178 @@ interface Props {
|
||||
onSortChange: (column: DashboardtypesListSortDTO) => void;
|
||||
sortOrder: DashboardtypesListOrderDTO;
|
||||
onOrderChange: (order: DashboardtypesListOrderDTO) => void;
|
||||
onConfigureMetadata: () => void;
|
||||
}
|
||||
|
||||
const SORT_LABELS: Record<DashboardtypesListSortDTO, string> = {
|
||||
[DashboardtypesListSortDTO.updated_at]: 'Last updated',
|
||||
[DashboardtypesListSortDTO.created_at]: 'Last created',
|
||||
[DashboardtypesListSortDTO.name]: 'Name',
|
||||
};
|
||||
|
||||
// Created-at / created-by are always shown; only the "updated" columns toggle.
|
||||
const METADATA_COLUMNS: {
|
||||
key: keyof DashboardDynamicColumns;
|
||||
label: string;
|
||||
}[] = [
|
||||
{ key: 'updatedAt', label: 'Updated at' },
|
||||
{ key: 'updatedBy', label: 'Updated by' },
|
||||
];
|
||||
|
||||
function ListHeader({
|
||||
sortColumn,
|
||||
onSortChange,
|
||||
sortOrder,
|
||||
onOrderChange,
|
||||
onConfigureMetadata,
|
||||
}: Props): JSX.Element {
|
||||
const visibleColumns = useDashboardsListVisibleColumnsStore(
|
||||
(s) => s.visibleColumns,
|
||||
);
|
||||
const setVisibleColumns = useDashboardsListVisibleColumnsStore(
|
||||
(s) => s.setVisibleColumns,
|
||||
);
|
||||
|
||||
const metadataContent = (
|
||||
<div className={styles.metaPanel}>
|
||||
<Typography.Text className={styles.sortHeading}>Metadata</Typography.Text>
|
||||
{METADATA_COLUMNS.map((col) => (
|
||||
<div key={col.key} className={styles.metaRow}>
|
||||
<Typography.Text className={styles.metaLabel}>{col.label}</Typography.Text>
|
||||
<Switch
|
||||
value={visibleColumns[col.key]}
|
||||
testId={`metadata-toggle-${col.key}`}
|
||||
onChange={(checked): void =>
|
||||
setVisibleColumns({ ...visibleColumns, [col.key]: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<Typography.Text className={styles.label}>All Dashboards</Typography.Text>
|
||||
<Typography.Text className={styles.label}>Results</Typography.Text>
|
||||
<section className={styles.rightActions}>
|
||||
<Tooltip title="Sort">
|
||||
<Popover
|
||||
trigger="click"
|
||||
content={
|
||||
<div className={styles.sortContent}>
|
||||
<Typography.Text className={styles.sortHeading}>
|
||||
Sort By
|
||||
</Typography.Text>
|
||||
<Button
|
||||
type="text"
|
||||
className={styles.sortButton}
|
||||
onClick={(): void => onSortChange(DashboardtypesListSortDTO.name)}
|
||||
data-testid="sort-by-name"
|
||||
>
|
||||
Name
|
||||
{sortColumn === DashboardtypesListSortDTO.name && <Check size={14} />}
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
className={styles.sortButton}
|
||||
onClick={(): void =>
|
||||
onSortChange(DashboardtypesListSortDTO.created_at)
|
||||
}
|
||||
data-testid="sort-by-last-created"
|
||||
>
|
||||
Last created
|
||||
{sortColumn === DashboardtypesListSortDTO.created_at && (
|
||||
<Check size={14} />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
className={styles.sortButton}
|
||||
onClick={(): void =>
|
||||
onSortChange(DashboardtypesListSortDTO.updated_at)
|
||||
}
|
||||
data-testid="sort-by-last-updated"
|
||||
>
|
||||
Last updated
|
||||
{sortColumn === DashboardtypesListSortDTO.updated_at && (
|
||||
<Check size={14} />
|
||||
)}
|
||||
</Button>
|
||||
<div className={styles.sortDivider} />
|
||||
<Typography.Text className={styles.sortHeading}>Order</Typography.Text>
|
||||
<Button
|
||||
type="text"
|
||||
className={styles.sortButton}
|
||||
onClick={(): void => onOrderChange(DashboardtypesListOrderDTO.asc)}
|
||||
data-testid="sort-order-asc"
|
||||
>
|
||||
Ascending
|
||||
{sortOrder === DashboardtypesListOrderDTO.asc && <Check size={14} />}
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
className={styles.sortButton}
|
||||
onClick={(): void => onOrderChange(DashboardtypesListOrderDTO.desc)}
|
||||
data-testid="sort-order-desc"
|
||||
>
|
||||
Descending
|
||||
{sortOrder === DashboardtypesListOrderDTO.desc && <Check size={14} />}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
rootClassName="sortDashboardsPopover"
|
||||
placement="bottomRight"
|
||||
arrow={false}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconTrigger}
|
||||
data-testid="sort-by"
|
||||
aria-label="Sort"
|
||||
>
|
||||
<ArrowDownWideNarrow size={14} />
|
||||
</button>
|
||||
</Popover>
|
||||
</Tooltip>
|
||||
<Popover
|
||||
trigger="click"
|
||||
content={
|
||||
<div className={styles.configureContent}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.configureItem}
|
||||
onClick={(e): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onConfigureMetadata();
|
||||
}}
|
||||
data-testid="configure-metadata-trigger"
|
||||
<div className={styles.sortContent}>
|
||||
<Typography.Text className={styles.sortHeading}>Sort By</Typography.Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className={styles.sortButton}
|
||||
onClick={(): void => onSortChange(DashboardtypesListSortDTO.name)}
|
||||
testId="sort-by-name"
|
||||
suffix={
|
||||
sortColumn === DashboardtypesListSortDTO.name ? (
|
||||
<Check size={14} />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<span className={styles.configureIcon}>
|
||||
<HdmiPort size={14} />
|
||||
</span>
|
||||
<span>Configure metadata</span>
|
||||
</button>
|
||||
Name
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className={styles.sortButton}
|
||||
onClick={(): void => onSortChange(DashboardtypesListSortDTO.created_at)}
|
||||
testId="sort-by-last-created"
|
||||
suffix={
|
||||
sortColumn === DashboardtypesListSortDTO.created_at ? (
|
||||
<Check size={14} />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
Last created
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className={styles.sortButton}
|
||||
onClick={(): void => onSortChange(DashboardtypesListSortDTO.updated_at)}
|
||||
testId="sort-by-last-updated"
|
||||
suffix={
|
||||
sortColumn === DashboardtypesListSortDTO.updated_at ? (
|
||||
<Check size={14} />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
Last updated
|
||||
</Button>
|
||||
<div className={styles.sortDivider} />
|
||||
<Typography.Text className={styles.sortHeading}>Order</Typography.Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className={styles.sortButton}
|
||||
onClick={(): void => onOrderChange(DashboardtypesListOrderDTO.asc)}
|
||||
testId="sort-order-asc"
|
||||
suffix={
|
||||
sortOrder === DashboardtypesListOrderDTO.asc ? (
|
||||
<Check size={14} />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
Ascending
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className={styles.sortButton}
|
||||
onClick={(): void => onOrderChange(DashboardtypesListOrderDTO.desc)}
|
||||
testId="sort-order-desc"
|
||||
suffix={
|
||||
sortOrder === DashboardtypesListOrderDTO.desc ? (
|
||||
<Check size={14} />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
Descending
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
rootClassName="sortDashboardsPopover"
|
||||
placement="bottomRight"
|
||||
arrow={false}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
testId="sort-by"
|
||||
aria-label="Sort"
|
||||
suffix={
|
||||
sortOrder === DashboardtypesListOrderDTO.asc ? (
|
||||
<ArrowUp size={12} />
|
||||
) : (
|
||||
<ArrowDown size={12} />
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className={styles.sortPrefix}>Sort:</span>{' '}
|
||||
{SORT_LABELS[sortColumn]}{' '}
|
||||
</Button>
|
||||
</Popover>
|
||||
|
||||
<Popover
|
||||
trigger="click"
|
||||
content={metadataContent}
|
||||
rootClassName="configureGroupPopover"
|
||||
placement="bottomRight"
|
||||
arrow={false}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconTrigger}
|
||||
aria-label="More options"
|
||||
>
|
||||
<Ellipsis size={14} />
|
||||
</button>
|
||||
<Tooltip title="Metadata">
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Metadata"
|
||||
testId="configure-metadata-trigger"
|
||||
>
|
||||
<HdmiPort size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { type ChangeEvent, useState } from 'react';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- no @signozhq/ui multiline TextArea yet
|
||||
import { Input as AntInput } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { AxiosError } from 'axios';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { createDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { toPostableTags } from '../../utils';
|
||||
|
||||
import styles from './NewDashboardModal.module.scss';
|
||||
|
||||
const DEFAULT_NAME = 'Sample Dashboard';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function BlankDashboardPanel({ onClose }: Props): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const [name, setName] = useState(DEFAULT_NAME);
|
||||
const [description, setDescription] = useState('');
|
||||
const [tags, setTags] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const canSubmit = name.trim().length > 0 && !submitting;
|
||||
|
||||
const handleCreate = async (): Promise<void> => {
|
||||
if (!canSubmit) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSubmitting(true);
|
||||
logEvent('Dashboard List: Create dashboard clicked', {});
|
||||
const postableTags = toPostableTags(tags);
|
||||
const created = await createDashboardV2({
|
||||
schemaVersion: 'v6',
|
||||
generateName: true,
|
||||
tags: postableTags.length ? postableTags : null,
|
||||
spec: {
|
||||
display: {
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
},
|
||||
layouts: [],
|
||||
panels: {},
|
||||
variables: [],
|
||||
},
|
||||
});
|
||||
safeNavigate(
|
||||
generatePath(ROUTES.DASHBOARD, { dashboardId: created.data.id }),
|
||||
);
|
||||
} catch (e) {
|
||||
showErrorModal(e as APIError);
|
||||
toast.error((e as AxiosError).toString() || 'Failed to create dashboard');
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.form}>
|
||||
<div className={styles.field}>
|
||||
<Typography.Text className={styles.label}>
|
||||
Title <span className={styles.required}>*</span>
|
||||
</Typography.Text>
|
||||
<Input
|
||||
value={name}
|
||||
autoFocus
|
||||
placeholder="e.g. Sample Dashboard"
|
||||
testId="create-dashboard-name"
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
|
||||
setName(e.target.value)
|
||||
}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter') {
|
||||
void handleCreate();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<Typography.Text className={styles.label}>Description</Typography.Text>
|
||||
{/* eslint-disable-next-line signoz/no-antd-components -- no @signozhq TextArea yet */}
|
||||
<AntInput.TextArea
|
||||
value={description}
|
||||
rows={3}
|
||||
placeholder="What is this dashboard for?"
|
||||
data-testid="create-dashboard-description"
|
||||
onChange={(e): void => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<Typography.Text className={styles.label}>Tags</Typography.Text>
|
||||
<Input
|
||||
value={tags}
|
||||
placeholder="team:jarvis, prod"
|
||||
testId="create-dashboard-tags"
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
|
||||
setTags(e.target.value)
|
||||
}
|
||||
/>
|
||||
<Typography.Text className={styles.hint}>
|
||||
Comma-separated. Use key:value (e.g. team:jarvis) or a single label.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="md"
|
||||
onClick={onClose}
|
||||
testId="create-dashboard-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
disabled={!canSubmit}
|
||||
testId="create-dashboard-submit"
|
||||
onClick={(): void => {
|
||||
void handleCreate();
|
||||
}}
|
||||
>
|
||||
Create dashboard
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BlankDashboardPanel;
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { red } from '@ant-design/colors';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- Upload has no @signozhq/ui equivalent yet
|
||||
import { Upload, UploadProps } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { CircleAlert, MonitorDot, MoveRight } from '@signozhq/icons';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { createDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { normalizeToPostable } from './importUtils';
|
||||
import JsonEditor from './JsonEditor';
|
||||
|
||||
import styles from './NewDashboardModal.module.scss';
|
||||
|
||||
function ImportJsonPanel(): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { t } = useTranslation(['dashboard', 'common']);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const [editorValue, setEditorValue] = useState('');
|
||||
const [isUploadError, setIsUploadError] = useState(false);
|
||||
const [isCreateError, setIsCreateError] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
const handleUpload: UploadProps['onChange'] = (info) => {
|
||||
const lastFile = info.fileList[info.fileList.length - 1];
|
||||
if (!lastFile?.originFileObj) {
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event): void => {
|
||||
try {
|
||||
const target = event.target?.result;
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
const parsed = JSON.parse(target.toString());
|
||||
setEditorValue(JSON.stringify(parsed, null, 2));
|
||||
setIsUploadError(false);
|
||||
} catch {
|
||||
setIsUploadError(true);
|
||||
}
|
||||
};
|
||||
reader.readAsText(lastFile.originFileObj);
|
||||
};
|
||||
|
||||
const handleImport = async (): Promise<void> => {
|
||||
try {
|
||||
setIsCreating(true);
|
||||
logEvent('Dashboard List V2: Import and next clicked', {});
|
||||
const parsed = JSON.parse(editorValue) as Record<string, unknown>;
|
||||
const payload = normalizeToPostable(parsed);
|
||||
const response = await createDashboardV2(payload);
|
||||
safeNavigate(
|
||||
generatePath(ROUTES.DASHBOARD, { dashboardId: response.data.id }),
|
||||
);
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
setIsCreateError(true);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('error_loading_json'),
|
||||
);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<Typography.Text className={styles.importHeader}>
|
||||
{t('import_json')}
|
||||
</Typography.Text>
|
||||
|
||||
<JsonEditor value={editorValue} onChange={setEditorValue} height="280px" />
|
||||
|
||||
{(isCreateError || isUploadError) && (
|
||||
<div className={styles.jsonError}>
|
||||
<CircleAlert size="md" color={red[7]} />
|
||||
<Typography className={styles.errorText}>
|
||||
{isUploadError ? t('error_upload_json') : t('error_loading_json')}
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.importFooter}>
|
||||
<Upload
|
||||
accept=".json"
|
||||
showUploadList={false}
|
||||
multiple={false}
|
||||
onChange={handleUpload}
|
||||
beforeUpload={(): boolean => false}
|
||||
action="none"
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="md"
|
||||
prefix={<MonitorDot size={14} />}
|
||||
testId="upload-json-file"
|
||||
onClick={(): void => {
|
||||
logEvent('Dashboard List V2: Upload JSON file clicked', {});
|
||||
}}
|
||||
>
|
||||
{t('upload_json_file')}
|
||||
</Button>
|
||||
</Upload>
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
loading={isCreating}
|
||||
suffix={<MoveRight size={14} />}
|
||||
testId="import-json-submit"
|
||||
onClick={handleImport}
|
||||
>
|
||||
{t('import_and_next')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImportJsonPanel;
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useState } from 'react';
|
||||
import MEditor, { Monaco } from '@monaco-editor/react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Maximize2 } from '@signozhq/icons';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import styles from './NewDashboardModal.module.scss';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange?: (value: string) => void;
|
||||
readOnly?: boolean;
|
||||
height?: string;
|
||||
}
|
||||
|
||||
const defineTheme = (monaco: Monaco): void => {
|
||||
monaco.editor.defineTheme('my-theme', {
|
||||
base: 'vs-dark',
|
||||
inherit: true,
|
||||
rules: [
|
||||
{ token: 'string.key.json', foreground: Color.BG_VANILLA_400 },
|
||||
{ token: 'string.value.json', foreground: Color.BG_ROBIN_400 },
|
||||
],
|
||||
colors: { 'editor.background': Color.BG_INK_300 },
|
||||
});
|
||||
};
|
||||
|
||||
// JSON editor with a one-click "expand" into an extra-wide modal for easier
|
||||
// editing/review. The expanded editor shares the same value, so edits persist.
|
||||
function JsonEditor({
|
||||
value,
|
||||
onChange,
|
||||
readOnly = false,
|
||||
height = '38vh',
|
||||
}: Props): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const renderEditor = (editorHeight: string): JSX.Element => (
|
||||
<MEditor
|
||||
language="json"
|
||||
height={editorHeight}
|
||||
value={value}
|
||||
onChange={(next): void => onChange?.(next || '')}
|
||||
options={{
|
||||
readOnly,
|
||||
scrollbar: { alwaysConsumeMouseWheel: false },
|
||||
minimap: { enabled: false },
|
||||
fontSize: 14,
|
||||
fontFamily: 'Space Mono',
|
||||
}}
|
||||
theme={isDarkMode ? 'my-theme' : 'light'}
|
||||
beforeMount={defineTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.editorWrap}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={styles.expandBtn}
|
||||
aria-label="Expand editor"
|
||||
testId="json-editor-expand"
|
||||
onClick={(): void => setExpanded(true)}
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
</Button>
|
||||
<div className={styles.editor}>{renderEditor(height)}</div>
|
||||
|
||||
<DialogWrapper
|
||||
title={readOnly ? 'Preview JSON' : 'Edit JSON'}
|
||||
open={expanded}
|
||||
width="extra-wide"
|
||||
onOpenChange={(next): void => {
|
||||
if (!next) {
|
||||
setExpanded(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={styles.editorExpanded}>{renderEditor('70vh')}</div>
|
||||
</DialogWrapper>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default JsonEditor;
|
||||
@@ -0,0 +1,195 @@
|
||||
// Fixed height so the modal doesn't resize when switching tabs.
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding-top: 12px;
|
||||
height: 460px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.required {
|
||||
color: var(--danger-background);
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.loading {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: var(--l3-foreground);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.cardName {
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.cardDesc {
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.requestRow {
|
||||
border-top: 1px solid var(--l2-border);
|
||||
padding-top: 12px;
|
||||
|
||||
:global(.request-entity-container) {
|
||||
gap: 10px 16px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.importHeader {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.editorWrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.expandBtn {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.editor {
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editorExpanded {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.templatesLayout {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.templatesList {
|
||||
flex: none;
|
||||
width: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.templateItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.12s,
|
||||
border-color 0.12s;
|
||||
}
|
||||
|
||||
.templateItem:hover {
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.templateItemActive {
|
||||
background: var(--l2-background);
|
||||
border-color: var(--primary-background);
|
||||
}
|
||||
|
||||
.templateName {
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.templateCat {
|
||||
color: var(--l3-foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.templatesPreview {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.previewHead {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.jsonError {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.errorText {
|
||||
color: var(--danger-background);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.importFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: auto;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Tabs } from '@signozhq/ui/tabs';
|
||||
|
||||
import BlankDashboardPanel from './BlankDashboardPanel';
|
||||
import ImportJsonPanel from './ImportJsonPanel';
|
||||
import TemplatesPanel from './TemplatesPanel';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function NewDashboardModal({ open, onClose }: Props): JSX.Element {
|
||||
const [tab, setTab] = useState('blank');
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTab('blank');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
title="New dashboard"
|
||||
open={open}
|
||||
width="wide"
|
||||
onOpenChange={(next): void => {
|
||||
if (!next) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={(key): void => setTab(key)}
|
||||
items={[
|
||||
{
|
||||
key: 'blank',
|
||||
label: 'Blank',
|
||||
children: <BlankDashboardPanel onClose={onClose} />,
|
||||
},
|
||||
{
|
||||
key: 'template',
|
||||
label: 'From a template',
|
||||
children: <TemplatesPanel />,
|
||||
},
|
||||
{
|
||||
key: 'import',
|
||||
label: 'Import JSON',
|
||||
children: <ImportJsonPanel />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default NewDashboardModal;
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useState } from 'react';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { ExternalLink, LoaderCircle } from '@signozhq/icons';
|
||||
import { AxiosError } from 'axios';
|
||||
import cx from 'classnames';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { createDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { RequestDashboardBtn } from 'container/ListOfDashboard/RequestDashboardBtn';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import { normalizeToPostable } from './importUtils';
|
||||
import JsonEditor from './JsonEditor';
|
||||
import { useDashboardTemplates } from './templatesData';
|
||||
|
||||
import styles from './NewDashboardModal.module.scss';
|
||||
|
||||
// Browse the template gallery (mock data until the API lands): pick one on the
|
||||
// left to preview its JSON on the right, then use it or open the docs.
|
||||
function TemplatesPanel(): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { data, isLoading } = useDashboardTemplates(true);
|
||||
const templates = data ?? [];
|
||||
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const selected = templates.find((t) => t.id === selectedId) ?? templates[0];
|
||||
|
||||
const handleUse = async (): Promise<void> => {
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setCreating(true);
|
||||
logEvent('Dashboard List: Use template clicked', { template: selected.id });
|
||||
const parsed = JSON.parse(selected.json) as Record<string, unknown>;
|
||||
const created = await createDashboardV2(normalizeToPostable(parsed));
|
||||
safeNavigate(
|
||||
generatePath(ROUTES.DASHBOARD, { dashboardId: created.data.id }),
|
||||
);
|
||||
} catch (e) {
|
||||
showErrorModal(e as APIError);
|
||||
toast.error(
|
||||
(e as AxiosError).toString() || 'Failed to create from template',
|
||||
);
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.loading}>
|
||||
<LoaderCircle size={18} className={styles.spinner} />
|
||||
<span>Loading templates…</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.templatesLayout}>
|
||||
<div className={styles.templatesList}>
|
||||
{templates.map((template) => (
|
||||
<button
|
||||
key={template.id}
|
||||
type="button"
|
||||
className={cx(styles.templateItem, {
|
||||
[styles.templateItemActive]: selected?.id === template.id,
|
||||
})}
|
||||
data-testid={`template-${template.id}`}
|
||||
onClick={(): void => setSelectedId(template.id)}
|
||||
>
|
||||
<span className={styles.templateName}>{template.name}</span>
|
||||
<span className={styles.templateCat}>{template.category}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selected && (
|
||||
<div className={styles.templatesPreview}>
|
||||
<div className={styles.previewHead}>
|
||||
<div>
|
||||
<Typography.Text className={styles.cardName}>
|
||||
{selected.name}
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.cardDesc}>
|
||||
{selected.description}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
suffix={<ExternalLink size={13} />}
|
||||
onClick={(): void => openInNewTab(selected.href)}
|
||||
testId="template-docs"
|
||||
>
|
||||
Docs
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<JsonEditor value={selected.json} readOnly height="240px" />
|
||||
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
loading={creating}
|
||||
testId="use-template"
|
||||
onClick={(): void => {
|
||||
void handleUse();
|
||||
}}
|
||||
>
|
||||
Use template
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.requestRow}>
|
||||
<RequestDashboardBtn />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TemplatesPanel;
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useQuery, type UseQueryResult } from 'react-query';
|
||||
|
||||
export interface DashboardTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
href: string;
|
||||
// Importable dashboard definition previewed in the gallery (mock for now).
|
||||
json: string;
|
||||
}
|
||||
|
||||
// A representative dashboard definition for a template — mock until the API
|
||||
// returns real ones.
|
||||
const buildTemplateJson = (
|
||||
name: string,
|
||||
description: string,
|
||||
category: string,
|
||||
): string =>
|
||||
JSON.stringify(
|
||||
{
|
||||
schemaVersion: 'v6',
|
||||
generateName: true,
|
||||
tags: [{ key: 'category', value: category.toLowerCase() }],
|
||||
spec: {
|
||||
display: { name, description },
|
||||
layouts: [],
|
||||
panels: {},
|
||||
variables: [],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
// Mock catalogue until the templates API lands. Mirrors the public gallery at
|
||||
// https://signoz.io/docs/dashboards/dashboard-templates/overview/
|
||||
const BASE_TEMPLATES: Omit<DashboardTemplate, 'json'>[] = [
|
||||
{
|
||||
id: 'apm',
|
||||
name: 'APM Metrics',
|
||||
description: 'Latency, error rate, and throughput across your services.',
|
||||
category: 'APM',
|
||||
href: 'https://signoz.io/docs/dashboards/dashboard-templates/apm/',
|
||||
},
|
||||
{
|
||||
id: 'hostmetrics',
|
||||
name: 'Host Metrics',
|
||||
description: 'CPU, memory, disk, and network for your hosts.',
|
||||
category: 'Infra',
|
||||
href: 'https://signoz.io/docs/dashboards/dashboard-templates/hostmetrics/',
|
||||
},
|
||||
{
|
||||
id: 'kubernetes',
|
||||
name: 'Kubernetes Pod Metrics',
|
||||
description: 'Pod, node, and container health for your clusters.',
|
||||
category: 'Infra',
|
||||
href:
|
||||
'https://signoz.io/docs/dashboards/dashboard-templates/kubernetes-pod-metrics-detailed/',
|
||||
},
|
||||
{
|
||||
id: 'postgres',
|
||||
name: 'PostgreSQL',
|
||||
description: 'Connections, throughput, and query performance.',
|
||||
category: 'Databases',
|
||||
href: 'https://signoz.io/docs/dashboards/dashboard-templates/postgresql/',
|
||||
},
|
||||
{
|
||||
id: 'redis',
|
||||
name: 'Redis',
|
||||
description: 'Memory, commands, and hit-rate for Redis instances.',
|
||||
category: 'Databases',
|
||||
href: 'https://signoz.io/docs/dashboards/dashboard-templates/redis/',
|
||||
},
|
||||
{
|
||||
id: 'nginx',
|
||||
name: 'NGINX',
|
||||
description: 'Request rate, connections, and error responses.',
|
||||
category: 'Web servers',
|
||||
href: 'https://signoz.io/docs/dashboards/dashboard-templates/nginx/',
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_TEMPLATES: DashboardTemplate[] = BASE_TEMPLATES.map((t) => ({
|
||||
...t,
|
||||
json: buildTemplateJson(t.name, t.description, t.category),
|
||||
}));
|
||||
|
||||
// TODO(@AshwinBhatkal): replace with the real templates API when available.
|
||||
// The small delay simulates the network round-trip so the loading state is
|
||||
// exercised (a real API call won't resolve instantly).
|
||||
const fetchDashboardTemplates = (): Promise<DashboardTemplate[]> =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(() => resolve(MOCK_TEMPLATES), 600);
|
||||
});
|
||||
|
||||
export function useDashboardTemplates(
|
||||
enabled: boolean,
|
||||
): UseQueryResult<DashboardTemplate[]> {
|
||||
return useQuery({
|
||||
queryKey: ['dashboard-templates'],
|
||||
queryFn: fetchDashboardTemplates,
|
||||
enabled,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
@@ -9,12 +9,18 @@ interface Props {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
function SearchBar({ value, onChange, onSubmit }: Props): JSX.Element {
|
||||
function SearchBar({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
placeholder = "Search with DSL (e.g. name CONTAINS 'foo')",
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<Input
|
||||
placeholder="Search with DSL (e.g. name CONTAINS 'foo')"
|
||||
placeholder={placeholder}
|
||||
prefix={<Search size={12} color={Color.BG_VANILLA_400} />}
|
||||
suffix={
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
.statusBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
height: 36px;
|
||||
flex: none;
|
||||
padding: 0 16px;
|
||||
border-top: 1px solid var(--l2-border);
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { PanelLeftClose, PanelLeftOpen } from '@signozhq/icons';
|
||||
|
||||
import styles from './StatusBar.module.scss';
|
||||
|
||||
interface Props {
|
||||
collapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
count: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
function StatusBar({
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
count,
|
||||
total,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<div className={styles.statusBar}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={
|
||||
collapsed ? <PanelLeftOpen size={14} /> : <PanelLeftClose size={14} />
|
||||
}
|
||||
onClick={onToggleCollapse}
|
||||
testId="dashboards-rail-toggle"
|
||||
>
|
||||
{collapsed ? 'Expand' : 'Collapse'}
|
||||
</Button>
|
||||
<Typography.Text className={styles.count}>
|
||||
{count} of {total} dashboards
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StatusBar;
|
||||
@@ -0,0 +1,110 @@
|
||||
import { type ChangeEvent, type ReactNode, useEffect, useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { PopoverSimple } from '@signozhq/ui/popover';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { VIEW_ICON_OPTIONS } from '../../views';
|
||||
|
||||
import styles from './ViewsRail.module.scss';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSave: (name: string, icon: string) => void;
|
||||
trigger: ReactNode;
|
||||
}
|
||||
|
||||
const DEFAULT_ICON = VIEW_ICON_OPTIONS[0].name;
|
||||
|
||||
function SaveViewPopover({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
trigger,
|
||||
}: Props): JSX.Element {
|
||||
const [name, setName] = useState('');
|
||||
const [icon, setIcon] = useState(DEFAULT_ICON);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName('');
|
||||
setIcon(DEFAULT_ICON);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const canSave = name.trim().length > 0;
|
||||
|
||||
const handleSave = (): void => {
|
||||
if (canSave) {
|
||||
onSave(name, icon);
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PopoverSimple
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
align="start"
|
||||
trigger={trigger}
|
||||
>
|
||||
<div className={styles.savePopover}>
|
||||
<div className={styles.saveTitle}>Save as view</div>
|
||||
<span className={styles.saveLabel}>Name</span>
|
||||
<Input
|
||||
value={name}
|
||||
autoFocus
|
||||
placeholder="e.g. Prod alerts"
|
||||
testId="save-view-name"
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
|
||||
setName(e.target.value)
|
||||
}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSave();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className={styles.saveLabel}>Icon</span>
|
||||
<div className={styles.iconGrid}>
|
||||
{VIEW_ICON_OPTIONS.map(({ name: iconName, Icon }) => (
|
||||
<button
|
||||
key={iconName}
|
||||
type="button"
|
||||
aria-label={iconName}
|
||||
className={cx(styles.iconCell, {
|
||||
[styles.iconCellOn]: icon === iconName,
|
||||
})}
|
||||
onClick={(): void => setIcon(iconName)}
|
||||
>
|
||||
<Icon size={14} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.saveActions}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={(): void => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
disabled={!canSave}
|
||||
testId="save-view-confirm"
|
||||
onClick={handleSave}
|
||||
>
|
||||
Save view
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverSimple>
|
||||
);
|
||||
}
|
||||
|
||||
export default SaveViewPopover;
|
||||
@@ -0,0 +1,256 @@
|
||||
.rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 300px;
|
||||
flex: none;
|
||||
border-right: 1px solid var(--l2-border);
|
||||
background: var(--l1-background);
|
||||
overflow: hidden;
|
||||
transition: width 0.18s cubic-bezier(0.2, 0.7, 0.3, 1);
|
||||
}
|
||||
|
||||
.collapsed {
|
||||
width: 0;
|
||||
border-right-color: transparent;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 14px 12px 10px 16px;
|
||||
}
|
||||
|
||||
.headerTitle {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.search {
|
||||
padding: 0 12px 10px;
|
||||
}
|
||||
|
||||
.searchEmpty {
|
||||
padding: 12px;
|
||||
color: var(--l3-foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 0 8px 8px;
|
||||
}
|
||||
|
||||
.groupLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 16px 8px 8px;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.groupLabelSpaced {
|
||||
margin-top: 10px;
|
||||
border-top: 1px solid var(--l2-border);
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
.groupCount {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-normal);
|
||||
letter-spacing: 0;
|
||||
color: var(--l3-foreground);
|
||||
background: var(--l2-background);
|
||||
border-radius: 10px;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 3px 0;
|
||||
border-radius: 6px;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.rowActive {
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.item {
|
||||
// Neutralise the signoz Button defaults so it reads as a full-width,
|
||||
// left-aligned list row; the row coordinates hover/active colours below.
|
||||
--button-display: flex;
|
||||
--button-justify-content: flex-start;
|
||||
--button-height: auto;
|
||||
--button-padding: 9px 10px;
|
||||
--button-gap: 10px;
|
||||
--button-variant-ghost-background-color: transparent;
|
||||
--button-variant-ghost-hover-background-color: transparent;
|
||||
--button-variant-ghost-color: var(--l2-foreground);
|
||||
--button-variant-ghost-hover-color: var(--l1-foreground);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.row:hover .item,
|
||||
.rowActive .item {
|
||||
--button-variant-ghost-color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.itemIcon {
|
||||
display: inline-flex;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.rowActive .itemIcon {
|
||||
color: var(--primary-background);
|
||||
}
|
||||
|
||||
.itemLabel {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dirtyDot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--warning-background);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.itemAction {
|
||||
// Square icon button that surfaces on row hover and turns red on its own
|
||||
// hover; colours flow through the signoz Button tokens.
|
||||
--button-height: auto;
|
||||
--button-padding: 0;
|
||||
--button-border-radius: 4px;
|
||||
--button-variant-ghost-background-color: transparent;
|
||||
--button-variant-ghost-color: var(--l3-foreground);
|
||||
--button-variant-ghost-hover-background-color: var(--danger-background);
|
||||
--button-variant-ghost-hover-color: var(--danger-color, #fff);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 8px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s;
|
||||
}
|
||||
|
||||
.row:hover .itemAction {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 4px 8px;
|
||||
padding: 12px;
|
||||
border: 1px dashed var(--l2-border);
|
||||
border-radius: 8px;
|
||||
color: var(--l3-foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dirtyPanel {
|
||||
flex: none;
|
||||
padding: 12px 14px;
|
||||
border-top: 1px solid var(--l2-border);
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.dirtyPanelDefault {
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
.dirtyTitle {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--l2-foreground);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.dirtyActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.savePopover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 280px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.saveTitle {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.saveLabel {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l3-foreground);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.iconGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.iconCell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 34px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 6px;
|
||||
background: var(--l2-background);
|
||||
color: var(--l2-foreground);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.12s,
|
||||
color 0.12s;
|
||||
}
|
||||
|
||||
.iconCell:hover {
|
||||
color: var(--l1-foreground);
|
||||
border-color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.iconCellOn {
|
||||
border-color: var(--primary-background);
|
||||
color: var(--primary-background);
|
||||
}
|
||||
|
||||
.saveActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import { type ChangeEvent, useCallback, useState } from 'react';
|
||||
import { Modal } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { CircleAlert, Plus, Search, Trash2 } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
|
||||
import type { SavedView } from '../../types';
|
||||
import { type BuiltinView, iconByName } from '../../views';
|
||||
import SaveViewPopover from './SaveViewPopover';
|
||||
|
||||
import styles from './ViewsRail.module.scss';
|
||||
|
||||
interface Props {
|
||||
activeViewId: string;
|
||||
builtinViews: BuiltinView[];
|
||||
customViews: SavedView[];
|
||||
isCustomActive: boolean;
|
||||
isModified: boolean;
|
||||
collapsed?: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
onSave: (name: string, icon: string) => void;
|
||||
onSaveChanges: () => void;
|
||||
onReset: () => void;
|
||||
onClearFilters: () => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
interface ViewRow {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: BuiltinView['icon'];
|
||||
deletable?: boolean;
|
||||
}
|
||||
|
||||
// Purely presentational — active view, dirty state, and handlers come from
|
||||
// `useActiveView`.
|
||||
function ViewsRail({
|
||||
activeViewId,
|
||||
builtinViews,
|
||||
customViews,
|
||||
isCustomActive,
|
||||
isModified,
|
||||
collapsed = false,
|
||||
onSelect,
|
||||
onSave,
|
||||
onSaveChanges,
|
||||
onReset,
|
||||
onClearFilters,
|
||||
onDelete,
|
||||
}: Props): JSX.Element {
|
||||
const [saveOpen, setSaveOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [modal, contextHolder] = Modal.useModal();
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
const matchesQuery = (label: string): boolean =>
|
||||
!q || label.toLowerCase().includes(q);
|
||||
|
||||
const personal = builtinViews.filter(
|
||||
(v) => v.section === 'personal' && matchesQuery(v.label),
|
||||
);
|
||||
const system = builtinViews.filter(
|
||||
(v) => v.section === 'system' && matchesQuery(v.label),
|
||||
);
|
||||
const custom = customViews.filter((v) => matchesQuery(v.name));
|
||||
const noMatches =
|
||||
!!q && personal.length === 0 && system.length === 0 && custom.length === 0;
|
||||
|
||||
const confirmDelete = useCallback(
|
||||
(id: string, label: string): void => {
|
||||
const { destroy } = modal.confirm({
|
||||
title: (
|
||||
<Typography.Title level={5}>
|
||||
Delete the
|
||||
<span style={{ color: 'var(--danger-background)', fontWeight: 500 }}>
|
||||
{' '}
|
||||
{label}{' '}
|
||||
</span>
|
||||
view?
|
||||
</Typography.Title>
|
||||
),
|
||||
content: 'This removes the saved view. Your dashboards are not affected.',
|
||||
icon: (
|
||||
<CircleAlert
|
||||
style={{ color: 'var(--danger-background)', marginInlineEnd: '12px' }}
|
||||
size="3xl"
|
||||
/>
|
||||
),
|
||||
okText: 'Delete',
|
||||
okButtonProps: {
|
||||
danger: true,
|
||||
onClick: (e): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onDelete(id);
|
||||
destroy();
|
||||
},
|
||||
},
|
||||
centered: true,
|
||||
});
|
||||
},
|
||||
[modal, onDelete],
|
||||
);
|
||||
|
||||
const renderItem = (row: ViewRow): JSX.Element => {
|
||||
const Icon = row.icon;
|
||||
const active = row.id === activeViewId;
|
||||
return (
|
||||
<div key={row.id} className={cx(styles.row, { [styles.rowActive]: active })}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className={styles.item}
|
||||
onClick={(): void => onSelect(row.id)}
|
||||
testId={`dashboards-view-${row.id}`}
|
||||
>
|
||||
<span className={styles.itemIcon}>
|
||||
<Icon size={14} />
|
||||
</span>
|
||||
<span className={styles.itemLabel}>{row.label}</span>
|
||||
{active && isModified && (
|
||||
<span className={styles.dirtyDot} title="Unsaved changes" />
|
||||
)}
|
||||
</Button>
|
||||
{row.deletable && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={styles.itemAction}
|
||||
aria-label="Delete view"
|
||||
title="Delete view"
|
||||
onClick={(): void => confirmDelete(row.id, row.label)}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className={cx(styles.rail, { [styles.collapsed]: collapsed })}>
|
||||
<div className={styles.header}>
|
||||
<h4 className={styles.headerTitle}>Views</h4>
|
||||
<SaveViewPopover
|
||||
open={saveOpen}
|
||||
onOpenChange={setSaveOpen}
|
||||
onSave={onSave}
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
title="Save current filters as a view"
|
||||
testId="dashboards-view-save-trigger"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.search}>
|
||||
<Input
|
||||
value={query}
|
||||
placeholder="Search views"
|
||||
prefix={<Search size={12} />}
|
||||
testId="dashboards-view-search"
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
|
||||
setQuery(e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.scroll}>
|
||||
{personal.length > 0 && (
|
||||
<>
|
||||
<div className={styles.groupLabel}>Personal</div>
|
||||
{personal.map((v) => renderItem(v))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{system.length > 0 && (
|
||||
<>
|
||||
<div className={cx(styles.groupLabel, styles.groupLabelSpaced)}>
|
||||
System
|
||||
</div>
|
||||
{system.map((v) => renderItem(v))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{(!q || custom.length > 0) && (
|
||||
<>
|
||||
<div className={cx(styles.groupLabel, styles.groupLabelSpaced)}>
|
||||
My views
|
||||
<span className={styles.groupCount}>{customViews.length}</span>
|
||||
</div>
|
||||
{customViews.length === 0 ? (
|
||||
<div className={styles.empty}>
|
||||
No saved views yet. Filter the list, then save it as a view.
|
||||
</div>
|
||||
) : (
|
||||
custom.map((v) =>
|
||||
renderItem({
|
||||
id: v.id,
|
||||
label: v.name,
|
||||
icon: iconByName(v.icon),
|
||||
deletable: true,
|
||||
}),
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{noMatches && (
|
||||
<div className={styles.searchEmpty}>
|
||||
No views match “{query}”
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isCustomActive && isModified && (
|
||||
<div className={styles.dirtyPanel}>
|
||||
<div className={styles.dirtyTitle}>Unsaved changes</div>
|
||||
<div className={styles.dirtyActions}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
onClick={onSaveChanges}
|
||||
testId="dashboards-view-save-changes"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={(): void => setSaveOpen(true)}
|
||||
>
|
||||
Save as…
|
||||
</Button>
|
||||
<Button variant="ghost" color="secondary" size="sm" onClick={onReset}>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isCustomActive && isModified && (
|
||||
<div className={cx(styles.dirtyPanel, styles.dirtyPanelDefault)}>
|
||||
<div className={styles.dirtyTitle}>Filters active</div>
|
||||
<div className={styles.dirtyActions}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
prefix={<Plus size={12} />}
|
||||
onClick={(): void => setSaveOpen(true)}
|
||||
testId="dashboards-view-save-as-new"
|
||||
>
|
||||
Save as new view
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={onClearFilters}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{contextHolder}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export default ViewsRail;
|
||||
@@ -1,11 +1,18 @@
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
height: 125px;
|
||||
width: 100%;
|
||||
height: 76px;
|
||||
|
||||
// antd sizes the inner input element; stretch it to fill the row.
|
||||
:global(.ant-skeleton-input) {
|
||||
width: 100% !important;
|
||||
height: 76px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,14 @@ import { Skeleton } from 'antd';
|
||||
|
||||
import styles from './LoadingState.module.scss';
|
||||
|
||||
const ROWS = [0, 1, 2, 3, 4];
|
||||
|
||||
function LoadingState(): JSX.Element {
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<Skeleton.Input active size="large" className={styles.skeleton} />
|
||||
<Skeleton.Input active size="large" className={styles.skeleton} />
|
||||
<Skeleton.Input active size="large" className={styles.skeleton} />
|
||||
<Skeleton.Input active size="large" className={styles.skeleton} />
|
||||
{ROWS.map((row) => (
|
||||
<Skeleton.Input key={row} active block className={styles.skeleton} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,3 +3,15 @@
|
||||
padding: 105px 190px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--l3-foreground);
|
||||
font-size: var(--font-size-sm);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -5,16 +5,20 @@ import emptyStateUrl from '@/assets/Icons/emptyState.svg';
|
||||
import styles from './NoResultsState.module.scss';
|
||||
|
||||
interface Props {
|
||||
searchString: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
function NoResultsState({ searchString }: Props): JSX.Element {
|
||||
function NoResultsState({ title, description }: Props): JSX.Element {
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<img src={emptyStateUrl} alt="img" height={32} width={32} />
|
||||
<Typography.Text>
|
||||
No dashboards found for {searchString}. Create a new dashboard?
|
||||
</Typography.Text>
|
||||
<img src={emptyStateUrl} alt="" height={32} width={32} />
|
||||
<Typography.Text className={styles.title}>{title}</Typography.Text>
|
||||
{description && (
|
||||
<Typography.Text className={styles.description}>
|
||||
{description}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
85
frontend/src/pages/DashboardsListPageV2/filterQuery.ts
Normal file
85
frontend/src/pages/DashboardsListPageV2/filterQuery.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
// Pure, side-effect-free helpers that translate the UI filter state into the
|
||||
// backend list-filter DSL string (`GET /api/v2/dashboards?query=…`). Kept
|
||||
// testable and free of React so the same builder backs live filtering, saved
|
||||
// views, and built-in views.
|
||||
//
|
||||
// DSL reference (subset used here):
|
||||
// name CONTAINS 'text'
|
||||
// created_by = 'a@b.com' | created_by IN ['a@b.com', 'c@d.com']
|
||||
// updated_at >= '2026-06-01T00:00:00.000Z' (RFC3339)
|
||||
// locked = true
|
||||
// clauses joined with AND
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import type { DashboardFilterState, UpdatedWindow } from './types';
|
||||
|
||||
export const DEFAULT_FILTER_STATE: DashboardFilterState = {
|
||||
search: '',
|
||||
createdBy: [],
|
||||
updated: 'any',
|
||||
};
|
||||
|
||||
const UPDATED_WINDOW_DAYS: Record<Exclude<UpdatedWindow, 'any'>, number> = {
|
||||
today: 1,
|
||||
'7d': 7,
|
||||
'30d': 30,
|
||||
};
|
||||
|
||||
// Single-quoted DSL string literal with embedded quotes escaped.
|
||||
const literal = (value: string): string => `'${value.replace(/'/g, "\\'")}'`;
|
||||
|
||||
const updatedClause = (window: UpdatedWindow): string | null => {
|
||||
if (window === 'any') {
|
||||
return null;
|
||||
}
|
||||
const cutoff = dayjs()
|
||||
.subtract(UPDATED_WINDOW_DAYS[window], 'day')
|
||||
.toISOString();
|
||||
return `updated_at >= ${literal(cutoff)}`;
|
||||
};
|
||||
|
||||
export const filterStateToQuery = (state: DashboardFilterState): string => {
|
||||
const clauses: string[] = [];
|
||||
|
||||
const search = state.search.trim();
|
||||
if (search) {
|
||||
clauses.push(`name CONTAINS ${literal(search)}`);
|
||||
}
|
||||
|
||||
if (state.createdBy.length === 1) {
|
||||
clauses.push(`created_by = ${literal(state.createdBy[0])}`);
|
||||
} else if (state.createdBy.length > 1) {
|
||||
clauses.push(`created_by IN [${state.createdBy.map(literal).join(', ')}]`);
|
||||
}
|
||||
|
||||
const updated = updatedClause(state.updated);
|
||||
if (updated) {
|
||||
clauses.push(updated);
|
||||
}
|
||||
|
||||
return clauses.join(' AND ');
|
||||
};
|
||||
|
||||
// Combine independent query fragments (e.g. a built-in view's `locked = true`
|
||||
// plus the live filter state) into a single AND-composed query.
|
||||
export const combineQueries = (
|
||||
...parts: (string | null | undefined)[]
|
||||
): string =>
|
||||
parts
|
||||
.map((p) => p?.trim())
|
||||
.filter((p): p is string => !!p)
|
||||
.join(' AND ');
|
||||
|
||||
export const isFilterStateEmpty = (state: DashboardFilterState): boolean =>
|
||||
!state.search.trim() &&
|
||||
state.createdBy.length === 0 &&
|
||||
state.updated === 'any';
|
||||
|
||||
export const areFilterStatesEqual = (
|
||||
a: DashboardFilterState,
|
||||
b: DashboardFilterState,
|
||||
): boolean =>
|
||||
a.search.trim() === b.search.trim() &&
|
||||
a.updated === b.updated &&
|
||||
a.createdBy.length === b.createdBy.length &&
|
||||
[...a.createdBy].sort().join(',') === [...b.createdBy].sort().join(',');
|
||||
142
frontend/src/pages/DashboardsListPageV2/hooks/useActiveView.ts
Normal file
142
frontend/src/pages/DashboardsListPageV2/hooks/useActiveView.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { parseAsString, useQueryState, type Options } from 'nuqs';
|
||||
|
||||
import { DEFAULT_FILTER_STATE, areFilterStatesEqual } from '../filterQuery';
|
||||
import { useDashboardViewsStore } from '../store/useDashboardViewsStore';
|
||||
import type { DashboardFilterState, SavedView } from '../types';
|
||||
import {
|
||||
BUILTIN_VIEWS,
|
||||
builtinViewQuery,
|
||||
builtinViewSnapshot,
|
||||
type BuiltinView,
|
||||
isClientView,
|
||||
} from '../views';
|
||||
|
||||
const opts: Options = { history: 'push' };
|
||||
|
||||
interface UseActiveViewArgs {
|
||||
filters: DashboardFilterState;
|
||||
applyFilters: (next: DashboardFilterState) => void;
|
||||
userEmail: string;
|
||||
}
|
||||
|
||||
export interface UseActiveViewResult {
|
||||
activeViewId: string;
|
||||
builtinViews: BuiltinView[];
|
||||
customViews: SavedView[];
|
||||
isCustomActive: boolean;
|
||||
// Current filters diverge from the active view's canonical snapshot.
|
||||
isModified: boolean;
|
||||
// Extra server-query fragment the active view contributes, and whether it
|
||||
// constrains the list client-side (favorites/recent).
|
||||
viewQuery: string;
|
||||
clientView: boolean;
|
||||
selectView: (id: string) => void;
|
||||
saveView: (name: string, icon: string) => void;
|
||||
saveActiveView: () => void;
|
||||
resetView: () => void;
|
||||
removeView: (id: string) => void;
|
||||
}
|
||||
|
||||
// Orchestrates the active view: which view is selected (URL `view` param),
|
||||
// merging built-in + persisted custom views, applying a view's snapshot on
|
||||
// select, dirty detection, and save/reset/delete.
|
||||
export function useActiveView({
|
||||
filters,
|
||||
applyFilters,
|
||||
userEmail,
|
||||
}: UseActiveViewArgs): UseActiveViewResult {
|
||||
const [activeViewId, setActiveViewId] = useQueryState(
|
||||
'view',
|
||||
parseAsString.withDefault('all').withOptions(opts),
|
||||
);
|
||||
|
||||
const customViews = useDashboardViewsStore((s) => s.customViews);
|
||||
const addView = useDashboardViewsStore((s) => s.addView);
|
||||
const updateView = useDashboardViewsStore((s) => s.updateView);
|
||||
const deleteView = useDashboardViewsStore((s) => s.deleteView);
|
||||
|
||||
const activeCustom = useMemo(
|
||||
() => customViews.find((v) => v.id === activeViewId),
|
||||
[customViews, activeViewId],
|
||||
);
|
||||
|
||||
// The filter state the active view "is" — used to detect divergence.
|
||||
const canonicalSnapshot = useMemo<DashboardFilterState | null>(
|
||||
() =>
|
||||
activeCustom
|
||||
? activeCustom.filters
|
||||
: builtinViewSnapshot(activeViewId, userEmail),
|
||||
[activeCustom, activeViewId, userEmail],
|
||||
);
|
||||
|
||||
const isModified = canonicalSnapshot
|
||||
? !areFilterStatesEqual(filters, canonicalSnapshot)
|
||||
: false;
|
||||
|
||||
const selectView = useCallback(
|
||||
(id: string): void => {
|
||||
void setActiveViewId(id);
|
||||
const custom = customViews.find((v) => v.id === id);
|
||||
applyFilters(
|
||||
custom?.filters ??
|
||||
builtinViewSnapshot(id, userEmail) ??
|
||||
DEFAULT_FILTER_STATE,
|
||||
);
|
||||
},
|
||||
[setActiveViewId, customViews, applyFilters, userEmail],
|
||||
);
|
||||
|
||||
const saveView = useCallback(
|
||||
(name: string, icon: string): void => {
|
||||
const id = `cv_${Date.now()}`;
|
||||
addView({
|
||||
id,
|
||||
name: name.trim(),
|
||||
icon,
|
||||
filters: { ...filters },
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
void setActiveViewId(id);
|
||||
},
|
||||
[addView, filters, setActiveViewId],
|
||||
);
|
||||
|
||||
const saveActiveView = useCallback((): void => {
|
||||
if (activeCustom) {
|
||||
updateView(activeCustom.id, { filters: { ...filters } });
|
||||
}
|
||||
}, [activeCustom, updateView, filters]);
|
||||
|
||||
const resetView = useCallback((): void => {
|
||||
if (canonicalSnapshot) {
|
||||
applyFilters(canonicalSnapshot);
|
||||
}
|
||||
}, [canonicalSnapshot, applyFilters]);
|
||||
|
||||
const removeView = useCallback(
|
||||
(id: string): void => {
|
||||
deleteView(id);
|
||||
if (activeViewId === id) {
|
||||
void setActiveViewId('all');
|
||||
applyFilters(DEFAULT_FILTER_STATE);
|
||||
}
|
||||
},
|
||||
[deleteView, activeViewId, setActiveViewId, applyFilters],
|
||||
);
|
||||
|
||||
return {
|
||||
activeViewId,
|
||||
builtinViews: BUILTIN_VIEWS,
|
||||
customViews,
|
||||
isCustomActive: !!activeCustom,
|
||||
isModified,
|
||||
viewQuery: builtinViewQuery(activeViewId),
|
||||
clientView: isClientView(activeViewId),
|
||||
selectView,
|
||||
saveView,
|
||||
saveActiveView,
|
||||
resetView,
|
||||
removeView,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
parseAsArrayOf,
|
||||
parseAsString,
|
||||
parseAsStringLiteral,
|
||||
useQueryState,
|
||||
type Options,
|
||||
} from 'nuqs';
|
||||
|
||||
import {
|
||||
DEFAULT_FILTER_STATE,
|
||||
filterStateToQuery,
|
||||
isFilterStateEmpty,
|
||||
} from '../filterQuery';
|
||||
import type { DashboardFilterState, UpdatedWindow } from '../types';
|
||||
|
||||
const UPDATED_WINDOWS: UpdatedWindow[] = ['any', 'today', '7d', '30d'];
|
||||
|
||||
const opts: Options = { history: 'push' };
|
||||
|
||||
export interface UseDashboardFiltersResult {
|
||||
filters: DashboardFilterState;
|
||||
// The backend list-filter `query` string derived from the current filters.
|
||||
query: string;
|
||||
isEmpty: boolean;
|
||||
setSearch: (value: string) => void;
|
||||
setCreatedBy: (emails: string[]) => void;
|
||||
setUpdated: (window: UpdatedWindow) => void;
|
||||
// Replace the whole filter state at once — used when applying a saved view.
|
||||
applyFilters: (next: DashboardFilterState) => void;
|
||||
clearAll: () => void;
|
||||
}
|
||||
|
||||
// Owns the dashboards-list filter state, synced to the URL (shareable links,
|
||||
// back/forward) and projected into the backend `query` string. Sort/order/page
|
||||
// live in their own query-param hooks; this hook is filters-only.
|
||||
export function useDashboardFilters(): UseDashboardFiltersResult {
|
||||
const [search, setSearchState] = useQueryState(
|
||||
'search',
|
||||
parseAsString.withDefault('').withOptions(opts),
|
||||
);
|
||||
const [createdBy, setCreatedByState] = useQueryState(
|
||||
'createdBy',
|
||||
parseAsArrayOf(parseAsString).withDefault([]).withOptions(opts),
|
||||
);
|
||||
const [updated, setUpdatedState] = useQueryState(
|
||||
'updated',
|
||||
parseAsStringLiteral(UPDATED_WINDOWS).withDefault('any').withOptions(opts),
|
||||
);
|
||||
|
||||
const filters = useMemo<DashboardFilterState>(
|
||||
() => ({ search, createdBy, updated }),
|
||||
[search, createdBy, updated],
|
||||
);
|
||||
|
||||
const query = useMemo(() => filterStateToQuery(filters), [filters]);
|
||||
|
||||
const setSearch = useCallback(
|
||||
(value: string): void => {
|
||||
void setSearchState(value);
|
||||
},
|
||||
[setSearchState],
|
||||
);
|
||||
|
||||
const setCreatedBy = useCallback(
|
||||
(emails: string[]): void => {
|
||||
void setCreatedByState(emails.length ? emails : null);
|
||||
},
|
||||
[setCreatedByState],
|
||||
);
|
||||
|
||||
const setUpdated = useCallback(
|
||||
(window: UpdatedWindow): void => {
|
||||
void setUpdatedState(window);
|
||||
},
|
||||
[setUpdatedState],
|
||||
);
|
||||
|
||||
const applyFilters = useCallback(
|
||||
(next: DashboardFilterState): void => {
|
||||
void setSearchState(next.search || null);
|
||||
void setCreatedByState(next.createdBy.length ? next.createdBy : null);
|
||||
void setUpdatedState(next.updated);
|
||||
},
|
||||
[setSearchState, setCreatedByState, setUpdatedState],
|
||||
);
|
||||
|
||||
const clearAll = useCallback((): void => {
|
||||
applyFilters(DEFAULT_FILTER_STATE);
|
||||
}, [applyFilters]);
|
||||
|
||||
return {
|
||||
filters,
|
||||
query,
|
||||
isEmpty: isFilterStateEmpty(filters),
|
||||
setSearch,
|
||||
setCreatedBy,
|
||||
setUpdated,
|
||||
applyFilters,
|
||||
clearAll,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
|
||||
import type { SavedView } from '../types';
|
||||
|
||||
// Most-recently-viewed list is capped so it stays a useful shortlist.
|
||||
const RECENT_LIMIT = 20;
|
||||
|
||||
// Client-side persistence for everything the views feature owns until the views
|
||||
// API lands: user-saved views, favorite/recently-viewed dashboard ids, and the
|
||||
// rail collapse preference. Mirrors `useDashboardsListVisibleColumnsStore`.
|
||||
interface DashboardViewsState {
|
||||
customViews: SavedView[];
|
||||
favorites: string[]; // dashboard ids
|
||||
recent: string[]; // dashboard ids, most-recent first
|
||||
railCollapsed: boolean;
|
||||
addView: (view: SavedView) => void;
|
||||
updateView: (id: string, patch: Partial<Omit<SavedView, 'id'>>) => void;
|
||||
deleteView: (id: string) => void;
|
||||
toggleFavorite: (id: string) => void;
|
||||
markViewed: (id: string) => void;
|
||||
setRailCollapsed: (collapsed: boolean) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_STATE = {
|
||||
customViews: [] as SavedView[],
|
||||
favorites: [] as string[],
|
||||
recent: [] as string[],
|
||||
railCollapsed: false,
|
||||
};
|
||||
|
||||
export const useDashboardViewsStore = create<DashboardViewsState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
...DEFAULT_STATE,
|
||||
addView: (view): void => {
|
||||
set((s) => ({ customViews: [...s.customViews, view] }));
|
||||
},
|
||||
updateView: (id, patch): void => {
|
||||
set((s) => ({
|
||||
customViews: s.customViews.map((v) =>
|
||||
v.id === id ? { ...v, ...patch } : v,
|
||||
),
|
||||
}));
|
||||
},
|
||||
deleteView: (id): void => {
|
||||
set((s) => ({ customViews: s.customViews.filter((v) => v.id !== id) }));
|
||||
},
|
||||
toggleFavorite: (id): void => {
|
||||
set((s) => ({
|
||||
favorites: s.favorites.includes(id)
|
||||
? s.favorites.filter((f) => f !== id)
|
||||
: [...s.favorites, id],
|
||||
}));
|
||||
},
|
||||
markViewed: (id): void => {
|
||||
set((s) => ({
|
||||
recent: [id, ...s.recent.filter((r) => r !== id)].slice(0, RECENT_LIMIT),
|
||||
}));
|
||||
},
|
||||
setRailCollapsed: (collapsed): void => {
|
||||
set({ railCollapsed: collapsed });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: LOCALSTORAGE.DASHBOARDS_LIST_VIEWS,
|
||||
merge: (persisted, current) => ({
|
||||
...current,
|
||||
...DEFAULT_STATE,
|
||||
...((persisted as Partial<DashboardViewsState>) ?? {}),
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
27
frontend/src/pages/DashboardsListPageV2/types.ts
Normal file
27
frontend/src/pages/DashboardsListPageV2/types.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// Relative "updated within" windows offered by the Updated filter chip.
|
||||
export type UpdatedWindow = 'any' | 'today' | '7d' | '30d';
|
||||
|
||||
// The user-controllable filter state a view captures. (Tags are intentionally
|
||||
// excluded for now — the tag filter UI is deferred.) Sort/order are handled
|
||||
// separately via URL query params and are not part of a view snapshot.
|
||||
export interface DashboardFilterState {
|
||||
search: string;
|
||||
createdBy: string[]; // emails (created_by)
|
||||
updated: UpdatedWindow;
|
||||
}
|
||||
|
||||
// A saved view: a named, iconed snapshot of filter state. Persisted client-side
|
||||
// (localStorage) until the views API lands.
|
||||
export interface SavedView {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string; // @signozhq/icons icon name
|
||||
filters: DashboardFilterState;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
// Built-in views rendered above the user's saved views. Their result set is
|
||||
// derived (a fixed query fragment or a client-side id set), never persisted.
|
||||
export type BuiltinViewId = 'mine' | 'favorites' | 'recent' | 'all' | 'locked';
|
||||
|
||||
export type ViewSection = 'personal' | 'system' | 'custom';
|
||||
@@ -1,6 +1,9 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import type { DashboardtypesListedDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
DashboardtypesListedDashboardV2DTO,
|
||||
TagtypesPostableTagDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export type DashboardListItem = DashboardtypesListedDashboardV2DTO;
|
||||
|
||||
@@ -11,6 +14,24 @@ export const tagsToStrings = (
|
||||
tag.key === tag.value ? tag.key : `${tag.key}:${tag.value}`,
|
||||
);
|
||||
|
||||
// Inverse of `tagsToStrings`: each comma-separated tag is "key:value" or a bare
|
||||
// label (key === value).
|
||||
export const toPostableTags = (raw: string): TagtypesPostableTagDTO[] =>
|
||||
raw
|
||||
.split(',')
|
||||
.map((label) => label.trim())
|
||||
.filter(Boolean)
|
||||
.map((label) => {
|
||||
const sep = label.indexOf(':');
|
||||
if (sep > 0) {
|
||||
return {
|
||||
key: label.slice(0, sep).trim(),
|
||||
value: label.slice(sep + 1).trim(),
|
||||
};
|
||||
}
|
||||
return { key: label, value: label };
|
||||
});
|
||||
|
||||
export const lastUpdatedLabel = (time: string | undefined): string => {
|
||||
if (!time || isEmpty(time)) {
|
||||
return 'No updates yet!';
|
||||
|
||||
164
frontend/src/pages/DashboardsListPageV2/views.ts
Normal file
164
frontend/src/pages/DashboardsListPageV2/views.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
// Built-in view catalogue + the pure logic that maps a view to how it
|
||||
// constrains the list. Views fall into three mechanisms:
|
||||
// - snapshot: selecting applies a filter snapshot (All, My dashboards, custom)
|
||||
// - query: contributes an extra server clause AND-ed with the chips (Locked)
|
||||
// - client: constrains by a client-side id set (Favorites, Recently viewed)
|
||||
import {
|
||||
Activity,
|
||||
Bookmark,
|
||||
Clock,
|
||||
Code,
|
||||
Flag,
|
||||
Layers,
|
||||
Lock,
|
||||
Server,
|
||||
Star,
|
||||
Tag,
|
||||
User,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
import { DEFAULT_FILTER_STATE } from './filterQuery';
|
||||
import type { BuiltinViewId, DashboardFilterState, ViewSection } from './types';
|
||||
import type { DashboardListItem } from './utils';
|
||||
|
||||
// All @signozhq icons share this component type.
|
||||
export type ViewIcon = typeof Star;
|
||||
|
||||
export interface BuiltinView {
|
||||
id: BuiltinViewId;
|
||||
label: string;
|
||||
icon: ViewIcon;
|
||||
section: Exclude<ViewSection, 'custom'>;
|
||||
}
|
||||
|
||||
export const BUILTIN_VIEWS: BuiltinView[] = [
|
||||
{ id: 'mine', label: 'My dashboards', icon: User, section: 'personal' },
|
||||
{ id: 'favorites', label: 'Favorites', icon: Star, section: 'personal' },
|
||||
{ id: 'recent', label: 'Recently viewed', icon: Clock, section: 'personal' },
|
||||
{ id: 'all', label: 'All dashboards', icon: Layers, section: 'system' },
|
||||
{ id: 'locked', label: 'Locked', icon: Lock, section: 'system' },
|
||||
];
|
||||
|
||||
// Icons offered when naming a saved view; stored by name on the view.
|
||||
export const VIEW_ICON_OPTIONS: { name: string; Icon: ViewIcon }[] = [
|
||||
{ name: 'bookmark', Icon: Bookmark },
|
||||
{ name: 'star', Icon: Star },
|
||||
{ name: 'layers', Icon: Layers },
|
||||
{ name: 'activity', Icon: Activity },
|
||||
{ name: 'server', Icon: Server },
|
||||
{ name: 'code', Icon: Code },
|
||||
{ name: 'flag', Icon: Flag },
|
||||
{ name: 'tag', Icon: Tag },
|
||||
{ name: 'lock', Icon: Lock },
|
||||
{ name: 'clock', Icon: Clock },
|
||||
];
|
||||
|
||||
const ICON_BY_NAME = new Map(VIEW_ICON_OPTIONS.map((o) => [o.name, o.Icon]));
|
||||
|
||||
export const iconByName = (name: string): ViewIcon =>
|
||||
ICON_BY_NAME.get(name) ?? Bookmark;
|
||||
|
||||
// Favorites/Recently-viewed constrain by a client-side id set — the backend has
|
||||
// no id filter, so these are filtered on the fetched rows.
|
||||
export const isClientView = (id: string): boolean =>
|
||||
id === 'favorites' || id === 'recent';
|
||||
|
||||
// Extra server query fragment a built-in view contributes (AND-ed with chips).
|
||||
export const builtinViewQuery = (id: string): string =>
|
||||
id === 'locked' ? 'locked = true' : '';
|
||||
|
||||
// The canonical filter snapshot a built-in view applies when selected. `null`
|
||||
// for ids that aren't built-in (custom views carry their own snapshot).
|
||||
export const builtinViewSnapshot = (
|
||||
id: string,
|
||||
userEmail: string,
|
||||
): DashboardFilterState | null => {
|
||||
switch (id) {
|
||||
case 'mine':
|
||||
return {
|
||||
...DEFAULT_FILTER_STATE,
|
||||
createdBy: userEmail ? [userEmail] : [],
|
||||
};
|
||||
case 'all':
|
||||
case 'favorites':
|
||||
case 'recent':
|
||||
case 'locked':
|
||||
return { ...DEFAULT_FILTER_STATE };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export interface EmptyStateCopy {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
// Context-aware copy for the no-results state, so an empty Locked view doesn't
|
||||
// read like a failed search ("No dashboards found for .").
|
||||
export const noResultsCopy = (
|
||||
activeViewId: string,
|
||||
search: string,
|
||||
hasActiveFilters: boolean,
|
||||
): EmptyStateCopy => {
|
||||
const trimmed = search.trim();
|
||||
if (trimmed) {
|
||||
return {
|
||||
title: `No dashboards match "${trimmed}"`,
|
||||
description: 'Try a different search term or clear your filters.',
|
||||
};
|
||||
}
|
||||
switch (activeViewId) {
|
||||
case 'favorites':
|
||||
return {
|
||||
title: 'No favorite dashboards yet',
|
||||
description: 'Star a dashboard to pin it here.',
|
||||
};
|
||||
case 'recent':
|
||||
return {
|
||||
title: 'No recently viewed dashboards',
|
||||
description: 'Dashboards you open will appear here.',
|
||||
};
|
||||
case 'locked':
|
||||
return {
|
||||
title: 'No locked dashboards',
|
||||
description: 'Dashboards locked for editing will appear here.',
|
||||
};
|
||||
case 'mine':
|
||||
return {
|
||||
title: "You haven't created any dashboards",
|
||||
description: 'Dashboards you create will appear here.',
|
||||
};
|
||||
default:
|
||||
return hasActiveFilters
|
||||
? {
|
||||
title: 'No dashboards match your filters',
|
||||
description: 'Try adjusting or clearing your filters.',
|
||||
}
|
||||
: {
|
||||
title: 'No dashboards found',
|
||||
description: 'Create a dashboard to get started.',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Apply a client-side view's id-set constraint to already-fetched rows.
|
||||
// Recently-viewed preserves visit order regardless of the active sort.
|
||||
export const applyClientView = (
|
||||
items: DashboardListItem[],
|
||||
id: string,
|
||||
favorites: string[],
|
||||
recent: string[],
|
||||
): DashboardListItem[] => {
|
||||
if (id === 'favorites') {
|
||||
const set = new Set(favorites);
|
||||
return items.filter((d) => set.has(d.id));
|
||||
}
|
||||
if (id === 'recent') {
|
||||
const order = new Map(recent.map((rid, index) => [rid, index]));
|
||||
return items
|
||||
.filter((d) => order.has(d.id))
|
||||
.sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0));
|
||||
}
|
||||
return items;
|
||||
};
|
||||
2
go.mod
2
go.mod
@@ -180,7 +180,7 @@ require (
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
|
||||
github.com/ClickHouse/ch-go v0.71.0
|
||||
github.com/ClickHouse/ch-go v0.71.0 // indirect
|
||||
github.com/Masterminds/squirrel v1.5.4 // indirect
|
||||
github.com/Yiling-J/theine-go v0.6.2 // indirect
|
||||
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b
|
||||
|
||||
@@ -451,23 +451,6 @@ func (provider *provider) addQuerierRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v5/query_range/preview", handler.New(provider.authzMiddleware.ViewAccess(provider.querierHandler.QueryRangePreview), handler.OpenAPIDef{
|
||||
ID: "QueryRangePreviewV5",
|
||||
Tags: []string{"querier"},
|
||||
Summary: "Query range preview",
|
||||
Description: "Validate a composite query without executing it. Accepts the same payload as the query range endpoint. By default (verbose=true) returns, for each query, the rendered underlying ClickHouse statement(s) with each statement's EXPLAIN ESTIMATE (per-table parts/rows/marks) and granule index analysis (candidate/surviving granules, skip score, and the per-index pruning funnel), plus two top-level scores: selectivityScore (0-100 granule-skip selectivity; higher is better) and magnitudeScore (0-100 absolute scan cost; higher is cheaper). Pass ?verbose=false for the lightweight per-query verdict (valid/error/warnings) with no rendered SQL and no ClickHouse round trips. Intended for agentic/dry-run consumption: per-query errors are reported in the response rather than failing the whole request.",
|
||||
Request: new(qbtypes.QueryRangeRequest),
|
||||
RequestQuery: new(qbtypes.QueryRangePreviewParams),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(qbtypes.QueryRangePreviewResponse),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v5/substitute_vars", handler.New(provider.authzMiddleware.ViewAccess(provider.querierHandler.ReplaceVariables), handler.OpenAPIDef{
|
||||
ID: "ReplaceVariables",
|
||||
Tags: []string{"querier"},
|
||||
|
||||
@@ -21,7 +21,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
|
||||
Deprecated: false,
|
||||
Deprecated: true,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
@@ -37,7 +37,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
|
||||
Response: nil,
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
|
||||
Deprecated: false,
|
||||
Deprecated: true,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
@@ -54,7 +54,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
Deprecated: true,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
@@ -88,7 +88,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
Deprecated: true,
|
||||
SecuritySchemes: []handler.OpenAPISecurityScheme{{Name: authtypes.IdentNProviderTokenizer.StringValue()}},
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
@@ -111,6 +111,23 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/users", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.CreateUser), handler.OpenAPIDef{
|
||||
ID: "CreateUser",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Create user",
|
||||
Description: "This endpoint creates a user for the organization",
|
||||
Request: new(authtypes.PostableUser),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(types.Identifiable),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/users/me", handler.New(provider.authzMiddleware.OpenAccess(provider.userHandler.UpdateMyUser), handler.OpenAPIDef{
|
||||
ID: "UpdateMyUserV2",
|
||||
Tags: []string{"users"},
|
||||
@@ -139,7 +156,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
Deprecated: true,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
@@ -173,7 +190,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
Deprecated: true,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
|
||||
@@ -3,15 +3,16 @@ package flagger
|
||||
import "github.com/SigNoz/signoz/pkg/types/featuretypes"
|
||||
|
||||
var (
|
||||
FeatureUseSpanMetrics = featuretypes.MustNewName("use_span_metrics")
|
||||
FeatureKafkaSpanEval = featuretypes.MustNewName("kafka_span_eval")
|
||||
FeatureHideRootUser = featuretypes.MustNewName("hide_root_user")
|
||||
FeatureGetMetersFromZeus = featuretypes.MustNewName("get_meters_from_zeus")
|
||||
FeaturePutMetersInZeus = featuretypes.MustNewName("put_meters_in_zeus")
|
||||
FeatureUseMeterReporter = featuretypes.MustNewName("use_meter_reporter")
|
||||
FeatureUseJSONBody = featuretypes.MustNewName("use_json_body")
|
||||
FeatureUseFineGrainedAuthz = featuretypes.MustNewName("use_fine_grained_authz")
|
||||
FeatureUseDashboardV2 = featuretypes.MustNewName("use_dashboard_v2")
|
||||
FeatureUseSpanMetrics = featuretypes.MustNewName("use_span_metrics")
|
||||
FeatureKafkaSpanEval = featuretypes.MustNewName("kafka_span_eval")
|
||||
FeatureHideRootUser = featuretypes.MustNewName("hide_root_user")
|
||||
FeatureGetMetersFromZeus = featuretypes.MustNewName("get_meters_from_zeus")
|
||||
FeaturePutMetersInZeus = featuretypes.MustNewName("put_meters_in_zeus")
|
||||
FeatureUseMeterReporter = featuretypes.MustNewName("use_meter_reporter")
|
||||
FeatureUseJSONBody = featuretypes.MustNewName("use_json_body")
|
||||
FeatureUseFineGrainedAuthz = featuretypes.MustNewName("use_fine_grained_authz")
|
||||
FeatureUseDashboardV2 = featuretypes.MustNewName("use_dashboard_v2")
|
||||
FeatureEnableAIObservability = featuretypes.MustNewName("enable_ai_observability")
|
||||
)
|
||||
|
||||
func MustNewRegistry() featuretypes.Registry {
|
||||
@@ -88,6 +89,14 @@ func MustNewRegistry() featuretypes.Registry {
|
||||
DefaultVariant: featuretypes.MustNewName("disabled"),
|
||||
Variants: featuretypes.NewBooleanVariants(),
|
||||
},
|
||||
&featuretypes.Feature{
|
||||
Name: FeatureEnableAIObservability,
|
||||
Kind: featuretypes.KindBoolean,
|
||||
Stage: featuretypes.StageExperimental,
|
||||
Description: "Controls whether ai observability is enabled",
|
||||
DefaultVariant: featuretypes.MustNewName("disabled"),
|
||||
Variants: featuretypes.NewBooleanVariants(),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
|
||||
@@ -25,6 +25,42 @@ func NewHandler(setter root.Setter, getter root.Getter) root.Handler {
|
||||
return &handler{setter: setter, getter: getter}
|
||||
}
|
||||
|
||||
func (handler *handler) CreateUser(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
|
||||
}
|
||||
|
||||
req := new(authtypes.PostableUser)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := types.NewUser(req.DisplayName, req.Email, valuer.MustNewUUID(claims.OrgID), types.UserStatusPendingInvite)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
roleIDs := make([]valuer.UUID, 0, len(req.UserRoles))
|
||||
for _, role := range req.UserRoles {
|
||||
roleIDs = append(roleIDs, role.ID)
|
||||
}
|
||||
|
||||
user, err = handler.setter.CreatePendingInviteUser(ctx, valuer.MustNewUUID(claims.IdentityID()), valuer.MustNewEmail(claims.Email), req.FrontendBaseUrl, user, root.WithRoleIDs(roleIDs))
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusCreated, types.Identifiable{ID: user.ID})
|
||||
}
|
||||
|
||||
func (handler *handler) CreateInvite(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -215,6 +215,67 @@ func (module *setter) CreateUser(ctx context.Context, user *types.User, opts ...
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *setter) CreatePendingInviteUser(ctx context.Context, identityID valuer.UUID, identityEmail valuer.Email, frontendBaseURL string, user *types.User, opts ...root.CreateUserOption) (*types.User, error) {
|
||||
if err := user.ErrIfNotPending(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
createUserOpts := root.NewCreateUserOptions(opts...)
|
||||
|
||||
roleNames := createUserOpts.RoleNames
|
||||
if len(createUserOpts.RoleIDs) > 0 {
|
||||
roles, err := module.authz.ListByOrgIDAndIDs(ctx, user.OrgID, createUserOpts.RoleIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, role := range roles {
|
||||
roleNames = append(roleNames, role.Name)
|
||||
}
|
||||
}
|
||||
|
||||
var resetPasswordToken *types.ResetPasswordToken
|
||||
if err := module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
if err := module.createUserWithoutGrant(ctx, user, root.WithRoleNames(roleNames), root.WithFactorPassword(createUserOpts.FactorPassword)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
token, err := module.GetOrCreateResetPasswordToken(ctx, user.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resetPasswordToken = token
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
module.analytics.TrackUser(ctx, user.OrgID.String(), identityID.String(), "Invite Sent", map[string]any{
|
||||
"invitee_email": user.Email,
|
||||
"invitee_role": roleNames,
|
||||
})
|
||||
|
||||
if frontendBaseURL == "" {
|
||||
module.settings.Logger().InfoContext(ctx, "frontend base url is not provided, skipping email", slog.Any("invitee_email", user.Email))
|
||||
return user, nil
|
||||
}
|
||||
|
||||
resetLink := resetPasswordToken.FactorPasswordResetLink(frontendBaseURL)
|
||||
|
||||
tokenLifetime := module.config.Password.Invite.MaxTokenLifetime
|
||||
humanizedTokenLifetime := strings.TrimSpace(humanize.RelTime(time.Now(), time.Now().Add(tokenLifetime), "", ""))
|
||||
|
||||
if err := module.emailing.SendHTML(ctx, user.Email.String(), "You're Invited to Join SigNoz", emailtypes.TemplateNameInvitationEmail, map[string]any{
|
||||
"inviter_email": identityEmail.StringValue(),
|
||||
"link": resetLink,
|
||||
"Expiry": humanizedTokenLifetime,
|
||||
}); err != nil {
|
||||
module.settings.Logger().ErrorContext(ctx, "failed to send invite email", errors.Attr(err))
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (module *setter) UpdateUserDeprecated(ctx context.Context, orgID valuer.UUID, id string, user *types.DeprecatedUser) (*types.DeprecatedUser, error) {
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
type createUserOptions struct {
|
||||
FactorPassword *types.FactorPassword
|
||||
RoleNames []string
|
||||
RoleIDs []valuer.UUID
|
||||
}
|
||||
|
||||
type CreateUserOption func(*createUserOptions)
|
||||
@@ -24,6 +25,12 @@ func WithRoleNames(roleNames []string) CreateUserOption {
|
||||
}
|
||||
}
|
||||
|
||||
func WithRoleIDs(roleIDs []valuer.UUID) CreateUserOption {
|
||||
return func(o *createUserOptions) {
|
||||
o.RoleIDs = roleIDs
|
||||
}
|
||||
}
|
||||
|
||||
func NewCreateUserOptions(opts ...CreateUserOption) *createUserOptions {
|
||||
o := &createUserOptions{
|
||||
FactorPassword: nil,
|
||||
|
||||
@@ -45,6 +45,9 @@ type Setter interface {
|
||||
// invite
|
||||
CreateBulkInvite(ctx context.Context, orgID valuer.UUID, identityID valuer.UUID, identityEmail valuer.Email, bulkInvites *types.PostableBulkInviteRequest) ([]*types.Invite, error)
|
||||
|
||||
// Creates a pending invite user with the roles given via opts and emails them the invite link.
|
||||
CreatePendingInviteUser(ctx context.Context, identityID valuer.UUID, identityEmail valuer.Email, frontendBaseURL string, user *types.User, opts ...CreateUserOption) (*types.User, error)
|
||||
|
||||
// Roles
|
||||
UpdateUserRoles(ctx context.Context, orgID, userID valuer.UUID, finalRoleNames []string) error
|
||||
AddUserRole(ctx context.Context, orgID, userID valuer.UUID, roleName string) error
|
||||
@@ -107,6 +110,7 @@ type Handler interface {
|
||||
// users
|
||||
ListUsersDeprecated(http.ResponseWriter, *http.Request)
|
||||
ListUsers(http.ResponseWriter, *http.Request)
|
||||
CreateUser(http.ResponseWriter, *http.Request)
|
||||
UpdateUserDeprecated(http.ResponseWriter, *http.Request)
|
||||
UpdateUser(http.ResponseWriter, *http.Request)
|
||||
DeleteUser(http.ResponseWriter, *http.Request)
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
package clickhouseprometheus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
"github.com/prometheus/prometheus/prompb"
|
||||
"github.com/prometheus/prometheus/storage"
|
||||
)
|
||||
|
||||
// statementRecorder collects the ClickHouse statements a PromQL evaluation would
|
||||
// run. It is safe for concurrent use because the Prometheus engine may evaluate
|
||||
// (and therefore Select) multiple selectors concurrently.
|
||||
type statementRecorder struct {
|
||||
mu sync.Mutex
|
||||
statements []prometheus.CapturedStatement
|
||||
}
|
||||
|
||||
func (r *statementRecorder) record(query string, args []any) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.statements = append(r.statements, prometheus.CapturedStatement{Query: query, Args: args})
|
||||
}
|
||||
|
||||
func (r *statementRecorder) Statements() []prometheus.CapturedStatement {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([]prometheus.CapturedStatement, len(r.statements))
|
||||
copy(out, r.statements)
|
||||
return out
|
||||
}
|
||||
|
||||
// captureClient is a remote.ReadClient that builds the same ClickHouse SQL as
|
||||
// the real client but records it instead of executing, returning an empty
|
||||
// result so the engine completes without touching ClickHouse. It records the
|
||||
// self-contained samples query per selector (which embeds the series-selection
|
||||
// subquery), so the recorded statement reflects the actual data read.
|
||||
type captureClient struct {
|
||||
*client
|
||||
recorder *statementRecorder
|
||||
}
|
||||
|
||||
func (c *captureClient) Read(ctx context.Context, query *prompb.Query, _ bool) (storage.SeriesSet, error) {
|
||||
// Raw-SQL passthrough ({job="rawsql", query="..."}): record the raw query.
|
||||
if len(query.Matchers) == 2 {
|
||||
var hasJob bool
|
||||
var queryString string
|
||||
for _, m := range query.Matchers {
|
||||
if m.Type == prompb.LabelMatcher_EQ && m.Name == "job" && m.Value == "rawsql" {
|
||||
hasJob = true
|
||||
}
|
||||
if m.Type == prompb.LabelMatcher_EQ && m.Name == "query" {
|
||||
queryString = m.Value
|
||||
}
|
||||
}
|
||||
if hasJob && queryString != "" {
|
||||
c.recorder.record(queryString, nil)
|
||||
return storage.EmptySeriesSet(), nil
|
||||
}
|
||||
}
|
||||
|
||||
var metricName string
|
||||
for _, matcher := range query.Matchers {
|
||||
if matcher.Name == "__name__" {
|
||||
metricName = matcher.Value
|
||||
}
|
||||
}
|
||||
|
||||
// Build the series-selection subquery and the self-contained samples query
|
||||
// exactly as the executing path would, but only record them.
|
||||
subQuery, args, err := c.queryToClickhouseQuery(ctx, query, metricName, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
samplesQuery, samplesArgs := buildSamplesQuery(int64(query.StartTimestampMs), int64(query.EndTimestampMs), metricName, subQuery, args)
|
||||
c.recorder.record(samplesQuery, samplesArgs)
|
||||
|
||||
return storage.EmptySeriesSet(), nil
|
||||
}
|
||||
|
||||
// captureQueryable adapts the capturing read client to storage.Queryable,
|
||||
// mirroring how the real provider wraps its querier.
|
||||
type captureQueryable struct {
|
||||
inner storage.SampleAndChunkQueryable
|
||||
}
|
||||
|
||||
func (c captureQueryable) Querier(mint, maxt int64) (storage.Querier, error) {
|
||||
querier, err := c.inner.Querier(mint, maxt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return storage.NewMergeQuerier(nil, []storage.Querier{querier}, storage.ChainedSeriesMerge), nil
|
||||
}
|
||||
@@ -204,11 +204,8 @@ func (client *client) getFingerprintsFromClickhouseQuery(ctx context.Context, qu
|
||||
return fingerprints, nil
|
||||
}
|
||||
|
||||
// buildSamplesQuery renders the samples SQL (and its args) that fetches the
|
||||
// data points for the series selected by subQuery. It embeds the series
|
||||
// selection as a subquery, so the returned statement is self-contained — the
|
||||
// dry-run/preview path renders it without executing.
|
||||
func buildSamplesQuery(start int64, end int64, metricName string, subQuery string, args []any) (string, []any) {
|
||||
func (client *client) querySamples(ctx context.Context, start int64, end int64, fingerprints map[uint64][]prompb.Label, metricName string, subQuery string, args []any) ([]*prompb.TimeSeries, error) {
|
||||
ctx = client.withClickhousePrometheusContext(ctx, "querySamples")
|
||||
argCount := len(args)
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
@@ -220,13 +217,6 @@ func buildSamplesQuery(start int64, end int64, metricName string, subQuery strin
|
||||
|
||||
allArgs := append([]any{metricName}, args...)
|
||||
allArgs = append(allArgs, start, end)
|
||||
return query, allArgs
|
||||
}
|
||||
|
||||
func (client *client) querySamples(ctx context.Context, start int64, end int64, fingerprints map[uint64][]prompb.Label, metricName string, subQuery string, args []any) ([]*prompb.TimeSeries, error) {
|
||||
ctx = client.withClickhousePrometheusContext(ctx, "querySamples")
|
||||
|
||||
query, allArgs := buildSamplesQuery(start, end, metricName, subQuery, args)
|
||||
|
||||
rows, err := client.telemetryStore.ClickhouseDB().Query(ctx, query, allArgs...)
|
||||
if err != nil {
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
cmock "github.com/SigNoz/clickhouse-go-mock"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
|
||||
cmock "github.com/SigNoz/clickhouse-go-mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
|
||||
@@ -64,17 +64,3 @@ func (provider *provider) Querier(mint, maxt int64) (storage.Querier, error) {
|
||||
|
||||
return storage.NewMergeQuerier(nil, []storage.Querier{querier}, storage.ChainedSeriesMerge), nil
|
||||
}
|
||||
|
||||
// CapturingStorage implements prometheus.StatementCapturer: it returns a Storage
|
||||
// that records the ClickHouse SQL each selector would run (without executing
|
||||
// it) and a recorder to read the captured statements back. A fresh recorder is
|
||||
// created per call so concurrent dry-runs don't share state.
|
||||
func (provider *provider) CapturingStorage() (storage.Queryable, prometheus.StatementRecorder) {
|
||||
recorder := &statementRecorder{}
|
||||
capture := &captureClient{
|
||||
client: &client{settings: provider.settings, telemetryStore: provider.telemetryStore},
|
||||
recorder: recorder,
|
||||
}
|
||||
queryable := remote.NewSampleAndChunkQueryableClient(capture, labels.EmptyLabels(), []*labels.Matcher{}, false, stCallback)
|
||||
return captureQueryable{inner: queryable}, recorder
|
||||
}
|
||||
|
||||
@@ -15,25 +15,3 @@ type Prometheus interface {
|
||||
Storage() storage.Queryable
|
||||
Parser() Parser
|
||||
}
|
||||
|
||||
// CapturedStatement is one underlying datastore statement that a PromQL query would
|
||||
// run, captured without executing it.
|
||||
type CapturedStatement struct {
|
||||
Query string
|
||||
Args []any
|
||||
}
|
||||
|
||||
// StatementRecorder collects the Statements captured while a PromQL query is
|
||||
// evaluated against a capturing Storage (see StatementCapturer).
|
||||
type StatementRecorder interface {
|
||||
Statements() []CapturedStatement
|
||||
}
|
||||
|
||||
// StatementCapturer is an optional capability of a Prometheus provider: it
|
||||
// returns a Storage that records the datastore statement(s) each Select would
|
||||
// run — without executing them — together with a recorder to read them back.
|
||||
// The query dry-run path discovers it via a type assertion, so providers that
|
||||
// do not implement it simply expose no underlying SQL.
|
||||
type StatementCapturer interface {
|
||||
CapturingStorage() (storage.Queryable, StatementRecorder)
|
||||
}
|
||||
|
||||
@@ -73,61 +73,6 @@ func (handler *handler) QueryRange(rw http.ResponseWriter, req *http.Request) {
|
||||
|
||||
render.Success(rw, http.StatusOK, queryRangeResponse)
|
||||
}
|
||||
|
||||
// QueryRangePreview is the dry-run counterpart of QueryRange. It accepts the
|
||||
// same payload, validates and renders the underlying SQL/PromQL for each query
|
||||
// without executing it, and returns the per-query statements. ?verbose defaults
|
||||
// to true: each rendered statement carries its ClickHouse EXPLAIN ESTIMATE and
|
||||
// granule index analysis (with the top-level scores). ?verbose=false returns the
|
||||
// lightweight verdict-only response with no rendered SQL.
|
||||
func (handler *handler) QueryRangePreview(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "querier",
|
||||
instrumentationtypes.CodeFunctionName: "QueryRangePreview",
|
||||
})
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
var queryRangeRequest qbtypes.QueryRangeRequest
|
||||
if err := json.NewDecoder(req.Body).Decode(&queryRangeRequest); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
// NB: validation is intentionally NOT done here. QueryRangePreview checks
|
||||
// request-level invariants (aborting on failure) and validates each query's
|
||||
// spec individually, reporting per-query structural errors in the response
|
||||
// instead of failing fast on the first one — the point of the dry-run.
|
||||
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
// verbose defaults to true (full preview); ?verbose=false returns the
|
||||
// lightweight verdict-only response.
|
||||
previewParams := qbtypes.QueryRangePreviewParams{Verbose: req.URL.Query().Get("verbose")}
|
||||
previewOpts, err := previewParams.Validate()
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
preview, err := handler.querier.QueryRangePreview(ctx, orgID, &queryRangeRequest, previewOpts)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, preview)
|
||||
}
|
||||
|
||||
func (handler *handler) QueryRawStream(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
|
||||
|
||||
@@ -194,12 +194,6 @@ func (q *builderQuery[T]) isWindowList() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Statement renders the SQL statement for the builder query without executing
|
||||
// it. It is used by the dry-run/preview path.
|
||||
func (q *builderQuery[T]) Statement(ctx context.Context) (*qbtypes.Statement, error) {
|
||||
return q.stmtBuilder.Build(ctx, q.fromMS, q.toMS, q.kind, q.spec, q.variables)
|
||||
}
|
||||
|
||||
func (q *builderQuery[T]) Execute(ctx context.Context) (*qbtypes.Result, error) {
|
||||
|
||||
// can we do window based pagination?
|
||||
|
||||
@@ -99,16 +99,6 @@ func (q *chSQLQuery) renderVars(query string, vars map[string]qbtypes.VariableIt
|
||||
return newQuery.String(), nil
|
||||
}
|
||||
|
||||
// Statement renders the SQL statement for the ClickHouse SQL query without
|
||||
// executing it. It is used by the dry-run/preview path.
|
||||
func (q *chSQLQuery) Statement(_ context.Context) (*qbtypes.Statement, error) {
|
||||
rendered, err := q.renderVars(q.query.Query, q.vars, q.fromMS, q.toMS)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &qbtypes.Statement{Query: rendered, Args: q.args}, nil
|
||||
}
|
||||
|
||||
func (q *chSQLQuery) Execute(ctx context.Context) (*qbtypes.Result, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.QueryDuration: instrumentationtypes.DurationBucket(q.fromMS, q.toMS),
|
||||
|
||||
@@ -14,11 +14,6 @@ type Querier interface {
|
||||
QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest) (*qbtypes.QueryRangeResponse, error)
|
||||
QueryRawStream(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest, client *qbtypes.RawStream)
|
||||
statsreporter.StatsCollector
|
||||
// QueryRangePreview validates and renders the queries in req without
|
||||
// executing them. opts controls dry-run behavior such as which
|
||||
// EXPLAIN variant to attach to the response; the zero value performs
|
||||
// a validation-only preview with no EXPLAIN.
|
||||
QueryRangePreview(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest, opts qbtypes.QueryRangePreviewOptions) (*qbtypes.QueryRangePreviewResponse, error)
|
||||
}
|
||||
|
||||
// BucketCache is the interface for bucket-based caching.
|
||||
@@ -31,10 +26,6 @@ type BucketCache interface {
|
||||
|
||||
type Handler interface {
|
||||
QueryRange(rw http.ResponseWriter, req *http.Request)
|
||||
// QueryRangePreview is the dry-run endpoint: it validates and renders the
|
||||
// queries without executing them, optionally attaching each statement's
|
||||
// ClickHouse EXPLAIN ESTIMATE (?estimate=) and granule analysis (?granules=).
|
||||
QueryRangePreview(rw http.ResponseWriter, req *http.Request)
|
||||
QueryRawStream(rw http.ResponseWriter, req *http.Request)
|
||||
ReplaceVariables(rw http.ResponseWriter, req *http.Request)
|
||||
}
|
||||
|
||||
@@ -1,637 +0,0 @@
|
||||
package querier
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
chproto "github.com/ClickHouse/ch-go/proto"
|
||||
"github.com/ClickHouse/clickhouse-go/v2"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// magnitudeReferenceRows is the estimated row count treated as "fully expensive"
|
||||
// (magnitude score 0) by magnitudeScoreFromRows. It's a heuristic reference — the
|
||||
// point past which a scan is considered maximally costly — and is deliberately a
|
||||
// single tunable constant rather than per-table, so the score is comparable
|
||||
// across queries.
|
||||
const magnitudeReferenceRows = 1e9
|
||||
|
||||
// userFacingClickHouseErrorCodes mirrors PR #10679's userFacingCHCodes: the
|
||||
// ClickHouse error codes that indicate a problem with the query itself (bad SQL,
|
||||
// unknown table/column, …) rather than a server-side/infra failure — i.e. the
|
||||
// ones that should map to invalid input (400) instead of internal (500).
|
||||
//
|
||||
// TODO(#10679): once that PR lands, delete this and have explainBindCheck call
|
||||
// the shared querier.mapClickHouseError so there's a single source of truth.
|
||||
var userFacingClickHouseErrorCodes = map[chproto.Error]bool{
|
||||
chproto.ErrSyntaxError: true,
|
||||
chproto.ErrUnknownTable: true,
|
||||
chproto.ErrUnknownDatabase: true,
|
||||
chproto.ErrUnknownIdentifier: true,
|
||||
chproto.ErrUnknownFunction: true,
|
||||
chproto.ErrUnknownAggregateFunction: true,
|
||||
chproto.ErrUnknownType: true,
|
||||
chproto.ErrUnknownStorage: true,
|
||||
chproto.ErrUnknownElementInAst: true,
|
||||
chproto.ErrUnknownTypeOfQuery: true,
|
||||
chproto.ErrIllegalTypeOfArgument: true,
|
||||
chproto.ErrIllegalColumn: true,
|
||||
chproto.ErrNumberOfArgumentsDoesntMatch: true,
|
||||
chproto.ErrTooManyArgumentsForFunction: true,
|
||||
chproto.ErrTooLessArgumentsForFunction: true,
|
||||
}
|
||||
|
||||
// statementProvider is implemented by query types that can render the
|
||||
// underlying SQL/PromQL statement without executing it.
|
||||
type statementProvider interface {
|
||||
Statement(ctx context.Context) (*qbtypes.Statement, error)
|
||||
}
|
||||
|
||||
// previewTask is one rendered ClickHouse statement queued for ClickHouse-bound
|
||||
// preview work (the granules and/or estimate analysis). stmtIdx is the index into
|
||||
// the query's Statements list that this task's results merge back into.
|
||||
type previewTask struct {
|
||||
name string
|
||||
stmtIdx int
|
||||
query string
|
||||
args []any
|
||||
}
|
||||
|
||||
type explainPlanNode struct {
|
||||
NodeType string `json:"Node Type"`
|
||||
Description string `json:"Description"`
|
||||
Indexes []explainPlanIndex `json:"Indexes"`
|
||||
Plans []explainPlanNode `json:"Plans"`
|
||||
}
|
||||
|
||||
type explainPlanIndex struct {
|
||||
Type string `json:"Type"`
|
||||
Name string `json:"Name"`
|
||||
Keys []string `json:"Keys"`
|
||||
Condition string `json:"Condition"`
|
||||
InitialParts *int64 `json:"Initial Parts"`
|
||||
SelectedParts *int64 `json:"Selected Parts"`
|
||||
InitialGranules *int64 `json:"Initial Granules"`
|
||||
SelectedGranules *int64 `json:"Selected Granules"`
|
||||
}
|
||||
|
||||
// QueryRangePreview validates each query in the composite query without
|
||||
// executing it. With opts.Verbose=false it returns a lightweight per-query
|
||||
// verdict (valid/error/warnings). With opts.Verbose=true it also renders the
|
||||
// underlying ClickHouse statement(s) each query would run and attaches, per
|
||||
// statement, the EXPLAIN ESTIMATE and granule index analysis, deriving the
|
||||
// top-level SelectivityScore and MagnitudeScore.
|
||||
func (q *querier) QueryRangePreview(
|
||||
ctx context.Context,
|
||||
_ valuer.UUID,
|
||||
req *qbtypes.QueryRangeRequest,
|
||||
opts qbtypes.QueryRangePreviewOptions,
|
||||
) (*qbtypes.QueryRangePreviewResponse, error) {
|
||||
|
||||
validationOpts, err := req.ValidateRequestScope()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dependencyQueries, err := q.constructTraceOperatorDependencyMap(req.CompositeQuery.Queries)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
results := make(map[string]qbtypes.QueryPreview, len(req.CompositeQuery.Queries))
|
||||
|
||||
prepared := make(map[string]qbtypes.QueryPreview, len(req.CompositeQuery.Queries))
|
||||
missingMetricQuerySet := make(map[string]bool)
|
||||
for idx := range req.CompositeQuery.Queries {
|
||||
name := req.CompositeQuery.Queries[idx].GetQueryName()
|
||||
ps := qbtypes.QueryPreview{}
|
||||
|
||||
if vErr := req.CompositeQuery.Queries[idx].Validate(validationOpts...); vErr != nil {
|
||||
ps.Error = vErr
|
||||
prepared[name] = ps
|
||||
continue
|
||||
}
|
||||
|
||||
env := []qbtypes.QueryEnvelope{req.CompositeQuery.Queries[idx]}
|
||||
ps.Warnings = q.adjustStepInterval(env, req.Start, req.End)
|
||||
|
||||
missingMetricQueries, metricWarnings, mErr := q.resolveMetricMetadata(ctx, env, req.Start, req.End)
|
||||
if mErr != nil {
|
||||
// Don't abort the whole preview: report this query's error and keep
|
||||
// going so the agent sees every problem in one round trip.
|
||||
ps.Error = mErr
|
||||
} else {
|
||||
ps.Warnings = append(ps.Warnings, metricWarnings...)
|
||||
if len(missingMetricQueries) > 0 {
|
||||
missingMetricQuerySet[name] = true
|
||||
if len(metricWarnings) == 0 {
|
||||
if metricNames := missingMetricNames(env[0]); len(metricNames) > 0 {
|
||||
ps.Warnings = append(ps.Warnings, fmt.Sprintf(
|
||||
"query %q references metric(s) %s with no data available; it will return an empty result",
|
||||
name, strings.Join(metricNames, ", ")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
req.CompositeQuery.Queries[idx] = env[0]
|
||||
prepared[name] = ps
|
||||
}
|
||||
|
||||
skip := make(map[string]bool, len(prepared))
|
||||
for name, ps := range prepared {
|
||||
if ps.Error != nil || missingMetricQuerySet[name] {
|
||||
skip[name] = true
|
||||
}
|
||||
}
|
||||
providers, buildErrs := q.buildPreviewProviders(req, dependencyQueries, missingMetricQuerySet, skip)
|
||||
|
||||
// Render the statement for each query that actually executes, and collect the
|
||||
// ClickHouse-bound work (granules/estimate analyses) to run concurrently.
|
||||
var previewTasks []previewTask
|
||||
for _, query := range req.CompositeQuery.Queries {
|
||||
name := query.GetQueryName()
|
||||
ps := prepared[name]
|
||||
|
||||
// Surface a phase-1 error (e.g. a not-found metric) without rendering.
|
||||
if ps.Error != nil {
|
||||
results[name] = ps
|
||||
continue
|
||||
}
|
||||
// Every aggregation resolved to a missing metric: QueryRange returns an
|
||||
// empty result for this query and renders no SQL. Mirror that.
|
||||
if missingMetricQuerySet[name] {
|
||||
results[name] = ps
|
||||
continue
|
||||
}
|
||||
// A build error is this query's verdict — attribute it and move on
|
||||
// instead of aborting the whole preview.
|
||||
if bErr := buildErrs[name]; bErr != nil {
|
||||
ps.Error = bErr
|
||||
results[name] = ps
|
||||
continue
|
||||
}
|
||||
|
||||
provider, ok := providers[name]
|
||||
if !ok {
|
||||
// Formula/join/sub-query are valid query types that render no standalone
|
||||
// statement of their own — they're evaluated from the queries they
|
||||
// reference, which are previewed individually. Report them as valid with
|
||||
// a note rather than failing them.
|
||||
if !rendersStandaloneStatement(query.Type) {
|
||||
ps.Warnings = append(ps.Warnings, fmt.Sprintf(
|
||||
"query type %q has no standalone statement to preview; it is evaluated from the queries it references", query.Type.StringValue()))
|
||||
results[name] = ps
|
||||
continue
|
||||
}
|
||||
ps.Error = errors.NewInternalf(errors.CodeInternal, "query produced no provider")
|
||||
results[name] = ps
|
||||
continue
|
||||
}
|
||||
|
||||
stmtProvider, ok := provider.(statementProvider)
|
||||
if !ok {
|
||||
ps.Error = errors.NewInternalf(errors.CodeInternal, "query does not support preview")
|
||||
results[name] = ps
|
||||
continue
|
||||
}
|
||||
|
||||
stmt, sErr := stmtProvider.Statement(ctx)
|
||||
if sErr != nil {
|
||||
ps.Error = sErr
|
||||
results[name] = ps
|
||||
continue
|
||||
}
|
||||
|
||||
ps.Warnings = append(ps.Warnings, stmt.Warnings...)
|
||||
|
||||
// clickhouse_sql is user-authored raw SQL; rendering only substitutes
|
||||
// variables, so by itself it doesn't prove the SQL is valid. Verify it
|
||||
// parses and binds (tables/columns/types resolve) via EXPLAIN PLAN —
|
||||
// without executing. Builder/PromQL/trace-operator SQL is engine-generated
|
||||
// and well-formed by construction, so this is scoped to clickhouse_sql.
|
||||
if query.Type == qbtypes.QueryTypeClickHouseSQL {
|
||||
if bindErr := q.explainBindCheck(ctx, stmt.Query, stmt.Args); bindErr != nil {
|
||||
if errors.Ast(bindErr, errors.TypeInvalidInput) {
|
||||
ps.Error = bindErr
|
||||
results[name] = ps
|
||||
continue
|
||||
}
|
||||
// Validity unknown (infra/non-user-facing failure) — warn, don't
|
||||
// falsely mark the query invalid.
|
||||
ps.Warnings = append(ps.Warnings, "could not validate ClickHouse SQL: "+bindErr.Error())
|
||||
}
|
||||
}
|
||||
|
||||
if !opts.Verbose {
|
||||
results[name] = ps
|
||||
continue
|
||||
}
|
||||
|
||||
if query.Type == qbtypes.QueryTypePromQL {
|
||||
if pq, ok := provider.(*promqlQuery); ok {
|
||||
sqlStmts, pErr := pq.PreviewStatements(ctx)
|
||||
if pErr != nil {
|
||||
ps.Warnings = append(ps.Warnings, "could not render underlying ClickHouse SQL: "+pErr.Error())
|
||||
} else {
|
||||
for _, s := range sqlStmts {
|
||||
ps.Statements = append(ps.Statements, qbtypes.PreviewStatement{Query: s.Query, Args: s.Args})
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ps.Statements = []qbtypes.PreviewStatement{{Query: stmt.Query, Args: stmt.Args}}
|
||||
}
|
||||
|
||||
results[name] = ps
|
||||
|
||||
for j := range ps.Statements {
|
||||
previewTasks = append(previewTasks, previewTask{name: name, stmtIdx: j, query: ps.Statements[j].Query, args: ps.Statements[j].Args})
|
||||
}
|
||||
}
|
||||
|
||||
q.runPreviewTasks(ctx, previewTasks, results)
|
||||
|
||||
for name, ps := range results {
|
||||
var minSelectivity, minMagnitude *float64
|
||||
for i := range ps.Statements {
|
||||
if g := ps.Statements[i].Granules; g != nil && (minSelectivity == nil || g.SkipScore < *minSelectivity) {
|
||||
s := g.SkipScore
|
||||
minSelectivity = &s
|
||||
}
|
||||
if est := ps.Statements[i].Estimate; len(est) > 0 {
|
||||
var rows int64
|
||||
for j := range est {
|
||||
rows += est[j].Rows
|
||||
}
|
||||
if m := magnitudeScoreFromRows(rows); minMagnitude == nil || m < *minMagnitude {
|
||||
minMagnitude = &m
|
||||
}
|
||||
}
|
||||
}
|
||||
if minSelectivity != nil {
|
||||
ps.SelectivityScore = minSelectivity
|
||||
}
|
||||
if minMagnitude != nil {
|
||||
ps.MagnitudeScore = minMagnitude
|
||||
}
|
||||
results[name] = ps
|
||||
}
|
||||
|
||||
return &qbtypes.QueryRangePreviewResponse{
|
||||
CompositeQuery: results,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// missingMetricNames returns the distinct metric names referenced by a metric
|
||||
// builder query, in order of first appearance. It is used to name the metric(s)
|
||||
// in the warning attached to a fully-missing-metric query. Returns nil for any
|
||||
// non-metric query.
|
||||
func missingMetricNames(env qbtypes.QueryEnvelope) []string {
|
||||
spec, ok := env.Spec.(qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation])
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
names := make([]string, 0, len(spec.Aggregations))
|
||||
for _, agg := range spec.Aggregations {
|
||||
if agg.MetricName != "" && !slices.Contains(names, agg.MetricName) {
|
||||
names = append(names, agg.MetricName)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func (q *querier) buildPreviewProviders(
|
||||
req *qbtypes.QueryRangeRequest,
|
||||
dependencyQueries map[string]bool,
|
||||
missingMetricQuerySet map[string]bool,
|
||||
skip map[string]bool,
|
||||
) (providers map[string]qbtypes.Query, errs map[string]error) {
|
||||
providers = make(map[string]qbtypes.Query)
|
||||
errs = make(map[string]error)
|
||||
|
||||
// buildQueries records analytics on the event; the preview emits none.
|
||||
event := &qbtypes.QBEvent{}
|
||||
|
||||
for _, query := range req.CompositeQuery.Queries {
|
||||
name := query.GetQueryName()
|
||||
if skip[name] {
|
||||
continue
|
||||
}
|
||||
|
||||
sub := *req // shallow copy: only CompositeQuery and RequestType are swapped
|
||||
|
||||
// deps is the set buildQueries skips within this composite: empty for a
|
||||
// standalone query (so it gets built), and the operator's referenced
|
||||
// siblings for a trace operator (so only the operator is built from it).
|
||||
var deps map[string]bool
|
||||
|
||||
switch {
|
||||
case query.GetType() == qbtypes.QueryTypeTraceOperator:
|
||||
refs, rErr := q.traceOperatorPreviewComposite(req, query)
|
||||
if rErr != nil {
|
||||
errs[name] = rErr
|
||||
continue
|
||||
}
|
||||
sub.CompositeQuery = qbtypes.CompositeQuery{Queries: refs}
|
||||
deps = dependencyQueries
|
||||
case dependencyQueries[name]:
|
||||
sub.RequestType = qbtypes.RequestTypeRaw
|
||||
sub.CompositeQuery = qbtypes.CompositeQuery{Queries: []qbtypes.QueryEnvelope{query}}
|
||||
default:
|
||||
sub.CompositeQuery = qbtypes.CompositeQuery{Queries: []qbtypes.QueryEnvelope{query}}
|
||||
}
|
||||
|
||||
built, _, bErr := q.buildQueries(&sub, deps, missingMetricQuerySet, event)
|
||||
if bErr != nil {
|
||||
errs[name] = bErr
|
||||
continue
|
||||
}
|
||||
|
||||
if provider, ok := built[name]; ok {
|
||||
providers[name] = provider
|
||||
}
|
||||
}
|
||||
return providers, errs
|
||||
}
|
||||
|
||||
// rendersStandaloneStatement reports whether a query type renders to its own
|
||||
// ClickHouse/PromQL statement the preview can build and analyze. Formula, join,
|
||||
// and sub-query are valid query types but carry no statement of their own —
|
||||
// they're evaluated from the queries they reference — so buildQueries (and hence
|
||||
// the preview) renders nothing for them. Mirrors buildQueries' switch.
|
||||
func rendersStandaloneStatement(t qbtypes.QueryType) bool {
|
||||
switch t {
|
||||
case qbtypes.QueryTypeBuilder,
|
||||
qbtypes.QueryTypePromQL,
|
||||
qbtypes.QueryTypeClickHouseSQL,
|
||||
qbtypes.QueryTypeTraceOperator:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (q *querier) traceOperatorPreviewComposite(req *qbtypes.QueryRangeRequest, operator qbtypes.QueryEnvelope) ([]qbtypes.QueryEnvelope, error) {
|
||||
spec, ok := operator.Spec.(qbtypes.QueryBuilderTraceOperator)
|
||||
if !ok {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid trace operator query spec %T", operator.Spec)
|
||||
}
|
||||
if err := spec.ParseExpression(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
referenced := make(map[string]bool)
|
||||
for _, name := range spec.CollectReferencedQueries(spec.ParsedExpression) {
|
||||
referenced[name] = true
|
||||
}
|
||||
|
||||
queries := make([]qbtypes.QueryEnvelope, 0, len(referenced)+1)
|
||||
for _, qe := range req.CompositeQuery.Queries {
|
||||
if referenced[qe.GetQueryName()] {
|
||||
queries = append(queries, qe)
|
||||
}
|
||||
}
|
||||
return append(queries, operator), nil
|
||||
}
|
||||
|
||||
func (q *querier) runPreviewTasks(ctx context.Context, tasks []previewTask, previews map[string]qbtypes.QueryPreview) {
|
||||
if len(tasks) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
type outcome struct {
|
||||
granules *qbtypes.Granules
|
||||
estimate []qbtypes.EstimateEntry
|
||||
warnings []string
|
||||
}
|
||||
outcomes := make([]outcome, len(tasks))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := range tasks {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
t := tasks[i]
|
||||
var out outcome
|
||||
if granules, ok, scErr := q.computeGranuleStats(ctx, t.query, t.args); scErr != nil {
|
||||
// Surface the failure instead of silently dropping the score.
|
||||
out.warnings = append(out.warnings, "could not compute query score: "+scErr.Error())
|
||||
} else if ok {
|
||||
out.granules = &granules
|
||||
}
|
||||
if estimate, eErr := q.runExplainEstimate(ctx, t.query, t.args); eErr != nil {
|
||||
// Surface the failure instead of silently dropping the output.
|
||||
out.warnings = append(out.warnings, "could not run EXPLAIN ESTIMATE: "+eErr.Error())
|
||||
} else {
|
||||
out.estimate = estimate
|
||||
}
|
||||
outcomes[i] = out
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for i := range tasks {
|
||||
ps := previews[tasks[i].name]
|
||||
if idx := tasks[i].stmtIdx; idx >= 0 && idx < len(ps.Statements) {
|
||||
if outcomes[i].granules != nil {
|
||||
ps.Statements[idx].Granules = outcomes[i].granules
|
||||
}
|
||||
if len(outcomes[i].estimate) > 0 {
|
||||
ps.Statements[idx].Estimate = outcomes[i].estimate
|
||||
}
|
||||
}
|
||||
ps.Warnings = append(ps.Warnings, outcomes[i].warnings...)
|
||||
previews[tasks[i].name] = ps
|
||||
}
|
||||
}
|
||||
|
||||
func (q *querier) runExplainEstimate(ctx context.Context, stmt string, args []any) ([]qbtypes.EstimateEntry, error) {
|
||||
rows, err := q.telemetryStore.ClickhouseDB().Query(ctx, "EXPLAIN ESTIMATE "+stmt, args...)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to run EXPLAIN ESTIMATE")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
colTypes := rows.ColumnTypes()
|
||||
var entries []qbtypes.EstimateEntry
|
||||
for rows.Next() {
|
||||
dest := make([]any, len(colTypes))
|
||||
for i, ct := range colTypes {
|
||||
dest[i] = reflect.New(ct.ScanType()).Interface()
|
||||
}
|
||||
if err := rows.Scan(dest...); err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan EXPLAIN ESTIMATE row")
|
||||
}
|
||||
var entry qbtypes.EstimateEntry
|
||||
for i, ct := range colTypes {
|
||||
val := reflect.ValueOf(dest[i]).Elem().Interface()
|
||||
switch strings.ToLower(ct.Name()) {
|
||||
case "database":
|
||||
entry.Database = fmt.Sprintf("%v", val)
|
||||
case "table":
|
||||
entry.Table = fmt.Sprintf("%v", val)
|
||||
case "parts":
|
||||
entry.Parts = toInt64(val)
|
||||
case "rows":
|
||||
entry.Rows = toInt64(val)
|
||||
case "marks":
|
||||
entry.Marks = toInt64(val)
|
||||
}
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "EXPLAIN ESTIMATE row iteration failed")
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// toInt64 coerces a driver-scanned numeric value (ESTIMATE's parts/rows/marks
|
||||
// arrive as unsigned integers) to int64. A non-numeric value yields 0.
|
||||
func toInt64(v any) int64 {
|
||||
rv := reflect.ValueOf(v)
|
||||
switch rv.Kind() {
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return rv.Int()
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
return int64(rv.Uint())
|
||||
case reflect.Float32, reflect.Float64:
|
||||
return int64(rv.Float())
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func (q *querier) explainBindCheck(ctx context.Context, stmt string, args []any) error {
|
||||
rows, err := q.telemetryStore.ClickhouseDB().Query(ctx, "EXPLAIN PLAN "+stmt, args...)
|
||||
if err != nil {
|
||||
var ex *clickhouse.Exception
|
||||
if errors.As(err, &ex) && userFacingClickHouseErrorCodes[chproto.Error(ex.Code)] {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid ClickHouse SQL: %s", ex.Message)
|
||||
}
|
||||
return err
|
||||
}
|
||||
rows.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
func magnitudeScoreFromRows(rows int64) float64 {
|
||||
if rows <= 1 {
|
||||
return 100
|
||||
}
|
||||
ratio := math.Log10(float64(rows)) / math.Log10(magnitudeReferenceRows)
|
||||
if ratio < 0 {
|
||||
ratio = 0
|
||||
}
|
||||
if ratio > 1 {
|
||||
ratio = 1
|
||||
}
|
||||
return math.Round((1-ratio)*100*100) / 100 // percentage, 2 decimal places
|
||||
}
|
||||
|
||||
func (q *querier) computeGranuleStats(ctx context.Context, stmt string, args []any) (qbtypes.Granules, bool, error) {
|
||||
rows, err := q.telemetryStore.ClickhouseDB().Query(ctx, "EXPLAIN json = 1, indexes = 1 "+stmt, args...)
|
||||
if err != nil {
|
||||
return qbtypes.Granules{}, false, errors.WrapInternalf(err, errors.CodeInternal, "failed to run EXPLAIN for query score")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// json=1 emits the plan as a single JSON document; read every row and join
|
||||
// so we are robust to the driver splitting it across rows.
|
||||
var sb strings.Builder
|
||||
for rows.Next() {
|
||||
var line string
|
||||
if err := rows.Scan(&line); err != nil {
|
||||
return qbtypes.Granules{}, false, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan EXPLAIN json row")
|
||||
}
|
||||
sb.WriteString(line)
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return qbtypes.Granules{}, false, errors.WrapInternalf(err, errors.CodeInternal, "EXPLAIN json row iteration failed")
|
||||
}
|
||||
|
||||
var plans []struct {
|
||||
Plan explainPlanNode `json:"Plan"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(sb.String()), &plans); err != nil {
|
||||
return qbtypes.Granules{}, false, errors.WrapInternalf(err, errors.CodeInternal, "failed to parse EXPLAIN json")
|
||||
}
|
||||
|
||||
var totalInitial, totalSelected int64
|
||||
var reads []qbtypes.MergeTreeRead
|
||||
for i := range plans {
|
||||
collectMergeTreeReads(&plans[i].Plan, &reads, &totalInitial, &totalSelected)
|
||||
}
|
||||
if totalInitial <= 0 {
|
||||
// No MergeTree index analysis in the plan — nothing to score.
|
||||
return qbtypes.Granules{}, false, nil
|
||||
}
|
||||
if totalSelected < 0 {
|
||||
totalSelected = 0
|
||||
}
|
||||
skippedGranules := totalInitial - totalSelected
|
||||
if skippedGranules < 0 {
|
||||
skippedGranules = 0
|
||||
}
|
||||
ratio := float64(skippedGranules) / float64(totalInitial)
|
||||
score := math.Round(ratio*100*100) / 100 // percentage, 2 decimal places
|
||||
return qbtypes.Granules{
|
||||
Initial: totalInitial,
|
||||
Selected: totalSelected,
|
||||
Skipped: skippedGranules,
|
||||
SkipScore: score,
|
||||
Reads: reads,
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
func derefInt64(p *int64) int64 {
|
||||
if p == nil {
|
||||
return 0
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
func collectMergeTreeReads(node *explainPlanNode, reads *[]qbtypes.MergeTreeRead, totalInitial, totalSelected *int64) {
|
||||
if node.NodeType == "ReadFromMergeTree" && len(node.Indexes) > 0 {
|
||||
steps := make([]qbtypes.IndexStep, 0, len(node.Indexes))
|
||||
var initial, selected *int64
|
||||
for i := range node.Indexes {
|
||||
idx := node.Indexes[i]
|
||||
if idx.InitialGranules != nil && initial == nil {
|
||||
initial = idx.InitialGranules
|
||||
}
|
||||
if idx.SelectedGranules != nil {
|
||||
selected = idx.SelectedGranules
|
||||
}
|
||||
steps = append(steps, qbtypes.IndexStep{
|
||||
Type: idx.Type,
|
||||
Name: idx.Name,
|
||||
Keys: idx.Keys,
|
||||
Condition: idx.Condition,
|
||||
InitialParts: derefInt64(idx.InitialParts),
|
||||
SelectedParts: derefInt64(idx.SelectedParts),
|
||||
InitialGranules: derefInt64(idx.InitialGranules),
|
||||
SelectedGranules: derefInt64(idx.SelectedGranules),
|
||||
})
|
||||
}
|
||||
if initial != nil && selected != nil {
|
||||
*totalInitial += *initial
|
||||
*totalSelected += *selected
|
||||
}
|
||||
*reads = append(*reads, qbtypes.MergeTreeRead{Table: node.Description, Steps: steps})
|
||||
}
|
||||
for i := range node.Plans {
|
||||
collectMergeTreeReads(&node.Plans[i], reads, totalInitial, totalSelected)
|
||||
}
|
||||
}
|
||||
@@ -220,68 +220,6 @@ func (q *promqlQuery) renderVars(query string, vars map[string]qbv5.VariableItem
|
||||
return newQuery.String(), nil
|
||||
}
|
||||
|
||||
// Statement renders the PromQL query string after variable substitution. It
|
||||
// is used by the dry-run/preview path; PromQL queries do not have a
|
||||
// SQL-style argument list.
|
||||
func (q *promqlQuery) Statement(_ context.Context) (*qbv5.Statement, error) {
|
||||
rendered, err := q.renderVars(q.query.Query, q.vars, q.tr.From, q.tr.To)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &qbv5.Statement{Query: rendered}, nil
|
||||
}
|
||||
|
||||
// PreviewStatements returns the underlying ClickHouse statement(s) this PromQL
|
||||
// query would run, captured without executing them. PromQL is evaluated by the
|
||||
// Prometheus engine rather than compiled to one SQL statement: the engine calls
|
||||
// the storage adapter's Select per metric selector, which builds ClickHouse
|
||||
// SQL. We drive the engine with a capturing Storage that records that SQL and
|
||||
// returns empty results, so nothing is read from ClickHouse. Returns nil when
|
||||
// the provider does not support capture (e.g. test doubles).
|
||||
func (q *promqlQuery) PreviewStatements(ctx context.Context) ([]prometheus.CapturedStatement, error) {
|
||||
storer, ok := q.promEngine.(prometheus.StatementCapturer)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
rendered, err := q.renderVars(q.query.Query, q.vars, q.tr.From, q.tr.To)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
start := int64(querybuilder.ToNanoSecs(q.tr.From))
|
||||
end := int64(querybuilder.ToNanoSecs(q.tr.To))
|
||||
|
||||
capStorage, recorder := storer.CapturingStorage()
|
||||
qry, err := q.promEngine.Engine().NewRangeQuery(
|
||||
ctx,
|
||||
capStorage,
|
||||
nil,
|
||||
rendered,
|
||||
time.Unix(0, start),
|
||||
time.Unix(0, end),
|
||||
q.query.Step.Duration,
|
||||
)
|
||||
if err != nil {
|
||||
if e := tryEnhancePromQLExecError(err); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
return nil, enhancePromQLError(rendered, err)
|
||||
}
|
||||
defer qry.Close()
|
||||
|
||||
// Evaluate against the capturing storage: this drives a Select per selector
|
||||
// (recording the SQL) but reads no data, so the result is discarded.
|
||||
if res := qry.Exec(ctx); res.Err != nil {
|
||||
if e := tryEnhancePromQLExecError(res.Err); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "query execution error: %v", res.Err)
|
||||
}
|
||||
|
||||
return recorder.Statements(), nil
|
||||
}
|
||||
|
||||
func (q *promqlQuery) Execute(ctx context.Context) (*qbv5.Result, error) {
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
|
||||
@@ -32,12 +32,6 @@ func (q *traceOperatorQuery) Window() (uint64, uint64) {
|
||||
return q.fromMS, q.toMS
|
||||
}
|
||||
|
||||
// Statement renders the SQL statement for the trace operator query without
|
||||
// executing it. It is used by the dry-run/preview path.
|
||||
func (q *traceOperatorQuery) Statement(ctx context.Context) (*qbtypes.Statement, error) {
|
||||
return q.stmtBuilder.Build(ctx, q.fromMS, q.toMS, q.kind, q.spec, q.compositeQuery)
|
||||
}
|
||||
|
||||
func (q *traceOperatorQuery) Execute(ctx context.Context) (*qbtypes.Result, error) {
|
||||
stmt, err := q.stmtBuilder.Build(
|
||||
ctx,
|
||||
|
||||
@@ -1678,6 +1678,15 @@ func (aH *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
|
||||
Route: "",
|
||||
})
|
||||
|
||||
aiObservability := aH.Signoz.Flagger.BooleanOrEmpty(r.Context(), flagger.FeatureEnableAIObservability, evalCtx)
|
||||
featureSet = append(featureSet, &licensetypes.Feature{
|
||||
Name: valuer.NewString(flagger.FeatureEnableAIObservability.String()),
|
||||
Active: aiObservability,
|
||||
Usage: 0,
|
||||
UsageLimit: -1,
|
||||
Route: "",
|
||||
})
|
||||
|
||||
if constants.IsDotMetricsEnabled {
|
||||
for idx, feature := range featureSet {
|
||||
if feature.Name == licensetypes.DotMetricsEnabled {
|
||||
|
||||
@@ -145,7 +145,7 @@ func PrepareWhereClause(query string, opts FilterExprVisitorOpts) (PreparedWhere
|
||||
"Found %d syntax errors while parsing the search expression.",
|
||||
len(parserErrorListener.SyntaxErrors),
|
||||
)
|
||||
additionals := make([]string, 0, len(parserErrorListener.SyntaxErrors))
|
||||
additionals := make([]string, len(parserErrorListener.SyntaxErrors))
|
||||
for _, err := range parserErrorListener.SyntaxErrors {
|
||||
if err.Error() != "" {
|
||||
additionals = append(additionals, err.Error())
|
||||
|
||||
@@ -2,6 +2,7 @@ package authtypes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
var (
|
||||
ErrCodeUserRoleAlreadyExists = errors.MustNewCode("user_role_already_exists")
|
||||
ErrCodeUserRolesNotFound = errors.MustNewCode("user_roles_not_found")
|
||||
ErrCodeUserRoleInvalidInput = errors.MustNewCode("user_role_invalid_input")
|
||||
)
|
||||
|
||||
type UserRole struct {
|
||||
@@ -28,6 +30,44 @@ type UserRole struct {
|
||||
Role *Role `bun:"rel:belongs-to,join:role_id=id" json:"role" required:"true"`
|
||||
}
|
||||
|
||||
type UserWithRoles struct {
|
||||
*types.User
|
||||
UserRoles []*UserRole `json:"userRoles"`
|
||||
}
|
||||
|
||||
type PostableUser struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
Email valuer.Email `json:"email" required:"true"`
|
||||
FrontendBaseUrl string `json:"frontendBaseUrl"`
|
||||
UserRoles []*PostableUserRole `json:"userRoles" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
type PostableUserRole struct {
|
||||
ID valuer.UUID `json:"id" required:"true"`
|
||||
}
|
||||
|
||||
func (p *PostableUser) UnmarshalJSON(data []byte) error {
|
||||
type Alias PostableUser
|
||||
|
||||
var temp Alias
|
||||
if err := json.Unmarshal(data, &temp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if temp.UserRoles == nil {
|
||||
return errors.New(errors.TypeInvalidInput, ErrCodeUserRoleInvalidInput, "userRoles is required").WithSuggestions("send an empty array to create user without role")
|
||||
}
|
||||
|
||||
for _, role := range temp.UserRoles {
|
||||
if role == nil {
|
||||
return errors.New(errors.TypeInvalidInput, ErrCodeUserRoleInvalidInput, "userRoles cannot contain null entries")
|
||||
}
|
||||
}
|
||||
|
||||
*p = PostableUser(temp)
|
||||
return nil
|
||||
}
|
||||
|
||||
func newUserRole(userID valuer.UUID, roleID valuer.UUID) *UserRole {
|
||||
return &UserRole{
|
||||
ID: valuer.GenerateUUID(),
|
||||
@@ -48,11 +88,6 @@ func NewUserRoles(userID valuer.UUID, roles []*Role) []*UserRole {
|
||||
return userRoles
|
||||
}
|
||||
|
||||
type UserWithRoles struct {
|
||||
*types.User
|
||||
UserRoles []*UserRole `json:"userRoles"`
|
||||
}
|
||||
|
||||
type UserRoleStore interface {
|
||||
// create user roles in bulk
|
||||
CreateUserRoles(ctx context.Context, userRoles []*UserRole) error
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/swaggest/jsonschema-go"
|
||||
@@ -65,195 +64,6 @@ type QueryRangeResponse struct {
|
||||
QBEvent *QBEvent `json:"-"`
|
||||
}
|
||||
|
||||
// QueryRangePreviewResponse describes the dry-run output of a query range
|
||||
// request. CompositeQuery mirrors the request's compositeQuery: each entry is
|
||||
// the dry-run result for one query, keyed by the same query name the request
|
||||
// used.
|
||||
type QueryRangePreviewResponse struct {
|
||||
CompositeQuery map[string]QueryPreview `json:"compositeQuery"`
|
||||
}
|
||||
|
||||
// QueryRangePreviewOptions carries per-call options for the query range
|
||||
// preview (dry-run) endpoint. The zero value produces a lightweight,
|
||||
// verdict-only preview (valid/error/warnings per query, no rendered SQL).
|
||||
type QueryRangePreviewOptions struct {
|
||||
// Verbose is the single switch for the full preview, and the HTTP endpoint
|
||||
// defaults it to TRUE. When true, each rendered statement carries its EXPLAIN
|
||||
// ESTIMATE (PreviewStatement.Estimate) and granule index analysis
|
||||
// (PreviewStatement.Granules, including the per-index funnel), and the query
|
||||
// gets both headline scores (SelectivityScore and MagnitudeScore); the two
|
||||
// analyses cost one ClickHouse EXPLAIN per statement each. When false (set via
|
||||
// ?verbose=false) every query is still validated but the response is just the
|
||||
// per-query verdict, with no rendered SQL and no ClickHouse round trips.
|
||||
Verbose bool
|
||||
}
|
||||
|
||||
// QueryRangePreviewParams documents the query-string parameters accepted by the
|
||||
// query range preview (dry-run) endpoint.
|
||||
type QueryRangePreviewParams struct {
|
||||
// Verbose defaults to "true": the full preview — the rendered ClickHouse
|
||||
// statement(s) with each statement's EXPLAIN ESTIMATE and granule index
|
||||
// analysis, plus the top-level selectivityScore and magnitudeScore. Set
|
||||
// verbose=false for the lightweight per-query verdict (valid/error/warnings)
|
||||
// with no rendered SQL and no ClickHouse round trips.
|
||||
Verbose string `query:"verbose"`
|
||||
}
|
||||
|
||||
// PrepareJSONSchema adds description to the QueryRangePreviewResponse schema.
|
||||
func (q *QueryRangePreviewResponse) PrepareJSONSchema(schema *jsonschema.Schema) error {
|
||||
schema.WithDescription("Response from the v5 query range preview (dry-run) endpoint. For each query in the composite query, returns the underlying ClickHouse statement(s) it renders to without executing them (one per PromQL metric selector; exactly one for builder/ClickHouse/trace-operator queries), with the optional EXPLAIN ESTIMATE and granule analysis attached per statement when requested.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// QueryPreview is the dry-run result for a single query, keyed by query name
|
||||
// in QueryRangePreviewResponse.CompositeQuery.
|
||||
type QueryPreview struct {
|
||||
// Valid is the headline verdict for this query: true when it previewed
|
||||
// without error, false when Error is set. It is always present (derived from
|
||||
// Error at marshal time) so an agent can branch on a single boolean instead
|
||||
// of testing for the presence of the error object.
|
||||
Valid bool `json:"valid"`
|
||||
// Error describes why this query is invalid or could not be previewed; nil
|
||||
// when the query previewed successfully. It is the structured form
|
||||
// (code, message, and — when available — suggestions and invalidReferences)
|
||||
// so an agent can act on it programmatically instead of parsing a string.
|
||||
Error error `json:"error,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
// SelectivityScore is the headline selectivity for this query: the percentage
|
||||
// (0-100) of candidate granules eliminated by partition, primary-key, and
|
||||
// skip-index pruning before any data is read (higher = less data read). It is
|
||||
// the minimum of the per-statement Statements[].Granules.SkipScore values —
|
||||
// the least-selective (worst) underlying statement, which dominates cost.
|
||||
// Returned only when the granules analysis ran (?granules=true or ?verbose=true)
|
||||
// and at least one statement reads a MergeTree table. Paired with
|
||||
// MagnitudeScore as the two headline score axes.
|
||||
SelectivityScore *float64 `json:"selectivityScore,omitempty"`
|
||||
// MagnitudeScore is the headline *cost* for this query (0-100; higher = less
|
||||
// data scanned = cheaper), a separate axis from SelectivityScore. Selectivity
|
||||
// is how good the index pruning *ratio* is, while MagnitudeScore reflects the
|
||||
// *absolute* rows the query would scan (from EXPLAIN ESTIMATE), since a query
|
||||
// can prune 99% of granules and still scan billions of rows on a huge table.
|
||||
// Derived on a log scale from the estimated rows of the heaviest statement.
|
||||
// Returned only when the estimate analysis ran (?estimate=true or ?verbose=true)
|
||||
// and at least one statement has an estimate. The two scores are kept separate
|
||||
// (not fused) so a caller can see which axis — selectivity or magnitude — is
|
||||
// the problem.
|
||||
MagnitudeScore *float64 `json:"magnitudeScore,omitempty"`
|
||||
// Statements are the underlying ClickHouse statement(s) this query renders to,
|
||||
// in execution order. Builder, ClickHouse SQL, and trace-operator queries
|
||||
// render exactly one; a PromQL query renders one per metric selector (the
|
||||
// Prometheus engine issues a statement per selector). Empty for a
|
||||
// validation-only preview, a query that failed to render (see Error), or one
|
||||
// that resolves to no data (a fully-missing metric, see Warnings).
|
||||
Statements []PreviewStatement `json:"statements,omitempty"`
|
||||
}
|
||||
|
||||
// PreviewStatement is one rendered ClickHouse statement the query will execute,
|
||||
// with its bound args and — when requested — its EXPLAIN ESTIMATE (Estimate) and
|
||||
// granule breakdown (Granules). The query/args field names follow the
|
||||
// OpenTelemetry db.statement.* convention so an agent consuming the dry-run sees
|
||||
// the same keys it would on a span.
|
||||
type PreviewStatement struct {
|
||||
Query string `json:"db.statement.query"`
|
||||
Args []any `json:"db.statement.args,omitempty"`
|
||||
// Estimate is the parsed ClickHouse EXPLAIN ESTIMATE output, set only for
|
||||
// ?estimate=true (or ?verbose=true): one entry per table the statement reads,
|
||||
// each with the parts/rows/marks ClickHouse estimates it will scan. Parsed
|
||||
// into a struct (rather than the raw tab-separated table) so an agent can read
|
||||
// the absolute cost estimate programmatically — it complements the
|
||||
// ratio-based Granules.
|
||||
Estimate []EstimateEntry `json:"estimate,omitempty"`
|
||||
// Granules is the parsed granule-skip breakdown for this statement (candidate
|
||||
// vs. surviving granules and the resulting skip score). Populated only for
|
||||
// ?granules=true (or ?verbose=true) when the statement reads a MergeTree
|
||||
// table, so an agent can see why a statement is (un)selective, not just the
|
||||
// headline score.
|
||||
Granules *Granules `json:"granules,omitempty"`
|
||||
}
|
||||
|
||||
// EstimateEntry is ClickHouse's EXPLAIN ESTIMATE for one table the statement
|
||||
// reads: the parts, rows, and marks it estimates it will scan. Unlike Granules
|
||||
// (a pruning ratio), these are absolute counts, so they convey how much data a
|
||||
// statement touches in real terms.
|
||||
type EstimateEntry struct {
|
||||
Database string `json:"database"`
|
||||
Table string `json:"table"`
|
||||
Parts int64 `json:"parts"`
|
||||
Rows int64 `json:"rows"`
|
||||
Marks int64 `json:"marks"`
|
||||
}
|
||||
|
||||
// Granules is the granule-skip breakdown for one rendered statement, parsed from
|
||||
// ClickHouse's `EXPLAIN json = 1, indexes = 1` index analysis. Granules are the
|
||||
// unit of read in a MergeTree table; the fewer that survive pruning, the less
|
||||
// data the query reads. Summed across every ReadFromMergeTree node in the plan
|
||||
// so a multi-read statement is scored as a whole.
|
||||
type Granules struct {
|
||||
// Initial is the candidate granules before any pruning.
|
||||
Initial int64 `json:"initial"`
|
||||
// Selected is the granules surviving partition/primary-key/skip-index pruning
|
||||
// — the ones the query would actually read.
|
||||
Selected int64 `json:"selected"`
|
||||
// Skipped is Initial - Selected: granules eliminated before any read.
|
||||
Skipped int64 `json:"skipped"`
|
||||
// SkipScore is 100 * Skipped / Initial, rounded to two decimals (0-100;
|
||||
// higher = more selective).
|
||||
SkipScore float64 `json:"skipScore"`
|
||||
// Reads is the raw per-read index-pruning trace behind the aggregate above:
|
||||
// one entry per ReadFromMergeTree node in the plan, each listing the index
|
||||
// steps in the order ClickHouse applies them. It shows *which* index did the
|
||||
// pruning and which did nothing — a step whose selected == initial pruned no
|
||||
// granules (its index isn't engaging), and a read still selecting many
|
||||
// granules after every step is a candidate for a new index. Empty when the
|
||||
// plan exposes no MergeTree index analysis.
|
||||
Reads []MergeTreeRead `json:"reads,omitempty"`
|
||||
}
|
||||
|
||||
// MergeTreeRead is the index-pruning funnel for one ReadFromMergeTree node — one
|
||||
// physical read of one table. The Steps run in sequence, so each step's Initial*
|
||||
// matches the previous step's Selected*: the list reads as a funnel from
|
||||
// candidate parts/granules down to what survives and is actually read.
|
||||
type MergeTreeRead struct {
|
||||
// Table is the table this node reads, e.g. "signoz_logs.logs_v2".
|
||||
Table string `json:"table"`
|
||||
// Steps are the index steps applied to this read, in execution order.
|
||||
Steps []IndexStep `json:"steps"`
|
||||
}
|
||||
|
||||
// IndexStep is one index applied during a MergeTree read, with the parts and
|
||||
// granules entering it (Initial*) and surviving it (Selected*). Type is the
|
||||
// ClickHouse index kind (MinMax, Partition, PrimaryKey, or Skip); Name is set
|
||||
// for skip indexes; Keys/Condition describe what it matched on.
|
||||
type IndexStep struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Keys []string `json:"keys,omitempty"`
|
||||
Condition string `json:"condition,omitempty"`
|
||||
InitialParts int64 `json:"initialParts"`
|
||||
SelectedParts int64 `json:"selectedParts"`
|
||||
InitialGranules int64 `json:"initialGranules"`
|
||||
SelectedGranules int64 `json:"selectedGranules"`
|
||||
}
|
||||
|
||||
// MarshalJSON renders Error as the structured error form (code, message and,
|
||||
// when present, suggestions/invalidReferences) instead of the default {} that a
|
||||
// bare error interface produces, so an agent consuming the dry-run can act on it
|
||||
// programmatically.
|
||||
func (p QueryPreview) MarshalJSON() ([]byte, error) {
|
||||
type alias QueryPreview
|
||||
out := struct {
|
||||
alias
|
||||
Error *errors.JSON `json:"error,omitempty"`
|
||||
}{alias: alias(p)}
|
||||
out.alias.Error = nil
|
||||
// Derive the verdict from the error so callers can't desync the two.
|
||||
out.Valid = p.Error == nil
|
||||
if p.Error != nil {
|
||||
out.Error = errors.AsJSON(p.Error)
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
var _ jsonschema.Preparer = &QueryRangeResponse{}
|
||||
|
||||
// PrepareJSONSchema adds description to the QueryRangeResponse schema.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user