mirror of
https://github.com/SigNoz/signoz.git
synced 2026-08-01 02:30:35 +01:00
Compare commits
29 Commits
issue_5015
...
ns/saved-v
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61a42c78cc | ||
|
|
ecd665056f | ||
|
|
210cda03ec | ||
|
|
fe3314853d | ||
|
|
8111224728 | ||
|
|
f1f0e743f1 | ||
|
|
37746dc52c | ||
|
|
69bdf01a1c | ||
|
|
29375bb5fc | ||
|
|
6eae37409c | ||
|
|
ae522552f5 | ||
|
|
092468d6c8 | ||
|
|
0e4d5c87d0 | ||
|
|
dc387e1efe | ||
|
|
26b8d7d590 | ||
|
|
ef6ac791e8 | ||
|
|
b3ce3a5d1f | ||
|
|
8cddda3ed6 | ||
|
|
33fdde9939 | ||
|
|
affb94031d | ||
|
|
54787a63d8 | ||
|
|
06ab96984f | ||
|
|
104bcc55b9 | ||
|
|
ffbdf26ed7 | ||
|
|
97ee59636b | ||
|
|
6e87eff704 | ||
|
|
21ac0f44e0 | ||
|
|
735b9e7d68 | ||
|
|
29315d8c89 |
@@ -7745,6 +7745,101 @@ components:
|
||||
enum:
|
||||
- basic
|
||||
type: string
|
||||
SavedviewtypesDisplay:
|
||||
properties:
|
||||
color:
|
||||
type: string
|
||||
fontSize:
|
||||
type: string
|
||||
format:
|
||||
type: string
|
||||
maxLines:
|
||||
type: integer
|
||||
type: object
|
||||
SavedviewtypesGettableSavedView:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
createdBy:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
schemaVersion:
|
||||
type: string
|
||||
sourcePage:
|
||||
$ref: '#/components/schemas/SavedviewtypesSourcePage'
|
||||
spec:
|
||||
$ref: '#/components/schemas/SavedviewtypesSavedViewSpec'
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
updatedBy:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- createdAt
|
||||
- createdBy
|
||||
- updatedAt
|
||||
- updatedBy
|
||||
- sourcePage
|
||||
- schemaVersion
|
||||
- spec
|
||||
type: object
|
||||
SavedviewtypesPanelType:
|
||||
enum:
|
||||
- value
|
||||
- graph
|
||||
- table
|
||||
- list
|
||||
- trace
|
||||
type: string
|
||||
SavedviewtypesPostableSavedView:
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
schemaVersion:
|
||||
type: string
|
||||
sourcePage:
|
||||
$ref: '#/components/schemas/SavedviewtypesSourcePage'
|
||||
spec:
|
||||
$ref: '#/components/schemas/SavedviewtypesSavedViewSpec'
|
||||
required:
|
||||
- name
|
||||
- sourcePage
|
||||
- schemaVersion
|
||||
- spec
|
||||
type: object
|
||||
SavedviewtypesSavedViewSpec:
|
||||
properties:
|
||||
display:
|
||||
$ref: '#/components/schemas/SavedviewtypesDisplay'
|
||||
panelType:
|
||||
$ref: '#/components/schemas/SavedviewtypesPanelType'
|
||||
queries:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryEnvelope'
|
||||
type: array
|
||||
selectedFields:
|
||||
items:
|
||||
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
|
||||
type: array
|
||||
required:
|
||||
- panelType
|
||||
- queries
|
||||
- selectedFields
|
||||
- display
|
||||
type: object
|
||||
SavedviewtypesSourcePage:
|
||||
enum:
|
||||
- traces
|
||||
- logs
|
||||
- metrics
|
||||
- meter
|
||||
type: string
|
||||
ServiceaccounttypesDeprecatedPostableServiceAccountRole:
|
||||
properties:
|
||||
id:
|
||||
@@ -22577,6 +22672,299 @@ paths:
|
||||
summary: Test alert rule
|
||||
tags:
|
||||
- rules
|
||||
/api/v2/saved_views:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns saved views, optionally filtered by source page and name.
|
||||
operationId: ListSavedViews
|
||||
parameters:
|
||||
- in: query
|
||||
name: sourcePage
|
||||
schema:
|
||||
$ref: '#/components/schemas/SavedviewtypesSourcePage'
|
||||
- in: query
|
||||
name: name
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/components/schemas/SavedviewtypesGettableSavedView'
|
||||
nullable: true
|
||||
type: array
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: List saved views
|
||||
tags:
|
||||
- saved_view
|
||||
post:
|
||||
deprecated: false
|
||||
description: Persists a saved view for the explore page. Returns the id of the
|
||||
created view.
|
||||
operationId: CreateSavedView
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SavedviewtypesPostableSavedView'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
nullable: true
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
summary: Create saved view
|
||||
tags:
|
||||
- saved_view
|
||||
/api/v2/saved_views/{viewId}:
|
||||
delete:
|
||||
deprecated: false
|
||||
description: Deletes a saved view by id.
|
||||
operationId: DeleteSavedView
|
||||
parameters:
|
||||
- in: path
|
||||
name: viewId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
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
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
summary: Delete saved view
|
||||
tags:
|
||||
- saved_view
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns a saved view by id.
|
||||
operationId: GetSavedView
|
||||
parameters:
|
||||
- in: path
|
||||
name: viewId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/SavedviewtypesGettableSavedView'
|
||||
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
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Get saved view
|
||||
tags:
|
||||
- saved_view
|
||||
put:
|
||||
deprecated: false
|
||||
description: Replaces a saved view's name and query.
|
||||
operationId: UpdateSavedView
|
||||
parameters:
|
||||
- in: path
|
||||
name: viewId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SavedviewtypesPostableSavedView'
|
||||
responses:
|
||||
"200":
|
||||
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
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
summary: Update saved view
|
||||
tags:
|
||||
- saved_view
|
||||
/api/v2/sessions:
|
||||
delete:
|
||||
deprecated: false
|
||||
|
||||
491
frontend/src/api/generated/services/saved-view/index.ts
Normal file
491
frontend/src/api/generated/services/saved-view/index.ts
Normal file
@@ -0,0 +1,491 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
InvalidateOptions,
|
||||
MutationFunction,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
UseMutationOptions,
|
||||
UseMutationResult,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
CreateSavedView200,
|
||||
DeleteSavedViewPathParameters,
|
||||
GetSavedView200,
|
||||
GetSavedViewPathParameters,
|
||||
ListSavedViews200,
|
||||
ListSavedViewsParams,
|
||||
RenderErrorResponseDTO,
|
||||
SavedviewtypesPostableSavedViewDTO,
|
||||
UpdateSavedViewPathParameters,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
|
||||
|
||||
/**
|
||||
* Returns saved views, optionally filtered by source page and name.
|
||||
* @summary List saved views
|
||||
*/
|
||||
export const listSavedViews = (
|
||||
params?: ListSavedViewsParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<ListSavedViews200>({
|
||||
url: `/api/v2/saved_views`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListSavedViewsQueryKey = (params?: ListSavedViewsParams) => {
|
||||
return [`/api/v2/saved_views`, ...(params ? [params] : [])] as const;
|
||||
};
|
||||
|
||||
export const getListSavedViewsQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listSavedViews>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params?: ListSavedViewsParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listSavedViews>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getListSavedViewsQueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof listSavedViews>>> = ({
|
||||
signal,
|
||||
}) => listSavedViews(params, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listSavedViews>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListSavedViewsQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listSavedViews>>
|
||||
>;
|
||||
export type ListSavedViewsQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List saved views
|
||||
*/
|
||||
|
||||
export function useListSavedViews<
|
||||
TData = Awaited<ReturnType<typeof listSavedViews>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params?: ListSavedViewsParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listSavedViews>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListSavedViewsQueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary List saved views
|
||||
*/
|
||||
export const invalidateListSavedViews = async (
|
||||
queryClient: QueryClient,
|
||||
params?: ListSavedViewsParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListSavedViewsQueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Persists a saved view for the explore page. Returns the id of the created view.
|
||||
* @summary Create saved view
|
||||
*/
|
||||
export const createSavedView = (
|
||||
savedviewtypesPostableSavedViewDTO?: BodyType<SavedviewtypesPostableSavedViewDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateSavedView200>({
|
||||
url: `/api/v2/saved_views`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: savedviewtypesPostableSavedViewDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateSavedViewMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createSavedView>>,
|
||||
TError,
|
||||
{ data?: BodyType<SavedviewtypesPostableSavedViewDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createSavedView>>,
|
||||
TError,
|
||||
{ data?: BodyType<SavedviewtypesPostableSavedViewDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createSavedView'];
|
||||
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 createSavedView>>,
|
||||
{ data?: BodyType<SavedviewtypesPostableSavedViewDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return createSavedView(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateSavedViewMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createSavedView>>
|
||||
>;
|
||||
export type CreateSavedViewMutationBody =
|
||||
| BodyType<SavedviewtypesPostableSavedViewDTO>
|
||||
| undefined;
|
||||
export type CreateSavedViewMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Create saved view
|
||||
*/
|
||||
export const useCreateSavedView = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createSavedView>>,
|
||||
TError,
|
||||
{ data?: BodyType<SavedviewtypesPostableSavedViewDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createSavedView>>,
|
||||
TError,
|
||||
{ data?: BodyType<SavedviewtypesPostableSavedViewDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getCreateSavedViewMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Deletes a saved view by id.
|
||||
* @summary Delete saved view
|
||||
*/
|
||||
export const deleteSavedView = (
|
||||
{ viewId }: DeleteSavedViewPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v2/saved_views/${viewId}`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getDeleteSavedViewMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteSavedView>>,
|
||||
TError,
|
||||
{ pathParams: DeleteSavedViewPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteSavedView>>,
|
||||
TError,
|
||||
{ pathParams: DeleteSavedViewPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['deleteSavedView'];
|
||||
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 deleteSavedView>>,
|
||||
{ pathParams: DeleteSavedViewPathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return deleteSavedView(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type DeleteSavedViewMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteSavedView>>
|
||||
>;
|
||||
|
||||
export type DeleteSavedViewMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Delete saved view
|
||||
*/
|
||||
export const useDeleteSavedView = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteSavedView>>,
|
||||
TError,
|
||||
{ pathParams: DeleteSavedViewPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof deleteSavedView>>,
|
||||
TError,
|
||||
{ pathParams: DeleteSavedViewPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getDeleteSavedViewMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns a saved view by id.
|
||||
* @summary Get saved view
|
||||
*/
|
||||
export const getSavedView = (
|
||||
{ viewId }: GetSavedViewPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetSavedView200>({
|
||||
url: `/api/v2/saved_views/${viewId}`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetSavedViewQueryKey = ({
|
||||
viewId,
|
||||
}: GetSavedViewPathParameters) => {
|
||||
return [`/api/v2/saved_views/${viewId}`] as const;
|
||||
};
|
||||
|
||||
export const getGetSavedViewQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getSavedView>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ viewId }: GetSavedViewPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getSavedView>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetSavedViewQueryKey({ viewId });
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getSavedView>>> = ({
|
||||
signal,
|
||||
}) => getSavedView({ viewId }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!viewId,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getSavedView>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetSavedViewQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getSavedView>>
|
||||
>;
|
||||
export type GetSavedViewQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get saved view
|
||||
*/
|
||||
|
||||
export function useGetSavedView<
|
||||
TData = Awaited<ReturnType<typeof getSavedView>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ viewId }: GetSavedViewPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getSavedView>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetSavedViewQueryOptions({ viewId }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get saved view
|
||||
*/
|
||||
export const invalidateGetSavedView = async (
|
||||
queryClient: QueryClient,
|
||||
{ viewId }: GetSavedViewPathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetSavedViewQueryKey({ viewId }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Replaces a saved view's name and query.
|
||||
* @summary Update saved view
|
||||
*/
|
||||
export const updateSavedView = (
|
||||
{ viewId }: UpdateSavedViewPathParameters,
|
||||
savedviewtypesPostableSavedViewDTO?: BodyType<SavedviewtypesPostableSavedViewDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v2/saved_views/${viewId}`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: savedviewtypesPostableSavedViewDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpdateSavedViewMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateSavedView>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateSavedViewPathParameters;
|
||||
data?: BodyType<SavedviewtypesPostableSavedViewDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateSavedView>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateSavedViewPathParameters;
|
||||
data?: BodyType<SavedviewtypesPostableSavedViewDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['updateSavedView'];
|
||||
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 updateSavedView>>,
|
||||
{
|
||||
pathParams: UpdateSavedViewPathParameters;
|
||||
data?: BodyType<SavedviewtypesPostableSavedViewDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return updateSavedView(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UpdateSavedViewMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateSavedView>>
|
||||
>;
|
||||
export type UpdateSavedViewMutationBody =
|
||||
| BodyType<SavedviewtypesPostableSavedViewDTO>
|
||||
| undefined;
|
||||
export type UpdateSavedViewMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Update saved view
|
||||
*/
|
||||
export const useUpdateSavedView = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateSavedView>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateSavedViewPathParameters;
|
||||
data?: BodyType<SavedviewtypesPostableSavedViewDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateSavedView>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateSavedViewPathParameters;
|
||||
data?: BodyType<SavedviewtypesPostableSavedViewDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getUpdateSavedViewMutationOptions(options));
|
||||
};
|
||||
@@ -8842,6 +8842,99 @@ export interface RuletypesRuleDTO {
|
||||
export enum RuletypesThresholdKindDTO {
|
||||
basic = 'basic',
|
||||
}
|
||||
export interface SavedviewtypesDisplayDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
color?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
fontSize?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
format?: string;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
maxLines?: number;
|
||||
}
|
||||
|
||||
export enum SavedviewtypesSourcePageDTO {
|
||||
traces = 'traces',
|
||||
logs = 'logs',
|
||||
metrics = 'metrics',
|
||||
meter = 'meter',
|
||||
}
|
||||
export enum SavedviewtypesPanelTypeDTO {
|
||||
value = 'value',
|
||||
graph = 'graph',
|
||||
table = 'table',
|
||||
list = 'list',
|
||||
trace = 'trace',
|
||||
}
|
||||
export interface SavedviewtypesSavedViewSpecDTO {
|
||||
display: SavedviewtypesDisplayDTO;
|
||||
panelType: SavedviewtypesPanelTypeDTO;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
queries: Querybuildertypesv5QueryEnvelopeDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
selectedFields: TelemetrytypesTelemetryFieldKeyDTO[];
|
||||
}
|
||||
|
||||
export interface SavedviewtypesGettableSavedViewDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
createdBy: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
schemaVersion: string;
|
||||
sourcePage: SavedviewtypesSourcePageDTO;
|
||||
spec: SavedviewtypesSavedViewSpecDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
updatedBy: string;
|
||||
}
|
||||
|
||||
export interface SavedviewtypesPostableSavedViewDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
schemaVersion: string;
|
||||
sourcePage: SavedviewtypesSourcePageDTO;
|
||||
spec: SavedviewtypesSavedViewSpecDTO;
|
||||
}
|
||||
|
||||
export interface ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -12029,6 +12122,57 @@ export type TestRule200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListSavedViewsParams = {
|
||||
/**
|
||||
* @description undefined
|
||||
*/
|
||||
sourcePage?: SavedviewtypesSourcePageDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export type ListSavedViews200 = {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
data: SavedviewtypesGettableSavedViewDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateSavedView200 = {
|
||||
/**
|
||||
* @type string,null
|
||||
*/
|
||||
data: string | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type DeleteSavedViewPathParameters = {
|
||||
viewId: string;
|
||||
};
|
||||
export type GetSavedViewPathParameters = {
|
||||
viewId: string;
|
||||
};
|
||||
export type GetSavedView200 = {
|
||||
data: SavedviewtypesGettableSavedViewDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type UpdateSavedViewPathParameters = {
|
||||
viewId: string;
|
||||
};
|
||||
export type GetSessionContext200 = {
|
||||
data: AuthtypesSessionContextDTO;
|
||||
/**
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/promote"
|
||||
"github.com/SigNoz/signoz/pkg/modules/rawdataexport"
|
||||
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/savedview"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/session"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
|
||||
@@ -75,6 +76,7 @@ type provider struct {
|
||||
rulerHandler ruler.Handler
|
||||
llmPricingRuleHandler llmpricingrule.Handler
|
||||
statsHandler statsreporter.Handler
|
||||
savedViewHandler savedview.Handler
|
||||
}
|
||||
|
||||
func NewFactory(
|
||||
@@ -110,6 +112,7 @@ func NewFactory(
|
||||
traceDetailHandler tracedetail.Handler,
|
||||
rulerHandler ruler.Handler,
|
||||
statsHandler statsreporter.Handler,
|
||||
savedViewHandler savedview.Handler,
|
||||
) factory.ProviderFactory[apiserver.APIServer, apiserver.Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("signoz"), func(ctx context.Context, providerSettings factory.ProviderSettings, config apiserver.Config) (apiserver.APIServer, error) {
|
||||
return newProvider(
|
||||
@@ -148,6 +151,7 @@ func NewFactory(
|
||||
traceDetailHandler,
|
||||
rulerHandler,
|
||||
statsHandler,
|
||||
savedViewHandler,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -188,6 +192,7 @@ func newProvider(
|
||||
traceDetailHandler tracedetail.Handler,
|
||||
rulerHandler ruler.Handler,
|
||||
statsHandler statsreporter.Handler,
|
||||
savedViewHandler savedview.Handler,
|
||||
) (apiserver.APIServer, error) {
|
||||
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/apiserver/signozapiserver")
|
||||
router := mux.NewRouter().UseEncodedPath()
|
||||
@@ -227,6 +232,7 @@ func newProvider(
|
||||
rulerHandler: rulerHandler,
|
||||
llmPricingRuleHandler: llmPricingRuleHandler,
|
||||
statsHandler: statsHandler,
|
||||
savedViewHandler: savedViewHandler,
|
||||
}
|
||||
|
||||
provider.authzMiddleware = middleware.NewAuthZ(settings.Logger(), orgGetter, authzService)
|
||||
@@ -359,6 +365,10 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := provider.addSavedViewRoutes(router); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
101
pkg/apiserver/signozapiserver/savedview.go
Normal file
101
pkg/apiserver/signozapiserver/savedview.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package signozapiserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/savedviewtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func (provider *provider) addSavedViewRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v2/saved_views", handler.New(provider.authzMiddleware.ViewAccess(provider.savedViewHandler.ListV2), handler.OpenAPIDef{
|
||||
ID: "ListSavedViews",
|
||||
Tags: []string{"saved_view"},
|
||||
Summary: "List saved views",
|
||||
Description: "Returns saved views, optionally filtered by source page and name.",
|
||||
Request: nil,
|
||||
RequestQuery: new(savedviewtypes.ListSavedViewsParams),
|
||||
RequestContentType: "",
|
||||
Response: new([]*savedviewtypes.GettableSavedView),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/saved_views", handler.New(provider.authzMiddleware.EditAccess(provider.savedViewHandler.CreateV2), handler.OpenAPIDef{
|
||||
ID: "CreateSavedView",
|
||||
Tags: []string{"saved_view"},
|
||||
Summary: "Create saved view",
|
||||
Description: "Persists a saved view for the explore page. Returns the id of the created view.",
|
||||
Request: new(savedviewtypes.PostableSavedView),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(valuer.UUID),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/saved_views/{viewId}", handler.New(provider.authzMiddleware.ViewAccess(provider.savedViewHandler.GetV2), handler.OpenAPIDef{
|
||||
ID: "GetSavedView",
|
||||
Tags: []string{"saved_view"},
|
||||
Summary: "Get saved view",
|
||||
Description: "Returns a saved view by id.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(savedviewtypes.GettableSavedView),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/saved_views/{viewId}", handler.New(provider.authzMiddleware.EditAccess(provider.savedViewHandler.UpdateV2), handler.OpenAPIDef{
|
||||
ID: "UpdateSavedView",
|
||||
Tags: []string{"saved_view"},
|
||||
Summary: "Update saved view",
|
||||
Description: "Replaces a saved view's name and query.",
|
||||
Request: new(savedviewtypes.UpdatableSavedView),
|
||||
RequestContentType: "application/json",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/saved_views/{viewId}", handler.New(provider.authzMiddleware.EditAccess(provider.savedViewHandler.Delete), handler.OpenAPIDef{
|
||||
ID: "DeleteSavedView",
|
||||
Tags: []string{"saved_view"},
|
||||
Summary: "Delete saved view",
|
||||
Description: "Deletes a saved view by id.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/savedview"
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/savedviewtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
@@ -23,6 +25,85 @@ func NewHandler(module savedview.Module) savedview.Handler {
|
||||
return &handler{module: module}
|
||||
}
|
||||
|
||||
// legacyExtraData mirrors the frontend's extraData JSON shape so /api/v1
|
||||
// responses can synthesize the same shape back for the legacy frontend.
|
||||
type legacyExtraData struct {
|
||||
Color string `json:"color,omitempty"`
|
||||
SelectColumns []telemetrytypes.TelemetryFieldKey `json:"selectColumns,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
MaxLines int `json:"maxLines,omitempty"`
|
||||
FontSize string `json:"fontSize,omitempty"`
|
||||
}
|
||||
|
||||
func newPostableSavedViewFromLegacyView(v *v3.SavedView) savedviewtypes.PostableSavedView {
|
||||
var legacy legacyExtraData
|
||||
if v.ExtraData != "" {
|
||||
// Best-effort: malformed/older extraData shapes never fail the request
|
||||
_ = json.Unmarshal([]byte(v.ExtraData), &legacy)
|
||||
}
|
||||
|
||||
return savedviewtypes.PostableSavedView{
|
||||
Name: v.Name,
|
||||
SourcePage: savedviewtypes.SourcePage{String: valuer.NewString(v.SourcePage)},
|
||||
SavedViewData: savedviewtypes.SavedViewData{
|
||||
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
|
||||
Spec: savedviewtypes.SavedViewSpec{
|
||||
PanelType: savedviewtypes.PanelType{String: valuer.NewString(string(v.CompositeQuery.PanelType))},
|
||||
Queries: v.CompositeQuery.Queries,
|
||||
SelectedFields: legacy.SelectColumns,
|
||||
Display: savedviewtypes.Display{
|
||||
MaxLines: legacy.MaxLines,
|
||||
FontSize: legacy.FontSize,
|
||||
Format: legacy.Format,
|
||||
Color: legacy.Color,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newLegacyViewFromGettable(v *savedviewtypes.GettableSavedView) (*v3.SavedView, error) {
|
||||
extraData, err := json.Marshal(legacyExtraData{
|
||||
Color: v.Spec.Display.Color,
|
||||
SelectColumns: v.Spec.SelectedFields,
|
||||
Format: v.Spec.Display.Format,
|
||||
MaxLines: v.Spec.Display.MaxLines,
|
||||
FontSize: v.Spec.Display.FontSize,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error in marshalling extra data")
|
||||
}
|
||||
|
||||
return &v3.SavedView{
|
||||
ID: v.ID,
|
||||
Name: v.Name,
|
||||
CreatedAt: v.CreatedAt,
|
||||
CreatedBy: v.CreatedBy,
|
||||
UpdatedAt: v.UpdatedAt,
|
||||
UpdatedBy: v.UpdatedBy,
|
||||
SourcePage: v.SourcePage.StringValue(),
|
||||
CompositeQuery: &v3.CompositeQuery{
|
||||
PanelType: v3.PanelType(v.Spec.PanelType.StringValue()),
|
||||
// Saved views are only ever created from the explorer's builder mode.
|
||||
QueryType: v3.QueryTypeBuilder,
|
||||
Queries: v.Spec.Queries,
|
||||
},
|
||||
ExtraData: string(extraData),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newLegacyViewsFromGettable(views []*savedviewtypes.GettableSavedView) ([]*v3.SavedView, error) {
|
||||
out := make([]*v3.SavedView, 0, len(views))
|
||||
for _, view := range views {
|
||||
legacyView, err := newLegacyViewFromGettable(view)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, legacyView)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (handler *handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
@@ -44,7 +125,7 @@ func (handler *handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
uuid, err := handler.module.CreateView(ctx, claims.OrgID, view)
|
||||
uuid, err := handler.module.CreateView(ctx, claims.OrgID, newPostableSavedViewFromLegacyView(&view))
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
@@ -76,7 +157,13 @@ func (handler *handler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusOK, view)
|
||||
legacyView, err := newLegacyViewFromGettable(view)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusOK, legacyView)
|
||||
}
|
||||
|
||||
func (handler *handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -106,7 +193,7 @@ func (handler *handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.module.UpdateView(ctx, claims.OrgID, viewUUID, view)
|
||||
err = handler.module.UpdateView(ctx, claims.OrgID, viewUUID, newPostableSavedViewFromLegacyView(&view))
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
@@ -153,13 +240,18 @@ func (handler *handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
sourcePage := r.URL.Query().Get("sourcePage")
|
||||
name := r.URL.Query().Get("name")
|
||||
category := r.URL.Query().Get("category")
|
||||
|
||||
queries, err := handler.module.GetViewsForFilters(r.Context(), claims.OrgID, sourcePage, name, category)
|
||||
views, err := handler.module.GetViewsForFilters(r.Context(), claims.OrgID, savedviewtypes.SourcePage{String: valuer.NewString(sourcePage)}, name)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusOK, queries)
|
||||
legacyViews, err := newLegacyViewsFromGettable(views)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusOK, legacyViews)
|
||||
}
|
||||
|
||||
134
pkg/modules/savedview/implsavedview/handler_v2.go
Normal file
134
pkg/modules/savedview/implsavedview/handler_v2.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package implsavedview
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/savedviewtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func (handler *handler) CreateV2(w 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(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
var view savedviewtypes.PostableSavedView
|
||||
if err := binding.JSON.BindBody(r.Body, &view, binding.WithDisallowUnknownFields(true)); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
if err := view.Validate(); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
uuid, err := handler.module.CreateView(ctx, claims.OrgID, view)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusOK, uuid)
|
||||
}
|
||||
|
||||
func (handler *handler) GetV2(w 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(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
viewID := mux.Vars(r)["viewId"]
|
||||
viewUUID, err := valuer.NewUUID(viewID)
|
||||
if err != nil {
|
||||
render.Error(w, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to parse view id"))
|
||||
return
|
||||
}
|
||||
|
||||
view, err := handler.module.GetView(ctx, claims.OrgID, viewUUID)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
func (handler *handler) UpdateV2(w 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(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
viewID := mux.Vars(r)["viewId"]
|
||||
viewUUID, err := valuer.NewUUID(viewID)
|
||||
if err != nil {
|
||||
render.Error(w, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to parse view id"))
|
||||
return
|
||||
}
|
||||
var view savedviewtypes.UpdatableSavedView
|
||||
if err := binding.JSON.BindBody(r.Body, &view, binding.WithDisallowUnknownFields(true)); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
if err := view.Validate(); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.module.UpdateView(ctx, claims.OrgID, viewUUID, view)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusOK, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) ListV2(w 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(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
params := new(savedviewtypes.ListSavedViewsParams)
|
||||
if err := binding.Query.BindQuery(r.URL.Query(), params); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
if err := params.Validate(); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
queries, err := handler.module.GetViewsForFilters(r.Context(), claims.OrgID, params.SourcePage, params.Name)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusOK, queries)
|
||||
}
|
||||
@@ -2,185 +2,72 @@ package implsavedview
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/modules/savedview"
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/savedviewtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type module struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
store savedviewtypes.Store
|
||||
}
|
||||
|
||||
func NewModule(sqlstore sqlstore.SQLStore) savedview.Module {
|
||||
return &module{sqlstore: sqlstore}
|
||||
func NewModule(store savedviewtypes.Store) savedview.Module {
|
||||
return &module{store: store}
|
||||
}
|
||||
|
||||
func (module *module) GetViewsForFilters(ctx context.Context, orgID string, sourcePage string, name string, category string) ([]*v3.SavedView, error) {
|
||||
var views []savedviewtypes.SavedView
|
||||
var err error
|
||||
if len(category) == 0 {
|
||||
err = module.sqlstore.BunDB().NewSelect().Model(&views).Where("org_id = ? AND source_page = ? AND name LIKE ?", orgID, sourcePage, "%"+name+"%").Scan(ctx)
|
||||
} else {
|
||||
err = module.sqlstore.BunDB().NewSelect().Model(&views).Where("org_id = ? AND source_page = ? AND category LIKE ? AND name LIKE ?", orgID, sourcePage, "%"+category+"%", "%"+name+"%").Scan(ctx)
|
||||
}
|
||||
func (module *module) GetViewsForFilters(ctx context.Context, orgID string, sourcePage savedviewtypes.SourcePage, name string) ([]*savedviewtypes.GettableSavedView, error) {
|
||||
views, err := module.store.List(ctx, orgID, sourcePage, name)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error in getting saved views")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var savedViews []*v3.SavedView
|
||||
for _, view := range views {
|
||||
var compositeQuery v3.CompositeQuery
|
||||
err = json.Unmarshal([]byte(view.Data), &compositeQuery)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error in unmarshalling explorer query data: %s", err.Error())
|
||||
}
|
||||
savedViews = append(savedViews, &v3.SavedView{
|
||||
ID: view.ID,
|
||||
Name: view.Name,
|
||||
CreatedAt: view.CreatedAt,
|
||||
CreatedBy: view.CreatedBy,
|
||||
UpdatedAt: view.UpdatedAt,
|
||||
UpdatedBy: view.UpdatedBy,
|
||||
Tags: strings.Split(view.Tags, ","),
|
||||
SourcePage: view.SourcePage,
|
||||
CompositeQuery: &compositeQuery,
|
||||
ExtraData: view.ExtraData,
|
||||
})
|
||||
}
|
||||
return savedViews, nil
|
||||
return savedviewtypes.NewGettableSavedViewsFromSavedViews(views), nil
|
||||
}
|
||||
|
||||
func (module *module) CreateView(ctx context.Context, orgID string, view v3.SavedView) (valuer.UUID, error) {
|
||||
data, err := json.Marshal(view.CompositeQuery)
|
||||
func (module *module) CreateView(ctx context.Context, orgID string, view savedviewtypes.PostableSavedView) (valuer.UUID, error) {
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
return valuer.UUID{}, errors.WrapInternalf(err, errors.CodeInternal, "error in marshalling explorer query data")
|
||||
}
|
||||
|
||||
uuid := valuer.GenerateUUID()
|
||||
createdAt := time.Now()
|
||||
updatedAt := time.Now()
|
||||
|
||||
claims, errv2 := authtypes.ClaimsFromContext(ctx)
|
||||
if errv2 != nil {
|
||||
return valuer.UUID{}, errors.NewInternalf(errors.CodeInternal, "error in getting email from context")
|
||||
}
|
||||
|
||||
createBy := claims.Email
|
||||
updatedBy := claims.Email
|
||||
dbView := savedviewtypes.NewSavedView(orgID, claims.Email, claims.Email, view)
|
||||
|
||||
dbView := savedviewtypes.SavedView{
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
},
|
||||
UserAuditable: types.UserAuditable{
|
||||
CreatedBy: createBy,
|
||||
UpdatedBy: updatedBy,
|
||||
},
|
||||
OrgID: orgID,
|
||||
Identifiable: types.Identifiable{
|
||||
ID: uuid,
|
||||
},
|
||||
Name: view.Name,
|
||||
Category: view.Category,
|
||||
SourcePage: view.SourcePage,
|
||||
Tags: strings.Join(view.Tags, ","),
|
||||
Data: string(data),
|
||||
ExtraData: view.ExtraData,
|
||||
if err := module.store.Create(ctx, dbView); err != nil {
|
||||
return valuer.UUID{}, err
|
||||
}
|
||||
|
||||
_, err = module.sqlstore.BunDB().NewInsert().Model(&dbView).Exec(ctx)
|
||||
if err != nil {
|
||||
return valuer.UUID{}, errors.WrapInternalf(err, errors.CodeInternal, "error in creating saved view")
|
||||
}
|
||||
return uuid, nil
|
||||
return dbView.ID, nil
|
||||
}
|
||||
|
||||
func (module *module) GetView(ctx context.Context, orgID string, uuid valuer.UUID) (*v3.SavedView, error) {
|
||||
var view savedviewtypes.SavedView
|
||||
err := module.sqlstore.BunDB().NewSelect().Model(&view).Where("org_id = ? AND id = ?", orgID, uuid.StringValue()).Scan(ctx)
|
||||
func (module *module) GetView(ctx context.Context, orgID string, uuid valuer.UUID) (*savedviewtypes.GettableSavedView, error) {
|
||||
view, err := module.store.Get(ctx, orgID, uuid)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error in getting saved view")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var compositeQuery v3.CompositeQuery
|
||||
err = json.Unmarshal([]byte(view.Data), &compositeQuery)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error in unmarshalling explorer query data")
|
||||
}
|
||||
return &v3.SavedView{
|
||||
ID: view.ID,
|
||||
Name: view.Name,
|
||||
Category: view.Category,
|
||||
CreatedAt: view.CreatedAt,
|
||||
CreatedBy: view.CreatedBy,
|
||||
UpdatedAt: view.UpdatedAt,
|
||||
UpdatedBy: view.UpdatedBy,
|
||||
SourcePage: view.SourcePage,
|
||||
Tags: strings.Split(view.Tags, ","),
|
||||
CompositeQuery: &compositeQuery,
|
||||
ExtraData: view.ExtraData,
|
||||
}, nil
|
||||
return savedviewtypes.NewGettableSavedViewFromSavedView(view), nil
|
||||
}
|
||||
|
||||
func (module *module) UpdateView(ctx context.Context, orgID string, uuid valuer.UUID, view v3.SavedView) error {
|
||||
data, err := json.Marshal(view.CompositeQuery)
|
||||
func (module *module) UpdateView(ctx context.Context, orgID string, uuid valuer.UUID, view savedviewtypes.UpdatableSavedView) error {
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "error in marshalling explorer query data")
|
||||
}
|
||||
|
||||
claims, errv2 := authtypes.ClaimsFromContext(ctx)
|
||||
if errv2 != nil {
|
||||
return errors.NewInternalf(errors.CodeInternal, "error in getting email from context")
|
||||
}
|
||||
|
||||
updatedAt := time.Now()
|
||||
updatedBy := claims.Email
|
||||
dbView := savedviewtypes.NewSavedView(orgID, claims.Email, claims.Email, view)
|
||||
dbView.ID = uuid
|
||||
|
||||
_, err = module.sqlstore.BunDB().NewUpdate().
|
||||
Model(&savedviewtypes.SavedView{}).
|
||||
Set("updated_at = ?, updated_by = ?, name = ?, category = ?, source_page = ?, tags = ?, data = ?, extra_data = ?",
|
||||
updatedAt, updatedBy, view.Name, view.Category, view.SourcePage, strings.Join(view.Tags, ","), data, view.ExtraData).
|
||||
Where("id = ?", uuid.StringValue()).
|
||||
Where("org_id = ?", orgID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "error in updating saved view")
|
||||
}
|
||||
return nil
|
||||
return module.store.Update(ctx, dbView)
|
||||
}
|
||||
|
||||
func (module *module) DeleteView(ctx context.Context, orgID string, uuid valuer.UUID) error {
|
||||
_, err := module.sqlstore.BunDB().NewDelete().
|
||||
Model(&savedviewtypes.SavedView{}).
|
||||
Where("id = ?", uuid.StringValue()).
|
||||
Where("org_id = ?", orgID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "error in deleting explorer query")
|
||||
}
|
||||
return nil
|
||||
return module.store.Delete(ctx, orgID, uuid)
|
||||
}
|
||||
|
||||
func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[string]any, error) {
|
||||
savedViews := []*savedviewtypes.SavedView{}
|
||||
|
||||
err := module.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(&savedViews).
|
||||
Where("org_id = ?", orgID).
|
||||
Scan(ctx)
|
||||
savedViews, err := module.store.List(ctx, orgID.StringValue(), savedviewtypes.SourcePage{}, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
284
pkg/modules/savedview/implsavedview/module_test.go
Normal file
284
pkg/modules/savedview/implsavedview/module_test.go
Normal file
@@ -0,0 +1,284 @@
|
||||
package implsavedview_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/modules/savedview/implsavedview"
|
||||
"github.com/SigNoz/signoz/pkg/modules/savedview/implsavedviewtest"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/savedviewtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func testPostableSavedView(name string, sourcePage savedviewtypes.SourcePage) savedviewtypes.PostableSavedView {
|
||||
return savedviewtypes.PostableSavedView{
|
||||
Name: name,
|
||||
SourcePage: sourcePage,
|
||||
SavedViewData: savedviewtypes.SavedViewData{
|
||||
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
|
||||
Spec: savedviewtypes.SavedViewSpec{
|
||||
PanelType: savedviewtypes.PanelTypeGraph,
|
||||
Queries: []qbtypes.QueryEnvelope{
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
Aggregations: []qbtypes.LogAggregation{{Expression: "count()"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
SelectedFields: []telemetrytypes.TelemetryFieldKey{},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func testSavedView(orgID string, id valuer.UUID, updatedBy string, view savedviewtypes.PostableSavedView) *savedviewtypes.SavedView {
|
||||
savedView := savedviewtypes.NewSavedView(orgID, "creator@signoz.io", updatedBy, view)
|
||||
savedView.ID = id
|
||||
return savedView
|
||||
}
|
||||
|
||||
func contextWithClaims(orgID, email string) context.Context {
|
||||
return authtypes.NewContextWithClaims(context.Background(), authtypes.Claims{
|
||||
OrgID: orgID,
|
||||
Email: email,
|
||||
})
|
||||
}
|
||||
|
||||
func TestModule_CreateAndGetView(t *testing.T) {
|
||||
store := implsavedviewtest.NewMockSavedViewStore()
|
||||
m := implsavedview.NewModule(store)
|
||||
|
||||
orgID := valuer.GenerateUUID().StringValue()
|
||||
ctx := contextWithClaims(orgID, "creator@signoz.io")
|
||||
view := testPostableSavedView("my view", savedviewtypes.SourcePageLogs)
|
||||
|
||||
store.ExpectCreate()
|
||||
id, err := m.CreateView(ctx, orgID, view)
|
||||
require.NoError(t, err)
|
||||
require.False(t, id.IsZero())
|
||||
|
||||
stored := testSavedView(orgID, id, "creator@signoz.io", view)
|
||||
store.ExpectGet(orgID, id, stored)
|
||||
got, err := m.GetView(ctx, orgID, id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, id, got.ID)
|
||||
assert.Equal(t, "my view", got.Name)
|
||||
assert.Equal(t, savedviewtypes.SourcePageLogs, got.SourcePage)
|
||||
assert.Equal(t, "creator@signoz.io", got.CreatedBy)
|
||||
assert.Equal(t, "creator@signoz.io", got.UpdatedBy)
|
||||
assert.Equal(t, savedviewtypes.PanelTypeGraph, got.Spec.PanelType)
|
||||
|
||||
require.NoError(t, store.AssertExpectations())
|
||||
}
|
||||
|
||||
func TestModule_GetView_NotFound(t *testing.T) {
|
||||
store := implsavedviewtest.NewMockSavedViewStore()
|
||||
m := implsavedview.NewModule(store)
|
||||
|
||||
orgID := valuer.GenerateUUID().StringValue()
|
||||
id := valuer.GenerateUUID()
|
||||
|
||||
store.ExpectGet(orgID, id, nil)
|
||||
_, err := m.GetView(contextWithClaims(orgID, "someone@signoz.io"), orgID, id)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Ast(err, errors.TypeNotFound), "expected a not-found error, got %v", err)
|
||||
|
||||
require.NoError(t, store.AssertExpectations())
|
||||
}
|
||||
|
||||
func TestModule_GetView_ScopedToOrg(t *testing.T) {
|
||||
store := implsavedviewtest.NewMockSavedViewStore()
|
||||
m := implsavedview.NewModule(store)
|
||||
|
||||
orgB := valuer.GenerateUUID().StringValue()
|
||||
id := valuer.GenerateUUID()
|
||||
|
||||
// The mock only has an expectation for orgB's WHERE clause; a lookup
|
||||
// scoped to org A's real id must not accidentally match it.
|
||||
store.ExpectGet(orgB, id, nil)
|
||||
_, err := m.GetView(contextWithClaims(orgB, "b@signoz.io"), orgB, id)
|
||||
require.Error(t, err, "a view created under org A must not be visible to org B")
|
||||
assert.True(t, errors.Ast(err, errors.TypeNotFound), "expected a not-found error, got %v", err)
|
||||
|
||||
require.NoError(t, store.AssertExpectations())
|
||||
}
|
||||
|
||||
func TestModule_UpdateView(t *testing.T) {
|
||||
store := implsavedviewtest.NewMockSavedViewStore()
|
||||
m := implsavedview.NewModule(store)
|
||||
|
||||
orgID := valuer.GenerateUUID().StringValue()
|
||||
id := valuer.GenerateUUID()
|
||||
|
||||
updated := testPostableSavedView("renamed", savedviewtypes.SourcePageTraces)
|
||||
updated.Spec.PanelType = savedviewtypes.PanelTypeTable
|
||||
|
||||
store.ExpectUpdate(orgID, id, 1)
|
||||
require.NoError(t, m.UpdateView(contextWithClaims(orgID, "updater@signoz.io"), orgID, id, updated))
|
||||
|
||||
stored := testSavedView(orgID, id, "updater@signoz.io", updated)
|
||||
store.ExpectGet(orgID, id, stored)
|
||||
got, err := m.GetView(contextWithClaims(orgID, "creator@signoz.io"), orgID, id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "renamed", got.Name)
|
||||
assert.Equal(t, savedviewtypes.SourcePageTraces, got.SourcePage)
|
||||
assert.Equal(t, savedviewtypes.PanelTypeTable, got.Spec.PanelType)
|
||||
assert.Equal(t, "updater@signoz.io", got.UpdatedBy)
|
||||
|
||||
require.NoError(t, store.AssertExpectations())
|
||||
}
|
||||
|
||||
func TestModule_UpdateView_NotFound(t *testing.T) {
|
||||
store := implsavedviewtest.NewMockSavedViewStore()
|
||||
m := implsavedview.NewModule(store)
|
||||
|
||||
orgID := valuer.GenerateUUID().StringValue()
|
||||
ctx := contextWithClaims(orgID, "someone@signoz.io")
|
||||
id := valuer.GenerateUUID()
|
||||
|
||||
store.ExpectUpdate(orgID, id, 0)
|
||||
err := m.UpdateView(ctx, orgID, id, testPostableSavedView("does not exist", savedviewtypes.SourcePageLogs))
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Ast(err, errors.TypeNotFound), "expected a not-found error, got %v", err)
|
||||
|
||||
require.NoError(t, store.AssertExpectations())
|
||||
}
|
||||
|
||||
func TestModule_UpdateView_ScopedToOrg(t *testing.T) {
|
||||
store := implsavedviewtest.NewMockSavedViewStore()
|
||||
m := implsavedview.NewModule(store)
|
||||
|
||||
orgB := valuer.GenerateUUID().StringValue()
|
||||
id := valuer.GenerateUUID()
|
||||
|
||||
// Only an update scoped to orgB's WHERE clause is registered; updating
|
||||
// org A's view while authenticated as org B must not match it.
|
||||
store.ExpectUpdate(orgB, id, 0)
|
||||
err := m.UpdateView(contextWithClaims(orgB, "b@signoz.io"), orgB, id, testPostableSavedView("hijacked", savedviewtypes.SourcePageLogs))
|
||||
require.Error(t, err, "org B must not be able to update org A's view")
|
||||
assert.True(t, errors.Ast(err, errors.TypeNotFound))
|
||||
|
||||
require.NoError(t, store.AssertExpectations())
|
||||
}
|
||||
|
||||
func TestModule_DeleteView(t *testing.T) {
|
||||
store := implsavedviewtest.NewMockSavedViewStore()
|
||||
m := implsavedview.NewModule(store)
|
||||
|
||||
orgID := valuer.GenerateUUID().StringValue()
|
||||
ctx := contextWithClaims(orgID, "creator@signoz.io")
|
||||
id := valuer.GenerateUUID()
|
||||
|
||||
store.ExpectDelete(orgID, id, 1)
|
||||
require.NoError(t, m.DeleteView(ctx, orgID, id))
|
||||
|
||||
require.NoError(t, store.AssertExpectations())
|
||||
}
|
||||
|
||||
func TestModule_DeleteView_NotFound(t *testing.T) {
|
||||
store := implsavedviewtest.NewMockSavedViewStore()
|
||||
m := implsavedview.NewModule(store)
|
||||
|
||||
orgID := valuer.GenerateUUID().StringValue()
|
||||
ctx := contextWithClaims(orgID, "someone@signoz.io")
|
||||
id := valuer.GenerateUUID()
|
||||
|
||||
store.ExpectDelete(orgID, id, 0)
|
||||
err := m.DeleteView(ctx, orgID, id)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Ast(err, errors.TypeNotFound), "expected a not-found error, got %v", err)
|
||||
|
||||
require.NoError(t, store.AssertExpectations())
|
||||
}
|
||||
|
||||
func TestModule_DeleteView_ScopedToOrg(t *testing.T) {
|
||||
store := implsavedviewtest.NewMockSavedViewStore()
|
||||
m := implsavedview.NewModule(store)
|
||||
|
||||
orgB := valuer.GenerateUUID().StringValue()
|
||||
id := valuer.GenerateUUID()
|
||||
|
||||
store.ExpectDelete(orgB, id, 0)
|
||||
err := m.DeleteView(contextWithClaims(orgB, "b@signoz.io"), orgB, id)
|
||||
require.Error(t, err, "org B must not be able to delete org A's view")
|
||||
assert.True(t, errors.Ast(err, errors.TypeNotFound))
|
||||
|
||||
require.NoError(t, store.AssertExpectations())
|
||||
}
|
||||
|
||||
func TestModule_GetViewsForFilters(t *testing.T) {
|
||||
store := implsavedviewtest.NewMockSavedViewStore()
|
||||
m := implsavedview.NewModule(store)
|
||||
|
||||
orgID := valuer.GenerateUUID().StringValue()
|
||||
ctx := contextWithClaims(orgID, "creator@signoz.io")
|
||||
|
||||
logsOverview := testSavedView(orgID, valuer.GenerateUUID(), "creator@signoz.io", testPostableSavedView("logs overview", savedviewtypes.SourcePageLogs))
|
||||
logsErrors := testSavedView(orgID, valuer.GenerateUUID(), "creator@signoz.io", testPostableSavedView("logs errors", savedviewtypes.SourcePageLogs))
|
||||
tracesOverview := testSavedView(orgID, valuer.GenerateUUID(), "creator@signoz.io", testPostableSavedView("traces overview", savedviewtypes.SourcePageTraces))
|
||||
|
||||
t.Run("filters by source page", func(t *testing.T) {
|
||||
store.ExpectList(orgID, []*savedviewtypes.SavedView{logsOverview, logsErrors})
|
||||
views, err := m.GetViewsForFilters(ctx, orgID, savedviewtypes.SourcePageLogs, "")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, views, 2)
|
||||
})
|
||||
|
||||
t.Run("filters by name substring", func(t *testing.T) {
|
||||
store.ExpectList(orgID, []*savedviewtypes.SavedView{logsErrors})
|
||||
views, err := m.GetViewsForFilters(ctx, orgID, savedviewtypes.SourcePageLogs, "errors")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, views, 1)
|
||||
assert.Equal(t, "logs errors", views[0].Name)
|
||||
})
|
||||
|
||||
t.Run("omitted source page returns everything, not nothing", func(t *testing.T) {
|
||||
// Fixes a bug: source_page used to be an unconditional exact-match
|
||||
// clause, so a zero-value sourcePage matched zero rows -- even though
|
||||
// ListSavedViewsParams.Validate() treats a zero SourcePage as valid
|
||||
// ("no filter"). Store.List now only applies the source_page clause
|
||||
// when it's non-zero.
|
||||
store.ExpectList(orgID, []*savedviewtypes.SavedView{logsOverview, logsErrors, tracesOverview})
|
||||
views, err := m.GetViewsForFilters(ctx, orgID, savedviewtypes.SourcePage{}, "")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, views, 3)
|
||||
})
|
||||
|
||||
t.Run("scoped to org", func(t *testing.T) {
|
||||
otherOrgID := valuer.GenerateUUID().StringValue()
|
||||
store.ExpectList(otherOrgID, nil)
|
||||
views, err := m.GetViewsForFilters(ctx, otherOrgID, savedviewtypes.SourcePageLogs, "")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, views)
|
||||
})
|
||||
|
||||
require.NoError(t, store.AssertExpectations())
|
||||
}
|
||||
|
||||
func TestModule_Collect(t *testing.T) {
|
||||
store := implsavedviewtest.NewMockSavedViewStore()
|
||||
m := implsavedview.NewModule(store)
|
||||
|
||||
orgID := valuer.GenerateUUID()
|
||||
|
||||
logsA := testSavedView(orgID.StringValue(), valuer.GenerateUUID(), "creator@signoz.io", testPostableSavedView("logs a", savedviewtypes.SourcePageLogs))
|
||||
logsB := testSavedView(orgID.StringValue(), valuer.GenerateUUID(), "creator@signoz.io", testPostableSavedView("logs b", savedviewtypes.SourcePageLogs))
|
||||
tracesA := testSavedView(orgID.StringValue(), valuer.GenerateUUID(), "creator@signoz.io", testPostableSavedView("traces a", savedviewtypes.SourcePageTraces))
|
||||
|
||||
store.ExpectList(orgID.StringValue(), []*savedviewtypes.SavedView{logsA, logsB, tracesA})
|
||||
stats, err := m.Collect(context.Background(), orgID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(3), stats["savedview.count"])
|
||||
assert.Equal(t, int64(2), stats["savedview.source.logs.count"])
|
||||
assert.Equal(t, int64(1), stats["savedview.source.traces.count"])
|
||||
|
||||
require.NoError(t, store.AssertExpectations())
|
||||
}
|
||||
111
pkg/modules/savedview/implsavedview/store.go
Normal file
111
pkg/modules/savedview/implsavedview/store.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package implsavedview
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/savedviewtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type store struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewStore(sqlstore sqlstore.SQLStore) savedviewtypes.Store {
|
||||
return &store{sqlstore: sqlstore}
|
||||
}
|
||||
|
||||
func (store *store) Create(ctx context.Context, view *savedviewtypes.SavedView) error {
|
||||
_, err := store.sqlstore.BunDB().NewInsert().Model(view).Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "error in creating saved view")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) Get(ctx context.Context, orgID string, id valuer.UUID) (*savedviewtypes.SavedView, error) {
|
||||
var view savedviewtypes.SavedView
|
||||
err := store.sqlstore.BunDB().NewSelect().Model(&view).Where("org_id = ? AND id = ?", orgID, id.StringValue()).Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, savedviewtypes.ErrCodeSavedViewNotFound, "saved view %s not found", id.StringValue())
|
||||
}
|
||||
|
||||
normalizeSelectedFields(&view)
|
||||
return &view, nil
|
||||
}
|
||||
|
||||
func (store *store) Update(ctx context.Context, view *savedviewtypes.SavedView) error {
|
||||
res, err := store.sqlstore.BunDB().NewUpdate().
|
||||
Model(&savedviewtypes.SavedView{}).
|
||||
Set("updated_at = ?, updated_by = ?, name = ?, source_page = ?, data = ?",
|
||||
view.UpdatedAt, view.UpdatedBy, view.Name, view.SourcePage, view.Data).
|
||||
Where("id = ?", view.ID.StringValue()).
|
||||
Where("org_id = ?", view.OrgID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "error in updating saved view")
|
||||
}
|
||||
|
||||
rowsAffected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "error in verifying the updated saved view")
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return errors.NewNotFoundf(savedviewtypes.ErrCodeSavedViewNotFound, "saved view %s not found", view.ID.StringValue())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) Delete(ctx context.Context, orgID string, id valuer.UUID) error {
|
||||
res, err := store.sqlstore.BunDB().NewDelete().
|
||||
Model(&savedviewtypes.SavedView{}).
|
||||
Where("id = ?", id.StringValue()).
|
||||
Where("org_id = ?", orgID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "error in deleting saved view")
|
||||
}
|
||||
|
||||
rowsAffected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "error in verifying the deleted saved view")
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return errors.NewNotFoundf(savedviewtypes.ErrCodeSavedViewNotFound, "saved view %s not found", id.StringValue())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) List(ctx context.Context, orgID string, sourcePage savedviewtypes.SourcePage, name string) ([]*savedviewtypes.SavedView, error) {
|
||||
var views []*savedviewtypes.SavedView
|
||||
q := store.sqlstore.BunDB().NewSelect().Model(&views).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("name LIKE ?", "%"+name+"%")
|
||||
if !sourcePage.IsZero() {
|
||||
q = q.Where("source_page = ?", sourcePage)
|
||||
}
|
||||
|
||||
if err := q.Scan(ctx); err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error in getting saved views")
|
||||
}
|
||||
|
||||
for _, view := range views {
|
||||
normalizeSelectedFields(view)
|
||||
}
|
||||
|
||||
return views, nil
|
||||
}
|
||||
|
||||
// normalizeSelectedFields fixes up a scanned row's nil SelectedFields (an
|
||||
// empty/never-set spec round-trips through the DB as JSON null) to an
|
||||
// explicit empty slice, so the API response never serializes it as null.
|
||||
func normalizeSelectedFields(view *savedviewtypes.SavedView) {
|
||||
if view.Data.Spec.SelectedFields == nil {
|
||||
view.Data.Spec.SelectedFields = []telemetrytypes.TelemetryFieldKey{}
|
||||
}
|
||||
}
|
||||
120
pkg/modules/savedview/implsavedviewtest/store.go
Normal file
120
pkg/modules/savedview/implsavedviewtest/store.go
Normal file
@@ -0,0 +1,120 @@
|
||||
// Package implsavedviewtest provides a sqlmock-backed savedviewtypes.Store,
|
||||
// mirroring rulestore/rulestoretest and nfroutingstore/nfroutingstoretest:
|
||||
// it delegates to the real implsavedview store so tests assert against
|
||||
// genuinely-generated SQL, rather than a hand-rolled fake.
|
||||
package implsavedviewtest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/SigNoz/signoz/pkg/modules/savedview/implsavedview"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore/sqlstoretest"
|
||||
"github.com/SigNoz/signoz/pkg/types/savedviewtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
var savedViewColumns = []string{"id", "created_at", "updated_at", "created_by", "updated_by", "org_id", "name", "source_page", "data"}
|
||||
|
||||
type MockSavedViewStore struct {
|
||||
store savedviewtypes.Store
|
||||
mock sqlmock.Sqlmock
|
||||
}
|
||||
|
||||
func NewMockSavedViewStore() *MockSavedViewStore {
|
||||
sqlStore := sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherRegexp)
|
||||
store := implsavedview.NewStore(sqlStore)
|
||||
|
||||
return &MockSavedViewStore{
|
||||
store: store,
|
||||
mock: sqlStore.Mock(),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MockSavedViewStore) Mock() sqlmock.Sqlmock {
|
||||
return m.mock
|
||||
}
|
||||
|
||||
func (m *MockSavedViewStore) Create(ctx context.Context, view *savedviewtypes.SavedView) error {
|
||||
return m.store.Create(ctx, view)
|
||||
}
|
||||
|
||||
func (m *MockSavedViewStore) Get(ctx context.Context, orgID string, id valuer.UUID) (*savedviewtypes.SavedView, error) {
|
||||
return m.store.Get(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (m *MockSavedViewStore) Update(ctx context.Context, view *savedviewtypes.SavedView) error {
|
||||
return m.store.Update(ctx, view)
|
||||
}
|
||||
|
||||
func (m *MockSavedViewStore) Delete(ctx context.Context, orgID string, id valuer.UUID) error {
|
||||
return m.store.Delete(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (m *MockSavedViewStore) List(ctx context.Context, orgID string, sourcePage savedviewtypes.SourcePage, name string) ([]*savedviewtypes.SavedView, error) {
|
||||
return m.store.List(ctx, orgID, sourcePage, name)
|
||||
}
|
||||
|
||||
func savedViewRow(view *savedviewtypes.SavedView) []driver.Value {
|
||||
data, _ := json.Marshal(view.Data)
|
||||
return []driver.Value{
|
||||
view.ID.StringValue(),
|
||||
view.CreatedAt,
|
||||
view.UpdatedAt,
|
||||
view.CreatedBy,
|
||||
view.UpdatedBy,
|
||||
view.OrgID,
|
||||
view.Name,
|
||||
view.SourcePage.StringValue(),
|
||||
string(data),
|
||||
}
|
||||
}
|
||||
|
||||
// ExpectCreate sets up the SQL expectation for a Create call.
|
||||
func (m *MockSavedViewStore) ExpectCreate() {
|
||||
m.mock.ExpectExec(`INSERT INTO "saved_view"`).WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
}
|
||||
|
||||
// ExpectGet sets up the SQL expectation for a Get call. Pass view = nil to
|
||||
// simulate a not-found row.
|
||||
func (m *MockSavedViewStore) ExpectGet(orgID string, id valuer.UUID, view *savedviewtypes.SavedView) {
|
||||
rows := sqlmock.NewRows(savedViewColumns)
|
||||
if view != nil {
|
||||
rows.AddRow(savedViewRow(view)...)
|
||||
}
|
||||
|
||||
m.mock.ExpectQuery(`SELECT (.+) FROM "saved_view".+WHERE \(org_id = '` + regexp.QuoteMeta(orgID) + `' AND id = '` + regexp.QuoteMeta(id.StringValue()) + `'\)`).
|
||||
WillReturnRows(rows)
|
||||
}
|
||||
|
||||
// ExpectUpdate sets up the SQL expectation for an Update call scoped to
|
||||
// orgID/id. rowsAffected = 0 simulates a not-found target row.
|
||||
func (m *MockSavedViewStore) ExpectUpdate(orgID string, id valuer.UUID, rowsAffected int64) {
|
||||
m.mock.ExpectExec(`UPDATE "saved_view".+WHERE \(id = '` + regexp.QuoteMeta(id.StringValue()) + `'\) AND \(org_id = '` + regexp.QuoteMeta(orgID) + `'\)`).
|
||||
WillReturnResult(sqlmock.NewResult(0, rowsAffected))
|
||||
}
|
||||
|
||||
// ExpectDelete sets up the SQL expectation for a Delete call scoped to
|
||||
// orgID/id. rowsAffected = 0 simulates a not-found target row.
|
||||
func (m *MockSavedViewStore) ExpectDelete(orgID string, id valuer.UUID, rowsAffected int64) {
|
||||
m.mock.ExpectExec(`DELETE FROM "saved_view".+WHERE \(id = '` + regexp.QuoteMeta(id.StringValue()) + `'\) AND \(org_id = '` + regexp.QuoteMeta(orgID) + `'\)`).
|
||||
WillReturnResult(sqlmock.NewResult(0, rowsAffected))
|
||||
}
|
||||
|
||||
// ExpectList sets up the SQL expectation for a List call scoped to orgID.
|
||||
func (m *MockSavedViewStore) ExpectList(orgID string, views []*savedviewtypes.SavedView) {
|
||||
rows := sqlmock.NewRows(savedViewColumns)
|
||||
for _, view := range views {
|
||||
rows.AddRow(savedViewRow(view)...)
|
||||
}
|
||||
|
||||
m.mock.ExpectQuery(`SELECT (.+) FROM "saved_view".+WHERE \(org_id = '` + regexp.QuoteMeta(orgID) + `'\)`).WillReturnRows(rows)
|
||||
}
|
||||
|
||||
func (m *MockSavedViewStore) AssertExpectations() error {
|
||||
return m.mock.ExpectationsWereMet()
|
||||
}
|
||||
@@ -4,19 +4,19 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/SigNoz/signoz/pkg/statsreporter"
|
||||
"github.com/SigNoz/signoz/pkg/types/savedviewtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type Module interface {
|
||||
GetViewsForFilters(ctx context.Context, orgID string, sourcePage string, name string, category string) ([]*v3.SavedView, error)
|
||||
GetViewsForFilters(ctx context.Context, orgID string, sourcePage savedviewtypes.SourcePage, name string) ([]*savedviewtypes.GettableSavedView, error)
|
||||
|
||||
CreateView(ctx context.Context, orgID string, view v3.SavedView) (valuer.UUID, error)
|
||||
CreateView(ctx context.Context, orgID string, view savedviewtypes.PostableSavedView) (valuer.UUID, error)
|
||||
|
||||
GetView(ctx context.Context, orgID string, uuid valuer.UUID) (*v3.SavedView, error)
|
||||
GetView(ctx context.Context, orgID string, uuid valuer.UUID) (*savedviewtypes.GettableSavedView, error)
|
||||
|
||||
UpdateView(ctx context.Context, orgID string, uuid valuer.UUID, view v3.SavedView) error
|
||||
UpdateView(ctx context.Context, orgID string, uuid valuer.UUID, view savedviewtypes.UpdatableSavedView) error
|
||||
|
||||
DeleteView(ctx context.Context, orgID string, uuid valuer.UUID) error
|
||||
|
||||
@@ -33,9 +33,22 @@ type Handler interface {
|
||||
// Updates the saved view
|
||||
Update(http.ResponseWriter, *http.Request)
|
||||
|
||||
// Deletes the saved view
|
||||
// Deletes the saved view. Shared by both API generations -- delete has no
|
||||
// request/response body to reshape.
|
||||
Delete(http.ResponseWriter, *http.Request)
|
||||
|
||||
// Lists the saved views
|
||||
List(http.ResponseWriter, *http.Request)
|
||||
|
||||
// CreateV2 is the /api/v2/saved_views typed-spec variant of Create.
|
||||
CreateV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
// GetV2 is the /api/v2/saved_views typed-spec variant of Get.
|
||||
GetV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
// UpdateV2 is the /api/v2/saved_views typed-spec variant of Update.
|
||||
UpdateV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
// ListV2 is the /api/v2/saved_views typed-spec variant of List.
|
||||
ListV2(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ func NewModules(
|
||||
OrgGetter: orgGetter,
|
||||
OrgSetter: orgSetter,
|
||||
Preference: implpreference.NewModule(implpreference.NewStore(sqlstore), preferencetypes.NewAvailablePreference()),
|
||||
SavedView: implsavedview.NewModule(sqlstore),
|
||||
SavedView: implsavedview.NewModule(implsavedview.NewStore(sqlstore)),
|
||||
Apdex: implapdex.NewModule(sqlstore),
|
||||
Dashboard: dashboard,
|
||||
UserSetter: userSetter,
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/promote"
|
||||
"github.com/SigNoz/signoz/pkg/modules/rawdataexport"
|
||||
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/savedview"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/session"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
|
||||
@@ -88,6 +89,7 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
|
||||
struct{ tracedetail.Handler }{},
|
||||
struct{ ruler.Handler }{},
|
||||
struct{ statsreporter.Handler }{},
|
||||
struct{ savedview.Handler }{},
|
||||
).New(ctx, instrumentation.ToProviderSettings(), apiserver.Config{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -231,6 +231,7 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewMigrateDashboardsV1ToV2Factory(sqlstore, sqlschema, dashboardStore, tagModule),
|
||||
sqlmigration.NewFillDashboardMeterSourceFactory(sqlstore, dashboardStore),
|
||||
sqlmigration.NewUpdateRoleTransactionGroupsFactory(),
|
||||
sqlmigration.NewRestructureSavedViewSpecFactory(sqlstore),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -330,6 +331,7 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
|
||||
handlers.TraceDetail,
|
||||
handlers.RulerHandler,
|
||||
handlers.StatsHandler,
|
||||
handlers.SavedView,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
144
pkg/sqlmigration/106_restructure_saved_view_spec.go
Normal file
144
pkg/sqlmigration/106_restructure_saved_view_spec.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
)
|
||||
|
||||
type restructureSavedViewSpec struct {
|
||||
store sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewRestructureSavedViewSpecFactory(store sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("restructure_saved_view_spec"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &restructureSavedViewSpec{store: store}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (migration *restructureSavedViewSpec) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
// legacySavedViewCompositeQuery is the bare shape saved_view.data held
|
||||
// before this migration -- just the relevant fields of composite query.
|
||||
// Queries is kept as raw JSON since the migration only needs to relocate it, not interpret it.
|
||||
type legacySavedViewCompositeQuery struct {
|
||||
PanelType string `json:"panelType"`
|
||||
Queries json.RawMessage `json:"queries"`
|
||||
}
|
||||
|
||||
// legacySavedViewExtraData mirrors the frontend defined extraData JSON shape.
|
||||
type legacySavedViewExtraData struct {
|
||||
Color string `json:"color,omitempty"`
|
||||
SelectColumns json.RawMessage `json:"selectColumns,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
MaxLines int `json:"maxLines,omitempty"`
|
||||
FontSize string `json:"fontSize,omitempty"`
|
||||
}
|
||||
|
||||
type savedViewDisplay struct {
|
||||
MaxLines int `json:"maxLines"`
|
||||
FontSize string `json:"fontSize"`
|
||||
Format string `json:"format"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
type savedViewSpec struct {
|
||||
PanelType string `json:"panelType"`
|
||||
Queries json.RawMessage `json:"queries"`
|
||||
SelectedFields json.RawMessage `json:"selectedFields"`
|
||||
Display savedViewDisplay `json:"display"`
|
||||
}
|
||||
|
||||
type savedViewData struct {
|
||||
SchemaVersion string `json:"schemaVersion"`
|
||||
Spec savedViewSpec `json:"spec"`
|
||||
}
|
||||
|
||||
func (migration *restructureSavedViewSpec) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var savedViews []struct {
|
||||
ID string `bun:"id"`
|
||||
Data string `bun:"data"`
|
||||
ExtraData string `bun:"extra_data"`
|
||||
}
|
||||
|
||||
err = tx.NewSelect().
|
||||
Table("saved_views").
|
||||
Column("id", "data", "extra_data").
|
||||
Scan(ctx, &savedViews)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, savedView := range savedViews {
|
||||
var compositeQuery legacySavedViewCompositeQuery
|
||||
if err := json.Unmarshal([]byte(savedView.Data), &compositeQuery); err != nil {
|
||||
continue // skip the row on error rather than fail the whole migration
|
||||
}
|
||||
|
||||
var extraData legacySavedViewExtraData
|
||||
if savedView.ExtraData != "" {
|
||||
// best-effort: malformed/older extraData shapes never fail the migration,
|
||||
// they just leave selectedFields/display empty.
|
||||
_ = json.Unmarshal([]byte(savedView.ExtraData), &extraData)
|
||||
}
|
||||
|
||||
dataJSON, err := json.Marshal(savedViewData{
|
||||
SchemaVersion: "v2",
|
||||
Spec: savedViewSpec{
|
||||
PanelType: compositeQuery.PanelType,
|
||||
Queries: compositeQuery.Queries,
|
||||
SelectedFields: extraData.SelectColumns,
|
||||
Display: savedViewDisplay{
|
||||
MaxLines: extraData.MaxLines,
|
||||
FontSize: extraData.FontSize,
|
||||
Format: extraData.Format,
|
||||
Color: extraData.Color,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.NewUpdate().
|
||||
Table("saved_views").
|
||||
Set("data = ?", string(dataJSON)).
|
||||
Where("id = ?", savedView.ID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, column := range []string{"category", "tags"} {
|
||||
if err := migration.store.Dialect().DropColumn(ctx, tx, "saved_views", column); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// matching the singular table-name convention.
|
||||
if _, err := tx.ExecContext(ctx, "ALTER TABLE saved_views RENAME TO saved_view"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *restructureSavedViewSpec) Down(context.Context, *bun.DB) error {
|
||||
// this migration is not reversible as we're transforming the structure
|
||||
return nil
|
||||
}
|
||||
@@ -2,30 +2,150 @@ package savedviewtypes
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCodeSavedViewInvalidInput = errors.MustNewCode("saved_view_invalid_input")
|
||||
ErrCodeSavedViewNotFound = errors.MustNewCode("saved_view_not_found")
|
||||
)
|
||||
|
||||
// SavedView is the core domain type; it also doubles as the storage row
|
||||
// (schemaVersion + spec stored JSON-encoded in Data). GettableSavedView is a
|
||||
// separate type, not an alias: bun only treats SavedViewData as a single
|
||||
// opaque "data" column when it's a named field, but the API response needs
|
||||
// schemaVersion/spec flattened to the top level -- those two requirements
|
||||
// can't be satisfied by the same field, so the two shapes genuinely diverge.
|
||||
type SavedView struct {
|
||||
bun.BaseModel `bun:"table:saved_views"`
|
||||
bun.BaseModel `bun:"table:saved_view"`
|
||||
|
||||
types.Identifiable
|
||||
types.TimeAuditable
|
||||
types.UserAuditable
|
||||
OrgID string `json:"orgId" bun:"org_id,notnull"`
|
||||
Name string `json:"name" bun:"name,type:text,notnull"`
|
||||
Category string `json:"category" bun:"category,type:text,notnull"`
|
||||
SourcePage string `json:"sourcePage" bun:"source_page,type:text,notnull"`
|
||||
Tags string `json:"tags" bun:"tags,type:text"`
|
||||
Data string `json:"data" bun:"data,type:text,notnull"`
|
||||
ExtraData string `json:"extraData" bun:"extra_data,type:text"`
|
||||
OrgID string `json:"-" bun:"org_id,notnull"`
|
||||
Name string `json:"name" bun:"name,type:text,notnull"`
|
||||
SourcePage SourcePage `json:"sourcePage" bun:"source_page,type:text,notnull"`
|
||||
Data SavedViewData `json:"-" bun:"data,type:text,notnull"`
|
||||
}
|
||||
|
||||
type GettableSavedView struct {
|
||||
ID valuer.UUID `json:"id" required:"true"`
|
||||
Name string `json:"name" required:"true"`
|
||||
CreatedAt time.Time `json:"createdAt" required:"true"`
|
||||
CreatedBy string `json:"createdBy" required:"true"`
|
||||
UpdatedAt time.Time `json:"updatedAt" required:"true"`
|
||||
UpdatedBy string `json:"updatedBy" required:"true"`
|
||||
SourcePage SourcePage `json:"sourcePage" required:"true"`
|
||||
SavedViewData
|
||||
}
|
||||
|
||||
type PostableSavedView struct {
|
||||
Name string `json:"name" required:"true"`
|
||||
SourcePage SourcePage `json:"sourcePage" required:"true"`
|
||||
SavedViewData
|
||||
}
|
||||
|
||||
type UpdatableSavedView = PostableSavedView
|
||||
|
||||
type ListSavedViewsParams struct {
|
||||
SourcePage SourcePage `query:"sourcePage"`
|
||||
Name string `query:"name"`
|
||||
}
|
||||
|
||||
type SourcePage struct {
|
||||
valuer.String
|
||||
}
|
||||
|
||||
var (
|
||||
SourcePageTraces = SourcePage{valuer.NewString("traces")}
|
||||
SourcePageLogs = SourcePage{valuer.NewString("logs")}
|
||||
SourcePageMetrics = SourcePage{valuer.NewString("metrics")}
|
||||
SourcePageMeter = SourcePage{valuer.NewString("meter")}
|
||||
)
|
||||
|
||||
func (SourcePage) Enum() []any {
|
||||
return []any{
|
||||
SourcePageTraces,
|
||||
SourcePageLogs,
|
||||
SourcePageMetrics,
|
||||
SourcePageMeter,
|
||||
}
|
||||
}
|
||||
|
||||
func (s SourcePage) Validate() error {
|
||||
switch s {
|
||||
case SourcePageTraces, SourcePageLogs, SourcePageMetrics, SourcePageMeter:
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeSavedViewInvalidInput, "invalid source page: %s", s.StringValue())
|
||||
}
|
||||
}
|
||||
|
||||
func (p *PostableSavedView) Validate() error {
|
||||
if err := p.SourcePage.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return p.SavedViewData.Validate()
|
||||
}
|
||||
|
||||
func (p *ListSavedViewsParams) Validate() error {
|
||||
if p.SourcePage.IsZero() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return p.SourcePage.Validate()
|
||||
}
|
||||
|
||||
func NewSavedView(orgID string, createdBy string, updatedBy string, view PostableSavedView) *SavedView {
|
||||
now := time.Now()
|
||||
return &SavedView{
|
||||
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
|
||||
TimeAuditable: types.TimeAuditable{CreatedAt: now, UpdatedAt: now},
|
||||
UserAuditable: types.UserAuditable{CreatedBy: createdBy, UpdatedBy: updatedBy},
|
||||
OrgID: orgID,
|
||||
Name: view.Name,
|
||||
SourcePage: view.SourcePage,
|
||||
Data: view.SavedViewData,
|
||||
}
|
||||
}
|
||||
|
||||
func NewGettableSavedViewFromSavedView(view *SavedView) *GettableSavedView {
|
||||
data := view.Data
|
||||
if data.Spec.SelectedFields == nil {
|
||||
data.Spec.SelectedFields = []telemetrytypes.TelemetryFieldKey{}
|
||||
}
|
||||
|
||||
return &GettableSavedView{
|
||||
ID: view.ID,
|
||||
Name: view.Name,
|
||||
CreatedAt: view.CreatedAt,
|
||||
CreatedBy: view.CreatedBy,
|
||||
UpdatedAt: view.UpdatedAt,
|
||||
UpdatedBy: view.UpdatedBy,
|
||||
SourcePage: view.SourcePage,
|
||||
SavedViewData: data,
|
||||
}
|
||||
}
|
||||
|
||||
func NewGettableSavedViewsFromSavedViews(views []*SavedView) []*GettableSavedView {
|
||||
out := make([]*GettableSavedView, 0, len(views))
|
||||
for _, view := range views {
|
||||
out = append(out, NewGettableSavedViewFromSavedView(view))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func NewStatsFromSavedViews(savedViews []*SavedView) map[string]any {
|
||||
stats := make(map[string]any)
|
||||
for _, savedView := range savedViews {
|
||||
key := "savedview.source." + strings.ToLower(string(savedView.SourcePage)) + ".count"
|
||||
key := "savedview.source." + strings.ToLower(savedView.SourcePage.StringValue()) + ".count"
|
||||
if _, ok := stats[key]; !ok {
|
||||
stats[key] = int64(1)
|
||||
} else {
|
||||
|
||||
178
pkg/types/savedviewtypes/savedview_test.go
Normal file
178
pkg/types/savedviewtypes/savedview_test.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package savedviewtypes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func validPostableSavedView() PostableSavedView {
|
||||
return PostableSavedView{
|
||||
Name: "my view",
|
||||
SourcePage: SourcePageLogs,
|
||||
SavedViewData: SavedViewData{
|
||||
SchemaVersion: SavedViewSchemaVersion,
|
||||
Spec: SavedViewSpec{PanelType: PanelTypeGraph, Queries: validQueries()},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourcePageValidate(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
sourcePage SourcePage
|
||||
expectError bool
|
||||
}{
|
||||
{name: "traces", sourcePage: SourcePageTraces},
|
||||
{name: "logs", sourcePage: SourcePageLogs},
|
||||
{name: "metrics", sourcePage: SourcePageMetrics},
|
||||
{name: "meter", sourcePage: SourcePageMeter},
|
||||
{name: "unknown is rejected", sourcePage: SourcePage{valuer.NewString("bogus")}, expectError: true},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
err := c.sourcePage.Validate()
|
||||
if c.expectError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostableSavedViewValidate(t *testing.T) {
|
||||
t.Run("valid view", func(t *testing.T) {
|
||||
view := validPostableSavedView()
|
||||
assert.NoError(t, view.Validate())
|
||||
})
|
||||
|
||||
t.Run("invalid source page is rejected", func(t *testing.T) {
|
||||
view := validPostableSavedView()
|
||||
view.SourcePage = SourcePage{valuer.NewString("bogus")}
|
||||
assert.Error(t, view.Validate())
|
||||
})
|
||||
|
||||
t.Run("invalid saved view data is rejected", func(t *testing.T) {
|
||||
view := validPostableSavedView()
|
||||
view.SchemaVersion = "v1"
|
||||
assert.Error(t, view.Validate())
|
||||
})
|
||||
}
|
||||
|
||||
func TestListSavedViewsParamsValidate(t *testing.T) {
|
||||
t.Run("zero source page is allowed", func(t *testing.T) {
|
||||
params := ListSavedViewsParams{}
|
||||
assert.NoError(t, params.Validate())
|
||||
})
|
||||
|
||||
t.Run("valid source page is allowed", func(t *testing.T) {
|
||||
params := ListSavedViewsParams{SourcePage: SourcePageLogs}
|
||||
assert.NoError(t, params.Validate())
|
||||
})
|
||||
|
||||
t.Run("invalid source page is rejected", func(t *testing.T) {
|
||||
params := ListSavedViewsParams{SourcePage: SourcePage{valuer.NewString("bogus")}}
|
||||
assert.Error(t, params.Validate())
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewSavedView(t *testing.T) {
|
||||
orgID := valuer.GenerateUUID().StringValue()
|
||||
view := validPostableSavedView()
|
||||
|
||||
savedView := NewSavedView(orgID, "creator@signoz.io", "updater@signoz.io", view)
|
||||
|
||||
assert.False(t, savedView.ID.IsZero())
|
||||
assert.Equal(t, orgID, savedView.OrgID)
|
||||
assert.Equal(t, "creator@signoz.io", savedView.CreatedBy)
|
||||
assert.Equal(t, "updater@signoz.io", savedView.UpdatedBy)
|
||||
assert.Equal(t, view.Name, savedView.Name)
|
||||
assert.Equal(t, view.SourcePage, savedView.SourcePage)
|
||||
assert.Equal(t, view.SavedViewData, savedView.Data)
|
||||
assert.False(t, savedView.CreatedAt.IsZero())
|
||||
assert.Equal(t, savedView.CreatedAt, savedView.UpdatedAt)
|
||||
}
|
||||
|
||||
func TestNewGettableSavedViewFromSavedView(t *testing.T) {
|
||||
t.Run("nil selected fields are normalized to an empty slice", func(t *testing.T) {
|
||||
savedView := &SavedView{
|
||||
Name: "my view",
|
||||
SourcePage: SourcePageLogs,
|
||||
Data: SavedViewData{
|
||||
SchemaVersion: SavedViewSchemaVersion,
|
||||
Spec: SavedViewSpec{PanelType: PanelTypeGraph, Queries: validQueries(), SelectedFields: nil},
|
||||
},
|
||||
}
|
||||
|
||||
gettable := NewGettableSavedViewFromSavedView(savedView)
|
||||
|
||||
require.NotNil(t, gettable.Spec.SelectedFields)
|
||||
assert.Empty(t, gettable.Spec.SelectedFields)
|
||||
})
|
||||
|
||||
t.Run("existing selected fields are preserved", func(t *testing.T) {
|
||||
fields := []telemetrytypes.TelemetryFieldKey{{Name: "service.name"}}
|
||||
savedView := &SavedView{
|
||||
Name: "my view",
|
||||
SourcePage: SourcePageLogs,
|
||||
Data: SavedViewData{
|
||||
SchemaVersion: SavedViewSchemaVersion,
|
||||
Spec: SavedViewSpec{PanelType: PanelTypeGraph, Queries: validQueries(), SelectedFields: fields},
|
||||
},
|
||||
}
|
||||
|
||||
gettable := NewGettableSavedViewFromSavedView(savedView)
|
||||
|
||||
assert.Equal(t, fields, gettable.Spec.SelectedFields)
|
||||
})
|
||||
|
||||
t.Run("all fields carried over", func(t *testing.T) {
|
||||
now := time.Now()
|
||||
savedView := &SavedView{
|
||||
Name: "my view",
|
||||
SourcePage: SourcePageTraces,
|
||||
Data: SavedViewData{
|
||||
SchemaVersion: SavedViewSchemaVersion,
|
||||
Spec: SavedViewSpec{PanelType: PanelTypeTable, Queries: validQueries()},
|
||||
},
|
||||
}
|
||||
savedView.ID = valuer.GenerateUUID()
|
||||
savedView.CreatedAt = now
|
||||
savedView.UpdatedAt = now
|
||||
savedView.CreatedBy = "creator@signoz.io"
|
||||
savedView.UpdatedBy = "updater@signoz.io"
|
||||
|
||||
gettable := NewGettableSavedViewFromSavedView(savedView)
|
||||
|
||||
assert.Equal(t, savedView.ID, gettable.ID)
|
||||
assert.Equal(t, savedView.Name, gettable.Name)
|
||||
assert.Equal(t, savedView.CreatedAt, gettable.CreatedAt)
|
||||
assert.Equal(t, savedView.CreatedBy, gettable.CreatedBy)
|
||||
assert.Equal(t, savedView.UpdatedAt, gettable.UpdatedAt)
|
||||
assert.Equal(t, savedView.UpdatedBy, gettable.UpdatedBy)
|
||||
assert.Equal(t, savedView.SourcePage, gettable.SourcePage)
|
||||
assert.Equal(t, savedView.Data.SchemaVersion, gettable.SchemaVersion)
|
||||
assert.Equal(t, savedView.Data.Spec.PanelType, gettable.Spec.PanelType)
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewStatsFromSavedViews(t *testing.T) {
|
||||
views := []*SavedView{
|
||||
{SourcePage: SourcePageLogs},
|
||||
{SourcePage: SourcePageLogs},
|
||||
{SourcePage: SourcePageTraces},
|
||||
}
|
||||
|
||||
stats := NewStatsFromSavedViews(views)
|
||||
|
||||
assert.Equal(t, int64(3), stats["savedview.count"])
|
||||
assert.Equal(t, int64(2), stats["savedview.source.logs.count"])
|
||||
assert.Equal(t, int64(1), stats["savedview.source.traces.count"])
|
||||
assert.NotContains(t, stats, "savedview.source.metrics.count")
|
||||
}
|
||||
81
pkg/types/savedviewtypes/spec.go
Normal file
81
pkg/types/savedviewtypes/spec.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package savedviewtypes
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// SavedViewSchemaVersion is the only schemaVersion currently.
|
||||
const SavedViewSchemaVersion = "v2"
|
||||
|
||||
// Display holds view-rendering preferences.
|
||||
type Display struct {
|
||||
MaxLines int `json:"maxLines"`
|
||||
FontSize string `json:"fontSize"`
|
||||
Format string `json:"format"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
// SavedViewSpec is the typed content of a saved view, mirroring the dashboardtypes v2 spec pattern.
|
||||
type SavedViewSpec struct {
|
||||
PanelType PanelType `json:"panelType" required:"true"`
|
||||
Queries []qbtypes.QueryEnvelope `json:"queries" required:"true" nullable:"false"`
|
||||
SelectedFields []telemetrytypes.TelemetryFieldKey `json:"selectedFields" required:"true" nullable:"false"`
|
||||
Display Display `json:"display" required:"true"`
|
||||
}
|
||||
|
||||
// SavedViewData is what's persisted as saved view data.
|
||||
type SavedViewData struct {
|
||||
SchemaVersion string `json:"schemaVersion" required:"true"`
|
||||
Spec SavedViewSpec `json:"spec" required:"true"`
|
||||
}
|
||||
|
||||
// PanelType is the explore-page panel a saved view renders as.
|
||||
type PanelType struct {
|
||||
valuer.String
|
||||
}
|
||||
|
||||
var (
|
||||
PanelTypeValue = PanelType{valuer.NewString("value")}
|
||||
PanelTypeGraph = PanelType{valuer.NewString("graph")}
|
||||
PanelTypeTable = PanelType{valuer.NewString("table")}
|
||||
PanelTypeList = PanelType{valuer.NewString("list")}
|
||||
PanelTypeTrace = PanelType{valuer.NewString("trace")}
|
||||
)
|
||||
|
||||
func (PanelType) Enum() []any {
|
||||
return []any{
|
||||
PanelTypeValue,
|
||||
PanelTypeGraph,
|
||||
PanelTypeTable,
|
||||
PanelTypeList,
|
||||
PanelTypeTrace,
|
||||
}
|
||||
}
|
||||
|
||||
func (p PanelType) Validate() error {
|
||||
switch p {
|
||||
case PanelTypeValue, PanelTypeGraph, PanelTypeTable, PanelTypeList, PanelTypeTrace:
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeSavedViewInvalidInput, "invalid panel type: %s", p.StringValue())
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SavedViewSpec) Validate() error {
|
||||
if err := s.PanelType.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return (&qbtypes.CompositeQuery{Queries: s.Queries}).Validate()
|
||||
}
|
||||
|
||||
func (d *SavedViewData) Validate() error {
|
||||
if d.SchemaVersion != SavedViewSchemaVersion {
|
||||
return errors.NewInvalidInputf(ErrCodeSavedViewInvalidInput, "schemaVersion must be %q, got %q", SavedViewSchemaVersion, d.SchemaVersion)
|
||||
}
|
||||
|
||||
return d.Spec.Validate()
|
||||
}
|
||||
134
pkg/types/savedviewtypes/spec_test.go
Normal file
134
pkg/types/savedviewtypes/spec_test.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package savedviewtypes
|
||||
|
||||
import (
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func validQueries() []qbtypes.QueryEnvelope {
|
||||
return []qbtypes.QueryEnvelope{
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
Aggregations: []qbtypes.LogAggregation{{Expression: "count()"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestPanelTypeValidate(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
panelType PanelType
|
||||
expectError bool
|
||||
}{
|
||||
{name: "value", panelType: PanelTypeValue},
|
||||
{name: "graph", panelType: PanelTypeGraph},
|
||||
{name: "table", panelType: PanelTypeTable},
|
||||
{name: "list", panelType: PanelTypeList},
|
||||
{name: "trace", panelType: PanelTypeTrace},
|
||||
{name: "unknown is rejected", panelType: PanelType{valuer.NewString("bogus")}, expectError: true},
|
||||
{name: "empty is rejected", panelType: PanelType{}, expectError: true},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
err := c.panelType.Validate()
|
||||
if c.expectError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavedViewSpecValidate(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
spec SavedViewSpec
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "valid spec",
|
||||
spec: SavedViewSpec{PanelType: PanelTypeGraph, Queries: validQueries()},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "invalid panel type is rejected before queries are checked",
|
||||
spec: SavedViewSpec{PanelType: PanelType{valuer.NewString("bogus")}, Queries: validQueries()},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "no queries is rejected",
|
||||
spec: SavedViewSpec{PanelType: PanelTypeGraph},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "selected fields and display are not required",
|
||||
spec: SavedViewSpec{
|
||||
PanelType: PanelTypeTable,
|
||||
Queries: validQueries(),
|
||||
SelectedFields: []telemetrytypes.TelemetryFieldKey{{Name: "service.name"}},
|
||||
Display: Display{MaxLines: 3, FontSize: "small", Format: "table", Color: "blue"},
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
err := c.spec.Validate()
|
||||
if c.expectError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavedViewDataValidate(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
data SavedViewData
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "valid data",
|
||||
data: SavedViewData{SchemaVersion: SavedViewSchemaVersion, Spec: SavedViewSpec{PanelType: PanelTypeGraph, Queries: validQueries()}},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "wrong schema version is rejected",
|
||||
data: SavedViewData{SchemaVersion: "v1", Spec: SavedViewSpec{PanelType: PanelTypeGraph, Queries: validQueries()}},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "empty schema version is rejected",
|
||||
data: SavedViewData{Spec: SavedViewSpec{PanelType: PanelTypeGraph, Queries: validQueries()}},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid spec is rejected",
|
||||
data: SavedViewData{SchemaVersion: SavedViewSchemaVersion, Spec: SavedViewSpec{PanelType: PanelTypeGraph}},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
err := c.data.Validate()
|
||||
if c.expectError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
22
pkg/types/savedviewtypes/store.go
Normal file
22
pkg/types/savedviewtypes/store.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package savedviewtypes
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type Store interface {
|
||||
Create(ctx context.Context, view *SavedView) error
|
||||
|
||||
Get(ctx context.Context, orgID string, id valuer.UUID) (*SavedView, error)
|
||||
|
||||
Update(ctx context.Context, view *SavedView) error
|
||||
|
||||
Delete(ctx context.Context, orgID string, id valuer.UUID) error
|
||||
|
||||
// List returns the org's saved views, optionally filtered by sourcePage
|
||||
// (exact match) and name (substring). A zero-value sourcePage means "no
|
||||
// filter" -- it does not match a literal empty source_page column value.
|
||||
List(ctx context.Context, orgID string, sourcePage SourcePage, name string) ([]*SavedView, error)
|
||||
}
|
||||
524
tests/integration/tests/savedview/01_saved_view.py
Normal file
524
tests/integration/tests/savedview/01_saved_view.py
Normal file
@@ -0,0 +1,524 @@
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
|
||||
import requests
|
||||
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.types import Operation, SigNoz
|
||||
|
||||
BASE_URL = "/api/v2/saved_views"
|
||||
|
||||
|
||||
def _query(*, disabled: bool = False, legend: str = "") -> dict:
|
||||
return {
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "logs",
|
||||
"aggregations": [{"expression": "count()"}],
|
||||
"disabled": disabled,
|
||||
"legend": legend,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _body(
|
||||
*,
|
||||
name: str = "my-view",
|
||||
source_page: str = "logs",
|
||||
panel_type: str = "table",
|
||||
max_lines: int = 0,
|
||||
font_size: str = "",
|
||||
fmt: str = "",
|
||||
color: str = "",
|
||||
selected_fields: list | None = None,
|
||||
disabled: bool = False,
|
||||
legend: str = "",
|
||||
) -> dict:
|
||||
return {
|
||||
"name": name,
|
||||
"sourcePage": source_page,
|
||||
"schemaVersion": "v2",
|
||||
"spec": {
|
||||
"panelType": panel_type,
|
||||
"queries": [_query(disabled=disabled, legend=legend)],
|
||||
"selectedFields": [] if selected_fields is None else selected_fields,
|
||||
"display": {"maxLines": max_lines, "fontSize": font_size, "format": fmt, "color": color},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ─── failure cases (create no saved views) ───────────────────────────────────
|
||||
|
||||
|
||||
def test_create_rejects_wrong_schema_version(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
body = _body()
|
||||
body["schemaVersion"] = "v9"
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert response.json()["error"]["code"] == "saved_view_invalid_input"
|
||||
assert response.json()["error"]["message"] == 'schemaVersion must be "v2", got "v9"'
|
||||
|
||||
|
||||
def test_create_rejects_invalid_panel_type(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
body = _body()
|
||||
body["spec"]["panelType"] = "bogus"
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert response.json()["error"]["code"] == "saved_view_invalid_input"
|
||||
|
||||
|
||||
def test_create_rejects_empty_queries(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
body = _body()
|
||||
body["spec"]["queries"] = []
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
# CompositeQuery.Validate() (querybuildertypesv5) raises this with the generic
|
||||
# invalid_input code, not saved_view_invalid_input -- unlike schemaVersion/
|
||||
# panelType/sourcePage, which are validated directly by savedviewtypes.
|
||||
assert response.json()["error"]["code"] == "invalid_input"
|
||||
assert "at least one query is required" in response.json()["error"]["message"]
|
||||
|
||||
|
||||
def test_create_rejects_invalid_source_page(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
body = _body()
|
||||
body["sourcePage"] = "bogus"
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert response.json()["error"]["code"] == "saved_view_invalid_input"
|
||||
|
||||
|
||||
def test_create_rejects_unknown_field(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# Unlike dashboard v2's PostableDashboardV2 (which has a custom UnmarshalJSON
|
||||
# that rewraps this as its own dashboard_invalid_input code), PostableSavedView
|
||||
# relies solely on binding.WithDisallowUnknownFields, so this surfaces as the
|
||||
# generic invalid_input code rather than saved_view_invalid_input.
|
||||
body = _body()
|
||||
body["unknownfield"] = "boom"
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert response.json()["error"]["code"] == "invalid_input"
|
||||
assert "unknown field" in response.json()["error"]["message"]
|
||||
|
||||
|
||||
# ─── not-found cases ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_get_rejects_malformed_id(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/not-a-uuid"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
|
||||
|
||||
def test_get_missing_view_returns_not_found(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{uuid.uuid4()}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
assert response.json()["error"]["code"] == "saved_view_not_found"
|
||||
|
||||
|
||||
def test_update_missing_view_returns_not_found(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{uuid.uuid4()}"),
|
||||
json=_body(),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
assert response.json()["error"]["code"] == "saved_view_not_found"
|
||||
|
||||
|
||||
def test_delete_missing_view_returns_not_found(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{uuid.uuid4()}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
assert response.json()["error"]["code"] == "saved_view_not_found"
|
||||
|
||||
|
||||
# ─── lifecycle ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_saved_view_lifecycle(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
# ── create ────────────────────────────────────────────────────────────────
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
json=_body(name="lc-logs-overview", source_page="logs"),
|
||||
headers=headers,
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
view_id = response.json()["data"]
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
json=_body(name="lc-traces-overview", source_page="traces"),
|
||||
headers=headers,
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
try:
|
||||
# ── get echoes back the created shape ────────────────────────────────
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
|
||||
headers=headers,
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
got = response.json()["data"]
|
||||
assert got["id"] == view_id
|
||||
assert got["name"] == "lc-logs-overview"
|
||||
assert got["sourcePage"] == "logs"
|
||||
assert got["spec"]["panelType"] == "table"
|
||||
|
||||
# ── list filters by sourcePage and name ──────────────────────────────
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
params={"sourcePage": "logs"},
|
||||
headers=headers,
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert {v["name"] for v in response.json()["data"]} == {"lc-logs-overview"}
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
params={"sourcePage": "logs", "name": "overview"},
|
||||
headers=headers,
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert {v["name"] for v in response.json()["data"]} == {"lc-logs-overview"}
|
||||
|
||||
# ── update mutates name, sourcePage and spec ─────────────────────────
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
|
||||
json=_body(name="lc-logs-renamed", source_page="metrics", panel_type="graph"),
|
||||
headers=headers,
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
|
||||
headers=headers,
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
updated = response.json()["data"]
|
||||
assert updated["name"] == "lc-logs-renamed"
|
||||
assert updated["sourcePage"] == "metrics"
|
||||
assert updated["spec"]["panelType"] == "graph"
|
||||
finally:
|
||||
requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
|
||||
headers=headers,
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
# ── delete removes it from get and list ──────────────────────────────────
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
|
||||
headers=headers,
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
|
||||
|
||||
# ─── round-trip serialization: zero/empty values must not get corrupted ──────
|
||||
# A value that's genuinely zero (maxLines: 0), empty (""), or an explicit empty
|
||||
# list must survive being written and read back exactly as sent — never dropped,
|
||||
# defaulted, or turned into null. The riskier case is not create -> GET (a fresh
|
||||
# row), it's UPDATE -> GET: overwriting a *previously non-zero* value down to its
|
||||
# zero value must actually take effect on the persisted row, not silently retain
|
||||
# the old value or lose the field. See test_dashboard_v2_roundtrip_preserves_zero_values
|
||||
# for the analogous dashboard v2 case.
|
||||
|
||||
|
||||
def test_create_roundtrip_preserves_zero_values(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
json=_body(
|
||||
name="create-zero-values",
|
||||
max_lines=0,
|
||||
font_size="",
|
||||
fmt="",
|
||||
color="",
|
||||
selected_fields=[],
|
||||
disabled=False,
|
||||
legend="",
|
||||
),
|
||||
headers=headers,
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
view_id = response.json()["data"]
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
|
||||
headers=headers,
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
spec = response.json()["data"]["spec"]
|
||||
query = spec["queries"][0]["spec"]
|
||||
|
||||
cases = [
|
||||
("maxLines 0", spec["display"]["maxLines"], 0),
|
||||
("fontSize empty", spec["display"]["fontSize"], ""),
|
||||
("format empty", spec["display"]["format"], ""),
|
||||
("color empty", spec["display"]["color"], ""),
|
||||
("selectedFields explicit empty list", spec["selectedFields"], []),
|
||||
("query disabled false", query["disabled"], False),
|
||||
("query legend empty", query["legend"], ""),
|
||||
]
|
||||
for description, actual, expected in cases:
|
||||
assert actual == expected, description
|
||||
finally:
|
||||
requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
|
||||
headers=headers,
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
|
||||
def test_selected_fields_omitted_on_create_reads_back_as_empty_list_not_null(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
body = _body(name="omitted-selected-fields")
|
||||
del body["spec"]["selectedFields"]
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
json=body,
|
||||
headers=headers,
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
view_id = response.json()["data"]
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
|
||||
headers=headers,
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"]["spec"]["selectedFields"] == []
|
||||
finally:
|
||||
requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
|
||||
headers=headers,
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
|
||||
def test_update_does_not_corrupt_zero_values(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
"""The failure mode this guards against: an update that writes maxLines=0 (or
|
||||
any other zero/empty value) either silently keeps the previous non-zero value
|
||||
(a partial-update bug) or drops/nulls the field on read-back (a serialization
|
||||
bug). Both are round-trip corruption; only an exact zero on GET proves neither
|
||||
happened."""
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
# ── create with deliberately non-zero values everywhere ──────────────────
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
json=_body(
|
||||
name="update-zero-values",
|
||||
max_lines=25,
|
||||
font_size="large",
|
||||
fmt="table",
|
||||
color="blue",
|
||||
selected_fields=[{"name": "service.name"}],
|
||||
disabled=True,
|
||||
legend="Custom Legend",
|
||||
),
|
||||
headers=headers,
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
view_id = response.json()["data"]
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
|
||||
headers=headers,
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
spec = response.json()["data"]["spec"]
|
||||
assert spec["display"]["maxLines"] == 25
|
||||
# signal/fieldContext/fieldDataType always serialize on TelemetryFieldKey
|
||||
# (no omitempty -- see pkg/types/telemetrytypes/field.go), so an entry sent
|
||||
# with only "name" reads back with those three as explicit "".
|
||||
assert spec["selectedFields"] == [{"name": "service.name", "signal": "", "fieldContext": "", "fieldDataType": ""}]
|
||||
assert spec["queries"][0]["spec"]["disabled"] is True
|
||||
|
||||
# ── update overwrites every one of those fields down to its zero value ──
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
|
||||
json=_body(
|
||||
name="update-zero-values",
|
||||
max_lines=0,
|
||||
font_size="",
|
||||
fmt="",
|
||||
color="",
|
||||
selected_fields=[],
|
||||
disabled=False,
|
||||
legend="",
|
||||
),
|
||||
headers=headers,
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
# ── the zero values took effect -- not retained, not dropped, not null ──
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
|
||||
headers=headers,
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
spec = response.json()["data"]["spec"]
|
||||
query = spec["queries"][0]["spec"]
|
||||
|
||||
cases = [
|
||||
("maxLines reset to 0", spec["display"]["maxLines"], 0),
|
||||
("fontSize reset to empty", spec["display"]["fontSize"], ""),
|
||||
("format reset to empty", spec["display"]["format"], ""),
|
||||
("color reset to empty", spec["display"]["color"], ""),
|
||||
("selectedFields reset to empty list", spec["selectedFields"], []),
|
||||
("query disabled reset to false", query["disabled"], False),
|
||||
("query legend reset to empty", query["legend"], ""),
|
||||
]
|
||||
for description, actual, expected in cases:
|
||||
assert actual == expected, description
|
||||
finally:
|
||||
requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
|
||||
headers=headers,
|
||||
timeout=5,
|
||||
)
|
||||
Reference in New Issue
Block a user