mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-22 10:00:42 +01:00
Compare commits
12 Commits
feat/anoma
...
backup/thr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b941fe606 | ||
|
|
f45d683ec2 | ||
|
|
edca5d2e3e | ||
|
|
6c1f12f5d0 | ||
|
|
6c6632f940 | ||
|
|
39b752c428 | ||
|
|
dccd75e1de | ||
|
|
4f3e7fa50e | ||
|
|
531504c523 | ||
|
|
b770c249a0 | ||
|
|
6e6d7ebe8e | ||
|
|
aef60bad5d |
@@ -104,6 +104,7 @@ func runGenerateAuthz(_ context.Context) error {
|
||||
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceMetrics).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceMeterMetrics).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceMetaResourceQuickFilter).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceMetaResourceSavedView).String(): true,
|
||||
}
|
||||
|
||||
allowedTypes := map[string]bool{}
|
||||
|
||||
@@ -8046,6 +8046,31 @@ components:
|
||||
- metric
|
||||
- value
|
||||
type: object
|
||||
PromotetypesIndexMaterializedPathsResult:
|
||||
properties:
|
||||
indexed:
|
||||
items:
|
||||
$ref: '#/components/schemas/PromotetypesPromotePath'
|
||||
nullable: true
|
||||
type: array
|
||||
skipped:
|
||||
items:
|
||||
$ref: '#/components/schemas/PromotetypesPromotePath'
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- indexed
|
||||
- skipped
|
||||
type: object
|
||||
PromotetypesPostableIndexMaterializedPaths:
|
||||
properties:
|
||||
dryRun:
|
||||
type: boolean
|
||||
signal:
|
||||
type: string
|
||||
required:
|
||||
- signal
|
||||
type: object
|
||||
PromotetypesPromotePath:
|
||||
properties:
|
||||
context:
|
||||
@@ -14072,6 +14097,67 @@ paths:
|
||||
tags:
|
||||
- promote
|
||||
x-signoz-stability: alpha
|
||||
/api/v1/promoted_paths/materialized:
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint indexes the JSON sub-columns of materialized paths
|
||||
whose columns are indexed and returns the indexed and skipped paths.
|
||||
operationId: IndexMaterializedPaths
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PromotetypesPostableIndexMaterializedPaths'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/PromotetypesIndexMaterializedPathsResult'
|
||||
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:
|
||||
- logs-field:update
|
||||
- traces-field:update
|
||||
- tokenizer:
|
||||
- logs-field:update
|
||||
- traces-field:update
|
||||
summary: Index materialized paths
|
||||
tags:
|
||||
- promote
|
||||
x-signoz-stability: alpha
|
||||
/api/v1/roles:
|
||||
get:
|
||||
deprecated: false
|
||||
@@ -24922,6 +25008,75 @@ paths:
|
||||
tags:
|
||||
- rules
|
||||
x-signoz-stability: alpha
|
||||
/api/v2/rules/{id}/clone:
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint creates a new alert rule from the stored definition
|
||||
of an existing one. The clone keeps every modeled field of the source, including
|
||||
its enabled/disabled state, and gets the source's name suffixed with " - Copy"
|
||||
(or a bumped " - Copy (n)" counter when the source is itself a copy). The
|
||||
clone is owned by the caller.
|
||||
operationId: CloneRuleByID
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"201":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/RuletypesRule'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: Created
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"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: Clone alert rule
|
||||
tags:
|
||||
- rules
|
||||
x-signoz-stability: alpha
|
||||
/api/v2/rules/{id}/history/filter_keys:
|
||||
get:
|
||||
deprecated: false
|
||||
|
||||
@@ -423,7 +423,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
func (r *AnomalyRule) String() string {
|
||||
|
||||
ar := ruletypes.PostableRule{
|
||||
AlertName: r.Name(),
|
||||
AlertName: valuer.NewDisplayName(r.Name()),
|
||||
RuleCondition: r.Condition(),
|
||||
EvalWindow: r.EvalWindow(),
|
||||
Labels: r.Labels().Map(),
|
||||
|
||||
@@ -46,7 +46,7 @@ func TestAnomalyRule_NoData_AlertOnAbsent(t *testing.T) {
|
||||
target := 500.0
|
||||
|
||||
postableRule := ruletypes.PostableRule{
|
||||
AlertName: "Test anomaly no data",
|
||||
AlertName: valuer.NewDisplayName("Test anomaly no data"),
|
||||
AlertType: ruletypes.AlertTypeMetric,
|
||||
RuleType: ruletypes.RuleTypeAnomaly,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
||||
@@ -149,7 +149,7 @@ func TestAnomalyRule_NoData_AbsentFor(t *testing.T) {
|
||||
target := 500.0
|
||||
|
||||
postableRule := ruletypes.PostableRule{
|
||||
AlertName: "Test anomaly no data with AbsentFor",
|
||||
AlertName: valuer.NewDisplayName("Test anomaly no data with AbsentFor"),
|
||||
AlertType: ruletypes.AlertTypeMetric,
|
||||
RuleType: ruletypes.RuleTypeAnomaly,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
baserules "github.com/SigNoz/signoz/pkg/query-service/rules"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error) {
|
||||
@@ -118,7 +119,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, error) {
|
||||
}
|
||||
|
||||
parsedRule := opts.Rule
|
||||
var alertname = parsedRule.AlertName
|
||||
var alertname = parsedRule.AlertName.StringValue()
|
||||
if alertname == "" {
|
||||
// alertname is not mandatory for testing, so picking
|
||||
// a random string here
|
||||
@@ -126,7 +127,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, error) {
|
||||
}
|
||||
|
||||
// append name to indicate this is test alert
|
||||
parsedRule.AlertName = fmt.Sprintf("%s%s", alertname, ruletypes.TestAlertPostFix)
|
||||
parsedRule.AlertName = valuer.NewDisplayName(fmt.Sprintf("%s%s", alertname, ruletypes.TestAlertPostFix))
|
||||
|
||||
var rule baserules.Rule
|
||||
var err error
|
||||
|
||||
@@ -9,12 +9,14 @@ describe('ErrorResponseHandler', () => {
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
expect(ErrorResponseHandler(new CanceledError() as AxiosError)).toEqual({
|
||||
statusCode: 500,
|
||||
payload: null,
|
||||
error: 'Something went wrong',
|
||||
message: null,
|
||||
});
|
||||
expect(ErrorResponseHandler(new CanceledError() as AxiosError)).toStrictEqual(
|
||||
{
|
||||
statusCode: 500,
|
||||
payload: null,
|
||||
error: 'Something went wrong',
|
||||
message: null,
|
||||
},
|
||||
);
|
||||
expect(consoleError).not.toHaveBeenCalled();
|
||||
|
||||
consoleError.mockRestore();
|
||||
|
||||
@@ -18,8 +18,10 @@ import type {
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
IndexMaterializedPaths200,
|
||||
ListPromotedPaths200,
|
||||
ListPromotedPathsParams,
|
||||
PromotetypesPostableIndexMaterializedPathsDTO,
|
||||
PromotetypesPromotePathDTO,
|
||||
RenderErrorResponseDTO,
|
||||
} from '../sigNoz.schemas';
|
||||
@@ -230,3 +232,87 @@ export const usePromotePaths = <
|
||||
> => {
|
||||
return useMutation(getPromotePathsMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint indexes the JSON sub-columns of materialized paths whose columns are indexed and returns the indexed and skipped paths.
|
||||
* @summary Index materialized paths
|
||||
*/
|
||||
export const indexMaterializedPaths = (
|
||||
promotetypesPostableIndexMaterializedPathsDTO?: BodyType<PromotetypesPostableIndexMaterializedPathsDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<IndexMaterializedPaths200>({
|
||||
url: `/api/v1/promoted_paths/materialized`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: promotetypesPostableIndexMaterializedPathsDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getIndexMaterializedPathsMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof indexMaterializedPaths>>,
|
||||
TError,
|
||||
{ data?: BodyType<PromotetypesPostableIndexMaterializedPathsDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof indexMaterializedPaths>>,
|
||||
TError,
|
||||
{ data?: BodyType<PromotetypesPostableIndexMaterializedPathsDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['indexMaterializedPaths'];
|
||||
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 indexMaterializedPaths>>,
|
||||
{ data?: BodyType<PromotetypesPostableIndexMaterializedPathsDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return indexMaterializedPaths(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type IndexMaterializedPathsMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof indexMaterializedPaths>>
|
||||
>;
|
||||
export type IndexMaterializedPathsMutationBody =
|
||||
| BodyType<PromotetypesPostableIndexMaterializedPathsDTO>
|
||||
| undefined;
|
||||
export type IndexMaterializedPathsMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Index materialized paths
|
||||
*/
|
||||
export const useIndexMaterializedPaths = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof indexMaterializedPaths>>,
|
||||
TError,
|
||||
{ data?: BodyType<PromotetypesPostableIndexMaterializedPathsDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof indexMaterializedPaths>>,
|
||||
TError,
|
||||
{ data?: BodyType<PromotetypesPostableIndexMaterializedPathsDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getIndexMaterializedPathsMutationOptions(options));
|
||||
};
|
||||
|
||||
@@ -18,6 +18,8 @@ import type {
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
CloneRuleByID201,
|
||||
CloneRuleByIDPathParameters,
|
||||
CreateRule201,
|
||||
CreateRuleView201,
|
||||
DeleteRuleByIDPathParameters,
|
||||
@@ -963,6 +965,85 @@ export const useUpdateRuleByID = <
|
||||
> => {
|
||||
return useMutation(getUpdateRuleByIDMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint creates a new alert rule from the stored definition of an existing one. The clone keeps every modeled field of the source, including its enabled/disabled state, and gets the source's name suffixed with " - Copy" (or a bumped " - Copy (n)" counter when the source is itself a copy). The clone is owned by the caller.
|
||||
* @summary Clone alert rule
|
||||
*/
|
||||
export const cloneRuleByID = (
|
||||
{ id }: CloneRuleByIDPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CloneRuleByID201>({
|
||||
url: `/api/v2/rules/${id}/clone`,
|
||||
method: 'POST',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCloneRuleByIDMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof cloneRuleByID>>,
|
||||
TError,
|
||||
{ pathParams: CloneRuleByIDPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof cloneRuleByID>>,
|
||||
TError,
|
||||
{ pathParams: CloneRuleByIDPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['cloneRuleByID'];
|
||||
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 cloneRuleByID>>,
|
||||
{ pathParams: CloneRuleByIDPathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return cloneRuleByID(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CloneRuleByIDMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof cloneRuleByID>>
|
||||
>;
|
||||
|
||||
export type CloneRuleByIDMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Clone alert rule
|
||||
*/
|
||||
export const useCloneRuleByID = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof cloneRuleByID>>,
|
||||
TError,
|
||||
{ pathParams: CloneRuleByIDPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof cloneRuleByID>>,
|
||||
TError,
|
||||
{ pathParams: CloneRuleByIDPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getCloneRuleByIDMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns distinct label keys from rule history entries for the selected range.
|
||||
* @summary Get rule history filter keys
|
||||
|
||||
@@ -9878,6 +9878,28 @@ export interface PromotetypesPromotePathDTO {
|
||||
signal: string;
|
||||
}
|
||||
|
||||
export interface PromotetypesIndexMaterializedPathsResultDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
indexed: PromotetypesPromotePathDTO[] | null;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
skipped: PromotetypesPromotePathDTO[] | null;
|
||||
}
|
||||
|
||||
export interface PromotetypesPostableIndexMaterializedPathsDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
dryRun?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
signal: string;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5AggregationMetaDTO {
|
||||
/**
|
||||
* @type array
|
||||
@@ -13250,6 +13272,14 @@ export type ListPromotedPaths200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type IndexMaterializedPaths200 = {
|
||||
data: PromotetypesIndexMaterializedPathsResultDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListRoles200 = {
|
||||
/**
|
||||
* @type array
|
||||
@@ -14582,6 +14612,17 @@ export type PatchRuleByID200 = {
|
||||
export type UpdateRuleByIDPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type CloneRuleByIDPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type CloneRuleByID201 = {
|
||||
data: RuletypesRuleDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetRuleHistoryFilterKeysPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
@@ -61,7 +61,11 @@ describe('convertV5ResponseToLegacy', () => {
|
||||
],
|
||||
values: [
|
||||
{ timestamp: 1000, value: 10 } as unknown as TimeSeriesValue,
|
||||
{ timestamp: 2000, value: 12 } as unknown as TimeSeriesValue,
|
||||
{
|
||||
timestamp: 2000,
|
||||
value: 12,
|
||||
partial: true,
|
||||
} as unknown as TimeSeriesValue,
|
||||
],
|
||||
} as unknown as TimeSeries,
|
||||
],
|
||||
@@ -104,7 +108,7 @@ describe('convertV5ResponseToLegacy', () => {
|
||||
labels: { 'service.name': 'adservice' },
|
||||
values: [
|
||||
{ timestamp: 1000, value: '10' },
|
||||
{ timestamp: 2000, value: '12' },
|
||||
{ timestamp: 2000, value: '12', partial: true },
|
||||
],
|
||||
metaData: expect.objectContaining({
|
||||
alias: '__result_0',
|
||||
|
||||
@@ -125,6 +125,7 @@ function convertTimeSeriesData(
|
||||
values: series.values.map((value: any) => ({
|
||||
timestamp: value.timestamp,
|
||||
value: String(value.value),
|
||||
...(value.partial ? { partial: true } : {}),
|
||||
})),
|
||||
metaData: {
|
||||
alias,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { DrawerProps } from 'antd';
|
||||
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
|
||||
import { AddToQueryHOCProps } from 'components/Logs/AddToQueryHOC';
|
||||
import { ActionItemProps } from 'container/LogDetailedView/LogDetailedView.types';
|
||||
import { IField } from 'types/api/logs/fields';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
|
||||
@@ -20,9 +18,7 @@ export type LogDetailProps = {
|
||||
handleOpenInExplorer?: MouseEventHandler;
|
||||
getContainer?: DrawerProps['getContainer'];
|
||||
onApplyLogFilter?: (expression: string) => void;
|
||||
} & Pick<AddToQueryHOCProps, 'onAddToQuery'> &
|
||||
Partial<Pick<ActionItemProps, 'onClickActionItem'>> &
|
||||
Pick<DrawerProps, 'onClose'>;
|
||||
} & Pick<DrawerProps, 'onClose'>;
|
||||
|
||||
export type LogDetailInnerProps = LogDetailProps & {
|
||||
log: NonNullable<LogDetailProps['log']>;
|
||||
|
||||
@@ -46,8 +46,6 @@ function renderDrawer(props: Partial<LogDetailProps> = {}): void {
|
||||
<LogDetail
|
||||
log={mockLog}
|
||||
selectedTab={VIEW_TYPES.OVERVIEW}
|
||||
onAddToQuery={jest.fn()}
|
||||
onClickActionItem={jest.fn()}
|
||||
onClose={jest.fn()}
|
||||
{...props}
|
||||
/>,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { QuickFiltersSource } from '../../types';
|
||||
import QuickFiltersLayout from '../QuickFiltersLayout';
|
||||
@@ -197,6 +197,11 @@ describe('QuickFiltersLayout', () => {
|
||||
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
|
||||
expect(drawer()).not.toHaveClass('isOpen');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-open')).not.toHaveAttribute(
|
||||
'aria-busy',
|
||||
),
|
||||
);
|
||||
await user.click(screen.getByTestId('saved-views-open'));
|
||||
|
||||
expect(screen.getByTestId('saved-views-panel')).toBeInTheDocument();
|
||||
@@ -227,6 +232,11 @@ describe('QuickFiltersLayout', () => {
|
||||
'data-source',
|
||||
SavedviewtypesSourceDTO.traces,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-open')).not.toHaveAttribute(
|
||||
'aria-busy',
|
||||
),
|
||||
);
|
||||
await userEvent.setup().click(screen.getByTestId('saved-views-open'));
|
||||
expect(screen.getByTestId('saved-views-panel')).toHaveAttribute(
|
||||
'data-source',
|
||||
|
||||
@@ -25,10 +25,8 @@ import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants'
|
||||
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
import { getOldLogsOperatorFromNew } from 'hooks/logs/useActiveLog';
|
||||
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
|
||||
import useScrollToLog from 'hooks/logs/useScrollToLog';
|
||||
import { generateFilterQuery } from 'lib/logs/generateFilterQuery';
|
||||
import { saveRecentQueryByExpression } from 'lib/recentQueries/saveRecentQuery';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
@@ -82,29 +80,6 @@ function EntityLogsContent({
|
||||
const { activeLog, selectedTab, handleSetActiveLog, handleCloseLogDetail } =
|
||||
useLogDetailHandlers();
|
||||
|
||||
// TODO: Move away from using onAddToQuery after old drawer cleanup
|
||||
const onAddToQuery = useCallback(
|
||||
(fieldKey: string, fieldValue: string, operator: string): void => {
|
||||
handleCloseLogDetail();
|
||||
|
||||
const partExpression = generateFilterQuery({
|
||||
fieldKey,
|
||||
fieldValue,
|
||||
type: getOldLogsOperatorFromNew(operator),
|
||||
});
|
||||
|
||||
const currentUser = userExpression;
|
||||
const newUser = currentUser.trim()
|
||||
? `${currentUser} AND ${partExpression}`
|
||||
: partExpression;
|
||||
|
||||
querySearchOnRun(newUser);
|
||||
|
||||
logInfraDrawerFilterCustomizedEvent(category, 'logs', newUser, 'logs');
|
||||
},
|
||||
[userExpression, querySearchOnRun, handleCloseLogDetail, category],
|
||||
);
|
||||
|
||||
const onApplyLogFilter = useCallback(
|
||||
(expression: string): void => {
|
||||
handleCloseLogDetail();
|
||||
@@ -342,8 +317,6 @@ function EntityLogsContent({
|
||||
logs={logs}
|
||||
onNavigateLog={handleSetActiveLog}
|
||||
selectedTab={selectedTab}
|
||||
onAddToQuery={onAddToQuery}
|
||||
onClickActionItem={onAddToQuery}
|
||||
onApplyLogFilter={onApplyLogFilter}
|
||||
onScrollToLog={handleScrollToLog}
|
||||
handleOpenInExplorer={(e) => handleOpenInExplorer(e, activeLog)}
|
||||
|
||||
@@ -259,8 +259,6 @@ function LiveLogsList({
|
||||
selectedTab={selectedTab}
|
||||
log={activeLog}
|
||||
onClose={handleCloseLogDetail}
|
||||
onAddToQuery={onAddToQuery}
|
||||
onClickActionItem={onAddToQuery}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
logs={formattedLogs}
|
||||
onNavigateLog={handleSetActiveLog}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { MetricsType } from 'container/MetricsApplication/constant';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
|
||||
export type AnyObject = { [key: string]: any };
|
||||
|
||||
@@ -19,15 +18,3 @@ export interface DataType {
|
||||
field: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ActionItemProps {
|
||||
fieldKey: string;
|
||||
fieldValue: string;
|
||||
onClickActionItem: (
|
||||
fieldKey: string,
|
||||
fieldValue: string,
|
||||
operator: string,
|
||||
dataType?: DataTypes,
|
||||
fieldType?: string,
|
||||
) => void;
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ const normalizeDataType = (
|
||||
// key is fabricated locally (empty source list), so no click-time getAggregateKeys fetch.
|
||||
export const getFilterQueryData = (
|
||||
item: IBuilderQuery,
|
||||
target: LogFilterTarget,
|
||||
target: Pick<LogFilterTarget, 'fieldKey' | 'dataType' | 'metricsType'>,
|
||||
value: unknown,
|
||||
operator: string,
|
||||
): IBuilderQuery => {
|
||||
|
||||
@@ -381,8 +381,6 @@ function LogsExplorerList({
|
||||
selectedTab={selectedTab}
|
||||
log={activeLog}
|
||||
onClose={handleCloseLogDetail}
|
||||
onAddToQuery={onAddToQuery}
|
||||
onClickActionItem={onAddToQuery}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
logs={logs}
|
||||
onNavigateLog={handleSetActiveLog}
|
||||
|
||||
@@ -91,13 +91,8 @@ function LogsPanelComponent({
|
||||
() => logs.map((log) => FlatLogData(log) as RowData),
|
||||
[logs],
|
||||
);
|
||||
const {
|
||||
activeLog,
|
||||
onAddToQuery,
|
||||
selectedTab,
|
||||
handleSetActiveLog,
|
||||
handleCloseLogDetail,
|
||||
} = useLogDetailHandlers();
|
||||
const { activeLog, selectedTab, handleSetActiveLog, handleCloseLogDetail } =
|
||||
useLogDetailHandlers();
|
||||
|
||||
const handleRow = useCallback(
|
||||
(record: RowData): HTMLAttributes<RowData> => ({
|
||||
@@ -179,8 +174,6 @@ function LogsPanelComponent({
|
||||
selectedTab={selectedTab}
|
||||
log={activeLog}
|
||||
onClose={handleCloseLogDetail}
|
||||
onAddToQuery={onAddToQuery}
|
||||
onClickActionItem={onAddToQuery}
|
||||
isListViewPanel
|
||||
listViewPanelSelectedFields={widget?.selectedLogFields}
|
||||
logs={logs}
|
||||
|
||||
@@ -10,8 +10,7 @@ import { ILog } from 'types/api/logs/log';
|
||||
import './styles.scss';
|
||||
|
||||
function LogsList({ logs }: LogsListProps): JSX.Element {
|
||||
const { activeLog, onSetActiveLog, onClearActiveLog, onAddToQuery } =
|
||||
useActiveLog();
|
||||
const { activeLog, onSetActiveLog, onClearActiveLog } = useActiveLog();
|
||||
|
||||
const makeLogDetailsHandler = (log: ILog) => (): void => onSetActiveLog(log);
|
||||
|
||||
@@ -45,8 +44,6 @@ function LogsList({ logs }: LogsListProps): JSX.Element {
|
||||
selectedTab={VIEW_TYPES.OVERVIEW}
|
||||
log={activeLog}
|
||||
onClose={onClearActiveLog}
|
||||
onAddToQuery={onAddToQuery}
|
||||
onClickActionItem={onAddToQuery}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -329,7 +329,7 @@ describe('transformTransactionGroupsToResourcePermissions', () => {
|
||||
it('returns all resources from RESOURCE_ORDER even with empty transaction groups', () => {
|
||||
const result = transformTransactionGroupsToResourcePermissions([]);
|
||||
|
||||
expect(result).toHaveLength(11);
|
||||
expect(result).toHaveLength(12);
|
||||
expect(result.map((r) => r.resourceKind)).toStrictEqual([
|
||||
'factor-api-key',
|
||||
'dashboard',
|
||||
@@ -339,6 +339,7 @@ describe('transformTransactionGroupsToResourcePermissions', () => {
|
||||
'metrics',
|
||||
'quick-filter',
|
||||
'role',
|
||||
'saved-view',
|
||||
'serviceaccount',
|
||||
'subscription',
|
||||
'traces',
|
||||
@@ -422,7 +423,7 @@ describe('createEmptyRolePermissions', () => {
|
||||
it('creates permissions for all resources in RESOURCE_ORDER', () => {
|
||||
const result = createEmptyRolePermissions();
|
||||
|
||||
expect(result).toHaveLength(11);
|
||||
expect(result).toHaveLength(12);
|
||||
expect(result.map((r) => r.resourceKind)).toStrictEqual([
|
||||
'factor-api-key',
|
||||
'dashboard',
|
||||
@@ -432,6 +433,7 @@ describe('createEmptyRolePermissions', () => {
|
||||
'metrics',
|
||||
'quick-filter',
|
||||
'role',
|
||||
'saved-view',
|
||||
'serviceaccount',
|
||||
'subscription',
|
||||
'traces',
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Logs,
|
||||
Receipt,
|
||||
Shield,
|
||||
TowerControl,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
import permissionsType from 'lib/authz/hooks/useAuthZ/permissions.type';
|
||||
@@ -95,6 +96,14 @@ export const RESOURCE_PANELS: Record<AuthZResource, ResourcePanelConfig> = {
|
||||
'Type quick filter ID, separate multiple with comma or space',
|
||||
docsAnchor: 'quick-filter',
|
||||
},
|
||||
'saved-view': {
|
||||
label: 'Saved Views',
|
||||
description: 'Saved views in the logs, traces, metrics and meter explorers.',
|
||||
icon: TowerControl,
|
||||
selectorPlaceholder:
|
||||
'Type saved view ID, separate multiple with comma or space',
|
||||
docsAnchor: 'saved-view',
|
||||
},
|
||||
logs: {
|
||||
label: 'Logs',
|
||||
description: 'Log data collected across the workspace.',
|
||||
|
||||
@@ -1,41 +1,50 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Check } from '@signozhq/icons';
|
||||
import { Dropdown, type DropdownItemType } from '@signozhq/ui/dropdown';
|
||||
import AuthZDropdown from 'lib/authz/components/AuthZDropdown/AuthZDropdown';
|
||||
import type { AuthZDropdownItemType } from 'lib/authz/components/AuthZDropdown/types';
|
||||
import {
|
||||
buildSavedViewUpdatePermission,
|
||||
SavedViewCreatePermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
|
||||
import SavedViewsIconButton from './SavedViewsIconButton';
|
||||
import { SaveChangesMenuProps } from './types';
|
||||
|
||||
function SaveChangesMenu({
|
||||
viewId,
|
||||
disabled,
|
||||
onSaveAsNew,
|
||||
onUpdate,
|
||||
}: SaveChangesMenuProps): JSX.Element {
|
||||
const items = useMemo<DropdownItemType[]>(
|
||||
const items = useMemo<AuthZDropdownItemType[]>(
|
||||
() => [
|
||||
{
|
||||
type: 'item',
|
||||
value: 'save-as-new',
|
||||
label: 'Save as new view',
|
||||
checks: [SavedViewCreatePermission],
|
||||
onClick: onSaveAsNew,
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
value: 'update',
|
||||
label: 'Update selected view',
|
||||
checks: [buildSavedViewUpdatePermission(viewId)],
|
||||
onClick: onUpdate,
|
||||
},
|
||||
],
|
||||
[onSaveAsNew, onUpdate],
|
||||
[viewId, onSaveAsNew, onUpdate],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
<AuthZDropdown
|
||||
items={items}
|
||||
nativeButton
|
||||
align="end"
|
||||
side="bottom"
|
||||
disabled={disabled}
|
||||
disabledTooltip="Saving the view"
|
||||
testId="saved-views-save-changes"
|
||||
>
|
||||
<SavedViewsIconButton
|
||||
title="Save changes"
|
||||
@@ -43,7 +52,7 @@ function SaveChangesMenu({
|
||||
color="warning"
|
||||
testId="saved-views-save-changes"
|
||||
/>
|
||||
</Dropdown>
|
||||
</AuthZDropdown>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
|
||||
import { SavedViewCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
|
||||
import SaveViewModal from './SaveViewModal';
|
||||
import { SavedViewsEmptyStateProps } from './types';
|
||||
@@ -31,7 +32,8 @@ function SavedViewsEmptyState({
|
||||
<Typography.Text className={styles.stateText}>
|
||||
Save the current view to open it later.
|
||||
</Typography.Text>
|
||||
<Button
|
||||
<AuthZButton
|
||||
checks={[SavedViewCreatePermission]}
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
@@ -40,7 +42,7 @@ function SavedViewsEmptyState({
|
||||
testId="saved-views-list-create"
|
||||
>
|
||||
Create view
|
||||
</Button>
|
||||
</AuthZButton>
|
||||
{isModalOpen && (
|
||||
<SaveViewModal
|
||||
mode="create"
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
gap: var(--spacing-1);
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.collapseIcon {
|
||||
|
||||
@@ -5,9 +5,17 @@ import { Skeleton } from '@signozhq/ui/skeleton';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import {
|
||||
buildSavedViewReadPermission,
|
||||
SavedViewCreatePermission,
|
||||
SavedViewListPermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
|
||||
import {
|
||||
MY_VIEW_NAME,
|
||||
SAVED_VIEW_FORBIDDEN_NAME,
|
||||
SAVED_VIEW_LOAD_FAILED_NAME,
|
||||
SAVED_VIEW_TOAST_POSITION,
|
||||
} from './constants';
|
||||
@@ -31,8 +39,16 @@ function SavedViewsHeader({
|
||||
onOpenViews?: () => void;
|
||||
onCollapse?: () => void;
|
||||
}): JSX.Element {
|
||||
const { view, isLoading, isError, hasUnsavedChanges } =
|
||||
useActiveSavedView(source);
|
||||
const {
|
||||
view,
|
||||
isLoading,
|
||||
isError,
|
||||
isReadDenied,
|
||||
errorMessage,
|
||||
hasUnsavedChanges,
|
||||
} = useActiveSavedView(source);
|
||||
const { viewKey } = useGetSavedViewParams();
|
||||
const readPermission = buildSavedViewReadPermission(viewKey);
|
||||
const { revertView, clearView, createView, updateView, isSaving } =
|
||||
useSavedViewActions(source);
|
||||
|
||||
@@ -49,11 +65,27 @@ function SavedViewsHeader({
|
||||
const openSaveAsNew = useCallback((): void => setModalMode('saveAsNew'), []);
|
||||
const closeModal = useCallback((): void => setModalMode(null), []);
|
||||
|
||||
const name = isError
|
||||
? SAVED_VIEW_LOAD_FAILED_NAME
|
||||
: (view?.spec.displayName ?? MY_VIEW_NAME);
|
||||
let name = view?.spec.displayName ?? MY_VIEW_NAME;
|
||||
if (isReadDenied) {
|
||||
name = SAVED_VIEW_FORBIDDEN_NAME;
|
||||
} else if (isError) {
|
||||
name = SAVED_VIEW_LOAD_FAILED_NAME;
|
||||
}
|
||||
const isDirty = !isError && !!view && hasUnsavedChanges;
|
||||
|
||||
const nameChip = (
|
||||
<span
|
||||
className={cx(styles.name, {
|
||||
[styles.isDirty]: isDirty,
|
||||
[styles.isError]: isError,
|
||||
})}
|
||||
data-testid="saved-views-name"
|
||||
>
|
||||
{isDirty && <span className={styles.dot} />}
|
||||
<span className={styles.nameText}>{name}</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.header, {
|
||||
@@ -68,6 +100,7 @@ function SavedViewsHeader({
|
||||
<SavedViewsIconButton
|
||||
title="All views"
|
||||
icon={<List size={14} />}
|
||||
checks={[SavedViewListPermission]}
|
||||
onClick={onOpenViews}
|
||||
testId="saved-views-open"
|
||||
/>
|
||||
@@ -79,19 +112,10 @@ function SavedViewsHeader({
|
||||
className={styles.skeleton}
|
||||
testId="saved-views-loading"
|
||||
/>
|
||||
) : isReadDenied ? (
|
||||
<AuthZTooltip checks={[readPermission]}>{nameChip}</AuthZTooltip>
|
||||
) : (
|
||||
<Tooltip title={name}>
|
||||
<span
|
||||
className={cx(styles.name, {
|
||||
[styles.isDirty]: isDirty,
|
||||
[styles.isError]: isError,
|
||||
})}
|
||||
data-testid="saved-views-name"
|
||||
>
|
||||
{isDirty && <span className={styles.dot} />}
|
||||
<span className={styles.nameText}>{name}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title={errorMessage ?? name}>{nameChip}</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -102,6 +126,7 @@ function SavedViewsHeader({
|
||||
icon={<Plus size={14} />}
|
||||
disabled={isLoading}
|
||||
disabledTooltip="Loading views"
|
||||
checks={[SavedViewCreatePermission]}
|
||||
onClick={(): void => setModalMode('create')}
|
||||
testId="saved-views-create"
|
||||
/>
|
||||
@@ -117,6 +142,7 @@ function SavedViewsHeader({
|
||||
{view && isDirty && (
|
||||
<>
|
||||
<SaveChangesMenu
|
||||
viewId={view.id}
|
||||
disabled={isSaving}
|
||||
onSaveAsNew={openSaveAsNew}
|
||||
onUpdate={(): void => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { forwardRef } from 'react';
|
||||
import { Button, type VariantColorType } from '@signozhq/ui/button';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
|
||||
|
||||
import { SavedViewsIconButtonProps } from './types';
|
||||
|
||||
@@ -25,11 +26,33 @@ const SavedViewsIconButton = forwardRef<
|
||||
color = 'secondary',
|
||||
disabled,
|
||||
disabledTooltip,
|
||||
checks,
|
||||
onClick,
|
||||
...triggerProps
|
||||
},
|
||||
ref,
|
||||
): JSX.Element {
|
||||
if (checks?.length) {
|
||||
return (
|
||||
<Tooltip title={title}>
|
||||
<AuthZButton
|
||||
checks={checks}
|
||||
{...VARIANT_COLORS[color]}
|
||||
size="sm"
|
||||
icon
|
||||
aria-label={title}
|
||||
disabled={disabled}
|
||||
// AuthZButton skips its checks while a block reason is set.
|
||||
disabledTooltip={disabled ? disabledTooltip : undefined}
|
||||
onClick={onClick}
|
||||
testId={testId}
|
||||
>
|
||||
{icon}
|
||||
</AuthZButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip title={title}>
|
||||
<Button
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
gap: var(--spacing-4);
|
||||
height: var(--spacing-16);
|
||||
padding-right: var(--spacing-4);
|
||||
border-radius: var(--radius-2);
|
||||
@@ -71,7 +71,7 @@
|
||||
|
||||
.row:hover .rowMenu,
|
||||
.row:focus-within .rowMenu,
|
||||
.rowMenu:has([data-state='open']) {
|
||||
.rowMenu:has([data-popup-open]) {
|
||||
width: auto;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -40,8 +40,14 @@ function SavedViewsList({
|
||||
const saveNewView = useSaveNewView(createView);
|
||||
const { createdByMe, createdByOthers, isLoading, isError, refetch } =
|
||||
useSavedViewsList(source, search);
|
||||
const { copyViewLink, renameView, deleteView, isRenaming, isDeleting } =
|
||||
useSavedViewRowActions(source);
|
||||
const {
|
||||
copyViewLink,
|
||||
copyViewId,
|
||||
renameView,
|
||||
deleteView,
|
||||
isRenaming,
|
||||
isDeleting,
|
||||
} = useSavedViewRowActions(source);
|
||||
const [pendingAction, setPendingAction] =
|
||||
useState<PendingSavedViewAction | null>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
@@ -79,10 +85,14 @@ function SavedViewsList({
|
||||
copyViewLink(view);
|
||||
return;
|
||||
}
|
||||
if (key === 'copyId') {
|
||||
copyViewId(view);
|
||||
return;
|
||||
}
|
||||
closeHoverCard();
|
||||
setPendingAction({ key, view });
|
||||
},
|
||||
[copyViewLink, closeHoverCard],
|
||||
[copyViewLink, copyViewId, closeHoverCard],
|
||||
);
|
||||
|
||||
const closePendingAction = useCallback((): void => setPendingAction(null), []);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import cx from 'classnames';
|
||||
import { Undo2 } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
|
||||
import { buildSavedViewReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
|
||||
import SavedViewsRowButton from './SavedViewsRowButton';
|
||||
import SavedViewsRowMenu from './SavedViewsRowMenu';
|
||||
@@ -17,17 +19,26 @@ function SavedViewsListRow({
|
||||
hover,
|
||||
}: SavedViewsListRowProps): JSX.Element {
|
||||
const name = view.spec.displayName;
|
||||
const readPermission = buildSavedViewReadPermission(view.id);
|
||||
// Same cached check as the button; gates the hover card.
|
||||
const { isLoading, deniedPermissions } = useAuthZ([readPermission]);
|
||||
const canRead = !isLoading && deniedPermissions.length === 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.row, { [styles.isActive]: isActive })}
|
||||
data-testid="saved-views-row"
|
||||
data-view-id={view.id}
|
||||
onMouseEnter={(event): void => hover.onRowEnter(view, event.currentTarget)}
|
||||
onMouseEnter={(event): void => {
|
||||
if (canRead) {
|
||||
hover.onRowEnter(view, event.currentTarget);
|
||||
}
|
||||
}}
|
||||
onMouseLeave={hover.onRowLeave}
|
||||
>
|
||||
<div className={styles.rowName}>
|
||||
<Button
|
||||
<AuthZButton
|
||||
checks={[readPermission]}
|
||||
size="md"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
@@ -41,7 +52,7 @@ function SavedViewsListRow({
|
||||
testId="saved-views-row-name"
|
||||
>
|
||||
{name}
|
||||
</Button>
|
||||
</AuthZButton>
|
||||
</div>
|
||||
{isActive && (
|
||||
<SavedViewsRowButton
|
||||
@@ -54,6 +65,7 @@ function SavedViewsListRow({
|
||||
)}
|
||||
<span className={styles.rowMenu}>
|
||||
<SavedViewsRowMenu
|
||||
viewId={view.id}
|
||||
onAction={(key): void => onAction(view, key)}
|
||||
onOpen={hover.onMenuOpen}
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,9 @@ import { Input } from '@signozhq/ui/input';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { AuthZGuardContent } from 'lib/authz/components/AuthZGuard/AuthZGuardContent';
|
||||
import { SavedViewListPermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
|
||||
import SavedViewsIconButton from './SavedViewsIconButton';
|
||||
import SavedViewsList from './SavedViewsList';
|
||||
|
||||
@@ -39,16 +42,20 @@ function SavedViewsPanel({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.search}>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event): void => setSearch(event.target.value)}
|
||||
placeholder="Search..."
|
||||
aria-label="Search views"
|
||||
testId="saved-views-search"
|
||||
/>
|
||||
</div>
|
||||
<SavedViewsList source={source} search={search} />
|
||||
<AuthZGuardContent checks={[SavedViewListPermission]}>
|
||||
<>
|
||||
<div className={styles.search}>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event): void => setSearch(event.target.value)}
|
||||
placeholder="Search..."
|
||||
aria-label="Search views"
|
||||
testId="saved-views-search"
|
||||
/>
|
||||
</div>
|
||||
<SavedViewsList source={source} search={search} />
|
||||
</>
|
||||
</AuthZGuardContent>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Ellipsis } from '@signozhq/icons';
|
||||
import { Dropdown, type DropdownItemType } from '@signozhq/ui/dropdown';
|
||||
import AuthZDropdown from 'lib/authz/components/AuthZDropdown/AuthZDropdown';
|
||||
import type { AuthZDropdownItemType } from 'lib/authz/components/AuthZDropdown/types';
|
||||
|
||||
import { SAVED_VIEW_ROW_ACTIONS } from './constants';
|
||||
import SavedViewsRowButton from './SavedViewsRowButton';
|
||||
import { SavedViewsRowMenuProps } from './types';
|
||||
|
||||
function SavedViewsRowMenu({
|
||||
viewId,
|
||||
onAction,
|
||||
onOpen,
|
||||
}: SavedViewsRowMenuProps): JSX.Element {
|
||||
const items = useMemo<DropdownItemType[]>(
|
||||
const items = useMemo<AuthZDropdownItemType[]>(
|
||||
() =>
|
||||
SAVED_VIEW_ROW_ACTIONS.flatMap(
|
||||
({
|
||||
@@ -19,7 +21,8 @@ function SavedViewsRowMenu({
|
||||
icon: Icon,
|
||||
danger,
|
||||
hasDividerBefore,
|
||||
}): DropdownItemType[] => [
|
||||
buildPermission,
|
||||
}): AuthZDropdownItemType[] => [
|
||||
...(hasDividerBefore
|
||||
? [{ type: 'separator' as const, value: `separator-${key}` }]
|
||||
: []),
|
||||
@@ -29,15 +32,16 @@ function SavedViewsRowMenu({
|
||||
label,
|
||||
prefix: <Icon size={14} />,
|
||||
danger,
|
||||
checks: buildPermission ? [buildPermission(viewId)] : undefined,
|
||||
onClick: (): void => onAction(key),
|
||||
},
|
||||
],
|
||||
),
|
||||
[onAction],
|
||||
[viewId, onAction],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
<AuthZDropdown
|
||||
items={items}
|
||||
nativeButton
|
||||
side="right"
|
||||
@@ -47,6 +51,7 @@ function SavedViewsRowMenu({
|
||||
onOpen();
|
||||
}
|
||||
}}
|
||||
testId="saved-views-row-menu"
|
||||
>
|
||||
<SavedViewsRowButton
|
||||
icon={<Ellipsis size={14} />}
|
||||
@@ -54,7 +59,7 @@ function SavedViewsRowMenu({
|
||||
tooltip="More options"
|
||||
testId="saved-views-row-menu"
|
||||
/>
|
||||
</Dropdown>
|
||||
</AuthZDropdown>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { act, fireEvent, screen, within } from '@testing-library/react';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
@@ -12,6 +18,7 @@ import {
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import {
|
||||
expectUsable,
|
||||
explorerUrl,
|
||||
makeView,
|
||||
queryWith,
|
||||
@@ -110,6 +117,11 @@ async function renderPanel(
|
||||
dataSource,
|
||||
);
|
||||
await screen.findByTestId('saved-views-list');
|
||||
await waitFor(() =>
|
||||
screen
|
||||
.getAllByTestId('saved-views-row-name')
|
||||
.forEach((name) => expectUsable(name)),
|
||||
);
|
||||
jest.useFakeTimers();
|
||||
}
|
||||
|
||||
@@ -140,6 +152,10 @@ describe('SavedViewHoverCard', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// useAuthZ batches checks on a timer; left pending, it blocks later checks.
|
||||
act(() => {
|
||||
jest.runOnlyPendingTimers();
|
||||
});
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
buildSavedViewDeletePermission,
|
||||
buildSavedViewReadPermission,
|
||||
buildSavedViewUpdatePermission,
|
||||
SavedViewCreatePermission,
|
||||
SavedViewListPermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
import {
|
||||
AUTHZ_CHECK_URL,
|
||||
setupAuthzDeny,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import { API, mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import {
|
||||
explorerUrl,
|
||||
makeView,
|
||||
MENU_CHECK_TIMEOUT,
|
||||
queryWith,
|
||||
renderWithExplorerProviders,
|
||||
viewUrl,
|
||||
} from './savedViewsTestUtils';
|
||||
|
||||
const PATH = ROUTES.TRACES_EXPLORER;
|
||||
|
||||
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
|
||||
const slow = makeView({ id: 'view-2', displayName: 'Slow spans' });
|
||||
|
||||
function renderPanel(url = explorerUrl(PATH, {})): void {
|
||||
renderWithExplorerProviders(
|
||||
<SavedViewsPanel
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
url,
|
||||
);
|
||||
}
|
||||
|
||||
function renderHeader(
|
||||
url: string,
|
||||
): ReturnType<typeof renderWithExplorerProviders> {
|
||||
return renderWithExplorerProviders(
|
||||
<SavedViewsHeader
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onOpenViews={jest.fn()}
|
||||
/>,
|
||||
url,
|
||||
);
|
||||
}
|
||||
|
||||
const rowNamed = (name: string): HTMLElement =>
|
||||
screen
|
||||
.getAllByTestId('saved-views-row')
|
||||
.find((row) => row.textContent?.includes(name)) as HTMLElement;
|
||||
|
||||
async function openRowMenu(name: string): Promise<void> {
|
||||
await screen.findAllByTestId('saved-views-row');
|
||||
fireEvent.click(within(rowNamed(name)).getByTestId('saved-views-row-menu'));
|
||||
}
|
||||
|
||||
// Resolves once the row's check is back, so its state is final.
|
||||
async function settledRow(testId: string): Promise<HTMLElement> {
|
||||
await waitFor(
|
||||
() => expect(screen.getByTestId(testId)).not.toHaveAttribute('data-loading'),
|
||||
MENU_CHECK_TIMEOUT,
|
||||
);
|
||||
return screen.getByTestId(testId);
|
||||
}
|
||||
|
||||
describe('SavedViews - AuthZ', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('shows the denied callout instead of the list without list', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
server.use(setupAuthzDeny(SavedViewListPermission));
|
||||
renderPanel();
|
||||
|
||||
await expect(
|
||||
screen.findByText(/not authorized/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('saved-views-list')).toBeNull();
|
||||
expect(screen.queryByTestId('saved-views-search')).toBeNull();
|
||||
});
|
||||
|
||||
it('disables only the views it cannot read', async () => {
|
||||
mockSavedViewsApi([errors, slow]);
|
||||
server.use(setupAuthzDeny(buildSavedViewReadPermission(errors.id)));
|
||||
renderPanel();
|
||||
|
||||
await screen.findAllByTestId('saved-views-row');
|
||||
const nameOf = (view: string): HTMLElement =>
|
||||
within(rowNamed(view)).getByTestId('saved-views-row-name');
|
||||
await waitFor(() =>
|
||||
expect(nameOf('Errors')).toHaveAttribute('aria-disabled', 'true'),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(nameOf('Slow spans')).not.toHaveAttribute('aria-busy'),
|
||||
);
|
||||
expect(nameOf('Slow spans')).not.toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
it('keeps the row menu on a view it cannot read', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
server.use(setupAuthzDeny(buildSavedViewReadPermission(errors.id)));
|
||||
renderPanel();
|
||||
|
||||
await openRowMenu('Errors');
|
||||
|
||||
await expect(
|
||||
settledRow('saved-views-row-menu-item-delete'),
|
||||
).resolves.not.toHaveAttribute('data-disabled');
|
||||
await expect(
|
||||
settledRow('saved-views-row-menu-item-editDetails'),
|
||||
).resolves.not.toHaveAttribute('data-disabled');
|
||||
await expect(
|
||||
settledRow('saved-views-row-menu-item-copyLink'),
|
||||
).resolves.not.toHaveAttribute('data-disabled');
|
||||
});
|
||||
|
||||
it('disables creating without create', async () => {
|
||||
mockSavedViewsApi([]);
|
||||
server.use(setupAuthzDeny(SavedViewCreatePermission));
|
||||
renderPanel();
|
||||
renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-list-create')).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-create')).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps create disabled while the check is pending', async () => {
|
||||
mockSavedViewsApi([]);
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => res(ctx.delay('infinite'))),
|
||||
);
|
||||
renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
const create = screen.getByTestId('saved-views-create');
|
||||
expect(create).toHaveAttribute('aria-busy', 'true');
|
||||
await userEvent.click(create);
|
||||
expect(screen.queryByTestId('save-view-name')).toBeNull();
|
||||
});
|
||||
|
||||
it('disables edit details without update and delete without delete', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
server.use(
|
||||
setupAuthzDeny(
|
||||
buildSavedViewUpdatePermission(errors.id),
|
||||
buildSavedViewDeletePermission(errors.id),
|
||||
),
|
||||
);
|
||||
renderPanel();
|
||||
|
||||
await openRowMenu('Errors');
|
||||
|
||||
await expect(
|
||||
settledRow('saved-views-row-menu-item-editDetails'),
|
||||
).resolves.toHaveAttribute('data-disabled');
|
||||
await expect(
|
||||
settledRow('saved-views-row-menu-item-delete'),
|
||||
).resolves.toHaveAttribute('data-disabled');
|
||||
await expect(
|
||||
settledRow('saved-views-row-menu-item-copyLink'),
|
||||
).resolves.not.toHaveAttribute('data-disabled');
|
||||
});
|
||||
|
||||
it('disables updating the open view without update, keeping save as new', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
server.use(setupAuthzDeny(buildSavedViewUpdatePermission(errors.id)));
|
||||
const { history } = renderHeader(viewUrl(PATH, errors));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-name')).toHaveTextContent('Errors'),
|
||||
);
|
||||
|
||||
history.push(
|
||||
explorerUrl(PATH, {
|
||||
query: queryWith(DataSource.TRACES, 'has_error = false'),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
viewKey: errors.id,
|
||||
}),
|
||||
);
|
||||
await userEvent.click(await screen.findByTestId('saved-views-save-changes'));
|
||||
|
||||
await expect(
|
||||
settledRow('saved-views-save-changes-item-update'),
|
||||
).resolves.toHaveAttribute('data-disabled');
|
||||
await expect(
|
||||
settledRow('saved-views-save-changes-item-save-as-new'),
|
||||
).resolves.not.toHaveAttribute('data-disabled');
|
||||
});
|
||||
|
||||
it('says no access when the check denies read, even if the server returns the view', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
server.use(setupAuthzDeny(buildSavedViewReadPermission(errors.id)));
|
||||
renderHeader(viewUrl(PATH, errors));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(
|
||||
'No view access',
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-name')).toHaveAttribute(
|
||||
'data-denied-permissions',
|
||||
),
|
||||
);
|
||||
await userEvent.setup().hover(screen.getByTestId('saved-views-name'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryAllByText(/is not authorized to perform read:saved-view:view-1/)
|
||||
.length,
|
||||
).toBeGreaterThan(0),
|
||||
);
|
||||
});
|
||||
|
||||
it('treats a 403 the check allowed as a failed load, showing the server message', async () => {
|
||||
const message = 'saved-view:read is missing, ask an admin for it';
|
||||
mockSavedViewsApi([errors]);
|
||||
server.use(
|
||||
rest.get(`${API}/:id`, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(403),
|
||||
ctx.json({ status: 'error', error: { code: 'forbidden', message } }),
|
||||
),
|
||||
),
|
||||
);
|
||||
renderHeader(viewUrl(PATH, errors));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(
|
||||
'View failed to load',
|
||||
),
|
||||
);
|
||||
await userEvent.setup().hover(screen.getByTestId('saved-views-name'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryAllByText(message).length).toBeGreaterThan(0),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,9 @@ import { usePreferenceContext } from 'providers/preferences/context/PreferenceCo
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import {
|
||||
expectRowUsable,
|
||||
makeView,
|
||||
MENU_CHECK_TIMEOUT,
|
||||
renderWithExplorerProviders,
|
||||
viewUrl,
|
||||
} from './savedViewsTestUtils';
|
||||
@@ -115,9 +117,11 @@ describe('SavedViewsHeader columns', () => {
|
||||
await userEvent.click(screen.getByTestId('add-column'));
|
||||
await waitFor(() => expect(isDirty()).toBe(true));
|
||||
await userEvent.click(screen.getByTestId('saved-views-save-changes'));
|
||||
await userEvent.click(
|
||||
await screen.findByRole('menuitem', { name: 'Update selected view' }),
|
||||
const update = await screen.findByTestId(
|
||||
'saved-views-save-changes-item-update',
|
||||
);
|
||||
await waitFor(() => expectRowUsable(update), MENU_CHECK_TIMEOUT);
|
||||
await userEvent.click(update);
|
||||
await waitFor(() => expect(isDirty()).toBe(false));
|
||||
|
||||
await userEvent.click(screen.getByTestId('add-column'));
|
||||
|
||||
@@ -9,8 +9,11 @@ import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
import {
|
||||
expectRowUsable,
|
||||
expectUsable,
|
||||
explorerUrl,
|
||||
makeView,
|
||||
MENU_CHECK_TIMEOUT,
|
||||
queryWith,
|
||||
renderWithExplorerProviders,
|
||||
urlParam,
|
||||
@@ -67,9 +70,21 @@ async function openDirtyView(
|
||||
return rendered;
|
||||
}
|
||||
|
||||
const SAVE_MENU_ITEM_TEST_IDS: Record<string, string> = {
|
||||
'Save as new view': 'saved-views-save-changes-item-save-as-new',
|
||||
'Update selected view': 'saved-views-save-changes-item-update',
|
||||
};
|
||||
|
||||
async function chooseFromSaveMenu(item: string): Promise<void> {
|
||||
await userEvent.click(screen.getByTestId('saved-views-save-changes'));
|
||||
await userEvent.click(await screen.findByRole('menuitem', { name: item }));
|
||||
const menuItem = await screen.findByTestId(SAVE_MENU_ITEM_TEST_IDS[item]);
|
||||
await waitFor(() => expectRowUsable(menuItem), MENU_CHECK_TIMEOUT);
|
||||
await userEvent.click(menuItem);
|
||||
}
|
||||
|
||||
async function clickCreate(): Promise<void> {
|
||||
await waitFor(() => expectUsable(screen.getByTestId('saved-views-create')));
|
||||
await userEvent.click(screen.getByTestId('saved-views-create'));
|
||||
}
|
||||
|
||||
async function submitName(name: string): Promise<void> {
|
||||
@@ -87,7 +102,7 @@ describe('SavedViewsHeader saving', () => {
|
||||
const requests = mockSavedViewsApi([], { createdId: 'view-new' });
|
||||
const { history } = renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
await userEvent.click(screen.getByTestId('saved-views-create'));
|
||||
await clickCreate();
|
||||
|
||||
expect(within(modal()).getByText('Create new view')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('save-view-submit')).toHaveAttribute(
|
||||
@@ -130,7 +145,7 @@ describe('SavedViewsHeader saving', () => {
|
||||
const requests = mockSavedViewsApi([], { createStatus: 500 });
|
||||
const { history } = renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
await userEvent.click(screen.getByTestId('saved-views-create'));
|
||||
await clickCreate();
|
||||
await submitName('Slow checkout');
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||
@@ -145,7 +160,7 @@ describe('SavedViewsHeader saving', () => {
|
||||
const requests = mockSavedViewsApi([]);
|
||||
renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
await userEvent.click(screen.getByTestId('saved-views-create'));
|
||||
await clickCreate();
|
||||
await userEvent.type(screen.getByTestId('save-view-name'), 'Draft');
|
||||
await userEvent.click(screen.getByTestId('save-view-cancel'));
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import ROUTES from 'constants/routes';
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import {
|
||||
expectUsable,
|
||||
explorerUrl,
|
||||
makeView,
|
||||
renderWithExplorerProviders,
|
||||
@@ -84,9 +85,9 @@ describe('SavedViewsPanel list', () => {
|
||||
const { history } = renderPanel();
|
||||
|
||||
await screen.findByTestId('saved-views-list');
|
||||
await userEvent.click(
|
||||
within(row('Slow spans')).getByTestId('saved-views-row-name'),
|
||||
);
|
||||
const name = within(row('Slow spans')).getByTestId('saved-views-row-name');
|
||||
await waitFor(() => expectUsable(name));
|
||||
await userEvent.click(name);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBe(
|
||||
@@ -161,7 +162,9 @@ describe('SavedViewsPanel list', () => {
|
||||
const requests = mockSavedViewsApi([], { createdId: 'view-new' });
|
||||
const { history } = renderPanel();
|
||||
|
||||
await userEvent.click(await screen.findByTestId('saved-views-list-create'));
|
||||
const create = await screen.findByTestId('saved-views-list-create');
|
||||
await waitFor(() => expectUsable(create));
|
||||
await userEvent.click(create);
|
||||
await userEvent.type(screen.getByTestId('save-view-name'), 'First view');
|
||||
await userEvent.click(screen.getByTestId('save-view-submit'));
|
||||
|
||||
|
||||
@@ -9,8 +9,10 @@ import SavedViewsHeader from '../SavedViewsHeader';
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import {
|
||||
expectRowUsable,
|
||||
explorerUrl,
|
||||
makeView,
|
||||
MENU_CHECK_TIMEOUT,
|
||||
renderWithExplorerProviders,
|
||||
urlParam,
|
||||
viewUrl,
|
||||
@@ -58,13 +60,22 @@ const row = (name: string): HTMLElement =>
|
||||
.getAllByTestId('saved-views-row')
|
||||
.find((element) => element.textContent?.includes(name)) as HTMLElement;
|
||||
|
||||
const ROW_MENU_ITEM_TEST_IDS: Record<string, string> = {
|
||||
'Copy link': 'saved-views-row-menu-item-copyLink',
|
||||
'Copy ID': 'saved-views-row-menu-item-copyId',
|
||||
'Edit details': 'saved-views-row-menu-item-editDetails',
|
||||
Delete: 'saved-views-row-menu-item-delete',
|
||||
};
|
||||
|
||||
async function chooseFromRowMenu(
|
||||
viewName: string,
|
||||
item: string,
|
||||
): Promise<void> {
|
||||
await screen.findByTestId('saved-views-list');
|
||||
fireEvent.click(within(row(viewName)).getByTestId('saved-views-row-menu'));
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: item }));
|
||||
const menuItem = await screen.findByTestId(ROW_MENU_ITEM_TEST_IDS[item]);
|
||||
await waitFor(() => expectRowUsable(menuItem), MENU_CHECK_TIMEOUT);
|
||||
fireEvent.click(menuItem);
|
||||
}
|
||||
|
||||
const chip = (): HTMLElement => screen.getByTestId('saved-views-name');
|
||||
@@ -76,21 +87,38 @@ describe('SavedViewsPanel row menu', () => {
|
||||
jest.mocked(toast.success).mockClear();
|
||||
});
|
||||
|
||||
it('offers copy link, edit details and delete', async () => {
|
||||
it('offers copy link, copy id, edit details and delete', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
renderPanelWithHeader();
|
||||
await screen.findByTestId('saved-views-list');
|
||||
|
||||
fireEvent.click(within(row('Errors')).getByTestId('saved-views-row-menu'));
|
||||
|
||||
const items = await screen.findAllByRole('menuitem');
|
||||
expect(items.map((item) => item.textContent)).toStrictEqual([
|
||||
await screen.findByTestId(ROW_MENU_ITEM_TEST_IDS['Copy link']);
|
||||
const items = Object.values(ROW_MENU_ITEM_TEST_IDS).map(
|
||||
(testId) => screen.getByTestId(testId).textContent,
|
||||
);
|
||||
expect(items).toStrictEqual([
|
||||
'Copy link',
|
||||
'Copy ID',
|
||||
'Edit details',
|
||||
'Delete',
|
||||
]);
|
||||
});
|
||||
|
||||
it('copies the view id', async () => {
|
||||
mockSavedViewsApi([errors, slow]);
|
||||
renderPanelWithHeader();
|
||||
|
||||
await chooseFromRowMenu('Slow spans', 'Copy ID');
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith(slow.id);
|
||||
expect(toast.success).toHaveBeenCalledWith(
|
||||
'View ID copied',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('copies a link that opens the view with its query and tab', async () => {
|
||||
mockSavedViewsApi([errors, slow]);
|
||||
renderPanelWithHeader();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
|
||||
@@ -13,6 +14,7 @@ export interface Requests {
|
||||
}
|
||||
|
||||
// Mutates `views` on create, update and delete, so a refetch sees the change.
|
||||
// Grants every permission; a later server.use overrides it.
|
||||
export function mockSavedViewsApi(
|
||||
views: SavedviewtypesSavedViewDTO[],
|
||||
{
|
||||
@@ -30,6 +32,7 @@ export function mockSavedViewsApi(
|
||||
const requests: Requests = { created: [], updated: [], deleted: [] };
|
||||
let failedLists = 0;
|
||||
server.use(
|
||||
setupAuthzAdmin(),
|
||||
rest.get(API, (req, res, ctx) => {
|
||||
if (failedLists < listFailures) {
|
||||
failedLists += 1;
|
||||
|
||||
@@ -251,3 +251,20 @@ export function renderWithExplorerProviders(
|
||||
export function urlParam(history: MemoryHistory, key: string): string | null {
|
||||
return new URLSearchParams(history.location.search).get(key);
|
||||
}
|
||||
|
||||
// The ui Button marks a pending check with aria-busy and a block with
|
||||
// aria-disabled, never the disabled attribute.
|
||||
export function expectUsable(element: HTMLElement): void {
|
||||
expect(element).not.toHaveAttribute('aria-busy');
|
||||
expect(element).not.toHaveAttribute('aria-disabled', 'true');
|
||||
}
|
||||
|
||||
// A menu checks its rows on first open; on a loaded CI runner that outlasts
|
||||
// waitFor's 1s default.
|
||||
export const MENU_CHECK_TIMEOUT = { timeout: 5000 };
|
||||
|
||||
// A menu row waiting on its check is data-loading, not yet data-disabled.
|
||||
export function expectRowUsable(row: HTMLElement): void {
|
||||
expect(row).not.toHaveAttribute('data-loading');
|
||||
expect(row).not.toHaveAttribute('data-disabled');
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { Link, PenLine, Trash2 } from '@signozhq/icons';
|
||||
import { Copy, Link, PenLine, Trash2 } from '@signozhq/icons';
|
||||
import {
|
||||
SavedviewtypesSchemaVersionDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import {
|
||||
buildSavedViewDeletePermission,
|
||||
buildSavedViewUpdatePermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { SavedViewRowActionConfig, SaveViewModalMode } from './types';
|
||||
@@ -30,6 +34,8 @@ export const MY_VIEW_NAME = 'My view';
|
||||
|
||||
export const SAVED_VIEW_LOAD_FAILED_NAME = 'View failed to load';
|
||||
|
||||
export const SAVED_VIEW_FORBIDDEN_NAME = 'No view access';
|
||||
|
||||
export const SAVED_VIEWS_LIST_LOADING_ROWS = 4;
|
||||
|
||||
export const SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS = 1000;
|
||||
@@ -47,13 +53,20 @@ export const SAVE_VIEW_MODAL_TITLE: Record<SaveViewModalMode, string> = {
|
||||
|
||||
export const SAVED_VIEW_ROW_ACTIONS: SavedViewRowActionConfig[] = [
|
||||
{ key: 'copyLink', label: 'Copy link', icon: Link },
|
||||
{ key: 'editDetails', label: 'Edit details', icon: PenLine },
|
||||
{ key: 'copyId', label: 'Copy ID', icon: Copy },
|
||||
{
|
||||
key: 'editDetails',
|
||||
label: 'Edit details',
|
||||
icon: PenLine,
|
||||
buildPermission: buildSavedViewUpdatePermission,
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: 'Delete',
|
||||
icon: Trash2,
|
||||
danger: true,
|
||||
hasDividerBefore: true,
|
||||
buildPermission: buildSavedViewDeletePermission,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -6,7 +6,10 @@ import useOptionsMenu from 'container/OptionsMenu/useOptionsMenu';
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
|
||||
import { buildSavedViewReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import { SAVED_VIEW_OPTIONS_DATA_SOURCE } from '../constants';
|
||||
import { UseActiveSavedViewResult } from '../types';
|
||||
@@ -23,11 +26,19 @@ export function useActiveSavedView(
|
||||
dataSource: optionsDataSource ?? DataSource.LOGS,
|
||||
});
|
||||
|
||||
const { data, isLoading, isError } = useGetSavedView(
|
||||
{ id: viewKey },
|
||||
{ query: { enabled: !!viewKey } },
|
||||
const { isLoading: isCheckingRead, deniedPermissions } = useAuthZ(
|
||||
[buildSavedViewReadPermission(viewKey)],
|
||||
{ enabled: !!viewKey },
|
||||
);
|
||||
const view = viewKey ? data?.data : undefined;
|
||||
const isReadDenied = !!viewKey && deniedPermissions.length > 0;
|
||||
|
||||
const canFetch = !!viewKey && !isCheckingRead && !isReadDenied;
|
||||
|
||||
const { data, isLoading, isError, error } = useGetSavedView(
|
||||
{ id: viewKey },
|
||||
{ query: { enabled: canFetch } },
|
||||
);
|
||||
const view = canFetch ? data?.data : undefined;
|
||||
|
||||
// The query builder stages the url query a render later. Until the ids
|
||||
// match, keep the last answer for this view; another view starts clean.
|
||||
@@ -68,10 +79,17 @@ export function useActiveSavedView(
|
||||
lastResult.current = { viewId: view?.id, hasUnsavedChanges };
|
||||
}, [view?.id, hasUnsavedChanges]);
|
||||
|
||||
const errorMessage =
|
||||
viewKey && isError && error
|
||||
? toAPIError(error, '').getErrorMessage() || undefined
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
view,
|
||||
isLoading: !!viewKey && isLoading,
|
||||
isError: !!viewKey && isError,
|
||||
isLoading: !!viewKey && (isCheckingRead || isLoading),
|
||||
isError: !!viewKey && (isError || isReadDenied),
|
||||
isReadDenied,
|
||||
errorMessage,
|
||||
hasUnsavedChanges,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -59,6 +59,14 @@ export function useSavedViewRowActions(
|
||||
[pathname, copyToClipboard],
|
||||
);
|
||||
|
||||
const copyViewId = useCallback(
|
||||
(view: SavedviewtypesSavedViewDTO): void => {
|
||||
copyToClipboard(view.id);
|
||||
toast.success('View ID copied', { position: SAVED_VIEW_TOAST_POSITION });
|
||||
},
|
||||
[copyToClipboard],
|
||||
);
|
||||
|
||||
const renameView = useCallback(
|
||||
async (
|
||||
view: SavedviewtypesSavedViewDTO,
|
||||
@@ -101,5 +109,12 @@ export function useSavedViewRowActions(
|
||||
[deleteSavedView, source, queryClient],
|
||||
);
|
||||
|
||||
return { copyViewLink, renameView, deleteView, isRenaming, isDeleting };
|
||||
return {
|
||||
copyViewLink,
|
||||
copyViewId,
|
||||
renameView,
|
||||
deleteView,
|
||||
isRenaming,
|
||||
isDeleting,
|
||||
};
|
||||
}
|
||||
|
||||
329
frontend/src/container/SavedViews/stories/SavedViews.stories.mocks.tsx
generated
Normal file
329
frontend/src/container/SavedViews/stories/SavedViews.stories.mocks.tsx
generated
Normal file
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { rest, type RequestHandler } from 'msw';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import remove from 'api/browser/localstorage/remove';
|
||||
import {
|
||||
type AuthtypesTransactionDTO,
|
||||
type SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
type SavedviewtypesUpdatableSavedViewDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { SIDEBAR_TOOLTIP_DELAY_MS } from 'components/QuickFilters/QuickFiltersLayout/constants';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import {
|
||||
AUTHZ_CHECK_URL,
|
||||
authzMockResponse,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { accessFor } from '@/storybook/access/access';
|
||||
import {
|
||||
choiceControl,
|
||||
countControl,
|
||||
toggleControl,
|
||||
} from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { MockControlValues, StoryMocks } from '@/storybook/controls/types';
|
||||
import { withCanvas } from '@/storybook/decorators/withCanvas';
|
||||
import { authzMocks, type AuthzArgs } from '@/storybook/globals/authzMocks';
|
||||
import { respondWith } from '@/storybook/runtime/responseState';
|
||||
|
||||
import {
|
||||
createdSavedView,
|
||||
OPEN_VIEW_ERRORS,
|
||||
OPEN_VIEWS,
|
||||
type OpenView,
|
||||
SAVED_VIEW_SOURCES,
|
||||
SAVED_VIEWS_MAX,
|
||||
savedViewById,
|
||||
savedViewResponse,
|
||||
savedViewRoute,
|
||||
savedViewsListResponse,
|
||||
type SavedViewSource,
|
||||
unreadableViewId,
|
||||
} from './__story_mockdata__/savedViews';
|
||||
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import type { SavedViewRowActionKey } from '../types';
|
||||
|
||||
const API = 'http://localhost/api/v2/saved_views';
|
||||
|
||||
const LIST = 'Saved Views · list';
|
||||
const VIEW = 'Saved Views · open view';
|
||||
|
||||
const controls = {
|
||||
source: choiceControl<SavedViewSource>('Source', {
|
||||
group: VIEW,
|
||||
description: 'Which explorer the views belong to.',
|
||||
options: SAVED_VIEW_SOURCES,
|
||||
value: SavedviewtypesSourceDTO.traces,
|
||||
}),
|
||||
openView: choiceControl<OpenView>('Open view', {
|
||||
group: VIEW,
|
||||
description:
|
||||
'The `viewKey` the explorer url carries. `unsaved-changes` stages a query that differs from the view, which swaps in the ✓ save menu; `forbidden`, `failed` and `not-found` are the 403, 500 and 404 the view fetch answers with; each shows View failed to load.',
|
||||
options: OPEN_VIEWS,
|
||||
value: 'none',
|
||||
}),
|
||||
unreadableView: toggleControl('Unreadable view', {
|
||||
group: VIEW,
|
||||
description:
|
||||
'Denies `read` on the first view created by others alone, on top of the Access controls: its row greys out, and with Open view on `created-by-others` the header shows No view access.',
|
||||
value: false,
|
||||
}),
|
||||
createdByMe: countControl('Created by me', {
|
||||
group: LIST,
|
||||
value: 3,
|
||||
max: SAVED_VIEWS_MAX,
|
||||
}),
|
||||
createdByOthers: countControl('Created by others', {
|
||||
group: LIST,
|
||||
description: 'Both at 0 is the empty state, with its Create view button.',
|
||||
value: 4,
|
||||
max: SAVED_VIEWS_MAX,
|
||||
}),
|
||||
};
|
||||
|
||||
type SavedViewsValues = MockControlValues<typeof controls>;
|
||||
|
||||
type WrittenView = Pick<SavedviewtypesSavedViewDTO, 'spec' | 'source'>;
|
||||
|
||||
const viewDenied = (
|
||||
transaction: AuthtypesTransactionDTO,
|
||||
deniedId: string,
|
||||
): boolean =>
|
||||
transaction.relation === 'read' &&
|
||||
transaction.object.resource.kind === 'saved-view' &&
|
||||
transaction.object.selector === deniedId;
|
||||
|
||||
const definition = defineStoryMocks({
|
||||
controls,
|
||||
handlers: (
|
||||
values: SavedViewsValues & AuthzArgs,
|
||||
response,
|
||||
): RequestHandler[] => {
|
||||
const { source, openView, createdByMe, createdByOthers } = values;
|
||||
|
||||
// What the panel writes stays in this handler set until a control changes.
|
||||
const created: SavedviewtypesSavedViewDTO[] = [];
|
||||
const updated = new Map<string, WrittenView>();
|
||||
const deleted = new Set<string>();
|
||||
|
||||
const current = (
|
||||
view: SavedviewtypesSavedViewDTO,
|
||||
): SavedviewtypesSavedViewDTO => ({ ...view, ...updated.get(view.id) });
|
||||
|
||||
const findView = (id: string): SavedviewtypesSavedViewDTO | undefined => {
|
||||
const view = created.find((item) => item.id === id) ?? savedViewById(id);
|
||||
|
||||
return view && !deleted.has(id) ? current(view) : undefined;
|
||||
};
|
||||
|
||||
const openError = OPEN_VIEW_ERRORS[openView];
|
||||
|
||||
return [
|
||||
rest.post(API, async (req, res, ctx) => {
|
||||
const body = (await req.json()) as SavedviewtypesUpdatableSavedViewDTO;
|
||||
const id = `storybook-${source}-created-${created.length + 1}`;
|
||||
|
||||
created.push(createdSavedView(source, id, body.spec));
|
||||
|
||||
return res(ctx.status(201), ctx.json({ status: 'success', data: { id } }));
|
||||
}),
|
||||
|
||||
rest.get(
|
||||
API,
|
||||
response.json(() => {
|
||||
const list = savedViewsListResponse(source, createdByMe, createdByOthers);
|
||||
|
||||
return {
|
||||
...list,
|
||||
data: [...(list.data ?? []), ...created]
|
||||
.filter((view) => !deleted.has(view.id))
|
||||
.map(current),
|
||||
};
|
||||
}),
|
||||
),
|
||||
|
||||
rest.get(`${API}/:id`, (req, res, ctx) => {
|
||||
if (openError) {
|
||||
return res(ctx.status(openError.status), ctx.json(openError.body));
|
||||
}
|
||||
|
||||
const view = findView(String(req.params.id));
|
||||
|
||||
if (!view) {
|
||||
return res(ctx.status(404), ctx.json(OPEN_VIEW_ERRORS['not-found']?.body));
|
||||
}
|
||||
|
||||
return response.json(() => savedViewResponse(view))(req, res, ctx);
|
||||
}),
|
||||
|
||||
rest.put(`${API}/:id`, async (req, res, ctx) => {
|
||||
const body = (await req.json()) as SavedviewtypesUpdatableSavedViewDTO;
|
||||
|
||||
updated.set(String(req.params.id), {
|
||||
spec: body.spec,
|
||||
source: body.source,
|
||||
});
|
||||
|
||||
return res(ctx.status(204));
|
||||
}),
|
||||
|
||||
rest.delete(`${API}/:id`, (req, res, ctx) => {
|
||||
deleted.add(String(req.params.id));
|
||||
|
||||
return res(ctx.status(204));
|
||||
}),
|
||||
|
||||
...(values.unreadableView
|
||||
? [
|
||||
rest.post(
|
||||
AUTHZ_CHECK_URL,
|
||||
respondWith(values.authzState, async (req) => {
|
||||
const payload = (await req.json()) as AuthtypesTransactionDTO[];
|
||||
const granted = accessFor(
|
||||
values.access,
|
||||
values.permissions,
|
||||
values.revoked,
|
||||
);
|
||||
const deniedId = unreadableViewId(source);
|
||||
|
||||
return authzMockResponse(
|
||||
payload,
|
||||
payload.map(
|
||||
(transaction) =>
|
||||
!viewDenied(transaction, deniedId) && granted.allows(transaction),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
]
|
||||
: []),
|
||||
];
|
||||
},
|
||||
config: ({ source, openView }) => ({
|
||||
route: savedViewRoute(source, openView),
|
||||
}),
|
||||
effect: () => {
|
||||
remove(LOCALSTORAGE.LAST_USED_SAVED_VIEWS);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* The per-view read denial answers `authz/check` in place of the global access
|
||||
* mocks, so it reads their controls as well as its own to answer every other
|
||||
* check the way they would.
|
||||
*/
|
||||
export const savedViewsMocks: StoryMocks<typeof controls> = {
|
||||
...definition,
|
||||
read: (args) => ({ ...authzMocks.read(args), ...definition.read(args) }),
|
||||
};
|
||||
|
||||
/** The sidebar slot of an explorer without quick filters. */
|
||||
export const sidebarCanvas = withCanvas({
|
||||
width: 280,
|
||||
height: 640,
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderRight: '1px solid var(--l1-border)',
|
||||
});
|
||||
|
||||
/**
|
||||
* The header above the pinned list. `onOpenViews` adds All views, which the
|
||||
* header only shows in an explorer whose quick filters hide the list.
|
||||
*/
|
||||
export function SavedViewsSidebar({
|
||||
source,
|
||||
onOpenViews,
|
||||
}: {
|
||||
source: SavedViewSource;
|
||||
onOpenViews?: () => void;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<TooltipProvider delay={SIDEBAR_TOOLTIP_DELAY_MS}>
|
||||
<SavedViewsHeader source={source} onOpenViews={onOpenViews} />
|
||||
<SavedViewsPanel source={source} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/** The panel fetches the list and checks every row's permissions before it settles. */
|
||||
export const UNTIL_LOADED = { timeout: 15_000 };
|
||||
|
||||
/**
|
||||
* Re-queried on every attempt: a button whose check settles is a new node, so a
|
||||
* reference taken while it was pending never changes.
|
||||
*/
|
||||
type FindElement = () => HTMLElement;
|
||||
|
||||
export const byTestId =
|
||||
(testId: string): FindElement =>
|
||||
(): HTMLElement =>
|
||||
screen.getByTestId(testId);
|
||||
|
||||
/** A check still in flight: `aria-busy` on a button, `data-loading` on a menu row. */
|
||||
const isPending = (element: HTMLElement): boolean =>
|
||||
element.getAttribute('aria-busy') === 'true' ||
|
||||
element.hasAttribute('data-loading');
|
||||
|
||||
const isDisabled = (element: HTMLElement): boolean =>
|
||||
element.hasAttribute('disabled') || element.hasAttribute('data-disabled');
|
||||
|
||||
export const expectDisabled = (
|
||||
find: FindElement,
|
||||
disabled = true,
|
||||
): Promise<void> =>
|
||||
waitFor(async () => {
|
||||
const element = find();
|
||||
|
||||
await expect(isPending(element)).toBe(false);
|
||||
await expect(isDisabled(element)).toBe(disabled);
|
||||
}, UNTIL_LOADED);
|
||||
|
||||
export const expectPending = (find: FindElement): Promise<void> =>
|
||||
waitFor(() => expect(isPending(find())).toBe(true), UNTIL_LOADED);
|
||||
|
||||
export const findRows = (canvasElement: HTMLElement): Promise<HTMLElement[]> =>
|
||||
within(canvasElement).findAllByTestId(
|
||||
'saved-views-row',
|
||||
undefined,
|
||||
UNTIL_LOADED,
|
||||
);
|
||||
|
||||
export const rowName =
|
||||
(row: HTMLElement): FindElement =>
|
||||
(): HTMLElement =>
|
||||
within(row).getByTestId('saved-views-row-name');
|
||||
|
||||
export const openRowMenu = async (row: HTMLElement): Promise<void> => {
|
||||
await userEvent.click(within(row).getByTestId('saved-views-row-menu'));
|
||||
await screen.findByRole('menu', undefined, UNTIL_LOADED);
|
||||
};
|
||||
|
||||
export const rowMenuItem = (key: SavedViewRowActionKey): FindElement =>
|
||||
byTestId(`saved-views-row-menu-item-${key}`);
|
||||
|
||||
export const openSaveChangesMenu = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<void> => {
|
||||
const trigger = await waitFor(async () => {
|
||||
const button = within(canvasElement).getByTestId('saved-views-save-changes');
|
||||
|
||||
await expect(button).not.toHaveAttribute('aria-busy');
|
||||
|
||||
return button;
|
||||
}, UNTIL_LOADED);
|
||||
|
||||
await userEvent.click(trigger);
|
||||
await screen.findByRole('menu', undefined, UNTIL_LOADED);
|
||||
};
|
||||
|
||||
export const saveChangesItem = (key: 'save-as-new' | 'update'): FindElement =>
|
||||
byTestId(`saved-views-save-changes-item-${key}`);
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import {
|
||||
findRows,
|
||||
openRowMenu,
|
||||
openSaveChangesMenu,
|
||||
SavedViewsSidebar,
|
||||
savedViewsMocks,
|
||||
sidebarCanvas,
|
||||
} from './SavedViews.stories.mocks';
|
||||
|
||||
type SavedViewsArgs = PageStoryArgs<typeof savedViewsMocks>;
|
||||
|
||||
const componentStory = storyMocks(savedViewsMocks);
|
||||
|
||||
/**
|
||||
* The saved views sidebar of an explorer without quick filters: the header with
|
||||
* the open view and its actions, above the list of views created by the user
|
||||
* and by others. Every action is gated on the `saved-view` authz permissions
|
||||
* (list, create, and read, update and delete per view).
|
||||
*
|
||||
* Route: the explorer of the Source control, with the Open view control's
|
||||
* `viewKey`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Components/Saved Views/Overview',
|
||||
tags: ['authz', 'play'],
|
||||
component: SavedViewsPanel,
|
||||
decorators: [sidebarCanvas],
|
||||
render: ({ source }): JSX.Element => <SavedViewsSidebar source={source} />,
|
||||
...componentStory,
|
||||
parameters: { ...componentStory.parameters },
|
||||
} satisfies Meta<SavedViewsArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<SavedViewsArgs>;
|
||||
|
||||
/** Traces views created by the user and by others, with no view open. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** A workspace with no views yet: the empty state offers Create view. */
|
||||
export const Empty: Story = {
|
||||
args: { createdByMe: 0, createdByOthers: 0 },
|
||||
};
|
||||
|
||||
/**
|
||||
* A view open with a query that differs from what it saved: the name carries
|
||||
* the unsaved dot, and ✓ and discard replace Clear view.
|
||||
*/
|
||||
export const UnsavedChanges: Story = {
|
||||
args: { openView: 'unsaved-changes' },
|
||||
};
|
||||
|
||||
/**
|
||||
* The first row's menu. Edit details and Delete follow `update` and `delete` on
|
||||
* that view, so revoking either disables its item.
|
||||
*/
|
||||
export const RowMenu: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const [row] = await findRows(canvasElement);
|
||||
|
||||
await openRowMenu(row);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The ✓ menu on a view with unsaved changes. Save as new view follows `create`
|
||||
* and Update selected view follows `update` on the open view.
|
||||
*/
|
||||
export const SaveChangesMenu: Story = {
|
||||
args: { openView: 'unsaved-changes' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openSaveChangesMenu(canvasElement);
|
||||
},
|
||||
};
|
||||
309
frontend/src/container/SavedViews/stories/__story_mockdata__/savedViews.ts
generated
Normal file
309
frontend/src/container/SavedViews/stories/__story_mockdata__/savedViews.ts
generated
Normal file
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import type {
|
||||
GetSavedView200,
|
||||
ListSavedViews200,
|
||||
RenderErrorResponseDTO,
|
||||
SavedviewtypesSavedViewDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
SavedviewtypesSchemaVersionDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import {
|
||||
initialQueriesMap,
|
||||
initialQueryMeterWithType,
|
||||
PANEL_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { getSavedViewQuery } from 'container/SavedViews/utils/getSavedViewQuery';
|
||||
import { toSavedViewSpec } from 'container/SavedViews/utils/toSavedViewSpec';
|
||||
import { createAppContextMock } from 'tests/fixtures/appContextMock';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
export const SAVED_VIEW_SOURCES = [
|
||||
SavedviewtypesSourceDTO.traces,
|
||||
SavedviewtypesSourceDTO.logs,
|
||||
SavedviewtypesSourceDTO.metrics,
|
||||
SavedviewtypesSourceDTO.meter,
|
||||
] as const;
|
||||
|
||||
export type SavedViewSource = (typeof SAVED_VIEW_SOURCES)[number];
|
||||
|
||||
export const OPEN_VIEWS = [
|
||||
'none',
|
||||
'created-by-me',
|
||||
'created-by-others',
|
||||
'unsaved-changes',
|
||||
'forbidden',
|
||||
'failed',
|
||||
'not-found',
|
||||
] as const;
|
||||
|
||||
export type OpenView = (typeof OPEN_VIEWS)[number];
|
||||
|
||||
export const SAVED_VIEWS_MAX = 8;
|
||||
|
||||
/** The user the story app context signs in as, so "Created by me" follows it. */
|
||||
const STORY_USER_EMAIL = createAppContextMock(USER_ROLES.ADMIN).user.email;
|
||||
|
||||
const OTHER_AUTHORS = ['priya@signoz.io', 'marco@signoz.io', 'lena@signoz.io'];
|
||||
|
||||
const MY_VIEW_NAMES = [
|
||||
'Checkout errors',
|
||||
'Slow payment calls',
|
||||
'Cart service, production',
|
||||
'Auth retries',
|
||||
'Inventory sync',
|
||||
'Search p99 regressions',
|
||||
'Webhook failures',
|
||||
'Cold starts after deploy',
|
||||
];
|
||||
|
||||
const OTHER_VIEW_NAMES = [
|
||||
'On-call triage',
|
||||
'Frontend 5xx',
|
||||
'Shipping latency by region',
|
||||
'Kafka consumer lag',
|
||||
'Staging smoke tests',
|
||||
'Recommendation timeouts',
|
||||
'Ad service sampling',
|
||||
'Email queue backlog',
|
||||
];
|
||||
|
||||
const EXPRESSIONS = [
|
||||
"service.name = 'checkout' AND has_error = true",
|
||||
"service.name = 'payments'",
|
||||
"service.name = 'cart' AND deployment.environment = 'production'",
|
||||
"service.name = 'auth'",
|
||||
"service.name IN ('inventory', 'catalog')",
|
||||
"service.name = 'search'",
|
||||
"service.name = 'webhooks' AND has_error = true",
|
||||
"deployment.environment = 'production'",
|
||||
];
|
||||
|
||||
const BASE_QUERY: Record<SavedViewSource, Query> = {
|
||||
traces: initialQueriesMap.traces,
|
||||
logs: initialQueriesMap.logs,
|
||||
metrics: initialQueriesMap.metrics,
|
||||
meter: initialQueryMeterWithType,
|
||||
};
|
||||
|
||||
const EXPLORER_ROUTE: Record<SavedViewSource, string> = {
|
||||
traces: ROUTES.TRACES_EXPLORER,
|
||||
logs: ROUTES.LOGS_EXPLORER,
|
||||
metrics: ROUTES.METRICS_EXPLORER_EXPLORER,
|
||||
meter: ROUTES.METER_EXPLORER,
|
||||
};
|
||||
|
||||
const queryWith = (source: SavedViewSource, expression: string): Query => {
|
||||
const base = BASE_QUERY[source];
|
||||
|
||||
return {
|
||||
...base,
|
||||
builder: {
|
||||
...base.builder,
|
||||
queryData: [
|
||||
{
|
||||
...base.builder.queryData[0],
|
||||
filter: { expression },
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as Query;
|
||||
};
|
||||
|
||||
type Author = 'mine' | 'others';
|
||||
|
||||
const viewId = (
|
||||
source: SavedViewSource,
|
||||
author: Author,
|
||||
index: number,
|
||||
): string => `storybook-${source}-${author}-${index + 1}`;
|
||||
|
||||
/** The view other people made that the per-view read denial applies to. */
|
||||
export const unreadableViewId = (source: SavedViewSource): string =>
|
||||
viewId(source, 'others', 0);
|
||||
|
||||
const savedView = (
|
||||
source: SavedViewSource,
|
||||
author: Author,
|
||||
index: number,
|
||||
): SavedviewtypesSavedViewDTO => {
|
||||
const displayName = (author === 'mine' ? MY_VIEW_NAMES : OTHER_VIEW_NAMES)[
|
||||
index
|
||||
];
|
||||
const createdBy =
|
||||
author === 'mine'
|
||||
? STORY_USER_EMAIL
|
||||
: OTHER_AUTHORS[index % OTHER_AUTHORS.length];
|
||||
const id = viewId(source, author, index);
|
||||
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
source,
|
||||
schemaVersion: SavedviewtypesSchemaVersionDTO.v2,
|
||||
createdAt: '2026-09-02T08:30:00Z',
|
||||
createdBy,
|
||||
updatedAt: `2026-10-${String(14 - (index % 10)).padStart(2, '0')}T11:42:00Z`,
|
||||
updatedBy: createdBy,
|
||||
spec: toSavedViewSpec({
|
||||
query: queryWith(
|
||||
source,
|
||||
EXPRESSIONS[(index + (author === 'mine' ? 0 : 3)) % EXPRESSIONS.length],
|
||||
),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
displayName,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const savedViews = (
|
||||
source: SavedViewSource,
|
||||
author: Author,
|
||||
count: number,
|
||||
): SavedviewtypesSavedViewDTO[] =>
|
||||
Array.from({ length: Math.min(count, SAVED_VIEWS_MAX) }, (_, index) =>
|
||||
savedView(source, author, index),
|
||||
);
|
||||
|
||||
export const savedViewsListResponse = (
|
||||
source: SavedViewSource,
|
||||
createdByMe: number,
|
||||
createdByOthers: number,
|
||||
): ListSavedViews200 => ({
|
||||
status: 'success',
|
||||
data: [
|
||||
...savedViews(source, 'mine', createdByMe),
|
||||
...savedViews(source, 'others', createdByOthers),
|
||||
],
|
||||
});
|
||||
|
||||
/** Every view the workspace has, whatever the list control trims it to. */
|
||||
export const savedViewById = (
|
||||
id: string,
|
||||
): SavedviewtypesSavedViewDTO | undefined =>
|
||||
SAVED_VIEW_SOURCES.flatMap((source) => [
|
||||
...savedViews(source, 'mine', SAVED_VIEWS_MAX),
|
||||
...savedViews(source, 'others', SAVED_VIEWS_MAX),
|
||||
]).find((view) => view.id === id);
|
||||
|
||||
/** A view the panel itself created, owned by the story user. */
|
||||
export const createdSavedView = (
|
||||
source: SavedViewSource,
|
||||
id: string,
|
||||
spec: SavedviewtypesSavedViewDTO['spec'],
|
||||
): SavedviewtypesSavedViewDTO => ({
|
||||
...savedView(source, 'mine', 0),
|
||||
id,
|
||||
name: id,
|
||||
createdAt: '2026-10-16T09:00:00Z',
|
||||
updatedAt: '2026-10-16T09:00:00Z',
|
||||
spec,
|
||||
});
|
||||
|
||||
export const savedViewResponse = (
|
||||
view: SavedviewtypesSavedViewDTO,
|
||||
): GetSavedView200 => ({ status: 'success', data: view });
|
||||
|
||||
export const OPEN_VIEW_ERRORS: Partial<
|
||||
Record<OpenView, { status: number; body: RenderErrorResponseDTO }>
|
||||
> = {
|
||||
forbidden: {
|
||||
status: 403,
|
||||
body: {
|
||||
status: 'error',
|
||||
error: {
|
||||
code: 'forbidden',
|
||||
message: 'You do not have access to this saved view',
|
||||
errors: [],
|
||||
suggestions: [],
|
||||
type: 'forbidden',
|
||||
},
|
||||
},
|
||||
},
|
||||
failed: {
|
||||
status: 500,
|
||||
body: {
|
||||
status: 'error',
|
||||
error: {
|
||||
code: 'internal',
|
||||
message: 'Failed to load the saved view',
|
||||
errors: [],
|
||||
suggestions: [],
|
||||
type: 'internal',
|
||||
},
|
||||
},
|
||||
},
|
||||
'not-found': {
|
||||
status: 404,
|
||||
body: {
|
||||
status: 'error',
|
||||
error: {
|
||||
code: 'not_found',
|
||||
message: 'Saved view not found',
|
||||
errors: [],
|
||||
suggestions: [],
|
||||
type: 'not_found',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const openViewId = (source: SavedViewSource, open: OpenView): string | null => {
|
||||
if (open === 'none') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (open === 'created-by-others' || open === 'forbidden') {
|
||||
return viewId(source, 'others', 0);
|
||||
}
|
||||
|
||||
if (open === 'not-found') {
|
||||
return `storybook-${source}-deleted`;
|
||||
}
|
||||
|
||||
return viewId(source, 'mine', 0);
|
||||
};
|
||||
|
||||
/**
|
||||
* The explorer url with a view open, its params written the way
|
||||
* `redirectWithQueryBuilderData` writes them. `panelTypes` is left out: the
|
||||
* query builder seeds its panel type from the raw, JSON-quoted param, which no
|
||||
* saved panel type equals, and without it the header compares against `list`.
|
||||
*/
|
||||
export const savedViewRoute = (
|
||||
source: SavedViewSource,
|
||||
open: OpenView,
|
||||
): string => {
|
||||
const params = new URLSearchParams({ relativeTime: '15m' });
|
||||
const id = openViewId(source, open);
|
||||
|
||||
if (id) {
|
||||
const view = savedViewById(id);
|
||||
const query = view
|
||||
? getSavedViewQuery(view)
|
||||
: queryWith(source, EXPRESSIONS[0]);
|
||||
const staged =
|
||||
open === 'unsaved-changes'
|
||||
? queryWith(source, `${EXPRESSIONS[0]} AND duration_nano > 500000000`)
|
||||
: query;
|
||||
|
||||
params.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(
|
||||
JSON.stringify({ ...staged, id: `storybook-staged-${open}` }),
|
||||
),
|
||||
);
|
||||
params.set(QueryParams.viewKey, JSON.stringify(id));
|
||||
}
|
||||
|
||||
return `${EXPLORER_ROUTE[source]}?${params.toString()}`;
|
||||
};
|
||||
@@ -0,0 +1,220 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { unreadableViewId } from '../__story_mockdata__/savedViews';
|
||||
import {
|
||||
byTestId,
|
||||
expectDisabled,
|
||||
expectPending,
|
||||
findRows,
|
||||
openRowMenu,
|
||||
openSaveChangesMenu,
|
||||
rowMenuItem,
|
||||
rowName,
|
||||
saveChangesItem,
|
||||
SavedViewsSidebar,
|
||||
savedViewsMocks,
|
||||
sidebarCanvas,
|
||||
UNTIL_LOADED,
|
||||
} from '../SavedViews.stories.mocks';
|
||||
|
||||
import SavedViewsPanel from '../../SavedViewsPanel';
|
||||
|
||||
type SavedViewsArgs = PageStoryArgs<typeof savedViewsMocks>;
|
||||
|
||||
const componentStory = storyMocks(savedViewsMocks);
|
||||
|
||||
/**
|
||||
* The saved views sidebar with one permission missing at a time. Every story
|
||||
* here is an admin denied exactly what its name says. `list` and `create` are
|
||||
* workspace-wide; `read`, `update` and `delete` are checked per view, so a row
|
||||
* menu only checks its view once it is opened.
|
||||
*
|
||||
* Route: the explorer of the Source control, with the Open view control's
|
||||
* `viewKey`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Components/Saved Views/Authz',
|
||||
tags: ['authz', 'play'],
|
||||
component: SavedViewsPanel,
|
||||
decorators: [sidebarCanvas],
|
||||
render: ({ source }): JSX.Element => <SavedViewsSidebar source={source} />,
|
||||
...componentStory,
|
||||
parameters: { ...componentStory.parameters },
|
||||
} satisfies Meta<SavedViewsArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<SavedViewsArgs>;
|
||||
|
||||
const LIST = 'list:saved-view';
|
||||
const CREATE = 'create:saved-view';
|
||||
const UPDATE = 'update:saved-view';
|
||||
const DELETE = 'delete:saved-view';
|
||||
|
||||
/**
|
||||
* The panel shows the permission callout in place of the list, and All views,
|
||||
* in an explorer whose quick filters hide the list, is disabled.
|
||||
*/
|
||||
export const NoList: Story = {
|
||||
args: { revoked: [LIST] },
|
||||
render: ({ source }): JSX.Element => (
|
||||
<SavedViewsSidebar source={source} onOpenViews={(): void => {}} />
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await expectDisabled(byTestId('saved-views-open'));
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(canvas.getByTestId('saved-views-panel')).toHaveTextContent(
|
||||
/is not authorized to perform list/,
|
||||
),
|
||||
UNTIL_LOADED,
|
||||
);
|
||||
await expect(canvas.queryByTestId('saved-views-search')).toBeNull();
|
||||
},
|
||||
};
|
||||
|
||||
/** An empty workspace with both ways to create a view disabled: + and Create view. */
|
||||
export const NoCreate: Story = {
|
||||
args: { revoked: [CREATE], createdByMe: 0, createdByOthers: 0 },
|
||||
play: async () => {
|
||||
await expectDisabled(byTestId('saved-views-create'));
|
||||
await expectDisabled(byTestId('saved-views-list-create'));
|
||||
},
|
||||
};
|
||||
|
||||
/** The ✓ menu on unsaved changes: Save as new view disabled, Update selected view open. */
|
||||
export const NoCreateSaveMenu: Story = {
|
||||
args: { revoked: [CREATE], openView: 'unsaved-changes' },
|
||||
play: async ({ canvasElement }) => {
|
||||
await openSaveChangesMenu(canvasElement);
|
||||
await expectDisabled(saveChangesItem('save-as-new'));
|
||||
await expectDisabled(saveChangesItem('update'), false);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* `read` denied on one view made by someone else: its name is disabled and its
|
||||
* hover card never opens, while every other view still opens.
|
||||
*/
|
||||
export const NoRead: Story = {
|
||||
args: { unreadableView: true },
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const rows = await findRows(canvasElement);
|
||||
const deniedId = unreadableViewId(args.source);
|
||||
|
||||
await Promise.all(
|
||||
rows.map((row) =>
|
||||
expectDisabled(rowName(row), row.getAttribute('data-view-id') === deniedId),
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** The unreadable view open from its link: the header names it No view access. */
|
||||
export const NoReadOpenView: Story = {
|
||||
args: { unreadableView: true, openView: 'created-by-others' },
|
||||
play: async ({ canvasElement }) => {
|
||||
await expect(
|
||||
await within(canvasElement).findByText(
|
||||
'No view access',
|
||||
undefined,
|
||||
UNTIL_LOADED,
|
||||
),
|
||||
).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
/** A row's menu: Edit details disabled; Copy link, Copy ID and Delete open. */
|
||||
export const NoUpdate: Story = {
|
||||
args: { revoked: [UPDATE] },
|
||||
play: async ({ canvasElement }) => {
|
||||
const [row] = await findRows(canvasElement);
|
||||
|
||||
await openRowMenu(row);
|
||||
await expectDisabled(rowMenuItem('editDetails'));
|
||||
await expectDisabled(rowMenuItem('copyLink'), false);
|
||||
await expectDisabled(rowMenuItem('copyId'), false);
|
||||
await expectDisabled(rowMenuItem('delete'), false);
|
||||
},
|
||||
};
|
||||
|
||||
/** The ✓ menu on unsaved changes: Update selected view disabled, Save as new view open. */
|
||||
export const NoUpdateSaveMenu: Story = {
|
||||
args: { revoked: [UPDATE], openView: 'unsaved-changes' },
|
||||
play: async ({ canvasElement }) => {
|
||||
await openSaveChangesMenu(canvasElement);
|
||||
await expectDisabled(saveChangesItem('update'));
|
||||
await expectDisabled(saveChangesItem('save-as-new'), false);
|
||||
},
|
||||
};
|
||||
|
||||
/** A row's menu: Delete disabled, Edit details still open. */
|
||||
export const NoDelete: Story = {
|
||||
args: { revoked: [DELETE] },
|
||||
play: async ({ canvasElement }) => {
|
||||
const [row] = await findRows(canvasElement);
|
||||
|
||||
await openRowMenu(row);
|
||||
await expectDisabled(rowMenuItem('delete'));
|
||||
await expectDisabled(rowMenuItem('editDetails'), false);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Everything but listing and reading: + is disabled, and a row's menu has only
|
||||
* Copy link and Copy ID left.
|
||||
*/
|
||||
export const ReadOnly: Story = {
|
||||
args: { revoked: [CREATE, UPDATE, DELETE] },
|
||||
play: async ({ canvasElement }) => {
|
||||
await expectDisabled(byTestId('saved-views-create'));
|
||||
|
||||
const [row] = await findRows(canvasElement);
|
||||
|
||||
await expectDisabled(rowName(row), false);
|
||||
await openRowMenu(row);
|
||||
await expectDisabled(rowMenuItem('editDetails'));
|
||||
await expectDisabled(rowMenuItem('delete'));
|
||||
await expectDisabled(rowMenuItem('copyLink'), false);
|
||||
await expectDisabled(rowMenuItem('copyId'), false);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The permission check never answering: + spins, and the list stays blank
|
||||
* behind its `list` check.
|
||||
*/
|
||||
export const CheckLoading: Story = {
|
||||
args: { authzState: 'loading' },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await expectPending(byTestId('saved-views-create'));
|
||||
await expect(canvas.queryByTestId('saved-views-search')).toBeNull();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The permission check failing rather than denying: the sidebar fails open,
|
||||
* so the list renders and every action stays enabled for the API to refuse.
|
||||
*/
|
||||
export const CheckFailed: Story = {
|
||||
args: { authzState: 'error' },
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
await expectDisabled(byTestId('saved-views-create'), false);
|
||||
|
||||
const [row] = await findRows(canvasElement);
|
||||
|
||||
await expectDisabled(rowName(row), false);
|
||||
await openRowMenu(row);
|
||||
await expectDisabled(rowMenuItem('editDetails'), false);
|
||||
await expectDisabled(rowMenuItem('delete'), false);
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ReactElement, ReactNode } from 'react';
|
||||
import type { Link } from '@signozhq/icons';
|
||||
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import {
|
||||
SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
@@ -41,6 +42,8 @@ export interface UseActiveSavedViewResult {
|
||||
view: SavedviewtypesSavedViewDTO | undefined;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
isReadDenied: boolean;
|
||||
errorMessage: string | undefined;
|
||||
hasUnsavedChanges: boolean;
|
||||
}
|
||||
|
||||
@@ -70,6 +73,7 @@ export interface SaveViewModalProps {
|
||||
}
|
||||
|
||||
export interface SaveChangesMenuProps {
|
||||
viewId: string;
|
||||
disabled: boolean;
|
||||
onSaveAsNew: () => void;
|
||||
onUpdate: () => void;
|
||||
@@ -82,6 +86,7 @@ export interface SavedViewsIconButtonProps {
|
||||
color?: 'secondary' | 'warning';
|
||||
disabled?: boolean;
|
||||
disabledTooltip?: ReactNode;
|
||||
checks?: BrandedPermission[];
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
@@ -116,7 +121,11 @@ export interface SavedViewsListRowProps {
|
||||
hover: SavedViewsListRowHoverProps;
|
||||
}
|
||||
|
||||
export type SavedViewRowActionKey = 'copyLink' | 'editDetails' | 'delete';
|
||||
export type SavedViewRowActionKey =
|
||||
| 'copyLink'
|
||||
| 'copyId'
|
||||
| 'editDetails'
|
||||
| 'delete';
|
||||
|
||||
export interface SavedViewRowActionConfig {
|
||||
key: SavedViewRowActionKey;
|
||||
@@ -124,10 +133,11 @@ export interface SavedViewRowActionConfig {
|
||||
icon: typeof Link;
|
||||
danger?: boolean;
|
||||
hasDividerBefore?: boolean;
|
||||
buildPermission?: (id: string) => BrandedPermission;
|
||||
}
|
||||
|
||||
export interface PendingSavedViewAction {
|
||||
key: Exclude<SavedViewRowActionKey, 'copyLink'>;
|
||||
key: Exclude<SavedViewRowActionKey, 'copyLink' | 'copyId'>;
|
||||
view: SavedviewtypesSavedViewDTO;
|
||||
}
|
||||
|
||||
@@ -140,6 +150,7 @@ export interface SavedViewsRowButtonProps {
|
||||
}
|
||||
|
||||
export interface SavedViewsRowMenuProps {
|
||||
viewId: string;
|
||||
onAction: (key: SavedViewRowActionKey) => void;
|
||||
onOpen: () => void;
|
||||
}
|
||||
@@ -152,6 +163,7 @@ export interface DeleteSavedViewDialogProps {
|
||||
|
||||
export interface UseSavedViewRowActionsResult {
|
||||
copyViewLink: (view: SavedviewtypesSavedViewDTO) => void;
|
||||
copyViewId: (view: SavedviewtypesSavedViewDTO) => void;
|
||||
renameView: (
|
||||
view: SavedviewtypesSavedViewDTO,
|
||||
displayName: string,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ExtendedSeries } from 'lib/uPlotV2/config/types';
|
||||
import { Widgets } from 'types/api/widgets/widget';
|
||||
import {
|
||||
MetricRangePayloadProps,
|
||||
@@ -164,6 +165,27 @@ describe('BarPanel utils', () => {
|
||||
expect(config.series).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('passes partial timestamps to the series', () => {
|
||||
const apiResponse = createApiResponse([
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'Q1',
|
||||
values: [
|
||||
[1000, '1'],
|
||||
[1060, '0.2'],
|
||||
],
|
||||
partialTimestamps: [1060],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
]);
|
||||
|
||||
const series = prepareBarPanelConfig({
|
||||
...baseParams,
|
||||
apiResponse,
|
||||
}).getConfig().series?.[1] as ExtendedSeries;
|
||||
|
||||
expect(series.partialTimestamps).toStrictEqual(new Set([1060]));
|
||||
});
|
||||
|
||||
it('uses getLegend for label when currentQuery is provided', () => {
|
||||
const apiResponse = createApiResponse([
|
||||
{
|
||||
|
||||
@@ -89,6 +89,9 @@ export function prepareBarPanelConfig({
|
||||
isDarkMode,
|
||||
stepInterval: currentStepInterval,
|
||||
metric: series.metric,
|
||||
partialTimestamps: series.partialTimestamps
|
||||
? new Set(series.partialTimestamps)
|
||||
: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from 'types/api/metrics/getQueryRange';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import type { ExtendedSeries } from 'lib/uPlotV2/config/types';
|
||||
import { PanelMode } from 'lib/visualization/panels/types';
|
||||
import { prepareUPlotConfig } from 'container/WidgetCard/Panels/TimeSeriesPanel/utils';
|
||||
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
|
||||
@@ -185,6 +186,28 @@ describe('TimeSeriesPanel utils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('passes partial timestamps to the series', () => {
|
||||
const apiResponse = createApiResponse([
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'Q1',
|
||||
values: [
|
||||
[1000, '1'],
|
||||
[1060, '0.2'],
|
||||
],
|
||||
partialTimestamps: [1060],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
]);
|
||||
|
||||
const series = prepareUPlotConfig({
|
||||
...baseParams,
|
||||
apiResponse,
|
||||
currentQuery: null as unknown as Query,
|
||||
}).getConfig().series?.[1] as ExtendedSeries;
|
||||
|
||||
expect(series.partialTimestamps).toStrictEqual(new Set([1060]));
|
||||
});
|
||||
|
||||
it('uses getLegend for label when currentQuery is provided', () => {
|
||||
const apiResponse = createApiResponse([
|
||||
{
|
||||
|
||||
@@ -101,6 +101,9 @@ export const prepareUPlotConfig = ({
|
||||
fillMode: widget.fillMode || FillMode.None,
|
||||
isDarkMode,
|
||||
metric: series.metric,
|
||||
partialTimestamps: series.partialTimestamps
|
||||
? new Set(series.partialTimestamps)
|
||||
: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
61
frontend/src/hooks/logs/__tests__/useActiveLog.test.ts
Normal file
61
frontend/src/hooks/logs/__tests__/useActiveLog.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { useActiveLog } from '../useActiveLog';
|
||||
|
||||
const mockRedirect = jest.fn();
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): unknown => ({
|
||||
currentQuery: initialQueriesMap.logs,
|
||||
redirectWithQueryBuilderData: mockRedirect,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useUrlQuery', () => ({
|
||||
__esModule: true,
|
||||
default: (): URLSearchParams => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
describe('useActiveLog', () => {
|
||||
beforeEach(() => {
|
||||
mockRedirect.mockClear();
|
||||
});
|
||||
|
||||
it('adds the chip field as a filter with its data type and redirects', () => {
|
||||
const { result } = renderHook(() => useActiveLog());
|
||||
|
||||
result.current.onAddToQuery(
|
||||
'service.name',
|
||||
'frontend',
|
||||
'=',
|
||||
DataTypes.String,
|
||||
);
|
||||
|
||||
expect(mockRedirect).toHaveBeenCalledTimes(1);
|
||||
const query = mockRedirect.mock.calls[0][0] as Query;
|
||||
const item = query.builder.queryData[0].filters?.items.at(-1);
|
||||
expect(item?.key).toMatchObject({
|
||||
key: 'service.name',
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
});
|
||||
expect(item?.op).toBe('=');
|
||||
expect(item?.value).toBe('frontend');
|
||||
});
|
||||
|
||||
it('keeps the existing filter items ahead of the new one', () => {
|
||||
const { result } = renderHook(() => useActiveLog());
|
||||
|
||||
result.current.onAddToQuery('k8s.pod.name', 'pod-1', '=');
|
||||
|
||||
const query = mockRedirect.mock.calls[0][0] as Query;
|
||||
const items = query.builder.queryData[0].filters?.items ?? [];
|
||||
expect(items).toHaveLength(
|
||||
(initialQueriesMap.logs.builder.queryData[0].filters?.items.length ?? 0) + 1,
|
||||
);
|
||||
expect(items.at(-1)?.key?.key).toBe('k8s.pod.name');
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,6 @@ jest.mock('hooks/useIsTextSelected');
|
||||
const mockOnSetActiveLog = jest.fn();
|
||||
const mockOnClearActiveLog = jest.fn();
|
||||
const mockOnAddToQuery = jest.fn();
|
||||
const mockOnGroupByAttribute = jest.fn();
|
||||
const mockIsTextSelected = jest.fn();
|
||||
|
||||
const mockLog: ILog = {
|
||||
@@ -44,7 +43,6 @@ beforeEach(() => {
|
||||
onSetActiveLog: mockOnSetActiveLog,
|
||||
onClearActiveLog: mockOnClearActiveLog,
|
||||
onAddToQuery: mockOnAddToQuery,
|
||||
onGroupByAttribute: mockOnGroupByAttribute,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,7 +78,6 @@ it('should toggle off when clicking the same active log', () => {
|
||||
onSetActiveLog: mockOnSetActiveLog,
|
||||
onClearActiveLog: mockOnClearActiveLog,
|
||||
onAddToQuery: mockOnAddToQuery,
|
||||
onGroupByAttribute: mockOnGroupByAttribute,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useLogDetailHandlers());
|
||||
|
||||
@@ -25,5 +25,4 @@ export type UseActiveLog = {
|
||||
operator: string,
|
||||
dataType?: DataTypes,
|
||||
) => void;
|
||||
onGroupByAttribute: (fieldKey: string, dataType?: DataTypes) => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -1,40 +1,16 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { getAggregateKeys } from 'api/queryBuilder/getAttributeKeys';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { OPERATORS, QueryBuilderKeys } from 'constants/queryBuilder';
|
||||
import { MetricsType } from 'container/MetricsApplication/constant';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
|
||||
import { getFilterQueryData } from 'container/LogDetailedView/logAttributeActions.utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import {
|
||||
BaseAutocompleteData,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { UseActiveLog } from './types';
|
||||
|
||||
export function getOldLogsOperatorFromNew(operator: string): string {
|
||||
switch (operator) {
|
||||
case OPERATORS['=']:
|
||||
return OPERATORS.IN;
|
||||
case OPERATORS['!=']:
|
||||
return OPERATORS.NIN;
|
||||
default:
|
||||
return operator;
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export const useActiveLog = (): UseActiveLog => {
|
||||
const queryClient = useQueryClient();
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const [activeLog, setActiveLog] = useState<ILog | null>(null);
|
||||
|
||||
@@ -58,121 +34,32 @@ export const useActiveLog = (): UseActiveLog => {
|
||||
|
||||
const onClearActiveLog = useCallback((): void => setActiveLog(null), []);
|
||||
|
||||
const onAddToQueryExplorer = useCallback(
|
||||
async (
|
||||
const onAddToQuery = useCallback(
|
||||
(
|
||||
fieldKey: string,
|
||||
fieldValue: string,
|
||||
operator: string,
|
||||
dataType?: DataTypes,
|
||||
fieldType?: MetricsType | undefined,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const keysAutocompleteResponse = await queryClient.fetchQuery(
|
||||
[QueryBuilderKeys.GET_AGGREGATE_KEYS, fieldKey],
|
||||
async () =>
|
||||
getAggregateKeys({
|
||||
searchText: fieldKey,
|
||||
aggregateOperator:
|
||||
currentQuery.builder.queryData[0].aggregateOperator || '',
|
||||
dataSource: currentQuery.builder.queryData[0].dataSource,
|
||||
aggregateAttribute:
|
||||
currentQuery.builder.queryData[0].aggregateAttribute?.key || '',
|
||||
}),
|
||||
);
|
||||
): void => {
|
||||
const nextQuery: Query = {
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: currentQuery.builder.queryData.map((item) =>
|
||||
getFilterQueryData(item, { fieldKey, dataType }, fieldValue, operator),
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
const keysAutocomplete: BaseAutocompleteData[] =
|
||||
keysAutocompleteResponse.payload?.attributeKeys || [];
|
||||
|
||||
const existAutocompleteKey = chooseAutocompleteFromCustomValue(
|
||||
keysAutocomplete,
|
||||
fieldKey,
|
||||
dataType,
|
||||
fieldType,
|
||||
);
|
||||
|
||||
const currentOperator = getOperatorValue(operator);
|
||||
|
||||
const nextQuery: Query = {
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: currentQuery.builder.queryData.map((item) => ({
|
||||
...item,
|
||||
filters: {
|
||||
...item.filters,
|
||||
items: [
|
||||
...(item.filters?.items || []),
|
||||
{
|
||||
id: uuid(),
|
||||
key: existAutocompleteKey,
|
||||
op: currentOperator,
|
||||
value: fieldValue,
|
||||
},
|
||||
],
|
||||
op: item.filters?.op || 'AND',
|
||||
},
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
redirectWithQueryBuilderData(nextQuery);
|
||||
} catch {
|
||||
notifications.error({ message: SOMETHING_WENT_WRONG });
|
||||
}
|
||||
redirectWithQueryBuilderData(nextQuery);
|
||||
},
|
||||
[currentQuery, notifications, queryClient, redirectWithQueryBuilderData],
|
||||
);
|
||||
|
||||
const onGroupByAttribute = useCallback(
|
||||
async (fieldKey: string, dataType?: DataTypes): Promise<void> => {
|
||||
try {
|
||||
const keysAutocompleteResponse = await queryClient.fetchQuery(
|
||||
[QueryBuilderKeys.GET_AGGREGATE_KEYS, fieldKey],
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
async () =>
|
||||
getAggregateKeys({
|
||||
searchText: fieldKey,
|
||||
aggregateOperator:
|
||||
currentQuery.builder.queryData[0].aggregateOperator || '',
|
||||
dataSource: currentQuery.builder.queryData[0].dataSource,
|
||||
aggregateAttribute:
|
||||
currentQuery.builder.queryData[0].aggregateAttribute?.key || '',
|
||||
}),
|
||||
);
|
||||
|
||||
const keysAutocomplete: BaseAutocompleteData[] =
|
||||
keysAutocompleteResponse.payload?.attributeKeys || [];
|
||||
|
||||
const existAutocompleteKey = chooseAutocompleteFromCustomValue(
|
||||
keysAutocomplete,
|
||||
fieldKey,
|
||||
dataType,
|
||||
);
|
||||
|
||||
const nextQuery: Query = {
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: currentQuery.builder.queryData.map((item) => ({
|
||||
...item,
|
||||
groupBy: [...item.groupBy, existAutocompleteKey],
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
redirectWithQueryBuilderData(nextQuery);
|
||||
} catch {
|
||||
notifications.error({ message: SOMETHING_WENT_WRONG });
|
||||
}
|
||||
},
|
||||
[currentQuery, notifications, queryClient, redirectWithQueryBuilderData],
|
||||
[currentQuery, redirectWithQueryBuilderData],
|
||||
);
|
||||
|
||||
return {
|
||||
activeLog,
|
||||
onSetActiveLog,
|
||||
onClearActiveLog,
|
||||
onAddToQuery: onAddToQueryExplorer,
|
||||
onGroupByAttribute,
|
||||
onAddToQuery,
|
||||
};
|
||||
};
|
||||
|
||||
58
frontend/src/hooks/trace/__tests__/useCopySpanLink.test.tsx
Normal file
58
frontend/src/hooks/trace/__tests__/useCopySpanLink.test.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { MouseEvent, ReactNode } from 'react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import {
|
||||
__resetSearchParamsGetter,
|
||||
__setSearchParamsGetterForTest,
|
||||
} from 'utils/getUnstableCurrentSearchParams';
|
||||
|
||||
import { useCopySpanLink } from '../useCopySpanLink';
|
||||
|
||||
const mockCopy = jest.fn();
|
||||
|
||||
jest.mock('react-use', () => ({
|
||||
__esModule: true,
|
||||
useCopyToClipboard: (): [unknown, jest.Mock] => [null, mockCopy],
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
toast: { success: jest.fn() },
|
||||
}));
|
||||
|
||||
// The router only knows the stale URL; nuqs has since written ?tab=thread.
|
||||
const wrapper = ({ children }: { children: ReactNode }): JSX.Element => (
|
||||
<MemoryRouter initialEntries={['/trace/abc?tab=overview&spanId=old']}>
|
||||
{children}
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const clickEvent = {
|
||||
preventDefault: jest.fn(),
|
||||
stopPropagation: jest.fn(),
|
||||
} as unknown as MouseEvent<HTMLElement>;
|
||||
|
||||
describe('useCopySpanLink', () => {
|
||||
beforeEach(() => {
|
||||
mockCopy.mockClear();
|
||||
__setSearchParamsGetterForTest(
|
||||
() => new URLSearchParams('tab=thread&spanId=old'),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
__resetSearchParamsGetter();
|
||||
});
|
||||
|
||||
it('builds the link from the live URL, keeping its tab', () => {
|
||||
const { result } = renderHook(() => useCopySpanLink({ span_id: 'new' }), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
result.current.onSpanCopy(clickEvent);
|
||||
|
||||
const link = new URL(mockCopy.mock.calls[0][0]);
|
||||
expect(link.pathname).toBe('/trace/abc');
|
||||
expect(link.searchParams.get('tab')).toBe('thread');
|
||||
expect(link.searchParams.get('spanId')).toBe('new');
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,8 @@ import { MouseEventHandler, useCallback } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
|
||||
|
||||
// Accepts both V2 (spanId) and V3 (span_id) span shapes
|
||||
// TODO: Remove V2 (spanId) support when phasing out V2
|
||||
@@ -15,7 +15,6 @@ interface SpanLike {
|
||||
export const useCopySpanLink = (
|
||||
span?: SpanLike,
|
||||
): { onSpanCopy: MouseEventHandler<HTMLElement> } => {
|
||||
const urlQuery = useUrlQuery();
|
||||
const { pathname } = useLocation();
|
||||
const [, setCopy] = useCopyToClipboard();
|
||||
|
||||
@@ -28,21 +27,22 @@ export const useCopySpanLink = (
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
urlQuery.delete('spanId');
|
||||
const params = getUnstableCurrentSearchParams();
|
||||
params.delete('spanId');
|
||||
|
||||
const id = span.span_id || span.spanId;
|
||||
if (id) {
|
||||
urlQuery.set('spanId', id);
|
||||
params.set('spanId', id);
|
||||
}
|
||||
|
||||
const link = getAbsoluteUrl(`${pathname}?${urlQuery.toString()}`);
|
||||
const link = getAbsoluteUrl(`${pathname}?${params.toString()}`);
|
||||
|
||||
setCopy(link);
|
||||
toast.success('Copied to clipboard', {
|
||||
position: 'top-right',
|
||||
});
|
||||
},
|
||||
[span, urlQuery, pathname, setCopy],
|
||||
[span, pathname, setCopy],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -23,6 +23,11 @@ export default {
|
||||
type: 'metaresource',
|
||||
allowedVerbs: ['list', 'read', 'update'],
|
||||
},
|
||||
{
|
||||
kind: 'saved-view',
|
||||
type: 'metaresource',
|
||||
allowedVerbs: ['create', 'delete', 'list', 'read', 'update'],
|
||||
},
|
||||
{
|
||||
kind: 'subscription',
|
||||
type: 'metaresource',
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { BrandedPermission } from '../types';
|
||||
import { buildPermission } from '../utils';
|
||||
|
||||
export const SavedViewListPermission = buildPermission('list', 'saved-view:*');
|
||||
export const SavedViewCreatePermission = buildPermission(
|
||||
'create',
|
||||
'saved-view:*',
|
||||
);
|
||||
|
||||
export const buildSavedViewReadPermission = (id: string): BrandedPermission =>
|
||||
buildPermission('read', `saved-view:${id}`);
|
||||
export const buildSavedViewUpdatePermission = (id: string): BrandedPermission =>
|
||||
buildPermission('update', `saved-view:${id}`);
|
||||
export const buildSavedViewDeletePermission = (id: string): BrandedPermission =>
|
||||
buildPermission('delete', `saved-view:${id}`);
|
||||
@@ -1,24 +0,0 @@
|
||||
import { QueryOperatorsMultiVal } from 'lib/logql/tokens';
|
||||
|
||||
type Keys = keyof typeof QueryOperatorsMultiVal;
|
||||
type Values = (typeof QueryOperatorsMultiVal)[Keys];
|
||||
|
||||
interface GenerateFilterQueryParams {
|
||||
fieldKey: string;
|
||||
fieldValue: string;
|
||||
type: Values;
|
||||
}
|
||||
export const generateFilterQuery = ({
|
||||
fieldKey,
|
||||
fieldValue,
|
||||
type,
|
||||
}: GenerateFilterQueryParams): string => {
|
||||
let generatedQueryString = `${fieldKey} ${type} `;
|
||||
if (typeof fieldValue === 'number') {
|
||||
generatedQueryString += `(${fieldValue})`;
|
||||
} else {
|
||||
generatedQueryString += `('${fieldValue}')`;
|
||||
}
|
||||
|
||||
return generatedQueryString;
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { MetricRangePayloadV3 } from 'types/api/metrics/getQueryRange';
|
||||
|
||||
import { convertNewDataToOld } from '../convertNewDataToOld';
|
||||
|
||||
describe('convertNewDataToOld', () => {
|
||||
it('carries partial values as epoch-second partialTimestamps', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
resultType: 'time_series',
|
||||
result: [
|
||||
{
|
||||
queryName: 'A',
|
||||
series: [
|
||||
{
|
||||
labels: {},
|
||||
labelsArray: [],
|
||||
values: [
|
||||
{ timestamp: 1_000_000, value: '1' },
|
||||
{ timestamp: 1_060_000, value: '0.2', partial: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
labels: {},
|
||||
labelsArray: [],
|
||||
values: [{ timestamp: 1_000_000, value: '1' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as MetricRangePayloadV3;
|
||||
|
||||
const [partial, complete] = convertNewDataToOld(payload).data.result;
|
||||
|
||||
expect(partial.values).toStrictEqual([
|
||||
[1000, '1'],
|
||||
[1060, '0.2'],
|
||||
]);
|
||||
expect(partial.partialTimestamps).toStrictEqual([1060]);
|
||||
expect(complete).not.toHaveProperty('partialTimestamps');
|
||||
});
|
||||
});
|
||||
@@ -25,11 +25,16 @@ export const convertNewDataToOld = (
|
||||
return [...acc, renderValues];
|
||||
}, []);
|
||||
|
||||
const partialTimestamps = series.values
|
||||
.filter((value) => value.partial)
|
||||
.map((value) => value.timestamp / 1000);
|
||||
|
||||
const result: QueryData = {
|
||||
metric: series.labels,
|
||||
values,
|
||||
queryName: `${item.queryName}`,
|
||||
metaData: series.metaData,
|
||||
...(partialTimestamps.length ? { partialTimestamps } : {}),
|
||||
};
|
||||
|
||||
oldResult.push(result);
|
||||
|
||||
@@ -122,6 +122,27 @@ describe('Tooltip', () => {
|
||||
expect(screen.getByText(expectedTitle)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('marks the header partial when a visible series has a partial bucket at the cursor', () => {
|
||||
const withSeries = (series: unknown[]): uPlot =>
|
||||
Object.assign(createUPlotInstance(0), { series });
|
||||
const partial = { show: true, partialTimestamps: new Set([1]) };
|
||||
|
||||
const { unmount } = renderTooltip({
|
||||
uPlotInstance: withSeries([{}, partial]),
|
||||
});
|
||||
expect(screen.getByTestId('uplot-tooltip-header-partial')).toHaveTextContent(
|
||||
'(partial)',
|
||||
);
|
||||
unmount();
|
||||
|
||||
renderTooltip({
|
||||
uPlotInstance: withSeries([{}, { ...partial, show: false }]),
|
||||
});
|
||||
expect(
|
||||
screen.queryByTestId('uplot-tooltip-header-partial'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('drops the date from the header title for a point on the current day', () => {
|
||||
const todayTimestamp = dayjs().tz('UTC').startOf('hour').unix();
|
||||
const uPlotInstance = createUPlotInstance(0, todayTimestamp);
|
||||
|
||||
@@ -13,6 +13,17 @@
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.headerEnd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.partialLabel {
|
||||
font-style: italic;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.pinnedItem {
|
||||
padding: var(--spacing-4);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTimezone } from 'providers/Timezone';
|
||||
import type uPlot from 'uplot';
|
||||
import { formatTimestampOmittingTodaysDate } from 'utils/timeUtils';
|
||||
|
||||
import type { ExtendedSeries } from '../../../../config/types';
|
||||
import { TooltipContentItem } from '../../../types';
|
||||
import TooltipItem from '../TooltipItem/TooltipItem';
|
||||
import TooltipPinnedBadge from '../TooltipPinnedBadge/TooltipPinnedBadge';
|
||||
@@ -34,7 +35,7 @@ export default function TooltipHeader({
|
||||
const { timezone: userTimezone } = useTimezone();
|
||||
const resolvedTimezone = timezone?.value ?? userTimezone.value;
|
||||
|
||||
const headerTitle = useMemo(() => {
|
||||
const header = useMemo(() => {
|
||||
if (!showTooltipHeader) {
|
||||
return null;
|
||||
}
|
||||
@@ -46,14 +47,21 @@ export default function TooltipHeader({
|
||||
if (timestamp == null) {
|
||||
return null;
|
||||
}
|
||||
return formatTimestampOmittingTodaysDate(
|
||||
timestamp * 1000,
|
||||
resolvedTimezone,
|
||||
dateFormat,
|
||||
);
|
||||
return {
|
||||
title: formatTimestampOmittingTodaysDate(
|
||||
timestamp * 1000,
|
||||
resolvedTimezone,
|
||||
dateFormat,
|
||||
),
|
||||
isPartial: (uPlotInstance.series ?? []).some(
|
||||
(series: ExtendedSeries) =>
|
||||
series.show && series.partialTimestamps?.has(timestamp),
|
||||
),
|
||||
};
|
||||
}, [
|
||||
resolvedTimezone,
|
||||
uPlotInstance.data,
|
||||
uPlotInstance.series,
|
||||
uPlotInstance.cursor.idx,
|
||||
showTooltipHeader,
|
||||
dateFormat,
|
||||
@@ -64,10 +72,22 @@ export default function TooltipHeader({
|
||||
className={Styles.headerContainer}
|
||||
data-testid="uplot-tooltip-header-container"
|
||||
>
|
||||
{showTooltipHeader && headerTitle && (
|
||||
{showTooltipHeader && header && (
|
||||
<div className={cx(Styles.headerRow, headerRowClassName)}>
|
||||
<span>{headerTitle}</span>
|
||||
{isPinned && <TooltipPinnedBadge />}
|
||||
<span>{header.title}</span>
|
||||
{(header.isPartial || isPinned) && (
|
||||
<span className={Styles.headerEnd}>
|
||||
{header.isPartial && (
|
||||
<span
|
||||
className={Styles.partialLabel}
|
||||
data-testid="uplot-tooltip-header-partial"
|
||||
>
|
||||
(partial)
|
||||
</span>
|
||||
)}
|
||||
{isPinned && <TooltipPinnedBadge />}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { SeriesVisibilityItem } from 'lib/visualization/panels/types';
|
||||
import { getStoredSeriesVisibility } from 'lib/visualization/panels/utils/legendVisibilityUtils';
|
||||
import { partialPointsDrawHook } from 'lib/uPlotV2/hooks/partialPointsDrawHook';
|
||||
import { ThresholdsDrawHookOptions } from 'lib/uPlotV2/hooks/types';
|
||||
import { thresholdsDrawHook } from 'lib/uPlotV2/hooks/useThresholdsDrawHook';
|
||||
import { resolveSeriesVisibility } from 'lib/uPlotV2/utils/seriesVisibility';
|
||||
@@ -81,6 +82,8 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
|
||||
private thresholds: Record<string, ThresholdsDrawHookOptions> = {};
|
||||
|
||||
private hasPartialPointsHook = false;
|
||||
|
||||
private tzDate: ((timestamp: number) => Date) | undefined;
|
||||
|
||||
private id: string;
|
||||
@@ -177,6 +180,10 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
*/
|
||||
addSeries(props: SeriesProps): void {
|
||||
this.series.push(new UPlotSeriesBuilder(props));
|
||||
if (props.partialTimestamps?.size && !this.hasPartialPointsHook) {
|
||||
this.hasPartialPointsHook = true;
|
||||
this.addHook('draw', partialPointsDrawHook);
|
||||
}
|
||||
}
|
||||
|
||||
getSeriesSpanGapsOptions(): SeriesSpanGapsOption[] {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { calculateWidthBasedOnStepInterval } from 'lib/uPlotV2/utils';
|
||||
import uPlot, { Series } from 'uplot';
|
||||
|
||||
import { resolveFillOpacity, toAlphaHex } from '../utils/fillOpacity';
|
||||
import { reportBarRect } from '../utils/barRect';
|
||||
import { generateGradientFill } from '../utils/generateGradientFill';
|
||||
import { isolatedPointFilter } from '../utils/seriesPointsFilter';
|
||||
import {
|
||||
@@ -218,7 +219,14 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
|
||||
}
|
||||
|
||||
getConfig(): ExtendedSeries {
|
||||
const { scaleKey, label, spanGaps, show = true, metric } = this.props;
|
||||
const {
|
||||
scaleKey,
|
||||
label,
|
||||
spanGaps,
|
||||
show = true,
|
||||
metric,
|
||||
partialTimestamps,
|
||||
} = this.props;
|
||||
|
||||
const resolvedLineColor = this.getLineColor();
|
||||
|
||||
@@ -246,6 +254,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
|
||||
...pathConfig,
|
||||
points: Object.keys(pointsConfig).length > 0 ? pointsConfig : undefined,
|
||||
metric,
|
||||
...(partialTimestamps?.size ? { partialTimestamps } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -358,6 +367,7 @@ function getBarPathBuilder({
|
||||
builders[barsCfgKey] = barsPathBuilderFactory({
|
||||
size: [barWidthFactor, effectiveBarMaxWidth],
|
||||
align: barAlignment,
|
||||
each: reportBarRect,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -372,6 +382,7 @@ function getBarPathBuilder({
|
||||
builders[barsCfgKey] = barsPathBuilderFactory({
|
||||
size: [barWidthFactor, barMaxWidth],
|
||||
align: barAlignment,
|
||||
each: reportBarRect,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -650,4 +650,20 @@ describe('UPlotConfigBuilder stacking', () => {
|
||||
|
||||
expect(builder.getConfig().bands).toStrictEqual([{ series: [1, 3] }]);
|
||||
});
|
||||
|
||||
it('registers the partial points draw hook once, only for series with partial timestamps', () => {
|
||||
const props: SeriesProps = {
|
||||
scaleKey: 'y',
|
||||
label: 'Requests',
|
||||
colorMapping: {},
|
||||
drawStyle: DrawStyle.Line,
|
||||
};
|
||||
const builder = new UPlotConfigBuilder({ id: 'partial' });
|
||||
builder.addSeries(props);
|
||||
expect(builder.getConfig().hooks?.draw ?? []).toHaveLength(0);
|
||||
|
||||
builder.addSeries({ ...props, partialTimestamps: new Set([40]) });
|
||||
builder.addSeries({ ...props, partialTimestamps: new Set([40]) });
|
||||
expect(builder.getConfig().hooks?.draw).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { themeColors } from 'constants/theme';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { reportBarRect } from '../../utils/barRect';
|
||||
import { isolatedPointFilter } from '../../utils/seriesPointsFilter';
|
||||
import type { SeriesProps } from '../types';
|
||||
import { DrawStyle, FillMode, LineInterpolation, LineStyle } from '../types';
|
||||
@@ -398,4 +399,29 @@ describe('UPlotSeriesBuilder', () => {
|
||||
|
||||
expect(builder.getConfig().fill).toBeUndefined();
|
||||
});
|
||||
|
||||
it('exposes partial timestamps only when there are some', () => {
|
||||
const partialTimestamps = new Set([40]);
|
||||
|
||||
expect(
|
||||
new UPlotSeriesBuilder(createBaseProps({ partialTimestamps })).getConfig(),
|
||||
).toHaveProperty('partialTimestamps', partialTimestamps);
|
||||
expect(
|
||||
new UPlotSeriesBuilder(
|
||||
createBaseProps({ partialTimestamps: new Set() }),
|
||||
).getConfig(),
|
||||
).not.toHaveProperty('partialTimestamps');
|
||||
});
|
||||
|
||||
it('reports bar rects from bar path builders', () => {
|
||||
const config = new UPlotSeriesBuilder(
|
||||
createBaseProps({ drawStyle: DrawStyle.Bar, barMaxWidth: 321 }),
|
||||
).getConfig();
|
||||
|
||||
config.paths?.({} as uPlot, 1, 0, 1);
|
||||
|
||||
expect(uPlot.paths.bars).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ each: reportBarRect }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -201,6 +201,7 @@ export enum FillMode {
|
||||
|
||||
export type ExtendedSeries = Series & {
|
||||
metric?: { [key: string]: string };
|
||||
partialTimestamps?: Set<number>;
|
||||
};
|
||||
|
||||
export interface SeriesProps extends LineConfig, PointsConfig, BarConfig {
|
||||
@@ -229,6 +230,8 @@ export interface SeriesProps extends LineConfig, PointsConfig, BarConfig {
|
||||
isDarkMode?: boolean;
|
||||
stepInterval?: number;
|
||||
metric?: { [key: string]: string };
|
||||
/** Epoch-second x values whose bucket doesn't cover a full step; marked with a dotted ring. */
|
||||
partialTimestamps?: Set<number>;
|
||||
}
|
||||
|
||||
export interface LegendItem {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import type { ExtendedSeries } from '../../config/types';
|
||||
import { reportBarRect } from '../../utils/barRect';
|
||||
import { partialPointsDrawHook } from '../partialPointsDrawHook';
|
||||
|
||||
function createPlot(
|
||||
series: Partial<ExtendedSeries>,
|
||||
values: (number | null)[] = [1, 2, 3],
|
||||
): { u: uPlot; ctx: Record<string, jest.Mock> } {
|
||||
const ctx = {
|
||||
save: jest.fn(),
|
||||
restore: jest.fn(),
|
||||
beginPath: jest.fn(),
|
||||
rect: jest.fn(),
|
||||
clip: jest.fn(),
|
||||
arc: jest.fn(),
|
||||
fill: jest.fn(),
|
||||
stroke: jest.fn(),
|
||||
setLineDash: jest.fn(),
|
||||
strokeRect: jest.fn(),
|
||||
};
|
||||
const xValues = [10, 20, 30];
|
||||
const u = {
|
||||
ctx,
|
||||
bbox: { left: 0, top: 0, width: 100, height: 100 },
|
||||
data: [xValues, values],
|
||||
series: [{}, { show: true, stroke: 'red', points: { size: 4 }, ...series }],
|
||||
valToIdx: (value: number): number => xValues.indexOf(value),
|
||||
valToPos: (value: number): number => value,
|
||||
} as unknown as uPlot;
|
||||
return { u, ctx };
|
||||
}
|
||||
|
||||
describe('partialPointsDrawHook', () => {
|
||||
it('draws a dot and a dotted ring on each partial point', () => {
|
||||
const { u, ctx } = createPlot({ partialTimestamps: new Set([30]) });
|
||||
|
||||
partialPointsDrawHook(u);
|
||||
|
||||
expect(ctx.arc).toHaveBeenCalledTimes(2);
|
||||
expect(ctx.arc).toHaveBeenCalledWith(30, 3, 2, 0, 2 * Math.PI);
|
||||
expect(ctx.arc).toHaveBeenCalledWith(30, 3, 5, 0, 2 * Math.PI);
|
||||
expect(ctx.setLineDash).toHaveBeenCalledWith([2, 2]);
|
||||
expect(ctx.restore).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips hidden series, null values and timestamps not in the data', () => {
|
||||
const hidden = createPlot({ show: false, partialTimestamps: new Set([30]) });
|
||||
const missing = createPlot({ partialTimestamps: new Set([30]) }, [
|
||||
1,
|
||||
2,
|
||||
null,
|
||||
]);
|
||||
const unknown = createPlot({ partialTimestamps: new Set([40]) });
|
||||
|
||||
[hidden, missing, unknown].forEach(({ u, ctx }) => {
|
||||
partialPointsDrawHook(u);
|
||||
expect(ctx.arc).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('draws a dotted outline around a partial bar', () => {
|
||||
const { u, ctx } = createPlot({
|
||||
partialTimestamps: new Set([30]),
|
||||
paths: (plot, seriesIdx, idx) => {
|
||||
reportBarRect(plot, seriesIdx, idx, 10, 20, 8, 30);
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
partialPointsDrawHook(u);
|
||||
|
||||
expect(ctx.strokeRect).toHaveBeenCalledWith(7, 17, 14, 36);
|
||||
expect(ctx.arc).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
89
frontend/src/lib/uPlotV2/hooks/partialPointsDrawHook.ts
Normal file
89
frontend/src/lib/uPlotV2/hooks/partialPointsDrawHook.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { ExtendedSeries } from '../config/types';
|
||||
import { BarRect, measureBar } from '../utils/barRect';
|
||||
|
||||
const DEFAULT_POINT_SIZE = 5;
|
||||
const RING_GAP = 3;
|
||||
const RING_WIDTH = 1.5;
|
||||
const RING_DASH = [2, 2];
|
||||
|
||||
function drawRing(
|
||||
u: uPlot,
|
||||
series: ExtendedSeries,
|
||||
idx: number,
|
||||
value: number,
|
||||
): void {
|
||||
const { ctx } = u;
|
||||
const { pxRatio } = uPlot;
|
||||
const cx = u.valToPos(u.data[0][idx], 'x', true);
|
||||
const cy = u.valToPos(value, series.scale ?? 'y', true);
|
||||
const radius = ((series.points?.size ?? DEFAULT_POINT_SIZE) / 2) * pxRatio;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, radius, 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
|
||||
ctx.lineWidth = RING_WIDTH * pxRatio;
|
||||
ctx.setLineDash(RING_DASH.map((segment) => segment * pxRatio));
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, radius + RING_GAP * pxRatio, 0, 2 * Math.PI);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function drawBarOutline(u: uPlot, rect: BarRect): void {
|
||||
const gap = RING_GAP * uPlot.pxRatio;
|
||||
u.ctx.lineWidth = RING_WIDTH * uPlot.pxRatio;
|
||||
u.ctx.setLineDash(RING_DASH.map((segment) => segment * uPlot.pxRatio));
|
||||
u.ctx.strokeRect(
|
||||
rect.left - gap,
|
||||
rect.top - gap,
|
||||
rect.width + 2 * gap,
|
||||
rect.height + 2 * gap,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks each series' partial buckets: a dotted outline around bars, a dot and dotted
|
||||
* ring on line and point series.
|
||||
*/
|
||||
export function partialPointsDrawHook(u: uPlot): void {
|
||||
const { ctx, bbox } = u;
|
||||
const xValues = u.data[0];
|
||||
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(bbox.left, bbox.top, bbox.width, bbox.height);
|
||||
ctx.clip();
|
||||
|
||||
for (let seriesIdx = 1; seriesIdx < u.series.length; seriesIdx += 1) {
|
||||
const series = u.series[seriesIdx] as ExtendedSeries;
|
||||
if (!series.show || !series.partialTimestamps) {
|
||||
continue;
|
||||
}
|
||||
const color =
|
||||
typeof series.stroke === 'function'
|
||||
? series.stroke(u, seriesIdx)
|
||||
: (series.stroke ?? '');
|
||||
ctx.fillStyle = color;
|
||||
ctx.strokeStyle = color;
|
||||
ctx.globalAlpha = series.alpha ?? 1;
|
||||
|
||||
series.partialTimestamps.forEach((timestamp) => {
|
||||
const idx = u.valToIdx(timestamp);
|
||||
const value = u.data[seriesIdx][idx];
|
||||
if (xValues[idx] !== timestamp || value == null) {
|
||||
return;
|
||||
}
|
||||
ctx.setLineDash([]);
|
||||
const bar = measureBar(u, seriesIdx, idx);
|
||||
if (bar) {
|
||||
drawBarOutline(u, bar);
|
||||
} else {
|
||||
drawRing(u, series, idx, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
30
frontend/src/lib/uPlotV2/utils/__tests__/barRect.test.ts
Normal file
30
frontend/src/lib/uPlotV2/utils/__tests__/barRect.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import { measureBar, reportBarRect } from '../barRect';
|
||||
|
||||
function plotWithPaths(paths?: uPlot.Series['paths']): uPlot {
|
||||
return { series: [{}, { paths }] } as unknown as uPlot;
|
||||
}
|
||||
|
||||
describe('measureBar', () => {
|
||||
it('returns the rect the bar paths report for that index', () => {
|
||||
const paths = jest.fn((u: uPlot, seriesIdx: number, idx: number) => {
|
||||
reportBarRect(u, seriesIdx, idx, 10, 20, 8, 30);
|
||||
return null;
|
||||
});
|
||||
const u = plotWithPaths(paths);
|
||||
|
||||
expect(measureBar(u, 1, 4)).toStrictEqual({
|
||||
left: 10,
|
||||
top: 20,
|
||||
width: 8,
|
||||
height: 30,
|
||||
});
|
||||
expect(paths).toHaveBeenCalledWith(u, 1, 4, 4);
|
||||
});
|
||||
|
||||
it('returns null for series whose paths report no bar', () => {
|
||||
expect(measureBar(plotWithPaths(jest.fn(() => null)), 1, 0)).toBeNull();
|
||||
expect(measureBar(plotWithPaths(), 1, 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
45
frontend/src/lib/uPlotV2/utils/barRect.ts
Normal file
45
frontend/src/lib/uPlotV2/utils/barRect.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
export interface BarRect {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
let onBarRect: ((rect: BarRect) => void) | null = null;
|
||||
|
||||
/** `each` callback for bar path builders; reports bar rects while {@link measureBar} runs. */
|
||||
// eslint-disable-next-line max-params
|
||||
export function reportBarRect(
|
||||
_u: uPlot,
|
||||
_seriesIdx: number,
|
||||
_idx: number,
|
||||
left: number,
|
||||
top: number,
|
||||
width: number,
|
||||
height: number,
|
||||
): void {
|
||||
onBarRect?.({ left, top, width, height });
|
||||
}
|
||||
|
||||
/**
|
||||
* Canvas rect of one bar, from re-running the series' paths for that index.
|
||||
* `null` for series not drawn as bars.
|
||||
*/
|
||||
export function measureBar(
|
||||
u: uPlot,
|
||||
seriesIdx: number,
|
||||
idx: number,
|
||||
): BarRect | null {
|
||||
let rect: BarRect | null = null;
|
||||
onBarRect = (measured): void => {
|
||||
rect = measured;
|
||||
};
|
||||
try {
|
||||
u.series[seriesIdx].paths?.(u, seriesIdx, idx, idx);
|
||||
} finally {
|
||||
onBarRect = null;
|
||||
}
|
||||
return rect;
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { resolveSeriesLabelV5 } from 'pages/DashboardPage/DashboardContainer/Panels/utils/resolveSeriesLabel';
|
||||
import type { PanelSeries } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
import {
|
||||
getPartialTimestamps,
|
||||
hasSingleVisiblePoint,
|
||||
toClickPluginPayload,
|
||||
} from 'pages/DashboardPage/DashboardContainer/queryV5/uplotData';
|
||||
@@ -155,6 +156,7 @@ function addSeries({
|
||||
fillOpacity,
|
||||
isDarkMode,
|
||||
metric: s.labels,
|
||||
partialTimestamps: getPartialTimestamps(s.values),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { DashboardtypesBarChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { UTC_TIMEZONE } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import type { ExtendedSeries } from 'lib/uPlotV2/config/types';
|
||||
import { PanelMode } from 'lib/visualization/panels/types';
|
||||
|
||||
import { buildBarChartConfig } from '../buildConfig';
|
||||
|
||||
describe('buildBarChartConfig partial buckets', () => {
|
||||
it('passes partial timestamps (epoch seconds) to the series', () => {
|
||||
const config = buildBarChartConfig({
|
||||
panelId: 'p1',
|
||||
spec: {} as DashboardtypesBarChartPanelSpecDTO,
|
||||
builderQueries: [],
|
||||
series: [
|
||||
{
|
||||
queryName: 'A',
|
||||
legend: 'A',
|
||||
labels: {},
|
||||
values: [
|
||||
{ timestamp: 1000, value: 1 },
|
||||
{ timestamp: 61000, value: 0.2, partial: true },
|
||||
],
|
||||
kind: 'series',
|
||||
aggregation: { index: 0, alias: 'A' },
|
||||
},
|
||||
],
|
||||
isDarkMode: false,
|
||||
timezone: UTC_TIMEZONE,
|
||||
panelMode: PanelMode.DASHBOARD_VIEW,
|
||||
}).getConfig().series?.[1] as ExtendedSeries;
|
||||
|
||||
expect(config.partialTimestamps).toStrictEqual(new Set([61]));
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,10 @@ import {
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/utils/baseConfigBuilder';
|
||||
import { resolveSeriesLabelV5 } from 'pages/DashboardPage/DashboardContainer/Panels/utils/resolveSeriesLabel';
|
||||
import type { PanelSeries } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
import { toClickPluginPayload } from 'pages/DashboardPage/DashboardContainer/queryV5/uplotData';
|
||||
import {
|
||||
getPartialTimestamps,
|
||||
toClickPluginPayload,
|
||||
} from 'pages/DashboardPage/DashboardContainer/queryV5/uplotData';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { DrawStyle } from 'lib/uPlotV2/config/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
@@ -102,6 +105,7 @@ function addSeries({
|
||||
isDarkMode,
|
||||
stepInterval,
|
||||
metric: s.labels,
|
||||
partialTimestamps: getPartialTimestamps(s.values),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -217,8 +217,6 @@ function ListPanelRenderer({
|
||||
log={logDetail.activeLog}
|
||||
selectedTab={logDetail.selectedTab}
|
||||
onClose={logDetail.onClose}
|
||||
onAddToQuery={logDetail.onAddToQuery}
|
||||
onClickActionItem={logDetail.onAddToQuery}
|
||||
onNavigateLog={logDetail.onNavigateLog}
|
||||
logs={logDetail.logs}
|
||||
isListViewPanel
|
||||
|
||||
@@ -18,7 +18,6 @@ export interface ListLogDetail {
|
||||
selectedTab: VIEWS;
|
||||
logs: ILog[];
|
||||
onClose: () => void;
|
||||
onAddToQuery: ReturnType<typeof useLogDetailHandlers>['onAddToQuery'];
|
||||
onNavigateLog: (log: ILog) => void;
|
||||
}
|
||||
|
||||
@@ -49,13 +48,8 @@ export function useListRowInteraction({
|
||||
const isLogs = signal === TelemetrytypesSignalDTO.logs;
|
||||
const isTraces = signal === TelemetrytypesSignalDTO.traces;
|
||||
|
||||
const {
|
||||
activeLog,
|
||||
onAddToQuery,
|
||||
selectedTab,
|
||||
handleSetActiveLog,
|
||||
handleCloseLogDetail,
|
||||
} = useLogDetailHandlers();
|
||||
const { activeLog, selectedTab, handleSetActiveLog, handleCloseLogDetail } =
|
||||
useLogDetailHandlers();
|
||||
|
||||
const logs = useMemo(
|
||||
() => (isLogs ? rows.map(rawRowToILog) : []),
|
||||
@@ -90,7 +84,6 @@ export function useListRowInteraction({
|
||||
selectedTab: selectedTab ?? VIEW_TYPES.OVERVIEW,
|
||||
logs,
|
||||
onClose: handleCloseLogDetail,
|
||||
onAddToQuery,
|
||||
onNavigateLog: handleSetActiveLog,
|
||||
}
|
||||
: null,
|
||||
@@ -100,7 +93,6 @@ export function useListRowInteraction({
|
||||
selectedTab,
|
||||
logs,
|
||||
handleCloseLogDetail,
|
||||
onAddToQuery,
|
||||
handleSetActiveLog,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { DashboardtypesTimeSeriesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { UTC_TIMEZONE } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import type { ExtendedSeries } from 'lib/uPlotV2/config/types';
|
||||
import { PanelMode } from 'lib/visualization/panels/types';
|
||||
import type { PanelSeries } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
|
||||
@@ -74,3 +75,27 @@ describe('buildTimeSeriesConfig spanGaps', () => {
|
||||
).toStrictEqual([10]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildTimeSeriesConfig partial buckets', () => {
|
||||
it('passes partial timestamps (epoch seconds) to the series', () => {
|
||||
const config = buildTimeSeriesConfig({
|
||||
panelId: 'p1',
|
||||
spec: {} as DashboardtypesTimeSeriesPanelSpecDTO,
|
||||
builderQueries: [],
|
||||
series: [
|
||||
{
|
||||
...series[0],
|
||||
values: [
|
||||
{ timestamp: 1000, value: 1 },
|
||||
{ timestamp: 61000, value: 0.2, partial: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
isDarkMode: false,
|
||||
timezone: UTC_TIMEZONE,
|
||||
panelMode: PanelMode.DASHBOARD_VIEW,
|
||||
}).getConfig().series?.[1] as ExtendedSeries;
|
||||
|
||||
expect(config.partialTimestamps).toStrictEqual(new Set([61]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import { resolveSpanGaps } from 'pages/DashboardPage/DashboardContainer/Panels/u
|
||||
import { resolveSeriesLabelV5 } from 'pages/DashboardPage/DashboardContainer/Panels/utils/resolveSeriesLabel';
|
||||
import type { PanelSeries } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
import {
|
||||
getPartialTimestamps,
|
||||
hasSingleVisiblePoint,
|
||||
toClickPluginPayload,
|
||||
} from 'pages/DashboardPage/DashboardContainer/queryV5/uplotData';
|
||||
@@ -149,6 +150,7 @@ function addSeries({
|
||||
fillMode,
|
||||
isDarkMode,
|
||||
metric: s.labels,
|
||||
partialTimestamps: getPartialTimestamps(s.values),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PanelSeries } from '../types';
|
||||
import {
|
||||
getPartialTimestamps,
|
||||
hasSingleVisiblePoint,
|
||||
prepareAlignedData,
|
||||
toClickPluginPayload,
|
||||
@@ -85,6 +86,22 @@ describe('hasSingleVisiblePoint', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPartialTimestamps', () => {
|
||||
it('returns partial timestamps in epoch seconds', () => {
|
||||
expect(
|
||||
getPartialTimestamps([
|
||||
{ timestamp: 1_000_500, value: 1, partial: true },
|
||||
{ timestamp: 2_000_000, value: 2 },
|
||||
{ timestamp: 3_000_000, value: 3, partial: true },
|
||||
]),
|
||||
).toStrictEqual(new Set([1000, 3000]));
|
||||
});
|
||||
|
||||
it('returns undefined when nothing is partial', () => {
|
||||
expect(getPartialTimestamps([{ timestamp: 1000, value: 1 }])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('toClickPluginPayload', () => {
|
||||
it('produces the tuple-shaped legacy result the shared click plugin reads', () => {
|
||||
const payload = toClickPluginPayload([
|
||||
|
||||
@@ -41,6 +41,19 @@ export function prepareAlignedData(series: PanelSeries[]): uPlot.AlignedData {
|
||||
return [timestamps, ...yValues] as uPlot.AlignedData;
|
||||
}
|
||||
|
||||
/** Epoch-second x values of the series' partial buckets; `undefined` when none are partial. */
|
||||
export function getPartialTimestamps(
|
||||
values: PanelSeriesPoint[],
|
||||
): Set<number> | undefined {
|
||||
const timestamps = new Set<number>();
|
||||
values.forEach((point) => {
|
||||
if (point.partial) {
|
||||
timestamps.add(Math.floor(point.timestamp / 1000));
|
||||
}
|
||||
});
|
||||
return timestamps.size ? timestamps : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the series has at most one finite point — can't draw a line, so renderers degrade to
|
||||
* points (V1 `hasSingleVisiblePoint` parity).
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import { Tooltip, TooltipProps } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './EntityMetadataItem.module.scss';
|
||||
|
||||
interface EntityMetadataItemProps {
|
||||
tooltip: string;
|
||||
tooltip: TooltipProps['title'];
|
||||
icon?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -8,3 +8,12 @@
|
||||
// Keep Typography.Text at the row's size (it reads this var, not inherited font-size).
|
||||
--typography-font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.tokenTooltip {
|
||||
composes: tooltipBody from '../shared.module.scss';
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.tokenTooltipTitle {
|
||||
padding-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { CalendarClock, Server, Timer } from '@signozhq/icons';
|
||||
import { CalendarClock, Coins, Landmark, Server, Timer } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import cx from 'classnames';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
|
||||
|
||||
import {
|
||||
AiTokenCounts,
|
||||
formatCost,
|
||||
formatTokens,
|
||||
getTotalInputTokens,
|
||||
hasValue,
|
||||
} from '../utils/aiTraceUtils';
|
||||
import EntityMetadataItem from './EntityMetadataItem';
|
||||
import TokenUsageTooltip from './TokenUsageTooltip';
|
||||
|
||||
import styles from './EntityMetadataRow.module.scss';
|
||||
|
||||
@@ -16,6 +24,8 @@ interface EntityMetadataRowProps {
|
||||
execTimePercent?: number;
|
||||
timestamp?: string;
|
||||
statusCode?: string | number;
|
||||
tokens?: AiTokenCounts;
|
||||
cost?: number | null;
|
||||
}
|
||||
|
||||
const ICON_SIZE = 14;
|
||||
@@ -32,8 +42,11 @@ function EntityMetadataRow({
|
||||
execTimePercent,
|
||||
timestamp,
|
||||
statusCode,
|
||||
tokens,
|
||||
cost,
|
||||
}: EntityMetadataRowProps): JSX.Element {
|
||||
const entityLabel = entity === 'trace' ? 'Trace' : 'Span';
|
||||
const totalInput = tokens && getTotalInputTokens(tokens);
|
||||
const durationTooltip =
|
||||
entity === 'trace' ? 'Trace Duration' : 'Span Duration';
|
||||
// Single source of duration formatting so both rows label units identically.
|
||||
@@ -91,6 +104,24 @@ function EntityMetadataRow({
|
||||
<HttpStatusBadge statusCode={statusCode} />
|
||||
</EntityMetadataItem>
|
||||
)}
|
||||
|
||||
{tokens && (
|
||||
<EntityMetadataItem
|
||||
tooltip={<TokenUsageTooltip tokens={tokens} />}
|
||||
icon={<Coins size={ICON_SIZE} />}
|
||||
>
|
||||
Tokens: {formatTokens(totalInput)} → {formatTokens(tokens.output)}
|
||||
</EntityMetadataItem>
|
||||
)}
|
||||
|
||||
{hasValue(cost) && (
|
||||
<EntityMetadataItem
|
||||
tooltip="Total cost"
|
||||
icon={<Landmark size={ICON_SIZE} />}
|
||||
>
|
||||
Cost — {formatCost(cost)}
|
||||
</EntityMetadataItem>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -102,6 +133,8 @@ EntityMetadataRow.defaultProps = {
|
||||
execTimePercent: undefined,
|
||||
timestamp: undefined,
|
||||
statusCode: undefined,
|
||||
tokens: undefined,
|
||||
cost: undefined,
|
||||
};
|
||||
|
||||
export default EntityMetadataRow;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import TokenBreakdown from '../TokenBreakdown/TokenBreakdown';
|
||||
import { AiTokenCounts } from '../utils/aiTraceUtils';
|
||||
|
||||
import styles from './EntityMetadataRow.module.scss';
|
||||
|
||||
interface TokenUsageTooltipProps {
|
||||
tokens: AiTokenCounts;
|
||||
}
|
||||
|
||||
function TokenUsageTooltip({ tokens }: TokenUsageTooltipProps): JSX.Element {
|
||||
const { input, output, cacheRead, cacheWrite, reasoning } = tokens;
|
||||
|
||||
return (
|
||||
<div className={styles.tokenTooltip}>
|
||||
<Typography.Text
|
||||
size="small"
|
||||
weight="normal"
|
||||
className={styles.tokenTooltipTitle}
|
||||
>
|
||||
Usage Breakdown
|
||||
</Typography.Text>
|
||||
<TokenBreakdown
|
||||
input={input}
|
||||
output={output}
|
||||
cacheRead={cacheRead}
|
||||
cacheWrite={cacheWrite}
|
||||
reasoning={reasoning}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TokenUsageTooltip;
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link2 } from '@signozhq/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import KeyValueLabel from 'periscope/components/KeyValueLabel';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import EntityMetadataRow from '../EntityMetadata/EntityMetadataRow';
|
||||
import { getAIUsageDetails, convertToAiTokens } from '../utils/aiTraceUtils';
|
||||
import { HIGHLIGHTED_OPTIONS } from './config';
|
||||
import {
|
||||
LinkedSpansPanel,
|
||||
@@ -31,6 +33,10 @@ function SpanSummary({
|
||||
}: SpanSummaryProps): JSX.Element {
|
||||
const percentile = useSpanPercentile(selectedSpan);
|
||||
const linkedSpans = useLinkedSpans((selectedSpan as any).references);
|
||||
const aiUsage = useMemo(
|
||||
() => getAIUsageDetails(selectedSpan)?.usage,
|
||||
[selectedSpan],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -63,6 +69,8 @@ function SpanSummary({
|
||||
: undefined
|
||||
}
|
||||
timestamp={dayjs(selectedSpan.timestamp).format('HH:mm:ss — MMM D, YYYY')}
|
||||
tokens={aiUsage && convertToAiTokens(aiUsage)}
|
||||
cost={aiUsage?.cost}
|
||||
/>
|
||||
|
||||
<div className={styles.spanInfoItem}>
|
||||
|
||||
@@ -95,4 +95,26 @@ describe('SpanSummary', () => {
|
||||
|
||||
expect(screen.queryByText(/of total exec time/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders tokens and cost from the gen_ai usage attributes', () => {
|
||||
const span = {
|
||||
...createMockSpan(),
|
||||
attributes: {
|
||||
'gen_ai.usage.input_tokens': 1000,
|
||||
'gen_ai.usage.output_tokens': 9,
|
||||
'signoz.gen_ai.usage.tokens.cost': 0.000009,
|
||||
},
|
||||
};
|
||||
render(<SpanSummary selectedSpan={span} />);
|
||||
|
||||
expect(screen.getByText(/Tokens: 1,000 → 9/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Cost — \$ 0.000009/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits tokens and cost for non-AI spans', () => {
|
||||
render(<SpanSummary selectedSpan={createMockSpan()} />);
|
||||
|
||||
expect(screen.queryByText(/Tokens:/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Cost —/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { getAiSpanKind } from '../utils/aiTraceUtils';
|
||||
import { AI_SPAN_BADGES } from './constants';
|
||||
|
||||
interface SpanHintBadgeProps {
|
||||
span: SpanV3;
|
||||
}
|
||||
|
||||
function SpanHintBadge({ span }: SpanHintBadgeProps): JSX.Element | null {
|
||||
const aiSpanKind = getAiSpanKind(span);
|
||||
|
||||
if (aiSpanKind) {
|
||||
const { label, color } = AI_SPAN_BADGES[aiSpanKind];
|
||||
return (
|
||||
<span style={{ display: 'inline-flex' }}>
|
||||
<Badge
|
||||
variant="outlined"
|
||||
color={color}
|
||||
testId={`span-hint-badge-${aiSpanKind}`}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (!span.response_status_code) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <HttpStatusBadge statusCode={span.response_status_code} />;
|
||||
}
|
||||
|
||||
export default SpanHintBadge;
|
||||
12
frontend/src/pages/TraceDetailsV3/SpanHintBadge/constants.ts
Normal file
12
frontend/src/pages/TraceDetailsV3/SpanHintBadge/constants.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { BadgeColorType } from '@signozhq/ui/badge';
|
||||
|
||||
import { AiSpanKind } from '../utils/aiTraceUtils';
|
||||
|
||||
export const AI_SPAN_BADGES: Record<
|
||||
AiSpanKind,
|
||||
{ label: string; color: BadgeColorType }
|
||||
> = {
|
||||
[AiSpanKind.Llm]: { label: 'LLM', color: 'success' },
|
||||
[AiSpanKind.Tool]: { label: 'TOOL', color: 'warning' },
|
||||
[AiSpanKind.Agent]: { label: 'AGENT', color: 'highlight-danger' },
|
||||
};
|
||||
@@ -8,16 +8,12 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 3px;
|
||||
padding: 2px 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--l2-foreground);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.name {
|
||||
|
||||
@@ -9,28 +9,17 @@
|
||||
}
|
||||
|
||||
.content {
|
||||
font-size: 12px;
|
||||
color: var(--l1-foreground);
|
||||
composes: tooltipBody from '../shared.module.scss';
|
||||
min-width: 223px;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
.title {
|
||||
composes: tooltipTitle from '../shared.module.scss';
|
||||
}
|
||||
|
||||
.row {
|
||||
line-height: 1.5;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.preview {
|
||||
// container for additional preview rows
|
||||
}
|
||||
|
||||
.previewKey {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.previewValue {
|
||||
color: var(--l1-foreground);
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: var(--spacing-2);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { convertTimeToRelevantUnit } from 'utils/traceUtils';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useTraceStore } from 'pages/TraceDetailsV3/stores/traceStore';
|
||||
@@ -7,6 +8,11 @@ import { useMemo } from 'react';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
import { toFixed } from 'utils/toFixed';
|
||||
|
||||
import { getAIUsageDetails, SpanAiDetails } from '../utils/aiTraceUtils';
|
||||
import SpanUsageBreakdown from './SpanUsageBreakdown';
|
||||
import TraceTooltipRow from '../TraceTooltip/TraceTooltipRow';
|
||||
import TraceTooltipSection from '../TraceTooltip/TraceTooltipSection';
|
||||
|
||||
import styles from './SpanHoverCard.module.scss';
|
||||
|
||||
/**
|
||||
@@ -33,6 +39,7 @@ export interface SpanTooltipContentProps {
|
||||
relativeStartMs: number;
|
||||
durationMs: number;
|
||||
previewRows?: SpanPreviewRow[];
|
||||
ai?: SpanAiDetails;
|
||||
}
|
||||
|
||||
export function SpanTooltipContent({
|
||||
@@ -42,33 +49,60 @@ export function SpanTooltipContent({
|
||||
relativeStartMs,
|
||||
durationMs,
|
||||
previewRows,
|
||||
ai,
|
||||
}: SpanTooltipContentProps): JSX.Element {
|
||||
const { time: formattedDuration, timeUnitName } =
|
||||
convertTimeToRelevantUnit(durationMs);
|
||||
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
<div className={styles.name} style={{ color }}>
|
||||
{spanName}
|
||||
</div>
|
||||
<div className={styles.row}>status: {hasError ? 'error' : 'ok'}</div>
|
||||
<div className={styles.row}>start: {toFixed(relativeStartMs, 2)} ms</div>
|
||||
<div className={styles.row}>
|
||||
duration: {toFixed(formattedDuration, 2)} {timeUnitName}
|
||||
<div className={styles.header}>
|
||||
<Typography.Text
|
||||
className={styles.title}
|
||||
size="small"
|
||||
weight="normal"
|
||||
style={{ color }}
|
||||
>
|
||||
{spanName}
|
||||
</Typography.Text>
|
||||
{ai?.usage && (
|
||||
<Typography.Text size="small" weight="normal">
|
||||
Usage Breakdown
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
{ai?.usage && <SpanUsageBreakdown usage={ai.usage} />}
|
||||
<TraceTooltipSection>
|
||||
{ai?.model && <TraceTooltipRow label="model" value={ai.model} />}
|
||||
{ai?.toolName && <TraceTooltipRow label="tool" value={ai.toolName} />}
|
||||
{ai?.agentName && <TraceTooltipRow label="agent" value={ai.agentName} />}
|
||||
<TraceTooltipRow
|
||||
label="status"
|
||||
value={hasError ? 'error' : 'ok'}
|
||||
testId="span-hover-card-status"
|
||||
/>
|
||||
<TraceTooltipRow
|
||||
label="start"
|
||||
value={`${toFixed(relativeStartMs, 2)} ms`}
|
||||
testId="span-hover-card-start"
|
||||
/>
|
||||
<TraceTooltipRow
|
||||
label="duration"
|
||||
value={`${toFixed(formattedDuration, 2)} ${timeUnitName}`}
|
||||
testId="span-hover-card-duration"
|
||||
/>
|
||||
</TraceTooltipSection>
|
||||
{previewRows && previewRows.length > 0 && (
|
||||
<div className={styles.preview}>
|
||||
<TraceTooltipSection>
|
||||
{previewRows.map((row) => (
|
||||
<div
|
||||
<TraceTooltipRow
|
||||
key={row.key}
|
||||
className={styles.row}
|
||||
data-testid={`span-hover-card-preview-${row.key}`}
|
||||
>
|
||||
<span className={styles.previewKey}>{row.key}:</span>{' '}
|
||||
<span className={styles.previewValue}>{row.value}</span>
|
||||
</div>
|
||||
label={row.key}
|
||||
value={row.value}
|
||||
testId={`span-hover-card-preview-${row.key}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</TraceTooltipSection>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -130,6 +164,7 @@ export function SpanHoverCard({
|
||||
relativeStartMs: span.timestamp - traceStartTime,
|
||||
durationMs: span.duration_nano / 1e6,
|
||||
previewRows,
|
||||
ai: getAIUsageDetails(span),
|
||||
},
|
||||
};
|
||||
}, [
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { formatCost, hasValue, SpanAiUsage } from '../utils/aiTraceUtils';
|
||||
import TokenBreakdown from '../TokenBreakdown/TokenBreakdown';
|
||||
import TraceTooltipRow from '../TraceTooltip/TraceTooltipRow';
|
||||
import TraceTooltipSection from '../TraceTooltip/TraceTooltipSection';
|
||||
|
||||
interface SpanUsageBreakdownProps {
|
||||
usage: SpanAiUsage;
|
||||
}
|
||||
|
||||
function SpanUsageBreakdown({ usage }: SpanUsageBreakdownProps): JSX.Element {
|
||||
const {
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheReadTokens,
|
||||
cacheCreationTokens,
|
||||
reasoningTokens,
|
||||
cost,
|
||||
} = usage;
|
||||
|
||||
return (
|
||||
<>
|
||||
<TokenBreakdown
|
||||
input={inputTokens}
|
||||
output={outputTokens}
|
||||
cacheRead={cacheReadTokens}
|
||||
cacheWrite={cacheCreationTokens}
|
||||
reasoning={reasoningTokens}
|
||||
/>
|
||||
{hasValue(cost) && (
|
||||
<TraceTooltipSection>
|
||||
<TraceTooltipRow isHeading label="Cost" value={formatCost(cost)} />
|
||||
</TraceTooltipSection>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpanUsageBreakdown;
|
||||
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
AiTokenCounts,
|
||||
formatTokens,
|
||||
getTokenLabel,
|
||||
getTotalInputTokens,
|
||||
} from '../utils/aiTraceUtils';
|
||||
import TraceTooltipRow from '../TraceTooltip/TraceTooltipRow';
|
||||
import TraceTooltipSection from '../TraceTooltip/TraceTooltipSection';
|
||||
|
||||
type TokenBreakdownProps = AiTokenCounts;
|
||||
|
||||
function TokenBreakdown({
|
||||
input,
|
||||
output,
|
||||
cacheRead,
|
||||
cacheWrite,
|
||||
reasoning,
|
||||
}: TokenBreakdownProps): JSX.Element {
|
||||
const totalInput = getTotalInputTokens({ input, cacheRead, cacheWrite });
|
||||
|
||||
return (
|
||||
<>
|
||||
<TraceTooltipSection>
|
||||
<TraceTooltipRow
|
||||
label={getTokenLabel('Total Input', totalInput)}
|
||||
value={formatTokens(totalInput)}
|
||||
isHeading
|
||||
/>
|
||||
<TraceTooltipRow
|
||||
label={getTokenLabel('Cache Read', cacheRead)}
|
||||
value={formatTokens(cacheRead)}
|
||||
isNested
|
||||
/>
|
||||
<TraceTooltipRow
|
||||
label={getTokenLabel('Cache Write', cacheWrite)}
|
||||
value={formatTokens(cacheWrite)}
|
||||
isNested
|
||||
/>
|
||||
<TraceTooltipRow
|
||||
label={getTokenLabel('Input', input)}
|
||||
value={formatTokens(input)}
|
||||
isNested
|
||||
/>
|
||||
</TraceTooltipSection>
|
||||
|
||||
<TraceTooltipSection>
|
||||
<TraceTooltipRow
|
||||
label={getTokenLabel('Reasoning', reasoning)}
|
||||
value={formatTokens(reasoning)}
|
||||
isHeading
|
||||
/>
|
||||
</TraceTooltipSection>
|
||||
|
||||
<TraceTooltipSection>
|
||||
<TraceTooltipRow
|
||||
label={getTokenLabel('Output', output)}
|
||||
value={formatTokens(output)}
|
||||
isHeading
|
||||
/>
|
||||
</TraceTooltipSection>
|
||||
|
||||
<TraceTooltipSection>
|
||||
<TraceTooltipRow
|
||||
label="Total Token Usage"
|
||||
value={formatTokens(totalInput + (output ?? 0))}
|
||||
isHeading
|
||||
/>
|
||||
</TraceTooltipSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
TokenBreakdown.defaultProps = {
|
||||
input: undefined,
|
||||
output: undefined,
|
||||
cacheRead: undefined,
|
||||
cacheWrite: undefined,
|
||||
reasoning: undefined,
|
||||
};
|
||||
|
||||
export default TokenBreakdown;
|
||||
@@ -29,6 +29,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.tabs {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.filterSection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
@@ -13,16 +13,20 @@ import KeyValueLabel from 'periscope/components/KeyValueLabel';
|
||||
import { TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { TraceDetailsTab } from '../constants';
|
||||
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
|
||||
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
|
||||
import { useTraceDetailsTab } from '../hooks/useTraceDetailsTab';
|
||||
import { useTraceStore } from '../stores/traceStore';
|
||||
import TraceDownloadPanel from './TraceDownloadPanel';
|
||||
import EntityMetadataRow from '../EntityMetadata/EntityMetadataRow';
|
||||
import AnalyticsPanel from '../SpanDetailsPanel/AnalyticsPanel/AnalyticsPanel';
|
||||
import Filters from '../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters';
|
||||
import { useTraceDetailsStripInfo } from '../useTraceDetailsStripInfo';
|
||||
import MissingSpansBanner from './MissingSpansBanner';
|
||||
import TraceOptionsMenu from './TraceOptionsMenu';
|
||||
import { useTraceSummary } from './useTraceSummary';
|
||||
import TraceDetailsTabs from './TraceDetailsTabs';
|
||||
import TraceOptionsMenu from './TraceOptionsMenu';
|
||||
|
||||
import styles from './TraceDetailsHeader.module.scss';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
@@ -60,6 +64,14 @@ function TraceDetailsHeader({
|
||||
const [isPreviewFieldsOpen, setIsPreviewFieldsOpen] = useState(false);
|
||||
const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false);
|
||||
const { data: traceSummary } = useTraceSummary(traceID);
|
||||
const [tab] = useTraceDetailsTab();
|
||||
const isOverview = tab === TraceDetailsTab.Overview;
|
||||
|
||||
// Overview-only panels must not linger over another tab.
|
||||
useEffect(() => {
|
||||
setIsPreviewFieldsOpen(false);
|
||||
setIsAnalyticsOpen(false);
|
||||
}, [tab]);
|
||||
const previewFields = useTraceStore((s) => s.previewFields);
|
||||
const setPreviewFields = useTraceStore((s) => s.setPreviewFields);
|
||||
|
||||
@@ -93,13 +105,14 @@ function TraceDetailsHeader({
|
||||
setShowTraceDetails((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
useTraceDetailsStripInfo({
|
||||
totalSpansCount: traceSummary?.totalSpansCount ?? 0,
|
||||
totalErrorSpansCount: traceSummary?.totalErrorSpansCount ?? 0,
|
||||
});
|
||||
|
||||
const startTime = (traceSummary?.startTimestampMillis ?? 0) / 1e3;
|
||||
const endTime = (traceSummary?.endTimestampMillis ?? 0) / 1e3;
|
||||
|
||||
const durationMs = traceSummary
|
||||
? traceSummary.endTimestampMillis - traceSummary.startTimestampMillis
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.header}>
|
||||
@@ -120,9 +133,10 @@ function TraceDetailsHeader({
|
||||
badgeValue={traceID}
|
||||
maxCharacters={100}
|
||||
/>
|
||||
<TraceDetailsTabs />
|
||||
</div>
|
||||
)}
|
||||
{showTraceDetailsHeaderOptions && traceSummary && (
|
||||
{isOverview && showTraceDetailsHeaderOptions && traceSummary && (
|
||||
<div
|
||||
className={cx(
|
||||
styles.filterSection,
|
||||
@@ -138,6 +152,7 @@ function TraceDetailsHeader({
|
||||
icon
|
||||
color="secondary"
|
||||
aria-label="Analytics"
|
||||
testId="trace-analytics-button"
|
||||
onClick={handleToggleAnalytics}
|
||||
>
|
||||
<ChartPie size={14} />
|
||||
@@ -172,28 +187,32 @@ function TraceDetailsHeader({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showTraceDetails && (
|
||||
{(showTraceDetails || !isOverview) && (
|
||||
<div className={styles.subHeader}>
|
||||
{traceSummary ? (
|
||||
{!traceSummary ? (
|
||||
<DetailsLoader />
|
||||
) : (
|
||||
<EntityMetadataRow
|
||||
entity="trace"
|
||||
service={{
|
||||
name: traceSummary.rootServiceName,
|
||||
entryPoint: traceSummary.rootServiceEntryPoint,
|
||||
}}
|
||||
durationMs={durationMs}
|
||||
durationMs={
|
||||
traceSummary.endTimestampMillis - traceSummary.startTimestampMillis
|
||||
}
|
||||
timestamp={dayjs(traceSummary.startTimestampMillis).format(
|
||||
DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS,
|
||||
)}
|
||||
statusCode={traceSummary.rootSpanStatusCode}
|
||||
tokens={traceSummary.ai?.tokens}
|
||||
cost={traceSummary.ai?.totalCost}
|
||||
/>
|
||||
) : (
|
||||
<DetailsLoader />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{traceSummary?.hasMissingSpans && <MissingSpansBanner />}
|
||||
{isOverview && traceSummary?.hasMissingSpans && <MissingSpansBanner />}
|
||||
|
||||
<FieldsSelector
|
||||
isOpen={isPreviewFieldsOpen}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user